2.3 差分更新を手で書く — DOM 同期の実装
前章の 2.2 Vanilla JS 実装 は、状態が変わるたびに一覧を list.innerHTML = "" で全部消してから作り直していました。動くには動きます。けれど、いくつか実用上の問題が出てきます。この章では、その問題を具体的に洗い出してから、「差分だけを当てる」方針に書き換えます。コードが一気に面倒になる様子を、一緒に見ていきましょう。
2.3.1 全消去 + 全再描画の問題
フォーカスが失われる
タスク行に編集用のテキスト入力を足したとします(仕様外ですが、案件では珍しくありません)。ユーザーが入力の途中で別のタスクのチェックを入れると、render() が走り、編集中の input 要素は DOM から外れて新しい input に置き換わります。カーソルはその瞬間に消えます。「編集中だったのに…」というストレスの典型例です。
スクロール位置がずれる
一覧が長くなり、下の方を表示している状態で 1 件追加したとします。全消去して作り直すと、ブラウザはスクロール位置を最善の形で保とうとしますが、要素が全部入れ替わるため、意図しない位置に跳ぶことがあります。
イベントハンドラの再登録コストが無駄
li を 100 個作り直すと、addEventListener が 100 回呼ばれます。古い要素に付いていたハンドラはそのまま捨てられます。1 秒に何度もクリックする UI では、単純に無駄です。
DOM ノードの生成コスト
document.createElement と appendChild は軽い操作ですが、大量に呼び出すとレイアウト計算を引き起こします。innerHTML = "" も内部でノードを破棄するので、合わせて効いてきます。
図 2.3.1 操作ごとの DOM 操作回数(全消去 vs 差分更新)
下のデモで、同じ操作を「全消去 + 全描画」と「差分更新」の両方で走らせたときの DOM 操作回数を比べられます。チェックを入れる・1 件追加するといった操作をするたびに、左右のカウントがどう変わるか観察してみてください。
- 牛乳を買う
- 課題を提出する
- React の章を読む
- 犬の散歩
- メール返信
- 牛乳を買う
- 課題を提出する
- React の章を読む
- 犬の散歩
- メール返信
左側の操作は全消去 + 全描き直しなので、1 つ触るだけで全行の li が作り直されます。右側の差分更新は、 変わった 1 行だけに操作が閉じます(黄色く点滅した行が更新対象)。
同じ操作でも、左側(全消去方式)は li の数に比例して create と remove が積み上がっていきます。右側(差分更新方式)は、変わった 1 行だけに操作が閉じます。リストが 100 件なら差は 100 倍。体感として、「差分更新が必要だ」と感じるのは、だいたいこの辺りのリスト規模からです。
2.3.2 「差分更新」の発想
解決策は 1 つだけです。
既に画面にある
liと、新しいtodos配列を突き合わせて、変わった部分だけを直す。
具体的には、次のどれかを選んで適用します。
todosにあるが DOM にない → 対応するliを作って差し込む- DOM にあるが
todosにない → 対応するliを取り除く - 両方にあるが内容が違う(完了フラグやテキストが変わった)→ その
liの中を書き換える - 両方にあり内容も同じ → 何もしない
図 2.3.2 差分更新の判定フロー
文字で見ると短く感じますが、実装に落とすと行数が増えます。
2.3.3 実装 — id でひもづける
全消去方式の要だった render 関数を、差分更新方式に書き換えます。判定の軸には、タスクオブジェクトの id を使います。DOM 側には li.dataset.id で同じ id を書き込んでおき、両者を突き合わせます。
// ----- 描画: 差分更新版 -----
function render() {
// フィルタで絞り込み
const visible = todos.filter((t) => {
if (filter === "active") return !t.completed;
if (filter === "completed") return t.completed;
return true;
});
// 既存の li を id でインデックス化
const existingById = new Map();
for (const li of list.querySelectorAll("li")) {
existingById.set(Number(li.dataset.id), li);
}
// visible の順に並べ直しつつ、差分を当てる
let previousLi = null;
for (const todo of visible) {
let li = existingById.get(todo.id);
if (!li) {
li = createTodoLi(todo); // 新規作成(後述)
} else {
updateTodoLi(li, todo); // 中身の更新(後述)
existingById.delete(todo.id); // 「残った = 消す対象」から外す
}
// 並び順を保つため、前の li の直後に挿入
if (previousLi) {
previousLi.after(li);
} else {
list.prepend(li);
}
previousLi = li;
}
// existingById に残った = visible に無い = 削除対象
for (const li of existingById.values()) {
li.remove();
}
// フィルタボタンと件数(ここはシンプルなので直接更新で十分)
for (const btn of filterButtons) {
btn.setAttribute(
"aria-pressed",
btn.dataset.filter === filter ? "true" : "false",
);
}
const active = todos.filter((t) => !t.completed).length;
count.textContent = `未完了: ${active} 件`;
}
function createTodoLi(todo) {
const li = document.createElement("li");
li.dataset.id = String(todo.id);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.addEventListener("change", () => {
const target = todos.find((t) => t.id === todo.id);
if (target) target.completed = checkbox.checked;
render();
});
const span = document.createElement("span");
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);
updateTodoLi(li, todo);
return li;
}
function updateTodoLi(li, todo) {
const checkbox = li.querySelector('input[type="checkbox"]');
const span = li.querySelector("span");
if (checkbox.checked !== todo.completed) {
checkbox.checked = todo.completed;
}
if (span.textContent !== todo.text) {
span.textContent = todo.text;
}
li.classList.toggle("completed", todo.completed);
}render 本体と補助関数 2 つで合計 60 行を超えます。前章の全消去版は 20 行弱だったので、同じ仕様で書き換えただけで約 3 倍 に膨らんでいます。これが「差分更新を手で書く」コストです。
2.3.4 ここで追加した「手作業」
差分更新にするために、次の仕事を自分で引き受けることになりました。
| 仕事 | 何をしたか |
|---|---|
| ノードの同一性判定 | dataset.id と todos[i].id を突き合わせるためのマップを自分で作った |
| 並び順の維持 | previousLi.after(li) で 1 件ずつ位置合わせした |
| 属性の差分検出 | checkbox.checked と todo.completed が違うときだけ代入した(等しいのに代入しても副作用は少ないが、意図を残すため明示) |
| 削除対象の抽出 | マップから「使った id」を消していき、最後に残ったものを削除した |
| イベントハンドラの寿命 | 1 件作るときに 1 度だけ登録し、更新時は登録しない(二重登録を防ぐ) |
図 2.3.3 手書きした「5 つの仕事」
全消去方式と比べて、確かにフォーカスもスクロールも保てます。ハンドラの登録回数も減ります。けれどコードは別物になりました。しかも、これは タスクの順序が変わらない単純ケース です。並び替えや途中への挿入が入ると、さらに分岐が増えていきます。
2.3.5 これでも不十分
この実装にも、まだ次のような隙があります。
todosを書き換える側がrender()を呼び忘れると、画面と状態がずれるcreateTodoLiの中でハンドラを登録するとき、ハンドラがtodoをクロージャで掴んでいるので、その後todo.completedを書き換えても参照しているのは同じオブジェクト。ここは偶然うまくいっている面があります- 並び替え(ユーザーがタスクを上下に動かす)を実装するには、さらに
before/afterの判定を増やす必要がある
次章では、こうした「同期のずれ」が実際に生むバグを、最小例で見ていきます。React の差分計算(reconciliation)は、まさにこの章で手書きしたような処理を、開発者に書かせずに済ませるためのエンジン、と理解してもらえればちょうどよいです。