Reactと交差オブザーバーで作る目次の強調表示


Gaji-Labo のフロントエンドエンジニアの茶木です。

目次のある Web ページには、現在読んでいるセクションに対応する目次の項目を強調表示する表現があるものがあります。例を挙げると MDN Web docsReact 公式の Learnページ などです。

この強調表示は、かつては スクロール位置とセクションの高さから表示位置を割り出していました。この方式は、コードが複雑になりやすく、パフォーマンス的にも負荷の高いものでした。今回は、 交差オブザーバーAPI ( Intersection Observer ) を使用して React でもっとシンプルに実装します。

完成形

2カラムの本文とサイドバーを持ち、サイドバーに目次があります。目次の見出しは本文のセクションに対応し、今読んでいるセクションの見出しを黄色く強調表示します。

DOM

まず静的な DOM を示します。本文( article )とサイドバー( nav )を持ち、
本文内の section がユーザーの可読対象となる要素で、サイドバーの中の見出しと対応します。見出しは、classNamehighlight を割り当てた要素が強調表示される想定です。

export default function Index() {
  return (
    <App>
      <article>
        <section id="section1">
          <h2>1.はじめに</h2>
        </section>
        <section id="section2">
          <h2>2.次のセクション</h2>
        </section>
        <section id="section3">
          <h2>3.最後のセクション</h2>
        </section>
      </article>
      <nav>
        <p className="highlight">1.はじめに</p>
        <p>2.次のセクション</p>
        <p>3.最後のセクション</p>
      </nav>
    </App>
  );
}

交差オブザーバー( Intersection Observer )

JavaScript の実装を説明する前に、交差オブザーバーに触れます。

IntersectionObserver は交差オブザーバー API のインターフェイスで、対象となる要素と祖先要素または文書の最上位のビューポートとがの交差状態(重なり合っている状態)の変化を非同期に監視する方法を提供します。

交差オブザーバーAPI ( Intersection Observer )

ビューポートは窓のようなもので、ユーザーにとってのブラウザの可視範囲の矩形です。したがって、ビューポートと交差しているセクションがユーザーに見えている領域となります。

したがって、今回は、ビューポートと可読対象のセクションの交差状態を監視すればよいといえます。

実装方針

交差オブザーバーの概要を踏まえて実装の解説です。
useHighlight というカスタムフックを作ります。このカスタムフックは、引数に対象としたいセクションの id のリスト( targetIds )を受け取り、強調表示対象のセクションの id を返すものです。

function useHighlight(targetIds: string[]): string | null {
  // : 実装部 :
  return id;
}

実装

さて、実装の説明をします。

function useHighlight(targetIds: string[]): string | null {
  const record = React.useRef<Record<string, boolean>>(
    targetIds.reduce((acc, id) => ({ ...acc, [id]: false }), {})
  );
  const [id, setId] = React.useState<string | null>(null);
  React.useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        record.current[entry.target.id] = entry.isIntersecting;
      });
      setId(
        Object.entries(record.current).find(
          ([, isIntersecting]) => isIntersecting
        )?.[0] || null
      );
    });
    targetIds.forEach((id) => {
      const target = document.getElementById(id);
      if (target) observer.observe(target);
    });
    return () => observer.disconnect();
  });
  return id;
}

IntersectionObserver インスタンスの生成

observer = new IntersectionObserver((entries) => ... );

useEffect 内で IntersectionObserver のインスタンスを生成しています。第一引数にはコールバックを指定します。引数については後述します。

各セクションの監視の開始

targetIds.forEach((id) => {
  const target = document.getElementById(id);
  if (target) observer.observe(target);
});

ovserve は引数に指定した要素とビューポートとの交差状態が変化したとき、コールバックを呼び出すように監視対象に加えるメソッドです。

ここでは、 targetIds.forEach 内で ovserve をコールして、各 section 要素について、交差状態の変化の監視を開始します。

終了処理

return () => observer.disconnect();

useEffect の戻り値に関数を指定するとクリーンアップ処理として働きます。つまり、useEffect を呼び出したコンポーネントが破棄されるときに、disconnect がコールされます。disconnect は監視を破棄するメソッドで、これにより安全に終了されます。

コールバック

const observer = new IntersectionObserver((entries) => { ... });

前述の通り、new IntersectionObserver の第一引数は、コールバック関数を指定します。コールバック関数は、observe で指定した要素のビューポートとの交差状態(交差している/交差していない)の値が変わったときに呼び出されます。

ここで注意すべきなのは、コールバックの引数(コード中の entries )には監視中の要素の配列が渡されますが、observe で登録した対象の要素全てではなく 交差状態が変わった要素だけです。

全セクションの交差状態の管理

const record = React.useRef<Record<string, boolean>>(
  targetIds.reduce((acc, id) => ({ ...acc, [id]: false }), {})
);

React.useRef で、record を定義し、全セクションの交差状態を保持します。
これは前述の注意のとおり、コールバック内では、変化のなかった要素の交差状態は参照できないためです。

どの要素を強調表示するかの判定

const observer = new IntersectionObserver((entries) => { 
  entries.forEach((entry) => {
    record.current[entry.target.id] = entry.isIntersecting;
  });
  setId(
    Object.entries(record.current).find(
    ([, isIntersecting]) => isIntersecting
    )?.[0] || null
  );
});

コールバックが呼ばれ、record を更新します。その後、交差状態が true のセクション(画面上に見えているもの)のうち先頭のものを、ユーザーが ”読んでいる” と判断して、そのセクションの id を返します。

id が取得できれば、あとは className を指定するだけです。

function Nav() {
  const id = useHighlight(["section1", "section2", "section3"]);
  return (
    <nav>
      <h2>サイドバー</h2>
      <p className={id === "section1" ? "highlight" : undefined}>
        1.はじめに
      </p>
      <p className={id === "section2" ? "highlight" : undefined}>
        2.次のセクション
      </p>
      <p className={id === "section3" ? "highlight" : undefined}>
        3.最後のセクション
      </p>
    </nav>
  );
}

おわりに

今回使用した、交差オブザーバーのモダンブラウザ対応が完了したのは 2019年であり、決して新しい技術とは言えません。しかしながら、カスタムフックに組み込むことで、React でもリーダブルで使い勝手の良いものになりますし、さらに新しい技術が生まれたときの差し替えも容易です。

これは、スクロールイベントを監視するより、開発上の工数やパフォーマンスの点で優れており、多くのスクロール系のイベント処理と差し替えが可能です。

Gaji-Labo は支援しているスタートアップに対して、成長支援といえる仕事のやり方を大切にしています。どういったコードを残すことが更新されていくプロダクトの中で有効なのか考えていこうと思います。

Gaji-Laboでは、React経験が豊富なフロントエンドエンジニアを募集しています

弊社ではReactの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!

タグ


投稿者 Chaki Hironori

webライターもやってるフロントエンドエンジニアです。Reactは自信があります。またデザイン畑の出身で、気持ちのいいアニメーションやインタラクティブな表現は丁寧に手掛けます。好きなものは中南米の遺跡で、スペイン語が少しできます。