[리액트] 스크롤 하는 영역의 TOC 활성화하기

윤다은·2022년 3월 9일
3
post-thumbnail

목표

많은 블로그를 보면 현재 내가 스크롤하는 영역에 따라 옆에 붙어있는 TOC가 굵은 글씨체 또는 강조 스타일이 적용되어있는 것을 볼 수 있습니다(예시).
이 기능을 만들어보도록 하겠습니다.

어떻게 만들 수 있을까

필자가 생각한 방법은 크게 두 가지 였습니다.
1. 스크롤 이벤트 사용
2. Intersection Observer 사용

이 둘을 비교하는 글은 이미 많이 있으니 다른 글과 함께 제가 보았던 링크도 참고하시면 좋을 것 같습니다.

Intersection Observer

필자는 아래와 같은 이유로 Intersection Observer를 선택했습니다.
1. 스크롤 이벤트의 경우 동기적으로 실행되어 메인 스레드의 활동을 막습니다(Intersection Observer의 경우 비동기로 실행됩니다).
2. 스크롤 이벤트로 만들 경우 특정 지점을 관찰하기 위해선 getBoundingClientRect()를 사용해야 하는데 이는 리플로우를 발생시킵니다.
3. Intersection Observer의 경우 IntersectionObserverEntry속성을 이용하면 리플로우 없이 특정 영역안에 요소가 있는지 없는지 확인할 수 있습니다.

코드 작성

하단에 완성된 코드를 기준으로 설명하겠습니다.

스타일링을 예쁘게 하는 건 아직 너무 어려워서 ㅎㅎ; 간단한 UI로 작업하였습니다. 빠르게 코드를 보실 수 있으시다면 직접 샌드박스 내에서 확인하면 됩니다!

App.jsx

import StickyNavigation from "./components/StickyNavigation";
import text from "./text";

export default function App() {
  return (
    <div>
      <StickyNavigation />
      <h2 id="heading1" className="heading">
        제목 1
      </h2>
      <p id="content1" className="content">
        {text}
      </p>
      <h2 id="heading2" className="heading">
        제목 2
      </h2>
      <p id="content2" className="content">
        {text}
      </p>
      <h2 id="heading3" className="heading">
        제목 3
      </h2>
      <p id="content3" className="content">
        {text}
      </p>
    </div>
  );
}

가장 상단에 딱 달라붙어있는 StickyNavigation 컴포넌트를 만들어줍니다. 그리고 그 아래에 본문에 해당하는 내용을 작성하였습니다. 본문에 쓰인 텍스트는 한글입숨에서 가져왔습니다.

이제 StickyNavigation을 살펴보겠습니다.

import React, { useState } from "react";
import useIntersectionObservation from "../hooks/useIntersectionObservation";

const StickyNavigation = () => {
  const [activeId, setActiveId] = useState("content1");
  useIntersectionObservation(setActiveId);
  return (
    <nav className="nav">
      <a
        href="#heading1"
        className={`nav__items ${activeId === "content1" ? "active" : ""}`}
      >
        제목 1
      </a>
      <a
        href="#heading2"
        className={`nav__items ${activeId === "content2" ? "active" : ""}`}
      >
        제목 2
      </a>
      <a
        href="#heading3"
        className={`nav__items ${activeId === "content3" ? "active" : ""}`}
      >
        제목 3
      </a>
    </nav>
  );
};

export default StickyNavigation;

StickyNavigation의 경우 동작 자체를 살펴봅니다. 앞서본 본문의 id 중에서 activeId와 일치하는 경우에 스타일을 적용합니다. 그 기능을 해주는 것이 useIntersectionObservation이라는 커스텀 훅입니다.

❓커스텀 훅으로 제작한 이유가 무엇인가요?
여기에서만 이 기능이 쓰일 경우 해당 컴포넌트내에 직접 쓰는 것도 하나의 방법이 될 수 있습니다. 필자의 경우 이 기능이 여러 군데에서 쓰일 가능성이 있을 것 같아 커스텀 훅으로 분리하였습니다.
id가 content1,2,3... 이 아닐 경우를 생각해서 id 목록을 받는 방식으로 하면 더 범용성 있어질 것입니다.
하지만 상황에 따라, 의도하는 바에 따라 적절하게 활용하는 것이 바람직합니다.

useIntersectionObservation.js

import { useEffect, useRef } from "react";

const useIntersectionObservation = (setActiveId) => {
  const contentRef = useRef({});

  useEffect(() => {
    const callback = (observedContent) => {
      observedContent.forEach((content) => {
        contentRef.current[content.target.id] = content;
      });

      const visibleContent = Object.values(contentRef.current).filter(
        (content) => content.isIntersecting
      );

      setActiveId(visibleContent[0].target.id);
    };
	//1. 새로운 observer 설정
    const observer = new IntersectionObserver(callback, {
      rootMargin: "-20% 0px",
      threshold: [0, 0.5, 1]
    });
	
    //2. DOM 요소 찾고 Observer달아주기
    const contents = [...document.querySelectorAll(".content")];

    contents.forEach((content) => observer.observe(content));

    //3. 언 마운트시 옵저버 해제
    return () => observer.disconnect();
  }, [setActiveId]);
};

export default useIntersectionObservation;

이 코드를 처음 보면 복잡할 수 있지만 차근차근 따라가 보도록 하겠습니다.

동작 순서

  1. 새로운 IntersectionObserver를 선언합니다(observer). 여기에 콜백함수와 옵션을 부여합니다

    💡callback, option
    callback : 관찰하려는 요소가 영역에 들어온 경우 실행되는 함수입니다.
    option: 교차하려는 요소를 감싸는 부모 요소의 너비는 얼마나 할 것인지(root) 어느 정도의 영역을 관찰할 것인지(rootMargin) 어느 정도 비율로 교차했을 때 콜백을 실행할지(treshold)를 설정할 수 있습니다. 기본값은 각각 뷰포트, 0, 1(완전히 뷰포트내로 들어옴)입니다.

    필자는 여기서 rootMargin은 상하 일부를 제거하고 관찰영역에서 사라질 때, 절반만 지날 때, 완전히 지날 때 콜백함수를 실행하도록 했습니다. 부드러운 UI를 위해 설정한 값이니 이 값은 페이지 레이아웃에 따라 조정할 수 있습니다.

  2. 본문에 해당하는 DOM 요소들을 불러옵니다.이들 각각에 옵저버를 달아줍니다.

  3. 언마운트 시 관찰을 해제합니다.

callback 함수 살펴보기

콜백 함수는 다음과 같이 동작합니다.

  1. 처음에 콜백 함수의 인자로는 마운트 시 등록한 모든 옵저버의 DOM이 들어오게 됩니다. 이를 useRef를 사용하여 저장해둡니다.

    ❓저장하는 이유가 무엇인가요?
    마운트 이후로는 교차 영역의 요소만 콜백의 인자로 들어오게 됩니다. 만약 본문1에서 스크롤을 내려 본문1과 본문2가 동시에 뷰포트에 존재하는 경우 콜백 인자로는 본문2에 대한 DOM노드만 인자로 들어옵니다. 하지만 사용자는 아직 본문1을 읽고 있을 수도 있으니 이에 대한 참조도 알고 있어야 합니다.

  2. 그 다음엔 isIntersecting프로퍼티를 이용하여 본문 중 교차영역에 들어와 있는 요소들을 걸러냅니다.

  3. 걸러낸 요소 중에서 첫번째로 해당하는 본문의 TOC를 활성화 합니다(활성화는 인자로 들어온 setActiveId를 사용합니다).

결론

스크롤보다 나은 방법으로 TOC활성화를 만들어보았습니다. 이 외에도 지연 로딩이나 무한 스크롤을 만들어볼 수도 있습니다. 이 포스팅을 통해 IntersectionObserver API에 대해 알고 적용해볼 수 있길 바라겠습니다.🥰

개선점이나 궁금한 점은 댓글로 달아주세요!

profile
코끼리가 코로 걸어다니는 코드를 지양합니다.

1개의 댓글

comment-user-thumbnail
2023년 2월 17일

도움 됐습니다. 감사합니다.

답글 달기