JSでは動くのにHTMLに書くと壊れる——リンクの入れ子とパーサの話
トップページのゲーム一覧を、JavaScriptで組み立てる方式からHTMLに直接書く方式へ移しました。作業自体は単純なはずでした。JSが作っている文字列を、そのままHTMLに書き写すだけです。
書き写そうとして、手が止まりました。
カードの中に、リンクが入れ子になっていた
ゲームのカードは、カード全体が「遊ぶ」へのリンクで、その中に「遊び方」「作品紹介」への小さなリンクが入る作りでした。JS側のコードはこうです。
const a = document.createElement("a"); // カード全体=リンク
a.href = g.play;
a.innerHTML = `
<div class="banner">…</div>
<div class="g-links">
<a href="${g.howto}">遊び方</a> <!-- ← aの中にa -->
</div>`;
リンクの中にリンクがあります。HTMLの仕様では認められていない書き方です。それでも画面上は何年も正常に動いていました。
同じ文字列が、経路によって別のDOMになる
なぜ動いていたのか。ここが面白いところで、HTMLの解析には2つの経路があり、この件では挙動が違います。
ページを読み込むときの通常の解析では、パーサは「いま開いているリンクの中に新しいリンクが来た」と判断すると、先に開いているほうを閉じてから新しいリンクを開きます。結果、入れ子は解消され、リンクが横に並んだ別の構造に組み替えられます。カード全体をリンクにしていたつもりが、バラバラになるわけです。
一方 innerHTML に文字列を入れる場合は、その要素を文脈とした断片の解析になります。このとき、外側のリンクは「開いている書式要素」のリストに入っていません。だから内側のリンクは組み替えの対象にならず、入れ子のままDOMに残ります。
| 経路 | 結果 |
|---|---|
| HTMLに直接書く | 入れ子が解消され、構造が組み替えられる |
| innerHTMLに入れる | 入れ子のまま残る(意図どおり動く) |
innerHTML は仕様違反を通してしまう入口で、そこで書いたマークアップをHTMLへ移すと表に出てきます。JS化する方向では絶対に気づけず、静的化する方向でだけ露見する種類のバグです。直し方は、リンクを入れ子にしないこと
カード全体をdivにして、「バナーとタイトルを包む本体リンク」と「その下に並べる補助リンク」に分けました。見た目は同じで、DOMからは入れ子が消えます。
<div class="game-card">
<a class="g-hit" href="/games/…/">バナー・タイトル・説明</a>
<div class="g-links">
<a href="/games/…/">プレイする</a>
<a href="/howto/…/">遊び方</a>
<a href="/…/">作品紹介</a>
</div>
</div>
CSSでカード全体にかけていたホバーの浮き上がりはdivのままでも効きますし、フォーカスの枠は「中のどれかにフォーカスが当たったら」という指定に変えれば同じ見た目になります。
兆候は前から出ていた
直したあとで気づいたのですが、壊れている合図は最初から書いてありました。内側のリンクにevent.stopPropagation()が付いていたのです。
あれは「内側を押したのに外側のリンクにも反応されてしまう」を抑え込むための処置でした。つまり入れ子が引き起こす不都合を、その場しのぎで押さえていたわけです。構造の問題をイベントで殴っているコードは、たいてい構造のほうが間違っています。
ついでに、リンクの入れ子は支援技術にも優しくありません。キーボードで辿ったときの順序も、読み上げたときの境界も曖昧になります。仕様違反を直したら、そちらも自然に片づきました。
教訓
今回の移行の目的は、検索エンジンからページの中身が見えるようにすることでした。JSが実行される前のHTMLはほとんど空だったので、そこを埋めたかった。マークアップの不正はまったく想定していませんでした。
結果として、静的化は「JSに隠れていたものを表に出す作業」でもあったと思っています。動的に組み立てている限り、多少おかしなHTMLでも動いてしまいます。一度HTMLに書き下ろしてみると、その甘さが全部出てきます。棚卸しとしては、なかなか良い手でした。