2.2 Vanilla JS 実装 — 最初のバージョン
前章で決めた仕様に従って、ToDo リストを素の JavaScript で実装します。この章では、まず動くことを最優先にした、もっとも単純な方式で書きます。方針は 1 つだけです。
画面を更新するときは、タスク一覧をいったん全部消してから、状態に従って全部描き直す。
いわゆる「差分」を一切考えない、素朴なやり方です。問題点は次章で扱います。ここでは、まず動くものを見ていきましょう。
2.2.1 使う DOM API のおさらい
この章で使う API は 4 つだけです。いずれも MDN に詳しい説明があります。
| API | 役割 |
|---|---|
document.createElement(tagName) | 新しい要素を作って返す。DOM にはまだ入っていない |
element.appendChild(child) | ある要素の末尾に別の要素を子として差し込む |
element.addEventListener(type, listener) | 要素にイベントハンドラを登録する |
element.textContent = "..." | 要素のテキストを安全にセットする |
document.createElement は要素を作って返すだけで、画面には出ません。appendChild で親の中に入れるまで表示されない、という点に注意しておいてください。
2.2.2 完成コード
index.html 1 ファイルに以下を貼り付けて、ブラウザで開くと動きます。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>Vanilla ToDo — v1</title>
<style>
body { font-family: sans-serif; max-width: 480px; margin: 2rem auto; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 0.5rem; align-items: center; padding: 0.25rem 0; }
li.completed span { text-decoration: line-through; color: #888; }
.filters button[aria-pressed="true"] { font-weight: bold; }
</style>
</head>
<body>
<h1>ToDo</h1>
<form id="form">
<input id="input" type="text" placeholder="タスクを入力" />
<button type="submit">追加</button>
</form>
<ul id="list"></ul>
<div class="filters">
<button data-filter="all">all</button>
<button data-filter="active">active</button>
<button data-filter="completed">completed</button>
</div>
<p id="count"></p>
<script>
// ----- 状態 -----
let todos = [];
let filter = "all";
let nextId = 1;
// ----- 要素参照 -----
const form = document.querySelector("#form");
const input = document.querySelector("#input");
const list = document.querySelector("#list");
const count = document.querySelector("#count");
const filterButtons = document.querySelectorAll(".filters button");
// ----- イベント -----
form.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
todos.push({ id: nextId++, text, completed: false });
input.value = "";
render();
});
filterButtons.forEach((btn) => {
btn.addEventListener("click", () => {
filter = btn.dataset.filter;
render();
});
});
// ----- 描画 -----
function render() {
// タスク一覧をいったん全消去する
list.innerHTML = "";
// フィルタで絞り込む
const visible = todos.filter((t) => {
if (filter === "active") return !t.completed;
if (filter === "completed") return t.completed;
return true;
});
// 全タスクを作り直す
for (const todo of visible) {
const li = document.createElement("li");
if (todo.completed) li.classList.add("completed");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = todo.completed;
checkbox.addEventListener("change", () => {
todo.completed = checkbox.checked;
render();
});
const span = document.createElement("span");
span.textContent = todo.text;
const del = document.createElement("button");
del.textContent = "x";
del.addEventListener("click", () => {
todos = todos.filter((t) => t.id !== todo.id);
render();
});
li.appendChild(checkbox);
li.appendChild(span);
li.appendChild(del);
list.appendChild(li);
}
// フィルタボタンの押下状態
filterButtons.forEach((btn) => {
btn.setAttribute(
"aria-pressed",
btn.dataset.filter === filter ? "true" : "false",
);
});
// 件数
const active = todos.filter((t) => !t.completed).length;
count.textContent = `未完了: ${active} 件`;
}
render();
</script>
</body>
</html>2.2.3 ページの中で触ってみる
このページに、同じ挙動のデモを埋め込んでおきます。Vue で作ってありますが、画面を更新するたびに全消去 + 全描画 という Vanilla JS 版と同じ振る舞いをしています。
右側のパネルで、「render() が何回呼ばれたか」「直前のレンダーで createElement(li) が何回走ったか」が見えるようにしています。チェックを 1 つ入れるだけでも、見えている行の分だけ DOM ノードを作り直しているのが分かると思います。
ToDo(Vanilla JS と同じ挙動)
- 牛乳を買う
- 課題を提出する
- React の章を読む
未完了: 2 件
どんな操作をしても、画面を更新するたびに list.innerHTML = "" で全消去し、全ての <li> を作り直します。チェックを 1 つ入れただけでも、 見えている行の数ぶんだけ DOM ノードを作り直すのがこの実装の特徴です。
触ってみると、チェック 1 つ入れたときに「リスト全部が再生成される」という違和感が見えてくるはずです。ここで感じた違和感は、次章で「差分更新」に書き換える動機になります。
2.2.4 何が起きているか
処理の流れを時系列で見ると、こうなります。
図 2.2.1 ユーザー操作から画面更新までのフロー
ここで 一番の肝 は、「画面を更新する唯一の手段が render() を呼ぶこと」です。状態(todos と filter)を書き換えたら、必ず最後に render() を呼ぶ。呼び忘れると、状態は変わっているのに画面が古いまま、という不整合が起きます。
2.2.5 「全消去して全描き直し」の素朴さ
render() の先頭で list.innerHTML = "" と書いて、中身を空文字列で上書きしています。この 1 行で、それまで作ったタスク行はすべて破棄されます。
- 既存の
li要素は DOM から外れる - 各行に付けたチェックボックスの
changeハンドラや削除ボタンのclickハンドラも、要素ごと捨てられる - その後、全タスクを
createElementで 1 からまた作り直す
図 2.2.2 全消去 + 全描画のイメージ
「古い DOM と新しい DOM を比べて、変わった箇所だけ直す」という発想は、ここには一切ありません。常に全部作り直します。
この方式にも、実は利点があります。状態 → DOM の対応づけは render 関数 1 つに書けば済む ので、「どこで何を更新したっけ」という迷いがなく、状態と DOM がずれにくいのです。React の考え方とも、この一点だけは似ています。
2.2.6 試すときのチェックポイント
上の HTML を貼って開いたら、次の操作で期待通りに動くか確認してみてください。
- タスクを追加すると一覧に増える
- チェックボックスを入れると取り消し線が付く
- 削除ボタンを押すと消える
- フィルタを切り替えると、表示が絞られる
- 未完了件数が操作に追従する
動作自体は仕様通りになるはずです。ただ、もう少し大きなリストで試したり、入力中に追加が発生したりすると、この素朴な実装には無視できない問題が見えてきます。次章でその問題を引き出し、「差分更新を手で書く」方へ踏み込みましょう。