👀

目次をハイライトするやつを作りたい

2022/02/13に公開

最初に

Zenn の目次は、今読んでいる部分が強調されて、長い記事でも読みやすいですよね。
この記事では、今読んでいる部分がハイライトされる目次を作ろうと思います。

実装方法を考える

目次を作るにあたって、はじめに 2 つの実装方法を考えました。

  1. 要素の高さを取得してスクロール量と比較する
  2. Intersection Observer API を使ってターゲットとビューポートの交差を判定する

https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

まずは、どちらで実装するか考えます。

1. 要素の高さを取得して比較する

const anchorsArray = Array.from(
  document.querySelectorAll(".header-anchor-link")
);
const tocArray = Array.from(document.querySelectorAll(".toc"));

//各要素の高さを取得
//各要素の位置=前の要素の高さの総和

window.addEventListener("scroll", () => {
  const currentScrollY = window.scrollY;
  for (let i = 0; i < tocArray.length; i++) {
    // 処理
  }
});

こんな感じになるでしょうね。

一見良さそうに見えますが window.scrollY などの処理をスクロールイベントごとに実行するのはあまり良い考えとは言えないかもしれません。これらの処理は同期的で、大量のイベントが発火してしまうスクロールイベント内で実行すると、スクロールを阻害してしまいますし、パフォーマンスが低下します。
また、この方法では <detail> 要素などを使う場合は、それらのチェンジイベントも取得する必要があるため、面倒です。

2. Intersection Observer API を使う

const anchorsArray = Array.from(
  document.querySelectorAll(".header-anchor-link")
);
const tocArray = Array.from(document.querySelectorAll(".toc"));

const observerCallback = (entries: any) => {
  // 処理
};

const observer = new IntersectionObserver(observerCallback);

anchorArray.forEach((item: Element) => {
  observer.observe(item);
});

Intersection Observer API を使うと、上記のような形で実装できます。

この方法ではスクロールイベントを用いないので、前述の方法に比べてパフォーマンスを期待できます。
Intersection Observer API は、 IE や Safari などのブラウザでは動作しませんが、今回は見なかったことにします。

実装してみる

今回は 2 つ目の Intersection Observer API を使う方法で実装しました。

完成品

const router = useRouter();

React.useEffect(() => {
  if (document !== undefined) {
    const highlightToc = () => {
      const anchorsArray = Array.from(
        document.querySelectorAll(".header-anchor-link")
      );
      const tocArray = Array.from(document.querySelectorAll(".toc"));

      const options = {
        root: null,
        rootMargin: "0% 0px -80% 0px",
        threshold: 1,
      };

      const observerCallback = (entries: any) => {
        const entry = entries.find(
          (entry: { isIntersecting: any }) => entry.isIntersecting
        );
        if (entry) {
          const index = anchorsArray.indexOf(entry.target);
          tocArray.forEach((item, i) => {
            i === index
              ? item.classList.add("active")
              : item.classList.remove("active");
          });
        }
      };

      const observer = new IntersectionObserver(observerCallback, options);

      anchorsArray.forEach((item: Element) => {
        observer.observe(item);
      });
    };

    router.events.on("routeChangeComplete", () => {
      highlightToc();
    });

    highlightToc();
  }
}, []);

API のオプション

const options = {
  root: null,
  rootMargin: "0% 0px -80% 0px",
  threshold: 1,
};

rootMargin: "0% 0px -80% 0px" は、アンカーを下から 80%の位置に入った時発火する設定しています。下図ではオレンジで囲まれた部分です。

threshold: 1は、アンカーが完全に入った時発火する設定です。普通は 1 に設定しませんが、今回はターゲットの要素が小さいので、1 に設定しています。

ページ遷移を取得する

ページ遷移を取得しようとして、個人的につまずいた点です。Next.js のルーティングについてよくわかっていませんでした。
Next.js では <Link> を使ったルーティングでは、遷移先で window.addEventListener(("DOMLoaded", () => {...}))window.onload = () => {...} など、ページを読み込んですぐ発火するイベントが使えないので router.events.on("routeChangeComplete", () => {...}) を代わりに使う必要があります。これは <Link> を使ったルーティングでルートが完全に変更されたときに発火するイベントです。

https://nextjs.org/docs/api-reference/next/router

最後に

今回は、今読んでいる部分がハイライトされる目次を作りました。
何かお役に立てれば幸いです。

参考

GitHubで編集を提案

Discussion

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