MDX 文章目录:从 rehype 提取到吸顶布局的踩坑记录
rehype-extract-toc、Portal 修复 fixed、以及「正文第一行到达才吸顶」的滚动逻辑
目录
给博客文章详情页加目录,听起来比页面切换动画还简单。结果布局 alone 就迭代了四轮——float-right 挤进正文、position: fixed 跟着页面滚走、Nav 滚出视口后 top 变成 -716px……
这篇文章记录完整方案和每个坑的根因。
目标
- 构建期从 MDX 自动提取 h2 / h3 生成目录
- 桌面端:目录在正文右侧视口空白,不占用 720px 文章宽度
- 页面顶部:目录与正文第一行对齐
- 滚动后:正文第一行到达吸顶线时固定,读完全文仍可见
- 移动端:正文顶部
<details>折叠 - 当前章节高亮(
IntersectionObserver)
技术选型:构建期提取,而非运行时扫 DOM
栈是 Next.js 16 + @next/mdx + RSC 静态渲染。社区里和这套架构最匹配的是:
| 包 | 作用 |
|---|---|
rehype-slug | 编译时为 heading 生成 id |
@stefanprobst/rehype-extract-toc | 从 AST 提取嵌套 TOC 数据 |
@stefanprobst/rehype-extract-toc/mdx | 导出 tableOfContents 命名 export |
在 next.config.ts 里,顺序必须在 Expressive Code 之前:
rehypePlugins: [ 'rehype-slug', '@stefanprobst/rehype-extract-toc', '@stefanprobst/rehype-extract-toc/mdx', // ...expressive-code],页面里直接解构:
const { default: MDXContent, tableOfContents } = await loadPostMDX(locale, slug)mdx-components.tsx 的 h2 / h3 需要接收 id,否则锚点跳转无效:
h2: ({ children, id }) => ( <h2 id={id} className="... scroll-mt-6">{children}</h2>),坑一:Server 不能调用 Client 文件里的函数
最初把 filterToc() 放在 'use client' 的 TocNav.tsx 里导出,Server Component Toc.tsx 调用时报错:
Attempted to call filterToc() from the server but filterToc is on the client
修复:纯函数抽到 src/lib/toc.ts,Server / Client 共用。
坑二:float-right 在 720px 正文里很怪
设计稿写了 float: right,但在 max-width: 720px 的文章容器内,目录会挤进段落流,和代码块抢位置。
修复:文章保持 720px;目录用 position: fixed 定位到视口右侧空白:
.toc-gutter { position: fixed; width: 168px; left: calc(50vw + min(360px, 50vw - 32px) + 32px);}参考 Josh W. Comeau 的布局思路——目录在文章结构之外,占据屏幕余白。
坑三:fixed 跟着页面滚走(Portal)
目录放在 PageTransition 的 motion.div 内部时,position: fixed 相对该层定位,滚动时跟着走,完全不吸顶。
修复:createPortal 挂到 document.body:
return createPortal(gutter, document.body)坑四:Nav 滚走后 top 变成负数
一度在 scrollY > 0 时用 Nav 的 getBoundingClientRect().bottom + 16 作为吸顶线。但本站 Nav 不是 sticky,滚出视口后 bottom 会变成 -716px 之类的值,目录直接飞出屏幕上方。
修复:
function getPinTop(): number { const nav = document.querySelector('.site-nav') if (!nav) return 24 return Math.max(24, nav.getBoundingClientRect().bottom + 16)}Nav 还在时贴 Nav 下方;Nav 滚走后保底距视口顶部 24px。
坑五:什么时候吸顶?
需求迭代了三次:
| 版本 | 逻辑 | 问题 |
|---|---|---|
| A | top = max(pin, proseTop) | pin 为负时整体失效 |
| B | scrollY > 0 立刻吸顶 | 一滚就跳,丢失「与正文对齐」 |
| C ✅ | 始终 top = max(pin, proseTop) + pin 下限 | 正文第一行到吸顶线前同步上移,之后固定 |
最终滚动逻辑就一行:
const pinTop = getPinTop()const proseTop = prose.getBoundingClientRect().topsetGutterTop(Math.max(pinTop, proseTop))#article-prose 标记正文起始位置;scroll / resize / ResizeObserver 触发重算。
最终架构
page.tsx├── PostDetail(720px 文章 + 移动端折叠目录)├── Toc variant="fixed"(Portal → body)└── PostNav
MDX 编译链rehype-slug → rehype-extract-toc → /mdx → Expressive Code
TocNav(Client)├── IntersectionObserver → 当前章节高亮├── getPinTop() → 吸顶线(含下限)└── max(pin, proseTop) → 对齐 + 吸顶宽屏(≥ 1080px)显示 fixed 目录;更窄时隐藏,改显示文章内 <details> 折叠版。
总结
| 问题 | 根因 | 修复 |
|---|---|---|
| Server 调 Client 函数 | 'use client' 边界 | 抽到 lib/toc.ts |
| 目录挤占正文 | float 在窄容器内 | fixed + 视口右侧定位 |
| 不吸顶 | motion 层破坏 fixed | Portal 到 body |
| 滚走后消失 | Nav bottom 变负 | Math.max(24, …) |
| 吸顶时机不对 | scrollY 判断过早 | max(pin, proseTop) |
如果博客 Nav 将来改成 position: sticky,getPinTop() 会自动适配,无需改目录逻辑。