Skip to content

2.4 状態と DOM の同期バグ — 何が起きるか ​

前章で、全消去方式よりマシな差分更新を手書きしました。コード量は増え、気をつけるべき点もずいぶん増えました。この章では、Vanilla JS で UI を書くときに 実際に起きる同期バグ を 4 種類、最小例で見ていきます。どれも「状態は正しいのに画面が嘘をつく」か、「画面の操作が状態に正しく届かない」の形をとります。

この章が、Part 2 で一番「痛い」ところかもしれません。ただ、ここで感じる痛みは Part 3 以降で React がどれだけ肩代わりしてくれるかを測るための物差しになります。

2.4.1 バグ 1 — 状態だけ書き換えて画面更新を忘れる ​

一番よくあるバグです。たとえば「全タスクを完了にする」ボタンを足したとします。

js
// 全完了ボタン(バグあり)
document.querySelector("#complete-all").addEventListener("click", () => {
  todos = todos.map((t) => ({ ...t, completed: true }));
  // render() を呼び忘れている
});

配列は確かに書き換わっています。しかし画面は何も変わりません。ユーザーはボタンを押しても「壊れている」と感じます。

図 2.4.1 状態と DOM の乖離 ​

原因: 状態更新と DOM 更新が別々の手順になっているので、片方を忘れると矛盾する。対処として「状態を変えた直後に必ず render() を呼ぶ」というルールを作っても、ハンドラが増えるたびに忘却リスクがついて回ります。

2.4.2 実際に「ずれる」様子を触ってみる ​

下のデモは、Bug 1(render 呼び忘れ)と Bug 2(ハンドラ二重登録)を実際に再現できるようにしたものです。

  • Bug 1: 「render 呼び忘れ版」ボタンを押すと、状態(左)は全件完了になりますが、画面(右)は古いまま残ります。両者がずれたことが警告バナーで分かるようになっています
  • Bug 2: 「再描画をトリガ」ボタンを何度か押してから「チェックボックスをクリック」を押すと、登録されたハンドラ数だけ呼び出しが走り、連鎖でさらにハンドラが増えていく様子を観察できます

Bug 1 状態だけ書き換えて画面更新を忘れる

状態(todos 配列)
  • ☐ 牛乳を買う
  • ☐ 課題を提出する
  • ☐ React を読む
画面(DOM)
  • ☐ 牛乳を買う
  • ☐ 課題を提出する
  • ☐ React を読む
状態と画面は一致しています

Bug 2 更新のたびに addEventListener を重ね掛け

render() 回数
1
登録されたハンドラ数
1
累計ハンドラ呼び出し
0

再描画するたびに、updateTodoLi 内の addEventListener が呼ばれ、匿名関数は毎回別参照なので ハンドラが重なっていきます。1 度のクリックで 登録されているハンドラ数ぶん処理が走ります。

触ってみると、「状態と DOM が別々に管理されている」ことがいかに危ういか、見えてくると思います。

2.4.3 バグ 2 — イベントハンドラの二重登録 ​

差分更新で「既存の li は使い回す、新規の li だけハンドラを登録する」という方針にしたのは、二重登録を避けるためでした。これを誤ると、次のような書き方で二重登録が発生します。

js
// updateTodoLi に誤って addEventListener を入れてしまった例
function updateTodoLi(li, todo) {
  const checkbox = li.querySelector('input[type="checkbox"]');
  checkbox.checked = todo.completed;

  // バグ: 更新のたびにハンドラを付け足している
  checkbox.addEventListener("change", () => {
    todo.completed = checkbox.checked;
    render();
  });
}

addEventListener の仕様 では、同じ関数参照はリストに 2 度目は追加されない(MDN の原文は "If the function or object is already in the list of event listeners for this target, the function or object is not added a second time.")と定められています。ところが上のコードは毎回新しいアロー関数を渡しており、関数参照としては毎回別物です。MDN も「同じソースコードの匿名関数であっても、呼ばれるたびに別物として扱われる」と注記しています。結果、render() が走るたびにハンドラが 1 つずつ積み重なります。

  • 1 回描画後: 1 個
  • 10 回描画後: 10 個
  • 100 回描画後: 100 個

チェックボックスを 1 回クリックすると、ハンドラが 100 回呼ばれ、render() が 100 回走り、さらにハンドラが積まれて 200 個になります。UI が重くなり、スタックオーバーフローで落ちることもあります。

原因: アロー関数を毎回作るので重複防止が効かない。addEventListener が同じリスナーを 2 度追加しないのは「同じ関数参照であること」が条件で、匿名関数は毎回別参照になるためこの判定をすり抜けます。

2.4.4 バグ 3 — 削除した要素のイベントや参照が残る ​

削除ボタンで li.remove() を呼んで DOM から外しても、次の条件のいずれかが成立すると、その要素は JavaScript のヒープに残り続けます。

  • どこかの変数や配列にその要素への参照が保持されている
  • そこに付いたイベントハンドラが、別の DOM に残っている変数をクロージャで掴んでいる

たとえば次のようなコードは、一見まともに見えます。

js
// 全 li を「最近クリックされた順」で保存したい(バグの種)
const recentlyClicked = [];

function createTodoLi(todo) {
  const li = document.createElement("li");
  li.addEventListener("click", () => {
    recentlyClicked.push(li); // li 自体を配列に入れている
  });
  return li;
}

ユーザーが li をクリックしたあと、その todo を削除しても、recentlyClicked 配列が li を掴んだままです。li は画面から消えても GC されず、li が保持するイベントハンドラも生き続けます。大量に繰り返せばメモリが増え続けます。これを デタッチされた DOM ノードのリーク と呼びます。

図 2.4.2 デタッチされた DOM ノードのリーク ​

DOM ツリーli #1li #2li #3 は remove() で外れたJS ヒープrecentlyClicked: [ li#3 ]li #3 (デタッチ)画面には無いが解放されない参照remove() しても…配列が離さないと GC されない

原因: DOM から外しても、JS 側で参照が残っていれば解放されません。寿命管理を自分で正確にやらない限り、避けにくいバグです。

2.4.5 バグ 4 — innerHTML 経由の XSS ​

素朴な実装では、つい li.innerHTML = \${todo.text}`` のように書きたくなります。短いからです。しかしこの書き方は、ユーザーが入力したテキストをそのまま HTML として解釈するため、入力に次のような文字列を入れられると、スクリプトが実行されます(クロスサイトスクリプティング、XSS)。

html
<img src=x onerror=alert(1)>
js
// 危険な書き方(やってはいけない)
function updateTodoLi(li, todo) {
  li.innerHTML = `<input type="checkbox"/><span>${todo.text}</span>`;
}

MDN の innerHTML ドキュメントは、このプロパティを「XSS の最も一般的な入り口(the most common vector for cross-site scripting)」と明記しています。文字列連結で HTML を組み立てる書き方は、信頼できない入力を 1 度でも通す設計になった瞬間に脆弱になります。

安全側の書き方は次のどちらかです。

  • element.textContent = todo.text;(HTML として解釈させない)
  • 個別の要素を createElement で作り、.textContent に代入する

前章までのコードは span.textContent = todo.text を使っているので、この観点では安全です。しかし innerHTML で置き換える「楽な書き方」に戻った瞬間、同じ問題が戻ってきます。

図 2.4.3 4 つのバグの発生源まとめ ​

2.4.6 共通する原因 ​

4 つのバグには共通点があります。

状態と DOM が別々のデータ構造として存在し、その同期を人間が 1 か所ずつ書いていること。

「状態が真のソースだ」と決めても、DOM を書き換えるのは結局 createElement や innerHTML を呼ぶ命令的な 1 行ずつです。状態が 100 箇所で書き換わるなら、対応する DOM 操作も 100 箇所で書かれます。その全てが正しく同期するよう、人間が保証しなくてはいけない。これはしんどい仕事です。

Part 3 以降で React の記法を見ると、この構造が反転します。状態を書き換えたら、React が「描く」側を全部引き受ける。開発者が書くのは 「状態」と「状態から UI への関数」だけ で、DOM 更新の一手一手は書きません。次章で、この Part で手書きした仕事の一覧を整理し、それぞれが React のどの機能に置き換わるかを先取りしましょう。

参照ソース ​