고양이hyebin
Table of Contents 생성하여 목차 구현
November 3, 2023

블로그의 기능 중 목차 기능을 꼭 만들어 보고 싶었다. 🚀

목차 기능 설명 ✨

  1. 페이지 내에 있는 h2h3 를 찾고, 스크롤 위치에 따라 현재 보이는 섹션의 제목을 강조하는 기능을 구현하였다.
  2. 이때 Intersection Observer API를 사용하였다.

Intersection Observer?

사용자 화면에 지금 보이는 요소인지 아닌지를 구별하는 기능을 제공한다.

이때, Intersection Observer는 scroll 이벤트가 아니다.

scroll 이벤트는 단시간에 수백번, 수천번 호출될 수 있고 동기적으로 실행되기 때문에 메인 스레드(Main Thread) 영향을 준다. 따라서 디바운싱(Debouncing)과 쓰로틀링(Throttling)을 통해 이러한 문제를 개선시켜줘야한다.

하지만 Intersection Observer는 비동기적으로 실행되기 때문에 메인 스레드에 영향을 주지 않으면서 변경 사항을 관찰할 수 있다.

1. 생성자 초기화

new IntersectionObserver()를 통해 관찰자(Observer)를 초기화하고 관찰할 대상(Element)을 지정한다.

생성자는 2개의 인수(callbackoptions)를 가진다.

const io = new IntersectionObserver(callback, options); // 관찰자 초기화
io.observe(element); // 관찰하고자 하는 HTML 요소

2. callback

관찰할 대상(Target)이 등록되거나 가시성(Visibility, 보이는지 보이지 않는지)에 변화가 생기면 관찰자는 콜백(Callback)을 실행한다.

콜백은 2개의 인수(entriesobserver)를 가집니다.

const io = new IntersectionObserver((entries, observer) => {}, options);
io.observe(element);

entries

entriesIntersectionObserverEntry 객체의 리스트이다. 배열 형식으로 반환하기 때문에 forEach를 사용해서 처리를 하거나, 단일 타겟의 경우 배열인 점을 고려해서 코드를 작성해야 한다.

3. options

rootMargin

바깥 여백(Margin)을 이용해 Root 범위를 확장하거나 축소할 수 있다. (px과 % 표현 가능)

ex) TOP, RIGHT, BOTTOM, LEFT ( 10px 0px 30px 0px )

threshold

옵저버가 실행되기 위해 타겟의 가시성이 얼마나 필요한지 백분율로 표시한다.

구현하기

getIntersectionObserver 함수

import { Dispatch, SetStateAction } from "react";
 
const observerOptions = {
  threshold: 0.4,
  rootMargin: "0px 0px -70% 0px",
};
 
export const getIntersectionObserver = (
  setState: Dispatch<SetStateAction<string>>
) => {
  const observer = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (entry.intersectionRect.top !== 0) {
        setState(entry.target.id);
      }
    }
  }, observerOptions);
 
  return observer;
};

Toc 컴포넌트

"use client";
 
import { getIntersectionObserver } from "@/utils/observer";
import { useEffect, useState } from "react";
 
export const Toc = () => {
  const [currentId, setCurrentId] = useState<string>("");
  const [headingEls, setHeadingEls] = useState<Element[]>([]);
 
  useEffect(() => {
    const observer = getIntersectionObserver(setCurrentId);
    const headings = document.querySelectorAll("h2, h3");
    const headingElements = Array.from(headings);
    setHeadingEls(headingElements);
 
    headingElements.map((header) => {
      const id = header.textContent!;
      header.id = id;
      observer.observe(header);
    });
  }, []);
 
  return (
    <>
      <div className="absolute ml-5 left-full">
        <div className="fixed hidden text-xs xl:flex xl:flex-col max-w-[220px] gap-3 text-[#999]">
          {headingEls.map((header, i) =>
            header.nodeName === "H2" ? (
              <div
                className={`text-[12px] ${
                  currentId === header.textContent &&
                  "text-white text-[13px] transition-all duration-125 ease-in delay-0"
                }`}
                key={i}
              >
                <a href={`#${header.id}`}>{header.textContent}</a>
              </div>
            ) : (
              <div
                className={`ml-5 text-[12px] ${
                  currentId === header.textContent &&
                  "text-white text-[13px] transition-all duration-125 ease-in delay-0"
                }`}
                key={i}
              >
                <a href={`#${header.id}`}>{header.textContent}</a>
              </div>
            )
          )}
        </div>
      </div>
    </>
  );
};

Reference

https://heropy.blog/2019/10/27/intersection-observer/

https://blog.hyeyoonjung.com/2019/01/09/intersectionobserver-tutorial/