← 文章
tutorial·2026-06-07·8 min read

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)

目录放在 PageTransitionmotion.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。

坑五:什么时候吸顶?

需求迭代了三次:

版本逻辑问题
Atop = max(pin, proseTop)pin 为负时整体失效
BscrollY > 0 立刻吸顶一滚就跳,丢失「与正文对齐」
C ✅始终 top = max(pin, proseTop) + pin 下限正文第一行到吸顶线前同步上移,之后固定

最终滚动逻辑就一行:

const pinTop = getPinTop()
const proseTop = prose.getBoundingClientRect().top
setGutterTop(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 层破坏 fixedPortal 到 body
滚走后消失Nav bottom 变负Math.max(24, …)
吸顶时机不对scrollY 判断过早max(pin, proseTop)

如果博客 Nav 将来改成 position: stickygetPinTop() 会自动适配,无需改目录逻辑。