2.4 状態と DOM の同期バグ — 何が起きるか
前章で、全消去方式よりマシな差分更新を手書きしました。コード量は増え、気をつけるべき点もずいぶん増えました。この章では、Vanilla JS で UI を書くときに 実際に起きる同期バグ を 4 種類、最小例で見ていきます。どれも「状態は正しいのに画面が嘘をつく」か、「画面の操作が状態に正しく届かない」の形をとります。
この章が、Part 2 で一番「痛い」ところかもしれません。ただ、ここで感じる痛みは Part 3 以降で React がどれだけ肩代わりしてくれるかを測るための物差しになります。
2.4.1 バグ 1 — 状態だけ書き換えて画面更新を忘れる
一番よくあるバグです。たとえば「全タスクを完了にする」ボタンを足したとします。
// 全完了ボタン(バグあり)
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 状態だけ書き換えて画面更新を忘れる
- ☐ 牛乳を買う
- ☐ 課題を提出する
- ☐ React を読む
- ☐ 牛乳を買う
- ☐ 課題を提出する
- ☐ React を読む
Bug 2 更新のたびに addEventListener を重ね掛け
再描画するたびに、updateTodoLi 内の addEventListener が呼ばれ、匿名関数は毎回別参照なので ハンドラが重なっていきます。1 度のクリックで 登録されているハンドラ数ぶん処理が走ります。
触ってみると、「状態と DOM が別々に管理されている」ことがいかに危ういか、見えてくると思います。
2.4.3 バグ 2 — イベントハンドラの二重登録
差分更新で「既存の li は使い回す、新規の li だけハンドラを登録する」という方針にしたのは、二重登録を避けるためでした。これを誤ると、次のような書き方で二重登録が発生します。
// 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 に残っている変数をクロージャで掴んでいる
たとえば次のようなコードは、一見まともに見えます。
// 全 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 から外しても、JS 側で参照が残っていれば解放されません。寿命管理を自分で正確にやらない限り、避けにくいバグです。
2.4.5 バグ 4 — innerHTML 経由の XSS
素朴な実装では、つい li.innerHTML = \${todo.text}`` のように書きたくなります。短いからです。しかしこの書き方は、ユーザーが入力したテキストをそのまま HTML として解釈するため、入力に次のような文字列を入れられると、スクリプトが実行されます(クロスサイトスクリプティング、XSS)。
<img src=x onerror=alert(1)>// 危険な書き方(やってはいけない)
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 のどの機能に置き換わるかを先取りしましょう。