🎲

React で h1-h6 を正しく使い分ける

2022/10/26に公開約14,600字2件のコメント

Web の基礎を支える HTML の最も重要な要素の一つである h1-h6 要素ですが、 React を始めとするコンポーネントベースのライブラリを特に意識せずに利用すると、SEOやアクセシビリティー上の意図せぬ問題を生むことがあります。

この記事では、 React を例に取り h1-h6 を使うことで生じる問題と、その解決策を3つずつご紹介します。

尚、この記事で紹介するコードスニペットは GitHub リポジトリに動作する状態で公開しておりますので、併せてご参照ください。

https://github.com/neetlab/react-accessible-headings-example

前提知識

読者のみなさまは、HTMLの要素 h1-h6 にどのような役割があるか説明できますか?

大きい文字を出したかったらh1を使って、それより少し小さい文字を出したかったらh2を使う...わけではありませんでした。h1-h6 は 「見出し要素」 と呼ばれ、文章の見出しとなるテキストをマークアップするのに用いられています。

h1要素は、ブログ記事のタイトルやウェブサイトのサイト名など、そのウェブページを代表する見出しに使います。ウェブページが複数の見出しを持つ場合はうち一つを必ずh1にしなければならず、MDNでは1つのウェブページ内ではh1要素を1つだけ使うことがベストプラクティスとして紹介されています。[1][2][3]

h2以降の要素は、ウェブページの文章内に入れ子構造になった「節(section)」を設けたいときに使います。階層の最初にh2を使い、そこから入れ子を作るのに伴ってh3, h4...h6と数字を増やしていきます。この数字のことを 「見出しレベル」 と呼びます。

HTMLファイルとHTMLのアウトラインが並べられ、HTMLファイルからアウトラインに矢印が伸びている画像

もう一つ重要な約束があります。h1-h6は入れ子構造の階層に対応しているため、h4の直後にh2を使ったり、h6の直後にh3を使うことはできません。すなわち、見出しレベルを飛ばすことはできません。[4][5]

これらの見出しの情報は、視覚的でないユーザーエージェントにウェブサイトの構造を伝えるのに役立っています。例えば、弱視や視覚障害のあるユーザーが用いるスクリーンリーダーというソフトウェアでは、この見出しレベルを使ってウェブサイト上の任意の位置にジャンプする機能があります。加えて、Googleなどの検索エンジンでは見出しレベルを用いてウェブサイトの内容を把握し、インデックスに役立てています。

NHKのウェブ記事をVoiceOverで操作するGIF画像
VoiceOver と Safari を使って見出しへのジャンプを行う様子。
引用元: https://www3.nhk.or.jp/news/html/20221020/k10013863981000.html

見出しレベルを正しくマークアップすることができないと、上述のような アクセシビリティーやSEOでの問題 が生じるため、最終的なHTMLが正しい見出しレベルを持っているかを常に確認する必要があります。

このことを踏まえて、普段私たちが作っている React アプリケーションがこれらの約束を満たしているか確認していきましょう。

問題1:コンポーネント化

React や Vue.js に代表されるSPAライブラリの代表的な機能の一つとして、ウェブページのコンポーネント化が挙げれれるでしょう。ページの複数箇所で何度も登場する「パーツ」を切り出して、コードの繰り返しを避けるための機能です。

では、コンポーネントにおいて上述のh1-h6を利用したくなった場合はどうなるでしょうか。次のようなコンポーネントを考えてください。ソーシャルメディアのプロフィールとして、ユーザー名と自己紹介を表示するコンポーネントです。

黒く縁取られた枠の中に、大きく太い文字で書かれた「ユーザー名」というテキストと、その下に小さい文字で「自己紹介文」と書かれているコンポーネント

このコンポーネントには次のような実装が考えられます。[7]

const Profile = ({ username, bio }) => (
  <div>
    <h4>{username}</h4>
    <p>{bio}</p>
  </div>
)

ここでユーザー名はh4としてマークアップしました。ユーザー名は自己紹介文を要約するテキストになっており、見出しと言えると考えたからです。この実装に違和感を感じる人は少ないと思います。

しかし、h4とした根拠はなんでしょうか? h2やh3でも良かったのではないでしょうか?

HTMLのセマンティクス(意味論)では、あるテキストをh4とすることで直前の見出しがレベル3であるという前提が課せられます。究極的には、直前の見出しがh3でなければ、不適切なHTMLとなりえるでしょう。[8]

次のような例であれば、適切です。

入力
<h3>プロフィールページ</h3>
<p>このページでは、プロフィールが閲覧できます</p>
<Profile username="@neet" bio="私のプロフィール" />
...
<h3>続きの文章</h3>
<p>あいうえお</p>
出力
<h3>プロフィールページ</h3>
<p>このページでは、プロフィールが閲覧できます</p>
<div>
  <h4>@neet</h4>
  <p>私のプロフィール</h3>
</div>
<h3>続きの文章</h3>
<p>あいうえお</p>

しかし、次のような例は見出しレベルのスキップが発生しているため、不適切です。

入力
<h2>プロフィールページ</h2>
<p>このページでは、プロフィールが閲覧できます</p>
<Profile username="@neet" bio="私のプロフィール" />
...
<h2>続きの文章</h2>
<p>あいうえお</p>
出力(❌ h4からh2にスキップしている)
<h2>プロフィールページ</h2>
<p>このページでは、プロフィールが閲覧できます</p>
<div>
  <h4>@neet</h4>
  <p>私のプロフィール</h3>
</div>
<h2>続きの文章</h2>
<p>あいうえお</p>

このように、コンポーネントを用いたウェブ開発において見出しのマークアップを適切に行うためには、すべてのコンポーネントで親要素がどの見出しレベルを持っているかを把握していなければなりません。

コンポーネントはページに繰り返し登場するパーツを共通化する方法として生まれたテクニックですが、見出しレベルを正しくマークアップするためには 「どの見出しレベルを持った親から呼び出されるか」を明示する必要があるのです

このことから、愚直に切り出しを行うだけのコンポーネント化は本質的に見出しレベルとの相性が悪いことがわかります。

解決策:見出しレベルを変数にする

先の問題に対処するために考えられる方法は、親の見出しレベルを変数として持つことです。再びProfileコンポーネントを考えます。

const Profile = ({ currentLevel, username, bio }) => {
  const nextLevel = Math.min(currentLevel + 1, 6);
  const Heading = `h${nextLevel}`;

  return (
    <div>
      <Heading>{username}</Heading>
      <p>{bio}</p>
    </div>
  );
}

今度はパラメーターに currentLevel が追加されたことに注目してください。尚、見出しレベルはh6までしか存在しないため、次の見出しレベルを計算する上で上限を設けています。

これによって、Profileは見出しレベルが異なる要素の子要素として用いることができるようになりました。

入力
<h1>見出しレベル1</h1>
<Profile currentLevel={1} username="@neet" bio="私のプロフィール" />

<h2>見出しレベル2</h2>
<Profile currentLevel={2} username="@neet" bio="私のプロフィール" />
出力
<h1>見出しレベル1</h1>
<div>
   <h2>@neet</h2>
   <p>私のプロフィール</p>
</div>
<h2>見出しレベル2</h2>
<div>
   <h3>@neet</h3>
   <p>私のプロフィール</p>
</div>

このように、コンポーネントを利用する側で現在の見出しレベルを明示することができれば、どの場所で呼び出されるかわからないコンポーネントでも見出しのマークアップを正しく行うことができることがわかりました。

次に、このようなコンポーネントを複数組み合わせた際に生じる問題を見ていきます。

問題2:さらに複雑な例

実際のユースケースで、先程の例のように単純な構造をしている場合はまれだと思います。さらに複雑なコンポーネントになると、先程の例ではわからなかった問題が馬脚を露わします。

次の例を考えてください。先程のユーザー名と自己紹介文を表示する部分に加え、そのユーザーが著した最新の記事を3件表示するコンポーネントです。

ユーザー名、自己紹介文の下にユーザーが著した最新の記事を3件表示するコンポーネント

まず最も内側にある記事のコンポーネントを作成します。画像のピンクの部分です

上述のコンポーネントの、記事の部分がピンクにハイライトされた画像

さきほどの例と同様に、親要素の見出しレベルを currentLevel として受け取り、その値を1つインクリメントした値を記事の見出しレベルとして扱ってみましょう。

Article.jsx
const Article = ({ currentLevel, title, excerpt }) => {
  const nextLevel = Math.min(currentLevel + 1, 6);
  const Heading = `h${nextLevel}`;

  return (
    <div>
      <Heading>{title}</Heading>
      <p>{excerpt}</p>
    </div>
  );
}

次に、記事をリストとして表示するコンポーネントを作成します。画像のピンクの部分です。

上述のコンポーネントの、記事のリストの部分がピンクにハイライトされた画像

このコンポーネントには見出しがないため currentLevel のインクリメントは行う必要は無さそうです。しかし、子要素に見出しレベルを必要とする Article が存在するため、 currentLevel を受け取り、子要素に渡すのがよいでしょう。

ArticleList.jsx
const ArticleList = ({ currentLevel, articles }) => {
  return (
    <div>
      {articles.map((article) => (
        <Article
	  currentLevel={currentLevel}
	  title={article.title}
	  excerpt={article.excerpt}
	/>
      ))}
      
      <a href="#">さらに読み込む</a>
    </div>
  );
}

最後に全体をラップするコンポーネントを考えます。画像のピンクの部分です。

上述のコンポーネントの、ユーザー名と自己紹介の部分がピンクにハイライトされた画像

先ほどと同様にcurrentLevelを受け取り、1つインクリメントした値を子要素の見出しとして扱ってみましょう。かなり複雑になってきました。

Profile.jsx
const Profile = ({ currentLevel, username, bio, articles }) => {
  const nextLevel = Math.min(currentLevel + 1, 6);
  const Heading = `h${nextLevel}`;

  return (
    <div>
      <Heading>{username}</Heading>
      <p>{bio}</p>
      <ArticleList currentLevel={nextLevel} articles={articles}>
    </div>
  );
}

このような複雑なユースケースでは、見出しレベルを prop に受け取り、それを子に引き回す...というバケツリレーが生じます。特に上掲の ArticleList のようなコンポーネントでは自身が見出しを持っていないにも関わらず子要素が見出しレベルを要求しているために追加のパラメータを設けなければなりませんでした。

大規模なアプリケーションでこの実装をすると、ほぼ全てのコンポーネントが prop に currentLevel を持つことになるでしょう。

また、「節を入れ子にする際は1つインクリメントした値を次の見出しレベルとして利用する」というロジックはすべてのコンポーネントで共通ですが、これをバケツリレーに関与するすべてのコンポーネントが遵守する保証はありません。

解決策:Context API を使った節の作成

さて、このようなバケツリレーに対処するためにライブラリによって様々な方法が提供されていると思いますが、この記事では React の Context API を使った方法を紹介します。

前述の例から、コンポーネントにおいて見出しレベルを扱うときは次のようなパターンがあることがわかりました。

  1. 節を意味するコンポーネントでは、見出しレベルが1つインクリメントする
  2. 節の子要素では、現在の見出しレベルを使って見出しをマークアップする

これを一般化するために、次のようなモジュールを作成します。まず、現在の階層の見出しレベル level を値として持つ context を作成します。また、現在の階層の見出しレベルを context から取得する hook useLevel を作成します。

context.js
import { createContext, useContext } from "react";

export const HeadingLevelContext = createContext({
  level: 1,
});

export const useLevel = () => {
  const context = useContext(HeadingLevelContext);
  return context.level;
}

次に、節を作る際に使うコンポーネントを作成します。 context から現在の見出しレベルを取得し、1つインクリメントした値を次の context の見出しレベルとして利用します。

Section.jsx
import { useLevel } from "./context.js";

export const Section = ({ children }) => {
  const level = useLevel();
  const nextLevel = Math.min(level + 1, 6);

  return (
    <HeadingLevelContext.Provider value={{ level: nextLevel }}>
      {children}
    </HeadingLevelContext.Provider>
  );
}

React hook useContext は、ツリーの親を遡って最も近くにある provider から提供された値を取得するため、複数の Section コンポーネントが入れ子で呼び出された際には直近の値が参照されます。次の例を参照してください。

{ useLevel() == 1 }
<Section>
  { useLevel() == 2 }
  <Section>{ useLevel() == 3 }</Section>
  <Section>{ useLevel() == 3 }</Section>
</Section>

次に、見出しを作る際に使うコンポーネント H を作成します。 context から現在の見出しレベルを取得し、h1-h6要素に対応させたものをレンダリングします。これにより、 レベルを意識せずに見出しをレンダリングできる ようになります。

H.jsx
import { useLevel } from "./context.js";

export const H = ({ children }) => {
  const level = useLevel();
  const Heading = `h${level}`;
  
  return (
    <Heading>{children}</Heading>
  );
}

このモジュールを使うことで、先程のProfileの例は次のように記述できます。節を作る、すなわち hn の階層から h(n+1) の階層に移る際に Section コンポーネントでラップしていることに注目してください。また、見出しはすべて H を使って context から自動で見出しレベルを算出しています。

Article.jsx
- const Article = ({ currentLevel, title, excerpt }) => {
+ const Article = ({ title, excerpt }) => {
-  const Heading = `h${nextLevel}`;
  return (
      <div>
-        <Heading>{title}</Heading>
+        <H>{title}</H>
        <p>{excerpt}</p>
      </div>
  );
}
ArticleList.jsx
- const ArticleList = ({ currentLevel, articles }) => {
+ const ArticleList = ({ articles }) => {
  return (
      <div>
        {articles.map((article) => (
          <Article
-	    currentLevel={nextLevel}
            title={article.title}
            excerpt={article.excerpt}
          />
        ))}
        <a href="#">さらに読み込む</a>
      </div>
  );
}
Profile.jsx
- const Profile = ({ currentLevel, username, bio, articles }) => {
+ const Profile = ({ username, bio, articles }) => {
-  const Heading = `h${nextLevel}`;
  return (
      <div>
-       <Heading>{username}</Heading>
+       <H>{username}</H>
+       <Section>
          <p>{bio}</p>
          <ArticleList currentLevel={nextLevel} articles={articles}>
+	</Section>
      </div>
  );
}

出力は変わりません。

入力
<Profile
  username="@neet"
  bio="自己紹介文"
  articles={[
    { title: "記事1", excerpt: "抜粋文" },
    { title: "記事2", excerpt: "抜粋文" },
    { title: "記事3", excerpt: "抜粋文" },
  ]}
/>
出力
<div>
  <h1>@neet</h1>
  <p>自己紹介文</p>
  <div>
    <div>
      <h2>記事1</h2>
      <p>抜粋文</p>
    </div>
    <div>
      <h2>記事2</h2>
      <p>抜粋文</p>
    </div>
    <div>
      <h2>記事3</h2>
      <p>抜粋文</p>
    </div>
    <a href="#">さらに読み込む</a>
  </div>
</div>

このように、見出しレベルを context で管理する方法は Keep Heading Levels Consistent with React Context by sergiodxa といった記事で紹介されているほか、同様の機能を提供するライブラリがいくつか公開されています。

https://github.com/alexnault/react-headings
https://github.com/springload/react-accessible-headings

問題3:CMSでのコンテンツの埋め込み

React を利用して CMS を作成している場合などに、外部のHTMLファイルやマークダウンファイルをコンポーネント内に埋め込んで表示したくなる場合があると思います。

Reactコンポーネントを表す長方形の隣にHTMLファイルを表す長方形があり、HTMLファイルからReactコンポーネントの方向に青い矢印が引かれている画像。HTMLファイルを表す長方形の中にはカレーのレシピが書いてある。

しかし、外部で作成された HTML ファイルや Markdown ファイルの内容は、それ自体で独立した見出しレベルの階層を持っています。したがって、単にファイルの内容をコンポーネント内に埋め込むだけでは、見出しレベルの順番が前後したり、見出しレベルのスキップが起こりえます。

上述の2つの長方形を組み合わせた画像。見出しレベル1のテキストが2つ存在することを示す赤字がある。

上記の例では埋め込みファイルの見出しである「美味しいカレーの作り方」が、埋め込み元の見出し「私の料理ブログ」と衝突しています。文章の構造としては、ブログの名称である「私の料理ブログ」に記事の名称である「美味しいカレーの作り方」が従属する形が正しいので、これは不適切です。

解決策:見出しレベルをマッピング

この問題を解決する手段は埋め込みコンテンツのレンダラーによって異なります。ここでは react-html-renderer の例を考えます。

react-html-renderer では components prop に HTML タグとコンポーネントのマップを指定することで、実際にマウントするコンポーネントを上書きすることができます。これを用いて、埋め込みコンテンツがマウントされた節の見出しレベルからの相対値にマッピングします。

入力
const makeRelativeH = (level) => {
  const RelativeH = (props) => {
    const baseLevel = useLevel();
    const absoluteLevel = Math.min(baseLevel + level - 1, 6);
    const Element = `h${absoluteLevel}`;
    return <Element {...props} />;
  };

  return RelativeH;
};

//---

import HTMLRenderer from 'react-html-renderer';

<div>
  <H>私の料理ブログ</H>

  <Section>
    <HTMLRenderer
      html={`
      <h1>美味しいカレーの作り方</h1>
      美味しいカレーの作り方を紹介します

      <h2>下準備</h2>
      まず、下準備をします。

      <h3>野菜</h3>
      野菜を切ります。

      <h2>調理</h2>
      調理に入ります。

      <h3>ルウ</h3>
      ルウを溶かします。

      <h2>仕上げ</h2>
      福神漬けを載せます  
      `}
      components={{
        h1: makeRelativeH(1),
        h2: makeRelativeH(2),
        h3: makeRelativeH(3),
        h4: makeRelativeH(4),
        h5: makeRelativeH(5),
        h6: makeRelativeH(6),
      }}
    />
  </Section>
</div>
出力
<div>
  <h1>私の料理ブログ</h1>

  <h2>美味しいカレーの作り方</h2>
  美味しいカレーの作り方を紹介します

  <h3>下準備</h3>
  まず、下準備をします。

  <h4>野菜</h4>
  野菜を切ります。

  <h3>調理</h3>
  調理に入ります。

  <h4>ルウ</h4>
  ルウを溶かします。

  <h3>仕上げ</h3>
  福神漬けを載せます  
</div>

このように、HTMLレンダラーのマッピング機能を利用してコンテキストから現在の節の見出しレベルを取得することで、埋め込みコンテンツの見出しレベルをページ全体の見出しレベルに合わせることができました。

まとめ

  • 複数箇所から呼び出されるコンポーネントでは、h1-h6を直接使うべきではない。
  • 見出しレベルと節の管理には Context API を使った実装が便利。react-headings などのライブラリを使うと良い。
  • 埋め込みコンテンツをレンダリングする際は、レンダラーのマッピング機能を利用してページ全体の見出しレベルに揃える。
脚注
  1. https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements#do_not_use_multiple_h1_elements_on_one_page "However, this was never considered a best practice." ↩︎

  2. https://html.spec.whatwg.org/multipage/sections.html#headings-and-outlines-2 If a document has one or more headings, at least a single heading within the outline should have a heading level of 1. ↩︎

  3. 最初の版でh1を複数使うことが仕様上誤りであるという旨の記述をしていましたが、WHATWGのspecにそのような記述はありませんでした。 https://twitter.com/momdo_/status/1585252338474049536 ↩︎

  4. https://html.spec.whatwg.org/multipage/sections.html#headings-and-outlines-2 "Each heading following another heading lead in the outline must have a heading level that is less than, equal to, or 1 greater than lead's heading level." ↩︎

  5. 最初の版ではh3→h6のスキップが仕様上誤りであるという旨の記述をしていましたが、specにそのような記述はなく、正しくはh6→h3のようなスキップでした。ただし、MDNでは "Avoid skipping heading levels" と紹介されています。 https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements#usage_notes ↩︎

  6. https://github.com/whatwg/html/pull/7829 "This PR is an attempt to bring the HTML spec in line with the decade+ old reality of non-implementation of the outline algorithm in User Agents and the continued presence of misleading content in the HTML spec relating to the outline algorithm." ↩︎

  7. コードは擬似コードですので、 React や Vue.js だと思っていただいて構いません。 ↩︎

  8. spec上では許可されていますが、MDNでは "Avoid skipping heading levels" と紹介されています。 https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements#usage_notes ↩︎

GitHubで編集を提案

Discussion

リンクありがとうございます。アウトラインアルゴリズムの動向については、こちらの記事の方が詳しいです。
https://blog.w0s.jp/671

おー!ありがとうございます🙇‍♂️こちらもリンクしておきます!

ログインするとコメントできます