【フロントエンド】リファクタリングについて
初めに
今まで5年ほどマークアップ&フロントエンドエンジニアとして働いてきたので、
フロントエンドエンジニアリング(HTML/CSS/JSやその他)において役に立ちそうなリファクタリング(コーディング全般の)をまとめます。
ここでいうリファクタリングは、保守性、アクセシビリティ、サイト高速化などが目的です。
HTML
画像
容量
pictureとsourceタグ
- HTMLの話になりますが、PCとSPで違う画像を使う場合、CSSで出しわけせず、pictureとsourceタグで出しわけすることで読み込む画像を一つにできます。
最後に
日々情報を調べることは多いと思うので、参照すると良い個人的オススメサイトを紹介します。
オススメサイト
MDN(Mozilla)さんでは、Web技術における正しい情報が掲載されていますので、迷ったらここを参照すると良いです。
developer.mozilla.org
コリスさんでは、フロントエンドの新しい技術を海外記事翻訳などしてくれた上で分かりやすく掲載されていて技術インプットするのにとても良いサイトです。
coliss.com
ICS MEDIAさんでは、古い記事も随時更新して情報が古くならないようにしたり、アクセシビリティに考慮した実装を紹介したり、たくさんの役に立つ情報を発信されています。
ics.media
AstroとGSAPでアニメーション実装
経緯
実際の案件で取り入れてることも多くなってきた、静的サイトのフレームワークAstro。
特にLPなどでは、スクロールに応じたアニメーションを使うことがあると思うので、試しに実装してみました。
デモ
準備中
解説
使い方など詳しくは、公式やICSさんのページが分かりやすいのでそちらを参照ください。
GSAP - GreenSock
GSAP入門 - アニメーション制作のための高機能なJSライブラリ(前編) - ICS MEDIA
構成
- 今回動かす対象は、矢印などのSVG要素です。
- 個人的に、アイコンなどの要素は、属性などをCSSやJSで操作しやすい為、インラインSVG(HTMLに<svg>で設置する形式)で設置しています。
- SVGは複雑なものであるほどコードが長くなりますが、その点、Astroではコンポーネントとして読み込む形で利用できるのが良いと思います。
例)
pages/index.astro
--- import IconArrow from "../components/IconArrow.astro"; --- <IconArrow classStr="js-anim-down center" />
components/IconArrow.astro
(「親」から、この「子」(コンポーネント)に値(props)を渡せるので、それによりclassを自由に変えられるようにしています)
--- const { classStr } = Astro.props; --- <svg // (中略) class={classStr} >
GSAP基本の形
import {gsap} from "gsap"; // 横軸に200px移動 gsap.to(".box", { x: 200 })
- jqueryみたいなメソッドが分かりやすく記述できて分かりやすい印象です。
※ちなみに、gsap発火させる要素は、単一要素だけじゃなく、複数要素でも問題なく全てに処理できました。
例)h2要素が複数ある場合:
const titleList = document.querySelectorAll('h2'); gsap.to(titleList, { x: 200 })
ここからは、よく使いそうなGSAPの機能を実装したので、その解説です。
リピート処理
- リピートさせるためには、timeline()を使うことが必要そうでした。
- それにより作成したインスタンスにadd()を繋げ、to()などで動かせる処理を書きます。細かい処理が楽に設定できますね。
const tl = gsap.timeline({ repeat: -1, repeatDelay: 0.5 }); tl.add( gsap.to(targetDown, { y: 0, autoAlpha: 1, duration: 1, }), ); tl.add( gsap.to(targetDown, { autoAlpha: 1, y: 50, ease: "power1.out", duration: 1.5, }), ); tl.add( gsap.to(targetDown, { autoAlpha: 0, y: 50, ease: "power1.out", duration: 1.5, }), );
複数要素を時間差で処理
- まずは、スクロールして画面に入った時に、アニメーションを実行したいので、、プラグインScrollTriggerクラスを読み込み、registerPlugin()で登録します。
- staggerプロパティにより、時間差を用いて処理を実行できます。
import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger); const scrollAnim = gsap.from(targetSlideInLeft, { autoAlpha: 0, xPercent: -500, ease: "power3", duration: 2, stagger: 0.8, }); // スクロールトリガーの設定 ScrollTrigger.create({ trigger: targetSlideInLeft, animation: scrollAnim, start: "top+=100 bottom", once: true, toggleActions: "play none none none", markers: false, });
matchMediaで端末ごとに処理を分ける
- 先述のScrollTriggerのメソッドとしてmatchMediaを記述し、その中に画面幅を登録することで、端末ごとの処理を書けます。
ScrollTrigger.matchMedia({ // 769px以上 "(min-width: 769px)": function () { gsap.to(targetMatchMedia, { autoAlpha: 1, x: 500, rotation: 360, duration: 3, scrollTrigger: { trigger: targetMatchMedia, start: "top+=100 bottom", }, }); }, // 768px以下 "(max-width: 768px)": function () { gsap.fromTo(targetMatchMedia, { autoAlpha: 0, y: -50, scale: 2, rotation: 0, duration: 2, scrollTrigger: { trigger: targetMatchMedia, start: "top bottom", }, }, { autoAlpha: 1, y: 50, scale: 2, rotation: 360, duration: 2, scrollTrigger: { trigger: targetMatchMedia, start: "top bottom", }, }); }, // メディアのサイズに関係なく、すべてに適用する all: function () { gsap.set(targetMatchMedia, { autoAlpha: 0 }); }, });
終わりに
GSAPはやはり扱いやすかったです。
料金が発生するサイトでの利用でなければ無料で使用可能とのことなので、ありがたいライブラリです。
ReactでSPAサイト作成記録
はじめに
- SPAとは、Single Page Applicationのことで、これを使うことでシームレスなページ遷移を実現できます。
- 以前から興味があったので、勉強中のReactでできるか試してみました。
- 完成物は、リンクメニュー付のシンプルな画面です。
使用技術など
Vite, React, TypeScript, Tailwind CSS
やったことと解説
1. 環境作成と進め方
- 環境はViteでReact + TypeScriptなどを選んで環境作成しました。
- そして、ある程度調べた上で、react-router-domおよび、Reactライブラリ「react-slide-routes」を使用すると、スライドなどアニメーションを用いたSPA実装が簡単そうで、これを採用しました。
2. ポイント
- 上記2つの記事どおり進めることで、ほぼ問題なく進められますが、ポイントを見ていきます。
ルーティング設定
main.tsxのルーティング機能を適応させたいコンポーネントを、
react-router-domから読み込んだ<BrowserRouter>コンポーネントで挟んであげます。
react\src\main.tsx
(中略) import { BrowserRouter } from 'react-router-dom'; (中略) ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( <React.StrictMode> {/* # ここからRouting設定(適応させたいコンポーネントをBrowserRouterで挟む。) */} <BrowserRouter> <App /> </BrowserRouter> {/* ここまでRouting設定 */} </React.StrictMode>, );
スライド機能設定
App.tsxにて、react-slide-routesから読み込んだ<SlideRoutes>コンポーネントで挟んであげます。
div id="content"の部分です。
属性(プロパティ)の書き方は、react-slide-routes公式Githubを見て設定したらうまくできました。
プロパティは他にも、切り替え時間、イージング、アニメーション種類(デフォルトは横スライド)など設定できるようでした。
GitHub - nanxiaobei/react-slide-routes: 🏄♂️ The easiest way to slide React routes
react\src\App.tsx
import { Route, NavLink } from 'react-router-dom'; import SlideRoutes from 'react-slide-routes'; const Home = () => ( <div className="card home"> <h2 className="heading">Home ホーム</h2> <p className="text"> <strong>Homeページです。</strong> <br /> テキストテキスト... </p> </div> ); const About = () => ( (中略) ); const News = () => ( (中略) ); function App() { return ( <> <h1>ReactでSPAの練習サイト</h1> <nav> <NavLink to="/" end> Home </NavLink> <NavLink to="/about">About</NavLink> <NavLink to="/news">News</NavLink> </nav> <div id="content"> <SlideRoutes animation="slide" duration={300}> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> <Route path="/news" element={<News />} /> </SlideRoutes> </div> </> ); }
終わりに
- SPAのアニメーション使ったライブラリは他にも、react-springなどがありましたが、このreact-slide-routesは実装はわかりやすく導入しやすかったです。
- 遷移時に付与されるclassも公式にサンプルがあるのですが、それを元に書き換えることで効果も工夫できました。
Reactでポートフォリオサイト作成記録
経緯
- フロントエンド開発において主流になった印象のフレームワークReact.js。
- 業務でも使えるようになりたいので、学習がてら実際に練習サイトを作成してみようと思いました。
Reactについての私の理解度
- JavaScriptはES6の仕様を含めた基本構文は抑えている程度。
- レンダリング、props、stateについての基本をざっくり学んだだけ。Progateで言うと無料レッスンをすべて終えた程度です。
使用技術など
Vite, React, TypeScript, Tailwind CSS
やったことと解説
1. 環境作成
以前、その快適さに慣れてしまっていたViteを使用しました。
初期構築のコマンドでも簡単にReactやTypeScriptを導入して使えます。
こちらのページを参考にさせていただきました。
ESLint周りはいつも複雑に思うので、分かりやすくて書かれていて助かります。
zenn.dev
2. コンポーネント分け
ViteのReact環境は、初期状態ではロゴだけですので、コーディングしていきます。
HTMLへ変換される大本のファイルApp.tsxです。
そして、ヘッダーやフッターは、componentsとして別ファイルで管理するのが一般的らしく、扱いやすくもあるので、
以下のようにcomponentsフォルダを作って、移動させます。
ディレクトリ例)
react/
└src/
├ components/
├ Header.tsx
└ Footer.tsx
├ App.tsx
├ index.css
├ main.tsx
(省略)
そして、App.tsxにて、Classコンポーネントとしてエクスポートしたコードをインポートします。
return ( <div className="App"> <Header /> (中略) <Footer /> </div> );
4. Stateを使ったコンポーネント実装
Reactにおいて状態を管理する役目の「State」を使った何かしら動きがあるコンポーネントを実装します。
【いいねボタンの解説】
react\src\components\LikeButton.tsx
import React, { useState } from 'react'; const LikeButton = () => { const [likes, setLikes] = useState(99); const [isClicked, setIsClicked] = useState(false); // stateを増減させる関数 const handleClick = () => { if (isClicked) { setLikes(likes - 1); } else { setLikes(likes + 1); } setIsClicked(!isClicked); }; return ( <button // &&は、インラインifで、isClickedがtrueならlikedが付与される className={`rounded-md bg-slate-200 p-1 ${isClicked && 'liked'}`} onClick={handleClick} > <span className="">{`いいね | ${likes}`}</span> </button> ); }; export default LikeButton;
基本的な構文なので、かいつまんで解説します。
useStateの定義について
構文は以下です。
const [state, setState] = useState(initialState);
setStateはstateを更新する関数であり、
initialStateはstateの初期値が格納されている箇所になります。
式は、分割代入( https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment )というものが使われています。
今回の場合、
1つ目のlikesというstateには、初期値99が入る定数を、
2つ目のisClickedというstateには、初期値falseが入る定数を宣言しています。
イベントについて
onClickで関数handleClickを起こしています。
関数の中身は、
真偽値(true or false)を管理しているstate「isClicked」を判定し、
falseなら-1を、trueなら+1を、数字を管理しているstate「likes」に対してsetLikes()にて更新します。
最後に、state「isClicked」に対してsetIsClicked()にて更新します。
class付与について
ボタン押下時、状態変化のclassを付与するため、
「論理 && 演算子によるインラインIf」という仕組みを使いシンプルに記述しています。
<button className={` ${isClicked && 'liked'}`} > </button>
上記の場合、 isclicked がtrue なら、likedが付与されます。
参考: https://ja.reactjs.org/docs/conditional-rendering.html#inline-if-with-logical--operator
【画像検索ギャラリーの解説】
引用元のサイト様にかなり詳しく書いてあるので、そちらを参照ください。
https://www.webcreatorbox.com/tech/react-unsplash-api
簡単に説明すると、
inputに入力した文字列がstateに入るようになっていて、
その上で「検索」ボタンを押すと、
事前にサイト登録したUnsplashのAPIへ、axiosでURLのリクエストを叩き、成功したら画像データなどを取ってこれます。
そのデータを、map()により、入力文字列に応じた検索でヒットした画像の入ったdivタグが並ぶように処理されます。
余談ですが、
Form.tsxとResults.tsxの各関数の引数に指定する型が分からなくて、anyにせざるを得なかったので消化不良です。
終わりに
- まだ基礎中の基礎部分しか触れていないので、もうちょっと深掘りしたいところですが、やはりJavaScriptの基礎を固めていないと難しいですね(TypeScriptも先に理解した方が良さげ)。
- 次回は、これもReact重要な機能である、useEffectについて実装した上でブログにまとめしたいです。
- Reactの利点として、コンポーネントの管理のしやすさ、レンダリングの速さ、JSXにおけるTailwind CSSとの相性の良さなどを実感しました。
TypeScriptの基本の備忘録
基本
- TypeScriptは型を推論する。
- const name: string = 'torahack';のようにして、型アノテーションを使い明示的な型を定義。
型
柔軟な型定義が可能。
例:
// 生まれの年は数字か文字列でも受け取れる変数 let birthYear: number | string;
valueが存在しないとエラーがでる時は、
Property 'value' does not exist on type 'Element’.
このように定義するとエラーは消える。
(document.querySelector('.hoge') as HTMLInputElement).value;
型を調べる
console.log(typeof(occDatas));
- null: 値が欠如していることを表す。または、代入すべき値が存在しないため、値が無い。
- undefinded: 初期化されておらず、値が割り当てられていないことを表す。または、値が代入されていないため、値がない。
できる限り、undefinedを使う。(nullは変数/定数があるのに値が無いなど、設計間違いが多い。)
- anyはどんな型でも許容する=全く安全でない。
- unknownは、どんな型になるか不明>、代入した値によって変化。使い所は、後から型定義したいデータがある時など。
- どうしても使わなきゃいけない時は、anyじゃなく、unknownを使う。
色々な型
- event.targetの型は、EventTarget
- アンカー要素の型は、HTMLAnchorElement
const targetId = (e.target as HTMLAnchorElement).hash;
クエスチョンマーク
必須のプロパティじゃない時につける(オプショナル)
つまり、引数が省略可能になる。
- 必ず引数の最後に書く
const test = (x:number, y?:number):void=>{ console.log(x); } test(1); //OK
Void
TypeScriptで戻り値がない関数の戻り値を型注釈するにはvoid型。
関数が何も返さない場合は、 : void をつけることで明示的に表現できます。
function print(message: string): void { console.log(message); }
- :never なら、決して戻ることのない戻り値。
エクスポート
// 変数、関数、クラスのエクスポート
export const favorite = "小籠包"; export function fortune() { const i = Math.floor(Math.random() * 2); return ["小吉", "大凶"][i]; } export class SmallAnimal {}
関数エクスポート(アロー関数)
export const tab = (wrapperId: string): void => {}
インポート
// 名前を指定してimport import { favorite, fortune, SmallAnimal } from "./smallanimal";
// リネーム import { favorite as favoriteFood } from "./smallanimal";
JavaScript(TypeScript)でToDo Webアプリ作成
初めに
cookie, localStorage, sessionStorageなど、セッション中のデータ保存できる仕組みがあります。
私はJavaScriptでこれらを使った何かしらの実装をしたことが無かったので、今回はlocalStorageを使った簡単なToDo Webアプリを作成してみました。
cookie, localStorage, sessionStorageの3つの仕組みの違いはこちらの解説が分かりやすかったです。↓
今回扱うことにしたlocalStorageの特長としては、「ブラウザに保存される」・「保存容量は10MBまで」・「保存期限が無い」などです。
「保存期限が無い」については、セキュリティ問題があるようなので、公開するサイトでの利用などでは注意が必要です。
https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851
成果物:
作ったコード
https://github.com/kaiyoshida57/js_app_session
ページ
http://kaibukuro.starfree.jp/practice-js/js_app_session/
機能 →
- 上部の枠でタイトルや日付を入力した上で新規追加し、ブラウザに保存されます。
- 削除とステータスの変更が可能。
- Todo名をダブルクリックで編集が可能。
解説:
src/index.html
<div class="wrapper"> <header class="header" id="header"> <div class="header__inner"><a class="header_logo" href="/" aria-label="Site logo"> <h1 class="header__title">Todo</h1> </a> </div> </header> <main> <p class="introduction">Todoを編集・保存できます。</p> <div class="control"> <p class="control__heading"> 新規作成 </p> <div class="control__form"> <label for="control__formInput01"> <span class="control__text">Todo:</span> <input type="text" value="" id="control__formInput01" placeholder="Todo" class="control__formInput01"> </label> <label for="control__formInput02"> <span class="control__text">期限:</span> <input type="datetime-local" value="2022-11-11T10:00" id="control__formInput02" class="control__formInput02"> </label> <button type="button" class="control__addButton"> + 新規作成する </button> </div> </div> <table id="todoTable" class="table"> <thead> <tr> <th class="table__title01">Todo</th> <th class="table__title02">期日</th> <th class="table__title03">状態</th> <th class="table__title05"> </th> </tr> </thead> <tbody class="table__tbody"> </tbody> </table> </main> </div>
- HTMLのコードで処理を解説すると、上の緑の入力バーにて「todo名」と「期限」を入力した上で「新規追加する」ボタン押下により、空のtbodyタグの中に、trタグ以下が一式追加されます。(ステータスは初期状態は必ず「未完」になります。)
src/js/_modules/todo.ts
コードにコメント入れてるので、要点のみ解説します。
※はてブのコードだと行数が参照されてないので、以下Github と照らし合わせて参照をお願いします。
https://github.com/kaiyoshida57/js_app_session/blob/main/src/js/_modules/todo.ts
/** * Todo操作処理 **/ export function todo(): void { //datetime-localの初期value値に現在時間追加 const timeInput = document.querySelector('.control__formInput02') as HTMLInputElement; const now = new Date(); now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); timeInput.value = now.toISOString().slice(0, -8); // todo入力情報を入れる配列 let listItems = Array(); // tableへ挿入関数 const insertingTable = (todo: string, time: string, status: string) => { // trにclass付与 let trClass = ''; if (status === '1') { trClass = 'is-uncomplete'; } else if (status === '2') { trClass = 'is-process'; } else if (status === '3') { trClass = 'is-complete'; } // selectにselected属性付与 let statusToSelected1 = status === '1' ? 'selected="selected"' : ''; let statusToSelected2 = status === '2' ? 'selected="selected"' : ''; let statusToSelected3 = status === '3' ? 'selected="selected"' : ''; const insertHtml = ` <tr class="table__row ${trClass}" data-todoname="${todo}"> <td class="table__todoName">${todo}</td> <td class="table__todoTime">${time}</td> <td> <select name="" id="" class="table__select"> <option value="1" class="table__selectItem" ${statusToSelected1}>未完</option> <option value="2" class="table__selectItem" ${statusToSelected2}>処理中</option> <option value="3" class="table__selectItem" ${statusToSelected3}>完了</option> </select> </td> <td> <button type="button" class="table__delButton">削除</button> </td> </tr> `; const targetTbody = document.querySelector('.table__tbody') as HTMLElement; if (targetTbody) { targetTbody.insertAdjacentHTML('afterbegin', insertHtml); } } // -- 新規追加 -- const addBtn = document.querySelector('.control__addButton') as HTMLButtonElement; addBtn.addEventListener('click', () => { //入れる要素 const todoInput = document.querySelector('.control__formInput01'); const todoInputVal = (todoInput as HTMLInputElement).value; const timeInputVal = (timeInput as HTMLInputElement).value; // 2026-08-28T23:00 を2026/08/28 23:00の形式に変更 const replacedTimeInputVal01 = timeInputVal.replace(/-/g, '/'); const replacedTimeInputVal02 = replacedTimeInputVal01.replace(/T/g, ' '); // 空なら終了 if(todoInputVal === '') { window.alert('todo名を入力してください。'); return false; } // -- localStorageの準備 -- // まずはlocalStorageからデータ取得 const storageJson = localStorage.mykey; if(storageJson === undefined) { return false; } // 呼び出し時はオブジェクト形式に戻す listItems = JSON.parse(storageJson); // 入力情報をオブジェクトの配列に入れる const item = { todoVal: todoInputVal, todoTime: replacedTimeInputVal02, status: 1, //状態は1=未完が初期値 isDeleted: false }; listItems.push(item); //オブジェクトを復元できるように文字列(JSON)形式にする const listItemsString = JSON.stringify(listItems); // localStorageに保存、構文→localStorage.setItem(キー, データ) localStorage.setItem('mykey', listItemsString); insertingTable(todoInputVal, replacedTimeInputVal02, "1"); }); //ページ読込時に、localStorageからリスト呼び出し document.addEventListener('DOMContentLoaded', () => { const storageJson = localStorage.mykey; const listItemsString = JSON.stringify(listItems); // 初回読み込み時データ無い場合動かないのでここで追加 if(!storageJson) { localStorage.setItem('mykey', listItemsString); } if(storageJson === undefined) { return false; } //呼び出し時は逆にオブジェクト形式に戻す listItems = JSON.parse(storageJson); listItems.forEach((item: { todoVal: string; todoTime: string; status: string; }) => { insertingTable(item.todoVal, item.todoTime, item.status); }) }); // 状態の変更 (追加要素への処理時のため、親要素にイベント設定) const tbody = document.querySelector('.table__tbody') as HTMLElement; tbody.addEventListener('change', (e) => { if ((e.target as HTMLElement).classList.contains('table__select')) { const selectValue = (e.target as HTMLSelectElement).value; const parent = (e.target as HTMLElement).closest('.table__row') as HTMLElement; //まず既存classは削除 if ( parent.classList.contains('is-uncomplete') || parent.classList.contains('is-process') || parent.classList.contains('is-complete') ) { (parent as HTMLElement).classList.remove('is-uncomplete', 'is-process', 'is-complete'); } if ((selectValue as string) === '1') { (parent as HTMLElement).classList.add('is-uncomplete'); } if ((selectValue as string) === '2') { (parent as HTMLElement).classList.add('is-process'); } if ((selectValue as string) === '3') { (parent as HTMLElement).classList.add('is-complete'); } // localStorageへも変更を保存 // tr data値 const trDataVal = (parent as HTMLElement).dataset['todoname'] as string; // todoごと(特定のkeyのみ)更新するにはデータ取り出してから更新 let storageJson = localStorage.mykey; //呼び出し時はオブジェクト形式に戻す let storageJsonObj = JSON.parse(storageJson); if (storageJsonObj) { //変更対象の要素のtodoVal値と一致させる //メモ:find()は、提供されたテスト関数を満たす配列内の要素を返す const changeValue = storageJsonObj.find( (item: { todoVal: string; }) => item.todoVal === trDataVal ); //一致した削除対象のオブジェクトのstatusプロパティにvalue値(整数)を入れる changeValue.status = selectValue; } // JSONに変換し直してローカルストレージに再設定 localStorage.setItem('mykey', JSON.stringify(storageJsonObj)); } }) // 削除する (追加要素への処理時のため、親要素にイベント設定) tbody.addEventListener('click', (e) => { if ((e.target as HTMLElement).classList.contains('table__delButton')) { const parent = (e.target as HTMLElement).closest('tr') as HTMLElement; const todoName = parent.querySelector('td:first-of-type') as HTMLElement; const todoNameValue = todoName.textContent; const confirmDel = window.confirm(`「${todoNameValue}」を削除しますか?`); //tr data値 const trDataVal = (parent as HTMLElement).dataset['todoname'] as string; if (confirmDel) { (parent as HTMLElement).remove(); // todoごと(特定のkeyのみ)削除するにはデータ取り出してから削除 let storageJson = localStorage.mykey; //呼び出し時はオブジェクト形式に戻す let storageJsonObj = JSON.parse(storageJson); if (storageJsonObj) { //削除対象の要素のtodoVal値と一致させる //メモ:find()は、提供されたテスト関数を満たす配列内の要素を返す const delValue = storageJsonObj.find( (item: { todoVal: string; }) => item.todoVal === trDataVal ); //一致した削除対象のオブジェクトのisDeletedプロパティにtrueを入れる delValue.isDeleted = true; //オブジェクトをfilter()で削除 //メモ:filter()はオブジェクトのうちfalseのものを配列で返す const newStorageJsonObj = storageJsonObj.filter( (item: { isDeleted: boolean; }) => item.isDeleted === false ); storageJsonObj = newStorageJsonObj; // JSONに変換し直してローカルストレージに再設定 localStorage.setItem('mykey', JSON.stringify(storageJsonObj)); } } else { return false; } } }); // 全て削除する const removeAllBtn = document.querySelector('.control__delButton') as HTMLButtonElement; removeAllBtn.addEventListener('click', (e) => { const confirmDel = window.confirm(`全て削除しますか?`); if (confirmDel) { //ストレージからの削除 localStorage.clear(); // 空になった後も新規追加はさせたいので、mykeyは残す listItems = Array(); let storageJson = localStorage.mykey; const listItemsString = JSON.stringify(listItems); if(!storageJson) { localStorage.setItem('mykey', listItemsString); } //HTMLからの削除 const main = (e.target as HTMLElement).closest('main') as HTMLElement; const todoList = main.querySelectorAll('.table__row') as NodeList; todoList.forEach((todo) => { (todo as HTMLElement).remove(); }); } else { return false; } }); //todo名の、入力による変更 // クリックで編集可能の属性付与 (追加要素への処理時のため、親要素にイベント設定) tbody.addEventListener('click', (e) => { if ((e.target as HTMLElement).classList.contains('table__todoName')) { (e.target as HTMLElement).setAttribute('contenteditable', 'true'); } }); // value変更されたら、focusoutイベントで保存させる // **contenteditable属性ではchangeが効かないためfocusout使う tbody.addEventListener('focusout', (e) => { if ((e.target as HTMLElement).classList.contains('table__todoName')) { const changedTodoName = (e.target as HTMLElement).textContent as string; const parent = (e.target as HTMLElement).closest('tr') as HTMLElement; const trDataVal = (parent as HTMLElement).dataset['todoname'] as string; // まずtrのdataも更新する(状態変更・削除処理の時に失敗するため) (parent as HTMLElement).dataset['todoname'] = changedTodoName; // todoごと(特定のkeyのみ)更新するにはデータ取り出してから更新 let storageJson = localStorage.mykey; //呼び出し時はオブジェクト形式に戻す let storageJsonObj = JSON.parse(storageJson); if (storageJsonObj) { //変更対象の要素のtodoVal値と一致させる const changeValue = storageJsonObj.find( (item: { todoVal: string; }) => item.todoVal === trDataVal ); //一致した削除対象のオブジェクトのtodoValプロパティにvalue値(文字列)を入れる changeValue.todoVal = changedTodoName; } // JSONに変換し直してローカルストレージに再設定 localStorage.setItem('mykey', JSON.stringify(storageJsonObj)); } }); }
9行目、
input type="datetime-local"は「2018-06-12 19:30」のような形式で入力できる要素です。
( https://developer.mozilla.org/ja/docs/Web/HTML/Element/input/datetime-local
https://qiita.com/terufumi1122/items/76bafb9eed7cfc77b798
)
この要素の初期表示に関しては、空欄より、現在時間が入ってた方が使いやすそうと思い、value値に動的に追加しています。
参考にさせていただきました:
18行目、
insertingTable という関数では、前述のtr以下一式を追加する処理がまとまっています。
引数には、(todo, time, status)が設定されていて、(todo名、期限、状態) を入れるように割り当てられています。
中身の処理ですが、
21行から、trClassという変数に、第三引数の「status(状態)」の数字1 or 2 or 3により、trに入れるべきclassを格納します。
これは、trの背景色を、未完、処理中、完了など、「状態」の種類ごとに違う色を適用するためです。
この変数trClassは、後のテンプレート文字列内で使います。
さらに29行からは、
statusToSelected1, statusToSelected2, statusToSelected3 という変数を定義し、これもstatusの数字により判定し、それぞれの合う数字(1~3)があれば、その数字を変数に入るようにしています。
これは、optionタグのselected属性へ、選択状態を適用する為です。
これも後のテンプレート文字列内で使います。
34行からは、
次は、todoの新規追加(保存含む)のイベント処理です
57行からは、新規追加するのaddButtonとして取得し、クリックイベントを設定しています。
61行からは、内容は、ボタン左の入力枠、todo名、期限、に入力されたテキストを変数に入れ、
66行からは、コメントにもあるような処理をしています↓
「// 2026-08-28T23:00 を2026/08/28 23:00の形式に変更」
これは、datetime-localのvalueのままだと、余計な文字列が入ってるので、replace()で引数に正規表現を用いて、
スラッシュ / と、Tを消すように、変換しています。
70行からは、todo名の入力が無しだった場合は、画面ではアラートを出し、イベント処理は終了するようにしています。
76行からは、localStorageに入れるためのオブジェクトを4つのプロパティに分けて作成しています。
- キーと値ですが次のようなセットにします。
const item = { //キー: 値 todoVal (todo名): todoInputVal, todoTime (期限): replacedTimeInputVal02, status (状態): 1, isDeleted(削除されたかどうか): false }
- 値に関しては、最初の2つまでは、60行目で定義した変数を入れますが、statusには、追加の場合は初期状態が未完にしたいので1を、isDeletedには、ここではfalseを明示的に入れます。
作ったオブジェクトは、16行で作った空の配列にpush()で入れます。
94行、保存する前に、オブジェクトを文字列(JSON)形式に変換します。
(ストレージの基本仕様として、ストレージへの保存時は、文字列に変換しなくてはいけません。
反対に、呼び出す時は、オブジェクトに復元させます。)
97行、
localStorage.setItem('任意のkey', '任意の文字列') このような構文で、localStorageに保存します。
次は読み込み時の呼び出しです
103行からは、読み込み時は、localStorageから保存したデータを読み出すようにします。
呼び出す時は、オブジェクトに復元させます。
116行では、配列を、forEachにより、オブジェクト分だけ、insertTable() にそれぞれプロパティの値として引数に割り当てた上で実行させます。
これにより画面では、保存してたtodoリストがtable内に全て表示されます。
ここまではアプリに必要な「追加」・「保存」の機能はできました。
次はtodoごとにある「状態」の変更(保存含む)処理です。
122行から状態変更の処理です
まずは、DOM更新の処理なので詳しくは割愛しますが、
「状態」selectの変更時のイベントにより、class付け替えてリストの背景色変えているだけです。
148行からが、localStorageへの状態変更の保存処理です。
trタグのdata属性 data-todoname にtodo名が入るように追加しているので、この値を文字列として取得しておきます。
続いてlocalStorageから、データを全て取り出し、そこからfind()を使うと、上記文字列を一致したkey(今回はtodoVal(todo名))のオブジェクトが返ってきます。
その中のキーstatusの値に、selectValue(変えたselectのvalue値)を入れます。
あとは例のごとく文字列にしてからlocalStorageへ保存しなおします。
これで、状態の変更が上書きされる形で保存されました。
続いて、削除の処理です
172行からの削除処理は、confirm() で削除の確認するアクションを挟んでいますが、「状態更新」の時とあまり変わらない処理になっています。
188行から、削除対象のオブジェクトをfind()で検出したあと、削除キーisDeletedをtrueに変更します。
196行からが、「状態更新」とは少し違う処理が入っています。
データから、filter()を使い、isDeletedがfalseのものを配列で返ってくるようにします。
(削除対象じゃないオブジェクトだけを絞りこみます。)
そうしたら、例のごとく文字列にしてからlocalStorageに保存し直します。
これで、削除対象を除いたデータだけがストレージに残るので、削除処理はこれにて完了です。
そして、全削除の処理は次の通りです
まず、CSSの話ですが、全削除ボタン(.control__delButton)は、has()を使いtrタグがある時だけdisplay:blockで表示させています。
//mainから見て、trタグを持っている時だけ表示 main:has(.table__row) .control__delButton { display: block; }
JSに戻ります。
216行から、こちらもconfirm()で確認した上で、
219行で、localStorage.clear() により、ストレージのデータを全て削除します。
(ただし、このアプリで統一して設定しているキー”mykey”ごと消えてしまうので下記の処理で対応します)。
221行、配列が入っている変数listItemsも空にします。
listItems = Array();
224行から、mykeyのストレージが存在しなければ、
空にしたlistItemsを代入して、mykeyは残し、空の配列だけになるように設定しています。
続いて、Todo名の入力による変更処理です
238行から、まずはリストのTodo欄をクリックさせた時に、
contenteditable属性 という、要素を入力可能にできる属性の付与により、編集可能にします。
developer.mozilla.org
そのあとは、focusoutイベントにより、新たに入力された値を取得し、
先述の「削除」や「状態更新」と同じように、ストレージからデータを取り出してTodo名を更新(上書き)すればOKです。
終わりに
- LocalStorageへの保存/取得時は、JSON ⇄ オブジェクトなどの変換が必要だったりで、オブジェクトの操作は今まであまりやってこなかったので、ここらへんを新たに学べました。
- また、Todoリストの項目(タイトル、日付など)をどうやったら効率よくデータ管理(保存、更新など)出来るコードになるかを考える必要があり、学びになりました。
参考にさせていただいた記事:
find()やfilter()を用いたオブジェクトの登録や、データ更新方法が、とても参考になりました。
https://univ-programmer.com/feweek4-4/
オブジェクトの追加・削除の参考
https://freelance-jak.com/technology/javascript/1617/
脱jQueryしました
初めに
- まだまだ多くのサイトで使われてるjQuery。最近ではパフォーマンスや保守性などの観点から、jQueryの必要性は無くなってきました。
- 私は数年前からJavaScriptを一から学び始めましたが、対応案件ではまだjQueryに頼る事が多かったです。
- そこで、案件でjQueryで書いていたところを、Vanilla(プレーン)JavaScriptで書くようにしました。備忘録的な内容ですが、どうやって進めたか・注意点などをまとめました。
注意点など
silideToggle()
- アコーディオンなどによく使うsilideToggle()はとても便利なメソッドですが、JavaScriptで再現すると記述が長くなります。
※ただし、現状、ユーザビリティなど考慮するなら、details & summaryタグ使ったり
(例: detailsとsummaryタグで作るアコーディオンUI - アニメーションのより良い実装方法 - ICS MEDIA )、
WAI-ARIAを操作して開閉するのがベターな実装方法だと思います。
メソッドチェーン
- メソッドチェーンも、使いやすくて便利でした。
jQuery
$('.hoge') .attr('name', 'elementName') .css('color', '#000') .text('Hello');
↓
JavaScriptでは、このようにクラス作れば使えるようです。
動的に生成された要素へのイベント
動的に生成された要素へのイベント について、JavaScriptでは、 jQueryとは違う方法が必要になります。
jQuery
$('.hoge').on('click', '動的追加要素', function(){ // ← ★ // やりたい処理 }); // ★ onの第二引数に要素指定することで動作する
↓
JavaScript
document.addEventListener("click", function (event) { // ← ★ if (event.target.classList.contains("test")) { // やりたい処理 } }, false); // ★ イベントをdocumentなど、あるいは「生成する要素の親」要素を発火要素にした上で、if文で要素を絞る
※他に僕が知る限りだと、MutationObserverというAPIでDOM監視することでも可能です。
qiita.com
終わりに
- jQueryは、JavaScriptと比べると、セレクタやメソッドがシンプルで直感的に記述できるので楽でした。そのため初学者にとって良いものだったと思えます。
- JavaScriptで置き換えてもそのまま使えるメソッドなどがあったり、割とすんなりJavaScriptに移行できた(無駄にはならなかった)ので、学んで来て良かったです。