Low Love - King Gnu

cd

obvoso

star

next js에서 TOC(Table Of Contents) 만들기

Intersection observer를 사용하여 TOC 구현

개발
Blog
React
thumbnail

TOC

TOC는 Table of Contents의 약자로 아티클의 목차입니다.

TOC가 있는 아티클은 전체적인 컨텐츠를 확인하고, 현재 어느 단락을 읽고 있는지를 파악할 수 있습니다.
또한 다른 단락으로의 이동을 간편하게 해줍니다.

이번 프로젝트에선 md문서를 html로 파싱하여 <h>태그를 찾은 다음 <section>태그로 단락을 나누고 intersection observer를 사용하여 TOC를 구현하였습니다.

소소한 목표..

이번 목표는 최초 렌더링되는 html에 TOC가 포함되어 seo와 ux를 고려하는 것입니다.

intersection observer 를 사용하기 때문에 RCC 로 동작하지만, 서버에서 미리 정의한 로직에 따라 사용되는 state의 initial state를 내려줌으로써 lazy loading을 제거해 UX를 향상시키고 SEO를 개선하고 싶었습니다.

Heading 추출하기

notion sdk를 사용하여 md문서 불러오기

저는 노션에서 작성한 페이지를 notion sdk를 사용하여 불러오고 있습니다.
우선 서버에서 아티클의 segment인 slug로 해당 게시글의 id를 찾은 다음, id로 아티클의 md파일을 불러옵니다.

//use notion sdk
const getNotionArticleData = unstable_cache(async (id: string) => {
  const mdblocks = await n2m.pageToMarkdown(id)
  const mdString = n2m.toMarkdownString(mdblocks)
  return mdString.parent
})

export async function getArticleData(slug: string) {
  const page = await getSlugPage(slug)
  const post = await getNotionArticleData(String(page.id))

  if (!post) {
    throw new Error("Notion data not found")
  }

  const { headings } = await getArticleHeadings(post)

  return { post, headings }
}

markdown을 html로 변환하기

불러온 markdown형식의 게시글을 getArticleHeadings함수를 사용하여 html로 변환하고 <h>태그를 추출합니다.

md 문서는 unified, remark, rehype를 사용하여 html로 변환할 수 있습니다.

async function getArticleHeadings(post: string) {
  const result = await unified()
    .use(remarkParse) // md -> AST
    .use(remarkRehype) // md AST -> html
    .use(rehypeSlug) // add id to <h> tag
    .use(rehypeExtractHeadings) // <h> 태그 추출
    .use(rehypeStringify) // 문자열로 변환
    .process(post) //변환할 md문서

  return {
    headings: result.data.headings as Heading[],
  }
}
  • remarkParse: 마크다운(MD) 문서를 Remark의 AST로 파싱합니다.
  • remarkRehype: Remark의 ASTrehype의 HTML AST로 변환합니다.
    마크다운의 구조를 HTML의 구조로 변환합니다. 예를 들어, # Heading 1<h1>Heading 1</h1>로 변환됩니다.
  • rehypeSlug: 모든 헤더 태그에 id 속성을 자동으로 추가하는 플러그인입니다.
    <h1>Title</h1> 같은 헤딩 태그에 고유 id를 부여하여, id="title" 속성을 추가합니다. 이를 통해 각 헤딩이 링크할 수 있는 대상이 됩니다.
  • rehypeExtractHeadings: 커스텀 rehype 플러그인으로, HTML AST에서 <h1>, <h2>, <h3> 태그를 찾아서 헤딩의 id, 텍스트 내용, 레벨(헤딩의 깊이)을 추출합니다.
  • rehypeStringify: HTML AST를 문자열로 변환하는 플러그인입니다.

visit 함수로 tree를 순회하며 <h>태그 찾기

rehypeExtractHeadings의 인자인 treefilerehype unified 파이프라인에서 호출될 때, 자동으로 전달받는 인자입니다.

  • tree : treerehype 가 파싱한 html문서로, AST 형태로 제공됩니다.
  • file : VFile 객체로, 파일 관련 메타 데이터 등을 포함할 수 있습니다.
  • visit : visit함수는 트리구조인 html을 DFS로 탐색하는 함수입니다. 때문에 트리의 자식뿐 아니라 리프노드까지 트리의 모든 노드를 재귀적으로 탐색합니다.

visit으로 html의 모든 노드를 탐색하며 <h>태그를 찾아 필요한 메타 데이터 저장하고 이를 file에 추가합니다.

import { Node } from "unist"
import { visit } from "unist-util-visit"
import { VFile } from "vfile"

type Heading = {
  id: string
  textContent: string
  level: number
}

export function rehypeExtractHeadings() {
  return (tree: Node, file: VFile) => {
    const headings: Heading[] = []

    const extractText = (node: Node): string => {
      if (node.type === "text") {
        return (node as any).value as string
      }

      if ("children" in node && Array.isArray(node.children)) {
        return node.children.map(extractText).join("")
      }

      return ""
    }

    visit(tree, "element", (node: Element) => {
      if (["h1", "h2", "h3"].includes(node.tagName)) {
        const id = node.properties?.id as string
        const textContent = extractText(node)

        headings.push({
          id,
          textContent,
          level: parseInt(node.tagName.replace("h", ""), 10),
        })
      }
    })

    file.data = { headings }
  }
}

section 추가하기

아티클 렌더 컴포넌트에서 getArticleData 함수를 호출하여 추출한 headings객체와 post를 렌더링 합니다.

import Markdown from "react-markdown"

<Markdown
    remarkPlugins={[remarkBreaks, remarkGfm, remarkEscapeHtml]}
    rehypePlugins={[rehypeSlug, rehypeHighlight, rehypeRaw, rehypeSection]}
    components={{
	    // 렌더링 컴포넌트
    }}
>
{post}
</Markdown>

react-markdown 은 md파일을 간편하게 react component로 변환해주는 라이브러리입니다.

getArticleHeadings 함수에서 변환한 html은 react-markdown 라이브러리에서 렌더링 할 수 없습니다. 이미 html 문자열로 변환되었기 때문에 라이브러리를 사용하면 한 번 더 변환되어 제대로된 렌더링이 불가합니다.

때문에 렌더링 컴포넌트에서 plugin을 사용하여 다시 html로 파싱해줍니다….

꺽새(<, >)를 문자열로 치환하기

rehypeRaw 플러그인은 Markdown을 HTML로 변환할 때 HTML 태그를 랜더링하는 방법을 제어하는 플러그인입니다.

md파일에 작성된 #### heading 4 를 리액트 컴포넌트로 렌더링 하면 해당 md문법은 html로 파싱되어 <h4> heading 4 </h4> 로 나타나게 됩니다.

rehypeRaw를 react-markdown에 추가하면 HTML 코드가 특수 문자로 이스케이프되어 heading 4 가 태그네임 없이 올바르게 랜더링됩니다.

그렇기 때문에, 노션에서 꺽새(<, >)태그를 사용하여 텍스트를 작성하게 되면, 텍스트인 <h4>heading 4</h4>가 실제 <h4> 요소로 렌더링 됩니다.
md 문서가 html로 파싱되기 전 텍스트로 작성된 모든 태그네임을 찾아 문자로 변환해주어야 합니다.

export function remarkEscapeHtml() {
  return (tree: Root) => {
    visit(tree, "html", (node: Html) => {
      if ("value" in node && typeof node.value === "string") {
        node.value = node.value.replace(/</g, "&lt;").replace(/>/g, "&gt;")
      }
    })

    visit(tree, "list", (node: List) => {
      node.children.forEach((child: ListItem) => {
        visit(child, "text", (textNode: Text) => {
          textNode.value = textNode.value
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
        })
      })
    })
  }
}

text로 작성된 모든 꺽새(<, >)태그를 찾아 문자로 치환하는 plugIn을 만들어 추가해주었습니다.

<section>태그 추가하기

렌더링을 위해 html로 파싱하는 과정에서 rehypeSection 커스텀 plugIn을 만들어 <section> 태그를 추가해줍니다.

<section>태그는 <h>태그가 다음 <h>태그를 만나기 전까지 존재하는 다른 모든 태그들을 하나의 <section>으로 묶어주고, <h>태그와 동일한 id를 추가하기 위해 사용합니다.

<h>태그와 그 하위 다른 태그들을 하나의 <section>으로 묶지 않고 <h>태그만 intersection observer로 관찰하면 뷰포트 내에 <h>태그가 존재하지 않을 때, TOC에 현재 보고 있는 단락이 표시되지 않게 됩니다.

<h>태그의 하위 태그들을 하나의 <section>으로 묶고 <h>태그와 동일한 id를 부여하여 intersection observer가 <section> 태그 전체를 주시하게 해야합니다.

export const rehypeSection = () => {
  return (tree: Parent) => {
    const newChildren: Element[] = []
    let currentSection: Element | null = null

    tree.children.forEach((node: Node) => {
      if (
        isElement(node, "h1") ||
        isElement(node, "h2") ||
        isElement(node, "h3")
      ) {
        if (currentSection) {
          newChildren.push(currentSection)
        }

        currentSection = {
          type: "element",
          tagName: "section",
          properties: { id: node.properties?.id },
          children: [node],
        }
      } else if (currentSection) {
        currentSection.children.push(node as Element)
      } else {
        newChildren.push(node as Element)
      }
    })

    if (currentSection) {
      newChildren.push(currentSection)
    }

    tree.children = newChildren
  }
}

이전 rehypeExtractHeadings 플러그인 함수와 동일하게 tree를 인자로 받습니다.

tree를 탐색할 때 DFS를 사용하는 visit 이 아닌 forEach로 <h>태그를 찾습니다.
모든 노드를 재귀적으로 탐색하는 visit 에 반해 forEach는 루트의 자식 노드만 탐색합니다. 자식의 자식 노드까지는 탐색할 수 없습니다.

<section>태그를 추가할 땐 모든 노드를 탐색할 필요가 없기 때문에 forEach로 루트 노드의 자식을 순차 탐색했습니다. <h> 태그와 다음 <h>태그 사이에 있는 모든 태그들을 하나의 <section> 태그로 묶어주고, rehypeSlug를 통해 부여한 <h>태그의 id를 <section>에 동일하게 부여합니다.

아티클 컴포넌트 렌더링

react-markdown을 통해 아티클을 렌더링 할 때 rehypeSection 플러그인에서 생성한 <section>태그와 id를 사용합니다.

<Markdown
    remarkPlugins={[remarkBreaks, remarkGfm, remarkEscapeHtml]}
    rehypePlugins={[rehypeSlug, rehypeHighlight, rehypeRaw, rehypeSection]}
    components={{
      section: ({ id, children }) => <section id={id}>{children}</section>,
      h1: ({ id, children }) => (
        <h1 id={id} className={styles.h1}>
          {children}
        </h1>
      ),
      h2: ({ id, children }) => (
        <h2 id={id} className={styles.h2}>
          {children}
        </h2>
      ),
      h3: ({ id, children }) => (
        <h3 id={id} className={styles.h3}>
          {children}
        </h3>
      )
    }}
  >
      {post}
</Markdown>

heading의 하위 요소를 <section>태그를 사용해 하나의 단락으로 묶고, id를 부여함으로써 intersection observer 가 감지할 element를 만들어주었습니다.

이제 intersection observer 에 관찰할 element를 등록하면 됩니다..!!

요소 관찰하기

TOC컴포넌트에서는 <section>을 관찰하는 커스텀 훅인 useScrollSpy 를 호출합니다.
서버에서 받아온 initialHeadings(id, text, level이 포함된 배열)를 인자로 넘겨줍니다.

export function TableOfContents({ initialHeadings }: TableOfContentsProps) {
  const { activeIndexs } = useScrollSpy(initialHeadings)

  return (
		// toc list 렌더링
  )
}

Intersection Observer란

IntersectionObserver 인터페이스는 대상 요소와 상위 요소, 또는 대상 요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수 있는 Web API입니다.

new IntersectionObserver((entries) ⇒ callbackFn(entreis));형태로 사용합니다.

new 연산자를 사용하여 객체를 만들고, 주시하는 요소가 뷰포트에 교차 될 때 비동기적으로 callbackFn을 호출합니다. 교차된 entries(관찰자 목록)는 배열의 형태로 받을 수 있습니다.

관찰할 요소는 IntersectionObserver.observe(targetElement); 로 설정할 수 있습니다.
생성한 객체의 observe 메서드를 사용하여 인자로 넘겨주면 됩니다.

관찰을 끝내고 싶다면 IntersectionObserver.unobserve(targetElement); 를 호출하면 됩니다.

useScrollSpy

해당 훅에서 intersection observer를 사용하여 <section>이 뷰포트에서 교차 될 때를 비동기적으로 감지할 수 있습니다.

useEffect(() => {
    const headingElements = initialHeadings.map(
      (heading) => document.getElementById(heading.id) as Element,
    )

    // DOM 요소를 참조하고 상태 업데이트
    ElementRef.current = headingElements.filter((el) => el !== null)
    const observer = new IntersectionObserver((entries) => {
      handleIntersectionHeader(entries)
    })

    ElementRef.current.forEach((heading) => observer.observe(heading))

    return () => {
      ElementRef.current.forEach((heading) => observer.unobserve(heading))
    }
  }, [initialHeadings])

훅 내부에서 initialHeadings 가 변할 때 마다 IntersectionObserver를 생성하고, 관찰할 section을 설정해주었습니다.

initialHeadings 배열에 존재하는 heading의 id를 이용하여 section Element를 찾고 ref변수에 할당합니다. 이후 배열을 순회하며 관찰할 대상으로 등록하고, 해당 요소가 뷰포트에 교차될 때handleIntersectionHeader가 호출되도록 하였습니다.

function addIntersectingEntries(entries: IntersectionObserverEntry[]) {
    const intersectingEntries = entries.filter((entry) => entry.isIntersecting)

    if (intersectingEntries.length) {
      setActiveIndexs((prev) => [
        ...prev,
        ...intersectingEntries.map((entry) =>
          ElementRef.current.indexOf(entry.target),
        ),
      ])
    }
  }

function removeUnintersectingEntries(entries: IntersectionObserverEntry[]) {
    const unIntersectingEntries = entries.filter(
      (entry) => !entry.isIntersecting,
    )

    if (unIntersectingEntries.length) {
      setActiveIndexs((prev) =>
        prev.filter(
          (index) =>
            !unIntersectingEntries.some(
              (entry) => ElementRef.current.indexOf(entry.target) === index,
            ),
        ),
      )
    }
  }

 function handleIntersectionHeader(entries: IntersectionObserverEntry[]) {
    addIntersectingEntries(entries)
    removeUnintersectingEntries(entries)
 }

감지된 관찰자목록은 IntersectionObserverEntry[] 형태로 넘겨집니다. 또한, 뷰포트에 새로 등장한 것과 사라진 것을 나누지 않고 변동된 모든 요소를 하나의 배열에 담아 주기 때문에, isIntersecting메서드를 사용하여 필터링해줘야 합니다.

addIntersectingEntries 함수에서 변경된 요소 중 뷰포트에 새로 들어온 요소만을 필터링하고 activeIndexs배열에 추가하여 상태를 업데이트 합니다.

removeUnintersectingEntries 함수에선 반대로 뷰포트에서 사라진 요소를 필터링하여 상태를 업데이트합니다.

export function TableOfContents({ initialHeadings }: TableOfContentsProps) {
  const { activeIndexs } = useScrollSpy(initialHeadings)
 
  return (
    <aside className="aside-container">
        <CustomTypography>목차</CustomTypography>
        <nav>
          <ul>
            {initialHeadings.map((heading, index) => (
              <li
                key={index}
                style={{ paddingLeft: `${(heading.level - 1) * 10}px`}}
                className={`heading-list ${
                  activeIndexs.includes(index) ? "active" : ""   }`}
              >
                <a
                  href={`#${heading.id}`}
                  onClick={(e) => handleScroll(e, heading.id)}
                >
                  {heading.textContent}
                </a>
              </li>
            ))}
          </ul>
        </nav>
    </aside>
  )
}

useScrollSpy 를 호출한 렌더링 컴포넌트에선 activeIndexs 상태를 리턴받고, 이 배열에 포함된 lndex에 active 클래스네임을 추가해줍니다.

이전에 트리를 순회하며 지정한 heading의 level에 따라 padding의 값을 조절하였습니다.

또한 이동하고 싶은 목차를 클릭 했을 때, 해당 위치로 스크롤이 이동되어야 하므로 앵커 태그에 href=heading.id 속성을 추가합니다.

 const handleScroll = (
    e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
    id: string,
  ) => {
    e.preventDefault()
    const targetElement = document.getElementById(id)

    if (targetElement) {
      const headerOffset = 50
      const elementPosition = targetElement.getBoundingClientRect().top
      const offsetPosition = elementPosition + window.pageYOffset - headerOffset

      window.scrollTo({
        top: offsetPosition,
        behavior: "smooth",
      })

      window.history.replaceState(null, "", `#${id}`)
    }
  }

dom에서 이동할 타겟의 위치를 찾습니다.

헤더 위치의 오프셋을 계산하여 위치를 정하고, 부드럽게 이동되기 위해 기본 동작을 차단한 후 behavior: "smooth" 옵션을 추가하여 widow.scrollTo()를 호출합니다.

e.preventDefault() 를 호출하였기 때문에 fragment 식별자(#)가 자동으로 업데이트 되지 않습니다. window.history.replaceState() 를 사용하여 url에 fragment 식별자를 추가해주어야 합니다.

회고

11a3cd4b-ffcd-804b-aca3-f094b1856bbd--0.jpg

칸세쟈

멋진 TOC가 완성되었습니다. 일단 전 만족합니다.

aside에 TOC를 배치하지 않고, 아티클의 최상단에만 배치하고 싶다면 remark-toc 플러그인을 사용하면 됩니다.
처음 만들었을 때, section 태그를 추가하지 않고 heading만을 주시하도록 구현했더니 있으니만 못한 TOC가 되어버려… 꾸역꾸역 section 태그를 추가하고 로직을 변경했습니다.

더 좋은 방법이 있겠지만, 전 생각해내지 못했습니다.

infinite scroll을 구현하며 react-intersection-observer 라이브러리를 설치하여 사용하고 있었기에, 해당 라이브러리를 그대로 쓱싹 하고싶었습니다.
하지만 주시하는 요소를 배열로 설정하지 못해서 web api를 사용하여 깡으로 만들게 되었습니다.

또한, 서버에서 heading 요소를 추출하며 html로 변환한 걸 react-markdown에서 사용하지 못했습니다. react-markdown은 children으로 md문서를 넘겨줘야 하니까요.
이미 render compenent를 모두 만들고 react-markdown에 연결해두었기 때문에… 깡 html을 렌더하고 싶지 않았습니다.

css를 잘 했다면 md를 html로 2번 파싱하는 행위는 하지 않아도 됐겠죠…. 일단 전 만족합니다.

레퍼런스

star

이전 포스트

Next.js 14의 unstable_cache

use cache, 제대로 알고 사용하자

2024년 8월 16일