innerHTML で DOM を作り直すとクリックが効かない原因と対処
一定間隔で innerHTML から UI を作り直していると、ボタンのクリックが時々効かなくなります。原因は、mousedown と mouseup の間に要素が置き換わると click が発火しないことです。
ブラウザで動く放置ゲームを作っていて、この構造を書いていました。画面としては動いていたのですが、実装を終えてコードを読み直している時に気づいたものです。この記事では、クリックが取りこぼされる理由と、既存の要素を更新する形に直した手順をまとめます。放置ゲームに限らず、定期的に表示を更新する画面を素の DOM で組んでいる場合に同じことが起きます。
症状: 0.1 秒ごとに作り直すボタンがクリックを取りこぼす
画面の下部には、コインを払って能力を上げていくボタンが並んでいます。所持コインが足りているかどうかで押せる状態が変わるため、表示をこまめに更新したい部分でした。描画ループの中にタイマーを持たせ、0.1 秒ごと、つまり 1 秒間に 10 回、画面を更新する関数を呼んでいます。
let uiTimer = 0;
startLoop((dt) => {
time += dt;
handleEvents(sim.update(dt, computeStats(save.levels)));
updateParticles(particles, dt);
drawScene(ctx, sim.cat, particles, time);
uiTimer += dt;
if (uiTimer >= 0.1) {
uiTimer = 0;
renderUi();
}
});
その renderUi() の中身が問題でした。アップグレード一覧をテンプレート文字列で組み立て、innerHTML に代入し直して、そのあとに生成されたボタンへクリックのハンドラを付ける形になっていました。表示を作る処理と更新する処理が 1 つにまとまっていて、呼ぶたびにボタンが新品に入れ替わります。
function renderUi(): void {
upgradeList.innerHTML = UPGRADES.map((def) => {
const level = save.levels[def.id];
const cost = upgradeCost(upgradeDef(def.id), level);
const maxed = level >= def.maxLevel;
return `
<li>
<button type="button" class="upgrade" data-id="${def.id}" ${maxed || save.coins < cost ? 'disabled' : ''}>
<span class="u-title">${def.name} Lv.${level}</span>
<span class="u-desc">${def.desc}</span>
<span class="u-cost">${maxed ? 'MAX' : `${formatNumber(cost)} コイン`}</span>
</button>
</li>`;
}).join('');
for (const btn of upgradeList.querySelectorAll<HTMLButtonElement>('button')) {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
if (id && tryBuy(save, id)) persist();
renderUi();
});
}
}
再現の条件は単純で、ボタンを押し下げてから離すまでの間に更新が 1 回でも挟まることです。人がボタンを押して離すまでの時間は 0.1 秒を超えることが珍しくないので、押し方やタイミングによって、効く時と効かない時が出てきます。
この不具合がやっかいなのは、失敗した時に何も起きないという点です。アップグレードは所持コインが足りないと押せない仕様なので、押しても反応しなかった時に「コインが足りなかったのだろう」と受け取られやすいです。
見つけたきっかけは、実装後にコードを読み直して更新頻度に目が留まったことでした。確率で起きる不具合は、動かして探すより、頻度と寿命をコードから読むほうが早く見つかることがあります。
原因: click は押し下げと離す操作を同じ要素で受けた時だけ発火する
click は、押し下げと離す操作が別々に届いた結果として組み立てられるイベントです。ブラウザは mousedown を受けた要素と mouseup を受けた要素を見て、両方を含むもっとも近い祖先要素に対して click を発火します。同じボタンの上で押して離せば、その共通の祖先はボタン自身になるので、ボタンに付けたハンドラが動きます。
ここで innerHTML に代入し直すと、中にあった要素は文書から取り除かれ、見た目が同じだけの別の要素に置き換わります。元のボタンは JavaScript のオブジェクトとしても別物で、付けていたイベントリスナーも一緒に失われます。

押し下げを受けたのは取り除かれた古いボタン、離す操作を受けるのは新しく作られたボタンです。両方を含む共通の祖先はボタンより上の階層になるので、ボタンに付けたハンドラまで click が届かないです。
1 秒間に 10 回も作り直していれば、押している間に置き換えが挟まる確率は十分に高くなります。更新の間隔を長くすれば確率は下がりますが、押し下げから離すまでの時間とタイマーが重なる可能性は残るので、頻度の調整では直らない種類の問題です。
pointerdown や touchstart のように押し下げの時点で発火するイベントを使えば個別には拾えますが、キーボード操作やアクセシビリティの扱いを自前で補う必要が出てきます。素直なのは、要素を置き換えないようにすることです。
https://developer.mozilla.org/ja/docs/Web/API/Element/click_event
対処: 要素は 1 回だけ作り、更新はテキストと disabled に限る
直し方は、生成と更新を分けることです。変わるのは次の 4 点です。
| 修正前 | 修正後 | |
|---|---|---|
| ボタンを作る回数 | 更新のたび | 起動時に 1 回 |
| 更新で書き換える対象 | 一覧全体の HTML | テキストと disabled |
| イベントリスナー | 作り直すたびに付け直す | 起動時に 1 回 |
| クリックの取りこぼし | 起きる | 起きない |
起動時にボタンを 1 回だけ作ってイベントリスナーを付け、あとから書き換える部分の要素を Map に覚えておきます。こうしておけば、更新のたびにボタンそのものには手を触れないです。
const upgradeList = $<HTMLUListElement>('upgrades');
interface UpgradeView {
btn: HTMLButtonElement;
title: HTMLSpanElement;
cost: HTMLSpanElement;
}
const views = new Map<string, UpgradeView>();
for (const def of UPGRADES) {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'upgrade';
const title = document.createElement('span');
title.className = 'u-title';
const desc = document.createElement('span');
desc.className = 'u-desc';
desc.textContent = def.desc;
const cost = document.createElement('span');
cost.className = 'u-cost';
btn.append(title, desc, cost);
btn.addEventListener('click', () => {
if (tryBuy(save, def.id)) persist();
renderUi();
});
li.appendChild(btn);
upgradeList.appendChild(li);
views.set(def.id, { btn, title, cost });
}
更新側では、覚えておいた要素のテキストと disabled だけを書き換えます。テキストの代入には、値が変わった時だけ代入する小さな関数を挟みました。同じ文字列を入れ直すだけでも、textContent への代入はテキストノードの差し替えになるので、変化がない時は何もしないほうが無駄が少ないです。
function setText(el: HTMLElement, text: string): void {
if (el.textContent !== text) el.textContent = text;
}
あとは更新のたびに、必要な値だけを流し込みます。1 秒間に 10 回呼ばれるのは変わらないですが、ボタン自身は起動時のものがそのまま残り続けるため、押し下げと離す操作は同じ要素が受け取れます。
function renderUi(): void {
const s = computeStats(save.levels);
setText($('coins'), formatNumber(save.coins));
setText($('stat-pet'), formatNumber(s.coinPerPet));
for (const def of UPGRADES) {
const v = views.get(def.id);
if (!v) continue;
const level = save.levels[def.id];
const maxed = level >= def.maxLevel;
const cost = upgradeCost(upgradeDef(def.id), level);
v.btn.disabled = maxed || save.coins < cost;
setText(v.title, `${def.name} Lv.${level}`);
setText(v.cost, maxed ? 'MAX' : `${formatNumber(cost)} コイン`);
}
}
書き換える対象が増えると Map に入れる参照も増えるため、一見すると手間が増えたように見えます。ただ、更新のたびに文字列を組み立てて解析させる処理がなくなるので、処理量としてはむしろ軽くなります。
定期更新する画面ではどこでも起きる
同じ問題は、放置ゲームに限らず、一定間隔で表示を入れ替える画面すべてで起きます。典型的なのは次のような画面です。
- 数秒ごとに最新の数値を取り直すダッシュボード
- 在庫や受注の一覧を自動更新する管理画面
- 通知やチャットのように、新しい行が随時差し込まれるリスト
行の中に操作ボタンやリンクが入っていれば、更新と操作が重なったぶんだけ取りこぼしが発生します。
失われるのはクリックだけではないです。作り直した範囲に含まれていたものは、まとめて初期状態に戻ります。
| 失われるもの | 画面上の見え方 |
|---|---|
| クリックの受け取り | 押しても反応しない |
| 入力中の値とカーソル位置 | 入力していた文字が消える |
| フォーカス | キーボード操作の位置を見失う |
| 開閉できる領域の状態 | 開いていた項目が閉じる |
| 選択していたテキスト | 選択が解除される |
| スクロール位置 | 先頭に戻る |
自動更新する画面で「入力していたら勝手に消えた」という挙動に心当たりがあるなら、作り直しが原因である可能性が高いです。
React や Vue のような枠組みを使っている場合、この手当ては内部で行われていて、変化した部分だけを実際の DOM に反映する作りになっています。素の DOM を直接触る場合も、考え方は同じで問題ないです。要素の生成は 1 回、更新は値の反映だけ、という分け方にしておけば、自分で同じ性質を確保できます。
再発を防ぐ
レビューでは、繰り返し呼ばれる関数の中に要素を生成する処理が混ざっていないかを見ます。次のものが更新ループやタイマーの中に現れたら、立ち止まって確認する価値があります。
innerHTMLやouterHTMLへの代入createElementとappendChildの組み合わせreplaceChildrenによる入れ替え
逆に、関数名が描画や更新を表していても、やっていることが値の代入だけなら安全です。
初回の実装では、その関数が 1 秒間に何回呼ばれるかという前提が抜けていました。処理そのものが正しく書けているかどうかとは別に、呼ばれる頻度と要素の寿命を追いかけてみると、こうした取りこぼしに気づきやすくなります。
設計の段階で防ぐなら、初期化と更新を最初から別の関数に分けておくのがよいでしょう。要素を作る処理が初期化側にしかなければ、更新のたびに作り直すコードは書きにくくなります。あわせて、更新の対象になる要素は Map やオブジェクトに保持し、更新側では getElementById のような探索もしない形にしておくと、どの要素が書き換わるのかがコードから読み取りやすくなります。
テストで拾うのも不可能ではないです。押し下げと離す操作の間に更新を 1 回挟み、クリックのハンドラが呼ばれるかを検証するテストを書けば、同じ構造に戻った時に落ちてくれます。ただ、確率的な不具合をテストで網羅するのは手間がかかるので、まずはレビューの観点として共有しておくほうが現実的だと思います。
まとめ
一定間隔で innerHTML から UI を作り直すと、押し下げと離す操作の間に要素が置き換わり、クリックが取りこぼされます。今回はコードを読み直した段階で気づけましたが、症状が出ても気づかれにくい種類の不具合でした。同じ構造を書いてしまわないために、次の点を押さえておくとよいでしょう。
- クリックは押し下げと離す操作を同じ要素で受け取った時に成立するため、その間に要素が入れ替わると反応しなくなる
- 定期的に更新する画面では、要素を作り直すのではなく、起動時に作った要素のテキストや有効無効の状態だけを書き換える
- 繰り返し呼ばれる関数の中で要素を生成していないかを、レビューの観点として持っておく