Skip to content

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 の章を読む
  • 犬の散歩
  • メール返信
create5
remove0
update0
合計5
差分更新
  • 牛乳を買う
  • 課題を提出する
  • React の章を読む
  • 犬の散歩
  • メール返信
create5
remove0
update0
合計5

左側の操作は全消去 + 全描き直しなので、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 を書き込んでおき、両者を突き合わせます。

js
// ----- 描画: 差分更新版 -----
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 つの仕事」 ​

差分更新を手で書く同一性判定dataset.id で突合並び順の維持after / prepend属性の差分検出等しいなら代入しない削除対象の抽出残ったものを removeハンドラ寿命作成時に 1 度だけ

全消去方式と比べて、確かにフォーカスもスクロールも保てます。ハンドラの登録回数も減ります。けれどコードは別物になりました。しかも、これは タスクの順序が変わらない単純ケース です。並び替えや途中への挿入が入ると、さらに分岐が増えていきます。

2.3.5 これでも不十分 ​

この実装にも、まだ次のような隙があります。

  • todos を書き換える側が render() を呼び忘れると、画面と状態がずれる
  • createTodoLi の中でハンドラを登録するとき、ハンドラが todo をクロージャで掴んでいるので、その後 todo.completed を書き換えても参照しているのは同じオブジェクト。ここは偶然うまくいっている面があります
  • 並び替え(ユーザーがタスクを上下に動かす)を実装するには、さらに before / after の判定を増やす必要がある

次章では、こうした「同期のずれ」が実際に生むバグを、最小例で見ていきます。React の差分計算(reconciliation)は、まさにこの章で手書きしたような処理を、開発者に書かせずに済ませるためのエンジン、と理解してもらえればちょうどよいです。

参照ソース ​