一个优雅的文章目录组件
Oct 24, 2024
11 min read
2484 字
在我的博客文章页面,随着页面滚动,右边的目录也会跟着发生变化,几乎所有静态网站生成的框架,例如 vitepress,都支持这种功能,这篇文章是讲解我实现 TOC 的思路,以及如何做到比 vitepress
更好的效果。
思路
首先,本文实现的 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>
)
}
最后的优化
你可能遇到过很长的目录,超过整个屏幕高度的那种,如果是上面的写法,那么当你翻阅到文章最下面时,目录组件高亮的部分可能在视区之外,用户需要手动滚动目录组件才行,因此目录组件也需要有个滚动关联功能:
我们可以调用 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
}
}
}