添加一个目录功能是非常有必要的,不仅能让文章结构一目了然,也方便读者按需阅读对应的章节。现在的目录样式比较接近于我常用的Typora大纲,功能实现则借鉴了其他博客的实现方式。

下面简单介绍下各个功能以及一些实现细节。
预处理
heading level
这是对标题等级做的一个预处理。由于个人的书写习惯,我常常使用H2作为最大的标题。如果不做任何处理,目录就会出现错位的效果:

因此在正式将tocContent数据传入TOC组件前,对变量做了过滤以及重新设置等级的操作:
1const tocContent = handleTocHeader(toc(content).json).filter(
2 (header: Itoc) => header.lvl <= SITE_CONFIG.tocMaxHeader, // 仅保留满足等级的标题
3);
4
5const handleTocHeader = (tocContent: Array<Itoc>) => {
6 const minLvl = Math.min(...tocContent.map((header) => header.lvl));
7 return tocContent.map((header) => {
8 const { lvl, ...rest } = header;
9 return {
10 lvl: lvl - minLvl, // 将等级最高的标题设置为H1
11 ...rest,
12 };
13 });
14};目录布局
没有添加目录前,博客整体是通过设置margin: auto的方式直接居中。所以最简单省事的方法是把目录塞进原有的容器里,比如CSS-Trick文章页面效果:

不过我的设想是将文章页面居中,目录紧贴在右侧,左侧空间留空以便后续使用。因此将页面布局修改为了flex容器,通过设置左右侧边栏宽度一致保持博文容器居中:
1// page.tsx
2import styles from "./page.module.css";
3
4export default function Page(){
5 const tocContent = getTocContent();
6 return (
7 <div className={styles.pageWrapper}>
8 // placeholder
9 <div className={styles.tocWrapper} />
10 <div className={`${styles.postWrapper} container`}>
11 ...
12 </div>
13 <div className={styles.tocWrapper}>
14 <TOC tocContent={tocContent} className={styles.toc} />
15 </div>
16 </div>
17 )
18}1/* page.module.css */
2.pageWrapper {
3 display: flex;
4 justify-content: center;
5}
6
7.tocWrapper {
8 width: calc(50vw - 490px);
9 padding-top: 48px;
10 padding-left: 16px;
11 border-left: solid 1px var(--border-color);
12 @media (max-width: 1340px) {
13 width: calc(50vw - 420px);
14 }
15 @media (max-width: 1200px) {
16 width: calc(50vw - 380px);
17 }
18 @media (max-width: 1080px) {
19 display: none;
20 }
21}
22
23.postWrapper {
24 @media (max-width: 1340px) {
25 max-width: 720px;
26 }
27 @media (max-width: 1200px) {
28 max-width: 640px;
29 }
30}
31
32.toc {
33 position: sticky;
34 top: 72px;
35 width: 100%;
36 height: calc(100vh - 96px);
37 overflow-y: auto;
38}样式设置略显笨拙,为了适应不同宽度的情况都做了几种情况判别,不过这样就基本实现在大部分常用情形下页面都有一个相对合理的排版。此外还设置了sticky属性,使目录在页面滚动时始终可见。
页面组件
由于标题内容实际要首先从页面处获取,我对页面组件做了一些修改。
标题提取
首先最关键的一步是从markdown文本中提取标题。这一功能markdown-toc已经有对应的实现,不过在nextjs中使用原版库会出现如下报错:
./node_modules/markdown-toc/lib/utils.js Cannot statically analyse 'require(…, …)' in line 16
因此该库在我这里无法正常使用,解决方法是参考官方仓库中的issue给出的方案,改为使用markdown-toc-unlazy。
1import toc from "markdown-toc-unlazy";
2
3const tocContent = toc(content).json;得到数组形式的目录后,就可以传入TOC组件中了:
1[ { content: 'AAA', slug: 'aaa', lvl: 1 },
2 { content: 'BBB', slug: 'bbb', lvl: 2 },
3 { content: 'CCC', slug: 'ccc', lvl: 3 } ]标题重写
由于markdown文本生成html的过程我全部交给了react-markdown完成,因此为了给标题添加自定义操作,需要重写渲染逻辑:
1export default function ReactMarkdown({ abbrlink, children }: { abbrlink: string; children: string }) {
2 const HeadingRenderer = ({ level, children }: { level: number; children?: React.ReactNode }) => {
3 const text = React.Children.toArray(children).join("");
4 const id =
5 text
6 .toLowerCase()
7 .replace(/[^\p{Script=Han}a-z0-9]+/gu, "-")
8 .replace(/^-|-$/g, "") || text;
9
10 const TitleTag = `h${level}` as keyof JSX.IntrinsicElements;
11 return (
12 <TitleTag id={id} className={styles.anchor}>
13 {children}
14 </TitleTag>
15 );
16 };
17 return (
18 <Markdown
19 className={styles.reactMarkdown}
20 // ...
21 components={{
22 // ...
23 h1: (props) => <HeadingRenderer level={1} {...props} />,
24 h2: (props) => <HeadingRenderer level={2} {...props} />,
25 h3: (props) => <HeadingRenderer level={3} {...props} />,
26 h4: (props) => <HeadingRenderer level={4} {...props} />,
27 h5: (props) => <HeadingRenderer level={5} {...props} />,
28 h6: (props) => <HeadingRenderer level={6} {...props} />,
29 }}
30 >
31 {children}
32 </Markdown>
33 );重写函数中主要有两个功能:
将标题按照指定正则化规则转化为元素的id,便于后续的跳转。
根据输入的level,生成对应的heading。
TitleTag需要手动声明类型。
跳转偏移
假设我们有一个链接<Link href="#anchor">Title 1</Link>,现在点击该标题链接,浏览器就会自动跳转到对应的章节处,且默认标题顶部对齐整个页面的顶部。这一逻辑非常合理,但针对我们顶部有一个固定的导航栏的情况,跳转后的标题就会被遮住。
因此参考stack overflow后,我为每一个文章标题添加了一个::before伪类,高度与导航栏保持一致:
1.reactMarkdown .anchor::before {
2 display: block;
3 content: " ";
4 visibility: hidden;
5 pointer-events: none;
6 margin-top: -72px;
7 height: 72px;
8 @media (min-width: 640px) {
9 margin-top: -96px;
10 height: 96px;
11 }
12}目录组件
TOC-侧边栏模式
我对于目录组件有以下的需求:
不同标题分级缩进
标题内容横向溢出自动省略,纵向溢出允许滚动
监听滚动位置,高亮激活标题,并自动滚动至居中位置
标题缩进
这个最简单,每一级增加12px的margin即可:
1.toc_h2 {
2 margin-left: 12px;
3}
4.toc_h3 {
5 margin-left: 24px;
6}
7.toc_h4 {
8 margin-left: 36px;
9}
10.toc_h5 {
11 margin-left: 48px;
12}
13.toc_h6 {
14 margin-left: 60px;
15}溢出行为
同样是纯css实现:
1/* page.module.css */
2.toc{
3 overflow-y: auto
4}
5.toc::-webkit-scrollbar {
6 width: 4px;
7}
8.toc::-webkit-scrollbar-track {
9 box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
10}
11.toc::-webkit-scrollbar-thumb {
12 background-color: var(--border-color);
13}
14
15/* TOC.module.css */
16.toc_h1,
17.toc_h2,
18.toc_h3,
19.toc_h4,
20.toc_h5,
21.toc_h6 {
22 text-overflow: ellipsis;
23 white-space: nowrap;
24 overflow: hidden;
25}监听滚动
标题高亮的逻辑很清晰,只需要监听到激活标题后添加active这一className即可。因此关键点在于如何判定读者阅读到某一章节内容。
这里我学习了IntersectionObserver的原理以及对应的使用方法。Intersection Observer API提供了一个callback function,在目标元素和指定的元素(或者viewport)满足特定的交互条件后触发函数。在TOC组件中,我注册了如下函数:
1useEffect(() => {
2 const observer = new IntersectionObserver(
3 (entries) => {
4 entries.forEach((entry) => {
5 if (entry.isIntersecting) setActiveId(entry.target.id);
6 });
7 },
8 {
9 root: null,
10 rootMargin: "0px 0px -50% 0px",
11 threshold: 0,
12 },
13 );
14 const headingElements = tocContent.map((toc) => document.getElementById(handleSlug(toc.slug)));
15 headingElements.forEach((element) => {
16 if (element) observer.observe(element);
17 });
18 return () => {
19 headingElements.forEach((element) => {
20 if (element) observer.unobserve(element);
21 });
22 };
23}, [tocContent]);这里我们设定root为null,也就是交互对象为viewport。rootMargin设置为0px 0px -50% 0px,表示距离顶部高度为0和50%、左右边缘的位置触发。threshold设置为0,表示只要标题接触该位置就触发。相反,如果设置为1则表示标题容器要完全经过该位置后(且容器在页面中完全可见)才触发,这样的设置会导致视图高度过小时函数不会触发。
触发后的函数参数entries为Array<IntersectionObserverEntry>,设置当元素进入viewport(entry.isIntersecting == true)时设置新的激活标题id。
最后,我们添加需要观察的元素,也就是目录里标题对应的文章章节标题headingElements。同时在useEffect被清理时移除观察,避免潜在的意外行为。
高亮居中
添加这一功能的主要目的是避免文章内容过长时,移动端用户点开目录后不能第一时间找到当前阅读章节的位置。
起初我以为只用css提供的scroll-snap-type以及scroll-snap-align即可实现。但是这样粗暴对齐后,用户就不能再滚动目录了。因此还是需要用js控制滚动位置:
1useEffect(() => {
2 if (containerRef.current && activeId) {
3 const element = document.getElementsByClassName(styles.active)[0] as HTMLElement;
4 if (element) {
5 const container = containerRef.current;
6 const containerHeight = container.clientHeight;
7 const elementOffsetTop = element.offsetTop;
8 const elementHeight = element.clientHeight;
9
10 const scrollTop = elementOffsetTop - (containerHeight / 2) + (elementHeight / 2);
11 container.scrollTo({
12 top: scrollTop,
13 behavior: "smooth",
14 });
15 }
16 }
17}, [activeId]);代码中的containerRef指向的是目录所属的容器(tocWrapper),当激活标题改变后,该函数会计算需要滚动的距离后滚动到对应位置,实现高亮标题的居中效果。
MobileTOC-移动端模式
移动端模式的关键功能已经在上一章节里全部拆解完毕了,唯一的区别是我添加了一个用于开关标题页面的按钮,并固定在右下角:
1.mobileTOC {
2 position: fixed;
3 bottom: 16px;
4 right: 16px;
5 @media (min-width: 1080px) {
6 display: none;
7 }
8}额外声明一个组件MobileTOC,在TOC的基础上添加按钮,并固定了目录面板的位置。这里也参考了stack overflow的回答。
1.mobileTOC {
2 position: fixed;
3 right: 56px;
4 bottom: 56px;
5 max-height: calc(100vh - 200px);
6 overflow-y: auto;
7 @media (max-width: 640px) {
8 max-height: calc(100vh - 128px);
9 }
10}需要注意目录面板的right和bottom相对的是整个页面的位置,而非设置按钮的相对位置。不过现在来说这俩并没有什么结果上的区别,所以也就不想再深入研究了。
另外我还设置了max-height,避免设备高度太小,目录又太高的情况下出现的意外行为。
总结
不知道是不是我的英语水平有所下滑,调研监听标题滚动激活的实现时始终没找到符合我需求的帖子以及解决方案。好在还有ChatGPT辅助,让我了解到了IntersectionObserver这个API,并顺利实现了功能。
这次功能添加算是一个新尝试,一开始也完全没有想到一个目录功能牵扯的地方如此之多。虽然麻烦,但学到了不少新花样,还是很高兴的。