一个优雅的文章目录组件

Oct 24, 2024
11 min read
2484

在我的博客文章页面,随着页面滚动,右边的目录也会跟着发生变化,几乎所有静态网站生成的框架,例如 vitepress,都支持这种功能,这篇文章是讲解我实现 TOC 的思路,以及如何做到比 vitepress 更好的效果。

toc

思路

首先,本文实现的 TOC,也就是目录组件是一个完全的“客户端组件”,它不是从服务端解析 Markdown 文件获取目录结构,而是通过 document 对象:

Code Playground
import { useEffect, useState } from 'react'

export default function App() {
  const [list, setList] = useState([])

  useEffect(() => {
    // 获取文章下所有的 h1,h2,h3 标签
    const nodes = document.querySelectorAll('.md > h1,h2,h3')
    setList(
      Array.from(nodes).map((node) => ({
        title: node.textContent,
        id: node.id,
        depth: +node.tagName[1],
      })),
    )
  }, [])
  return (
    <ul>
      {list.map(({ title, id, depth }) => (
        <li key={id}>
          <a href={`#${id}`} style={{ paddingLeft: depth * 16 }}>
            {title}
          </a>
        </li>
      ))}
    </ul>
  )
}

为什么 list 这个 state 不能从服务端获取呢,这和我们要实现的功能有关,如果我们要实现目录跟随,那么就不可避免的需要拿到 DOM 的引用,进而改变 DOM 的状态,而服务端组件只会渲染一遍。

接下来的事情就更简单了,如果你对八股熟悉的话,那么应该知道 getBoundingClientRect 这个 API,它可以获取是否有元素进入了窗口:

const rect = dom.getBoundingClientRect()
if (rect.bottom >= 0 && rect.top < window.innerHeight) {
  // 进入窗口
}

最后,在 scroll 的事件中,依次遍历我们得到的 NodeList,通过 getBoundingClientRect 获取元素是否到达可视区域即可:

import { useEffect, useState, useRef } from 'react'
import clsx from 'clsx'

export default function App() {
  const [list, setList] = useState([])
  const [activeIndex, setActiveIndex] = useState()
  // 记录所有 h1,h2,h3 标签
  const nodes = useRef()

  function scrollHandler() {
    const viewHeight = window.innerHeight
    // 注意:nodes 和 list 其实是一一对应的,所以我们可以设置一个索引判断哪个目录高亮了
    for (let i = 0; i < nodes.current.length; i++) {
      const node = nodes.current[i]
      const rect = node.getBoundingClientRect()
      if (rect.bottom >= 0 && rect.top < viewHeight) {
        setActiveIndex(i)
        break
      }
    }
  }

  useEffect(() => {
    nodes.current = document.querySelectorAll('.md > h1,h2,h3')
    scrollHandler()
    setList(
      Array.from(nodes.current).map((node) => ({
        title: node.textContent,
        id: node.id,
        depth: +node.tagName[1],
      })),
    )
    window.addEventListener('scroll', scrollHandler)

    return () => window.removeEventListener('scroll', scrollHandler)
  }, [])
  return (
    <ul>
      {list.map(({ title, id, depth }, i) => (
        <li key={id}>
          <a
            href={`#${id}`}
            className={clsx({
              active: activeIndex === i,
            })}
            style={{ paddingLeft: depth * 16 }}
          >
            {title}
          </a>
        </li>
      ))}
    </ul>
  )
}

最后的优化

你可能遇到过很长的目录,超过整个屏幕高度的那种,如果是上面的写法,那么当你翻阅到文章最下面时,目录组件高亮的部分可能在视区之外,用户需要手动滚动目录组件才行,因此目录组件也需要有个滚动关联功能:

toc-optimize

我们可以调用 dom.scrollTo 方法实现滚动,继续补充上述的 scrollHandler 函数(高亮部分):

import { useEffect, useState, useRef } from 'react'
import clsx from 'clsx'

export default function App() {
  const [list, setList] = useState([])
  const [activeIndex, setActiveIndex] = useState()
  // 记录所有 h1,h2,h3 标签
  const nodes = useRef()
  // TOC 组件的 DOM 引用
  const tocRef = useRef(null)

  function scrollHandler() {
    const viewHeight = window.innerHeight
    // 注意:nodes 和 list 其实是一一对应的,所以我们可以设置一个索引判断哪个目录高亮了
    for (let i = 0; i < nodes.current.length; i++) {
      const node = nodes.current[i]
      const rect = node.getBoundingClientRect()
      if (rect.bottom >= 0 && rect.top < viewHeight) {
        setActiveIndex(i)
        // 包括可滚动区域的 top 值
        const top = tocRef.current?.children[i]?.offsetTop
        if (top) {
          // 获取 TOC DOM 的高度
          const tocHeight = tocRef.current?.clientHeight ?? 0
          // 使用 scrollTo 滚动到 TOC DOM 当前视图高度一半的位置
          tocRef.current?.scrollTo({ top: top - tocHeight / 2 })
        }
        break
      }
    }
  }

  useEffect(() => {
    nodes.current = document.querySelectorAll('.md > h1,h2,h3')
    scrollHandler()
    setList(
      Array.from(nodes.current).map((node) => ({
        title: node.textContent,
        id: node.id,
        depth: +node.tagName[1],
      })),
    )
    window.addEventListener('scroll', scrollHandler)

    return () => window.removeEventListener('scroll', scrollHandler)
  }, [])
  return (
    <ul ref={tocRef}>
      {list.map(({ title, id, depth }, i) => (
        <li key={id}>
          <a
            href={`#${id}`}
            className={clsx({
              active: activeIndex === i,
            })}
            style={{ paddingLeft: depth * 16 }}
          >
            {title}
          </a>
        </li>
      ))}
    </ul>
  )
}

完结。

后续的补充

在后续开发中,我发现,如果下滑的过程中“刚好”碰到一个标题(A),这时候上滑,那么高亮的应该是 A 之前的标题,这一点上面代码没体现到,我们稍微补充一下 scrollHandler 函数:

function highlight(i) {
  setActiveIndex(i)
  // 包括可滚动区域的 top 值
  const top = tocRef.current?.children[i]?.offsetTop
  if (top) {
    // 获取 TOC DOM 的高度
    const tocHeight = tocRef.current?.clientHeight ?? 0
    // 使用 scrollTo 滚动到 TOC DOM 当前视图高度一半的位置
    tocRef.current?.scrollTo({ top: top - tocHeight / 2 })
  }
}

function scrollHandler() {
  const viewHeight = window.innerHeight
  // 注意:nodes 和 list 其实是一一对应的,所以我们可以设置一个索引判断哪个目录高亮了
  for (let i = 0; i < nodes.current.length; i++) {
    const node = nodes.current[i]
    const rect = node.getBoundingClientRect()
    if (rect.bottom >= 0 && rect.top < viewHeight) {
      hightlight(i)
      break
    }
    // 按照之前描述的场景,这个 nextRect 是标题 A
    const nextRect = nodes.current[i + 1]?.getBoundingClientRect()
    if (rect.bottom < 0 && nextRect && nextRect.top > window.innerHeight) {
      highlight(i)
      break
    }
  }
}
CC BY-NC-SA 4.0 2024 © Plumbiu