Table Of Contents 삽질기

블로그 개발기 는 지금 보고계시는 블로그의 개발 과정입니다. 삽질을 하며 부딪혔던 이슈와 해결해 나가는 과정, 그리고 느낀점을 고스란히 담았습니다 😁

1. TOC란?

Table Of Contents 의 약자로, 컨텐츠 목록으로 해석할 수 있다.

보통 포스팅에서 heading 태그를 목록으로 구성한다.

블로그_TOC이 블로그의 TOC

2. 일단 구현해보자

블로그 코드

구현 방법

구현에 사용한 custom hook 이다.

  • useHeadingsData
    • document.querySelectorAll('h2, h3') 이용
  • useIntersectionObserver
    • Intersection Observer API 이용
  1. useHeadingsData로 현재 포스팅의 h2, h3 태그를 가져와서 UI를 만든다.
  2. useIntersectionObserver 내부에서 viewport 와 h2, h3 태그들이 상호작용 할 때 작동할 로직을 정의한다.

문제점

개선 전1목록을 건너 뛰어 버리는 모습

문제가 되는 것 같은 부분의 코드를 바꾸어봤지만

개선 전2잘 되는 듯 보이나 올라갈 때 또 건너 뛰어버리는 모습...

생각을 정리하지 않은 수정은 옳지 않다.

나은 개선을 위해 제대로 공부한 후 구현해보자.

3. 개선 과정

Intersection Observer API 공부

Intersection Observer API 정리글 by heropy

주요 코드

// 포스팅의 h2, h3 태그 가져오기
const headingAll = Array.from(document.querySelectorAll('h2, h3'));

// 관찰할 대상이 등록(observe)되거나 등록한 heading이 viewport에 들어가고 나갈 때나 호출
// headings에는 callback이 호출될 때, viewport에 들어오거나 나간 heading이 들어있다.
// heading이 위아래로 viewport와 딱 맞게 위치한 경우 들어오고 나가는 heading 각각 하나씩, 총 2개가 들어있을 수 있다.
const callback = (headings: IntersectionObserverEntry[]) => { ... }

// observer 인스턴스 생성
const observer = new IntersectionObserver(callback, options);

// 관찰할 heading 등록
headingAll.forEach(
  heading => observer.observe(heading)
);

마우스 업다운 고려

업다운 예제스크롤에 따라 TOC에서 활성화되는 contents가 바뀐다 독자가 스크롤을 내릴 때는 글을 흐름대로 읽어나가고 있다는 의미이지만, 글의 흐름과 반대로 스크롤을 올린다는 것은 이전의 콘텐츠를 읽고 싶다는 의미라고 생각했다.

구현을 위해 hook을 추가했다.

  • useScrollDirection
    • window.pageYOffset 이용
    • 스크롤을 올리고 있는지 내리고 있는지 여부를 반환
// 스크롤 방향 받기
const scrollDirection = useScrollDirection();

const callback: IntersectionObserverCallback = (
  headings: IntersectionObserverEntry[]
) => {
  // TOC의 contents를 변경할 때 headings 내부 heading의 개수는 0, 1, 2 이어야한다.
  if (headings.length <= 0 || headings.length > 2) {
    return;
  }
  if (scrollDirection === 'down' && !headings[0].isIntersecting) {
    // 스크롤을 내리는 중에 heading이 viewport에서 나간 경우
    // 해당 heading을 TOC의 contents로 설정
    setSelected(headings[0].target.id);
  } else if (scrollDirection === 'up' && headings[0].isIntersecting) {
    // 스크롤을 올리는 중에 heading이 viewport에 들어온 경우
    // TOC의 contents를 해당 heading이전의 heading으로 설정
    let targetIndex = headingsAll.indexOf(headings[0].target) - 1;

    // 맨 위 heading의 경우 TOC의 활성화된 contents 없음
    targetIndex < 0
      ? setSelected('')
      : setSelected(headingsAll[targetIndex].id);
  }
};

사용자 시야에 맞게 viewport 수정

보통 독자는 heading 이 화면의 맨 위가 아닌 화면의 중간쯤에 왔을때 이미 글을 읽고 있다. (주변 사람들에게 물어봄)

이를 반영하기 위해 viewport 를 수정해보았다.

let options = {
  root: null, // 브라우저 뷰포트
  rootMargin: '-50% 0px 0px 0px', // top, right, bottom, left
  threshold: 1.0,
};

뷰포트수정1중간을 지나면 Contents가 바뀐다

하지만 문제가 있었는데, 독자가 마우스로 TOC를 직접 클릭하는 경우 headingviewport를 지나지 못해 변경이 안되었다.

뷰포트수정2viewport와 상호작용하지 못하는 문제점 heading

그래서 <a/>를 클릭했을 때 해당 element을 중간으로 오도록 커스텀 해보고 싶었지만 해결하지 못했다. (기존 동작은 맨 위로 위치시킨다)

아쉽지만 viewport는 default (0px 0px 0px 0px)로 설정했다. (혹시 아시면 알려주세요..!)

반응형 (media-query)

구현을 위해 개발자도구를 열때 발견한 문제점이다.

브라우저가 좁아졌을 때 position:fixedTOC 가 포스팅과 겹쳐 중복되어 보이고 있었다.

또한 좌우로 화면 분할을 해서 포스팅을 보는 독자들도 있을 것이기 때문에 Media Query를 적용할 필요가 있었다.

:: tailwindcss 이용

이 블로그는 tailwindcss를 이용해 스타일링을 하고있기 때문에 아래와 같이 간단히 적용해보았다.

<TOC className="invisible xl:visible"/>

// xl:visible의 내부 구현
@media (min-width: 1280px) {
  .xl\:visible {
    visibility: visible;
  }
}

반응형개선전1좀 더 빨리 사라져줄래..?

근데 빨리빨리 안사라지고 늦게 반응하는 문제가 있다.

찾아보니 react-responsive라는 라이브러리의 useMediaQuery라는 것을 사용하는 것 같은데..

-> 그냥 TOC에 부여했던 transition-all duration-300 이 문제였다.. 해당 style을 없애서 해결했다.

:: react-responsive 이용

새로 알게된 라이브러리인 react-responsive를 이용해서도 반응형쿼리를 구현해보았다.

nextjsCSR 방식으로 렌더링하는 react에 더해서 SSG, SSR도 하이브리드로 사용할 수 있게 해주는 아주 멋진 도구이다.

nextjsreact의 프레임워크이기 때문에 react 라이브러리를 사용할 수 있다.

라이브러리 설치

yarn add react-responsive @types/react-responsive

hook으로 만들어서 조건부 렌더링하여 구현했다.

import { useEffect, useState } from 'react';
import { useMediaQuery } from 'react-responsive';

export function useIsMobile() {
  const [isMobile, setIsMobile] = useState(false);
  const mobile = useMediaQuery({ query: '(min-width:1400px)' });

  useEffect(() => {
    setIsMobile(mobile);
  }, [mobile]);

  return isMobile;
}
const isMobile = useIsMobile();
return (
  <>
    ...
    {isMobile && (
      <div>
        <TableOfContents />
      </div>
    )}
    ...
  </>
);

::두 방식의 차이점 고민

react-responsive로 state를 받아서 조건부렌더링을 하면 불필요한 컴포넌트를 아예 렌더링하지 않아도되는 장점이 있는 것 같다.

css media-query를 이용하여 invisible, visible을 이용하면 이미 렌더링은 되기 때문에 만약 렌더링되는 컴포넌트가 크다면 로딩속도가 느려질 것이다.

따라서 브라우저의 크기에 따라 반응형으로

  • 단순 숨기고 보이는 경우엔 react-responsive로 조건부 렌더링 이용
  • style이 바뀌어야하는 경우엔 css media-query를 이용

하는 것이 좋다고 생각한다.

4. 후기

많은 웹사이트에서 보던 ToC를 구현하기까지 생각보다 많은 고민이 필요했다.

  • document.querySelectorAll('h2, h3') 로 제목들을 받아서 렌더링
  • IntersectionObserver API로 제목이 viewport에 들어왔는지 확인
  • UX에 대한 고민
  • 반응형 렌더링

단 하나의 컴포넌트지만 직접 구현하며 부딪히고 해결하는 과정이 재밌었다.

하지만 아직 문제점은 보이는데,

제목이 viewport 맨 위를 탈출할때만 focusing이 되기 때문에 마지막 제목이 맨 위로 올라가기 까지의 스크롤이 부족하면 focusing이 되지않고 있다.

이는 댓글밑에 "볼만한 다른 포스팅"과 같은 컴포넌트를 만들어서 side-effect로 자연스럽게 스크롤을 늘리는 방향으로 해결할 예정이다 😁