🆔

フロントエンドid属性管理戦略

2022/06/13に公開

アクセシビリティのチェックなどを行っているとよく発見される問題にIDの重複がある。HTMLではid属性はグローバル属性でありすべての要素に指定できるが、その値は一意である必要があり、ドキュメント内において重複があってはならないことになっている

ただ実際に実装してみたり開発経験のある人ならご存知のとおり、滅多なことでこの重複が問題になることは少ない。HTMLのパースは中断することなくブラウザは要素を描画するし、CSSのセレクタは期待通り要素を特定してスタイルを適用する。なのでこの重複に対してそこまで気を使ってこなかった人も多いだろうし、先のアクセシビリティチェックでよく発見されるのもそういった背景があるのだろうと思う。

しかし表面的に問題が起きていなくとも、実際には重大な構文エラーであり、潜在的に多くの問題を抱えている。IDの重複が引き起こす問題は単純で、そのIDを参照する処理はひとつめの要素しか参照しない[1]。つまり重複している2つ目以降の要素は無視されてしまうのだ[2]

<div id="foo">Foo1</div>
<div id="foo">Foo2</div>

<script>
  const el = document.getElementById("foo");
  console.log(el.textContent); // => "Foo1"
</script>

<label for="bar">Bar</label>
<input id="bar" /><!-- アクセシブルな名前: "Bar" -->
<select id="bar"></select><!-- アクセシブルな名前: (なし) -->
<textarea id="bar"></textarea><!-- アクセシブルな名前: (なし) -->

特にlabel要素のfor属性やIDを参照するARIAプロパティでは正確に要素の関係をつくれないので、アクセシブルな名前が空になりスクリーンリーダーで何も読み上げない事象が発生したりする。WCAGでは達成基準 4.1.1 構文解析(適合レベルA)がこれに関連し、達成方法 H93: ウェブページの id 属性値が一意的 (ユニーク) であるようにするF77: 達成基準 4.1.1 の失敗例 - ID 型の値が重複しているのように具体的にIDの重複について言及している。AxeでもARIAおよびラベルに使用されているIDは一意でなければなりませんといったルールが設けられている。

とにかく「アイディーチョウフク、ダメ、ゼッタイ」なので、開発において何かしらの工夫が必要になる。ほとんどのウェブ開発においてコンポーネントやHTMLのパーツはいくつかのファイルに分割されており、どこでどんな名前のIDが書かれていて、どこでインポートやインクルードされて結合しているのか把握するのは困難だ。最終的なレンダリングHTMLをチェックすれば確認はできるのだが、果たしてそのタイミング以外で防いだり確認する方法は無いのか戦略を考えたい。

動的にIDを生成する

まず簡単な戦略としては動的にIDを生成することだ。HTML要素をコンポーネントの単位で管理している昨今の開発手法において最も手軽な方法と言える。これはIDのハードコーディングを避けることによって、IDの命名や他と重複しているかどうかの確認や管理をする手間が省ける。@xrxoxcxoxさんのこの記事にあるような手法で、ID属性とその関連を示す属性(for属性やARIAプロパティ)などで利用しやすい。

https://qiita.com/xrxoxcxox/items/2c498537292c6388cb80

IDはどんなケースで利用されるのか

さて動的に生成すれば管理の手間が省けて重複を避けることができて問題解決!と思いきやそうは問屋が卸さない。なぜかというと、IDが固定値でなければないない場合が存在するからだ。

ではまずIDが利用されるケースを洗い出しみよう。

ひとつめの「属性からの参照」については先の動的生成で解決できる。ふたつめの「CSSのセレクタ」はそもそもセレクタとしてのIDの使用を禁止すればよい。CSS設計や命名規則で規定してStylelintを利用することでIDそのものを使わないようにすることができる。3つめの「DOM操作からの参照」も動的生成でうまくやることができるだろう。そもそも動的生成がDOM操作のひとつだ。

さて問題は「URLフラグメント」「ウェブビーコンの特定要素」「スクレイピングの目印」なわけだが、これはIDは固定な値である必要があり、ランダムもしくは何かしらを基準にしたハッシュ値を動的に生成したIDはかなり扱いにくい。どれも1文字でも変わってしまうと機能しなくなってしまう。「URLフラグメント」はエンドユーザーへ部分的なリンク切れを提供することになるし、ウェブビーコンはプロダクトの解析用のデータを残せなくなる。スクレイピングはレアケースでほとんどの場合無視してもいいかもしれないが、スクレイピングが行われていることを前提にしているサービスは気をつけないといけない。

markuplintを使った動的IDと固定IDの棲み分け

利用ケースを踏まえると、動的IDの定義に適した要素と固定IDでなければならない要素とそれぞれあることがわかる。そのため、IDを管理するルールとしては

  • 原則としてid属性値は動的に生成する
  • 固定値が必要な場合は任意の値を設定できるがページ内重複しないこと

となる。

具体的にルールが決まればあとはリンターの出番なわけで、markuplintでこのルールを元に問題を発見できるようにしてみる。

Next.jsなどのようにcomponentspagesのようにディレクトリが分かれている設計だと都合がよい。どれがページの元となるのか明確にわかるフレームワークであれば、ページ単位で重複管理が可能になる。

📂 src
├📂 components
│├📄 aaa.tsx
│├📄 bbb.tsx
│├📄 ccc.tsx
│└📄 ddd.tsx
└📂 pages
 ├📄 index.tsx
 ├📄 001.tsx
 ├📄 002.tsx
 └📄 003.tsx

たとえば、markuplintのコンフィグを次のように設定する。

{
  "rules": {
    // IDの重複を警告する
    "id-duplication": true,
    // IDがハードコーディングされていたら警告する(動的生成を促す)
    "no-hard-code-id": true
  },
  "overrides": {
    // pagesディレクトリ内のファイルのみにルールを再定義
    "./src/pages/**/*": {
      "nodeRules": [
        {
          // URLフラグメント用に対象を見出しのみに絞る
          "selector": "h1,h2,h3,h4,h5,h6",
          "rules": {
            // ルールを無効化してIDのハードコーディングを許容する
            "no-hard-code-id": false
          }
        },
        {
          // Google Tag Managerトリガー
          "selector": "#gtm-trigger01,#gtm-trigger02",
          "rules": {
            // ルールを無効化してIDのハードコーディングを許容する
            "no-hard-code-id": false
          }
        }
      ]
    }
  }
}

基本はno-hard-code-idによりid属性値をハードコーディングしていると警告されるが、pages内のページコンポーネントには警告がされないようになる。

export default function () {
  return (
    <>
      <h1 id="fruits">いろんなくだもの</h1>
      <h2 id="apple">りんご</h2>
      <DetailApple />
      <TriggerButton id="gtm-trigger01" />
      <h2 id="orange">みかん</h2>
      <DetailOrange />
      <TriggerButton id="gtm-trigger02" />
    </>
  );
}

なお、pages内でもid-duplicationが有効になっているので、ここで重複定義をしてしまうことはない。


このように非標準技術依存ではあるが、フレームワークやリンターの機能をうまく利用してルールを作ることで、安定で堅牢な管理ができるようになる。

フロントエンドでid属性の扱いで悩んでいた方の参考になると嬉しい。

脚注
  1. WAI-ARIAでは https://w3c.github.io/aria/#mapping_additional_relations_error_processing で規定されている ↩︎

  2. セレクタは仕様上この限りではなく、CSSのIDセレクタは重複していても全てのセレクタが適用される。DOM APIのquerySelectorAllでもIDセレクタはマッチした分の要素を全てコレクションに含める。 ↩︎

GitHubで編集を提案

Discussion