動的なリスト生成(例:Todoリストアプリ)

以下では、DOMを用いた「動的なリスト生成(Todoリストアプリ)」を題材に、実装の流れ・重要API・設計の考え方・発展要素までを体系的に解説します。最小構成のコード(HTML/CSS/JavaScript)も提示します。絵文字は使用しません。

1. ゴールと要件

  • 入力フォームからタスクを追加できる

  • 追加したタスクを一覧表示し、完了/未完了の切り替え、編集、削除ができる

  • 表示フィルタ(すべて/未完了/完了)を切り替えられる

  • ページ再読み込み後も保持(localStorage)

  • DOM操作は「パフォーマンス」と「可読性」を意識(イベント委任、DocumentFragment など)

  • 基本的なアクセシビリティ(ラベル、ロール、キーボード操作)

2. 最小実装(動くサンプル)

2.1 HTML

html
<!doctype html> <html lang="ja"> <head> <meta charset="utf-8" /> <title>Todo List (Vanilla JS)</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <style> body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; } form { display: flex; gap: .5rem; margin-bottom: 1rem; } input[type="text"] { flex: 1; padding: .5rem; } ul { list-style: none; padding-left: 0; } li { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: .5rem; padding: .5rem 0; border-bottom: 1px solid #eee; } li.completed .title { text-decoration: line-through; color: #777; } button { padding: .25rem .5rem; } .filters { display: flex; gap: .5rem; margin: .5rem 0 1rem; } .filters button[aria-pressed="true"] { font-weight: bold; text-decoration: underline; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } </style> </head> <body> <h1>Todoリスト</h1> <form id="todo-form" aria-label="新しいタスクを追加"> <label class="sr-only" for="todo-input">タスク名</label> <input id="todo-input" type="text" placeholder="タスクを入力して Enter" required /> <button type="submit">追加</button> </form> <div class="filters" role="toolbar" aria-label="表示フィルタ"> <button type="button" data-filter="all" aria-pressed="true">すべて</button> <button type="button" data-filter="active" aria-pressed="false">未完了</button> <button type="button" data-filter="completed" aria-pressed="false">完了</button> </div> <ul id="todo-list" role="list" aria-live="polite" aria-relevant="additions removals"> <!-- JSで項目を描画 --> </ul> <template id="todo-item-template"> <li> <input type="checkbox" class="toggle" aria-label="完了にする" /> <span class="title" contenteditable="false"></span> <button type="button" class="edit">編集</button> <button type="button" class="delete">削除</button> </li> </template> <script src="app.js"></script> </body> </html>

2.2 JavaScript(app.js)

javascript
// 状態と永続化 const STORAGE_KEY = "todos.v1"; const state = { todos: load(), filter: "all" // "all" | "active" | "completed" }; function load() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } function persist() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.todos)); } // DOM要素 const form = document.getElementById("todo-form"); const input = document.getElementById("todo-input"); const list = document.getElementById("todo-list"); const template = document.getElementById("todo-item-template"); const filterBar = document.querySelector(".filters"); // ユーティリティ const uid = () => Math.random().toString(36).slice(2, 10); const byFilter = todo => { if (state.filter === "active") return !todo.completed; if (state.filter === "completed") return todo.completed; return true; }; // 初期レンダリング render(); // 追加 form.addEventListener("submit", e => { e.preventDefault(); const title = input.value.trim(); if (!title) return; state.todos.push({ id: uid(), title, completed: false }); input.value = ""; persist(); render(); }); // イベント委任:チェック、編集、削除 list.addEventListener("click", e => { const li = e.target.closest("li"); if (!li) return; const id = li.dataset.id; if (e.target.classList.contains("delete")) { state.todos = state.todos.filter(t => t.id !== id); persist(); render(); } else if (e.target.classList.contains("edit")) { toggleEdit(li, id); } }); list.addEventListener("change", e => { if (e.target.classList.contains("toggle")) { const li = e.target.closest("li"); const id = li.dataset.id; const todo = state.todos.find(t => t.id === id); if (todo) { todo.completed = e.target.checked; persist(); // 完了状態は見た目だけ更新でもよいが、簡潔に全体再描画 render(); } } }); // 編集の確定(Enter)、キャンセル(Esc) list.addEventListener("keydown", e => { const li = e.target.closest("li"); if (!li) return; const id = li.dataset.id; const titleEl = li.querySelector(".title"); if (titleEl.isContentEditable) { if (e.key === "Enter") { e.preventDefault(); commitEdit(li, id); } else if (e.key === "Escape") { e.preventDefault(); cancelEdit(li, id); } } }); // フィルタ切替 filterBar.addEventListener("click", e => { const btn = e.target.closest("button[data-filter]"); if (!btn) return; state.filter = btn.dataset.filter; for (const b of filterBar.querySelectorAll("button[data-filter]")) { b.setAttribute("aria-pressed", String(b === btn)); } render(); }); // 描画 function render() { const frag = document.createDocumentFragment(); const todos = state.todos.filter(byFilter); for (const t of todos) { const node = template.content.firstElementChild.cloneNode(true); node.dataset.id = t.id; node.querySelector(".title").textContent = t.title; node.querySelector(".toggle").checked = t.completed; if (t.completed) node.classList.add("completed"); frag.appendChild(node); } // 差し替えでReflowを最小化 list.replaceChildren(frag); // 件数のアナウンスが必要なら aria-live にテキストノードを追加してもよい } function toggleEdit(li, id) { const titleEl = li.querySelector(".title"); const editing = titleEl.isContentEditable; if (!editing) { titleEl.contentEditable = "true"; titleEl.focus(); // 末尾にキャレットを移動 const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(titleEl); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); } else { commitEdit(li, id); } } function commitEdit(li, id) { const titleEl = li.querySelector(".title"); const newTitle = titleEl.textContent.trim(); if (!newTitle) { // 空にされたら削除と見なす state.todos = state.todos.filter(t => t.id !== id); } else { const todo = state.todos.find(t => t.id === id); if (todo) todo.title = newTitle; } titleEl.contentEditable = "false"; persist(); render(); } function cancelEdit(li, id) { const titleEl = li.querySelector(".title"); const todo = state.todos.find(t => t.id === id); titleEl.textContent = todo ? todo.title : ""; titleEl.contentEditable = "false"; }

3. 実装で押さえるDOM APIとパターン

  1. 要素生成と差し替え

    • document.createElement()template要素、cloneNode(true)

    • 一括差し替えに DocumentFragmentElement.replaceChildren() を用いると、Reflow回数を抑えやすい。

  2. イベント委任

    • ul に一度だけリスナーを付け、event.target.closest('...') で目的のボタンやチェックボックスを判定。項目数が増えてもリスナーが増えず高効率。

  3. 状態とUIの単方向反映

    • ソースオブトゥルースを JavaScript の state.todos に集約し、render() で DOM を生成して反映。部分更新よりもバグを避けやすい。

  4. 永続化

    • localStorage で直列化し、起動時に読み出す。例では JSON.stringify() / JSON.parse() を使用。

  5. アクセシビリティ

    • 入力に label、リストに role="list"、ライブ領域 aria-live。ツールバー風のフィルタに aria-pressed を使用。編集は contenteditable でキーボード確定・キャンセルを対応。

4. パフォーマンス上の注意

  • 項目数が多い場合、描画は都度フル再描画でも DocumentFragment によるバッチ更新で十分高速。さらに必要なら差分パッチ(キー付きで比較)を検討。

  • スタイル計算・レイアウトの強制同期を避けるため、読みと書きを分離し、ループ内で offsetWidth などレイアウト情報を繰り返し参照しない。

  • 入力時に連打されるイベントにはデバウンスやスロットリングを検討。

5. セキュリティ上の注意

  • ユーザー入力は textContent を使って挿入し、innerHTML で直接注入しない。テンプレートでも textContent を徹底して XSS を防ぐ。

  • contenteditable 使用時は貼り付けでHTMLが入らないよう、確定時に .textContent をトリムして保存。

6. テスト観点

  • 追加、削除、完了切替、編集確定・キャンセル、フィルタ切替の単体テスト

  • localStorage への保存・復元テスト(モック化)

  • アクセシビリティの静的検査(role、aria-属性の有無)

7. よくある拡張

  • 期日・優先度・タグ付けと並べ替え

  • 一括操作(すべて完了、完了の一括削除)

  • Drag & Drop による並び替え(HTML5 DnDやPointer Events)

  • サーバ同期(REST/GraphQL)とオフライン対応(IndexedDB、Service Worker)

  • 仮想スクロール(大量データ向け)

8. まとめ

動的なリスト生成は、DOMの基礎操作(生成・挿入・イベント・属性操作)を一通り実践できます。上記のサンプルは、イベント委任とフラグメントを使った効率的な再描画、localStorage による永続化、textContent による安全な描画、簡単なアクセシビリティといった実務的なベストプラクティスを含んでいます。必要に応じて拡張機能を重ねつつ、状態を一元管理して UI をレンダリングする設計を維持すると保守性が高まります。

ChatGPT5 生成日:2025/09/11