VANNAN GAMES.

← 制作ノート一覧

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に入れる入れ子のまま残る(意図どおり動く)
つまり「JSで動いているから正しいHTML」ではありません。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に書き下ろしてみると、その甘さが全部出てきます。棚卸しとしては、なかなか良い手でした。