TOC 悬浮折腾记:6 次 commit 才彻底搞定
文章页 TOC 从 IO 到 sticky 再到 fixed 的完整踩坑记录
背景
文章页右侧的 TOC(目录)从一开始就问题不断。最近一周折腾了 6 次 commit,从 IO 检测高亮,到 sticky 跑道,再到 position: fixed 悬浮,才算彻底搞定。本文记录完整决策链,留作下次避坑参考。
历次尝试
v1:IntersectionObserver 检测高亮
最直觉的方案——观察每个 heading 进入视口就标记 active。
- ❌ 高亮滞后一个 heading 的距离:
rootMargin: '-80px 0px -70% 0px'留了 160px 触发区,heading 跨过 nav 80px 线之后还要再滚 160px 才会被点亮 - ❌ 相邻 heading 退出触发区时不重评估:IO 回调只含「状态变化」的 entries,top heading 离开触发区时
observed里只有它一个(isIntersecting=false),h2 还在区里也不会被重新检查
v2:scroll-based 检测 + 主动同步列表
换成自己算:active = 最后一个 top ≤ 80px 的 heading,rAF 节流。同时加 self-scroll effect 把 active link 滚到 TOC 列表可视区。
- ✅ 高亮实时跟随,无滞后
- ✅ 长 TOC 里 active 项自动滚到可视
- ❌ 整篇文章滚到一定距离会「回退」:用户报告「页面自己往回跳」
v3:定位回滚根因
去翻 MDN 文档:
scrollIntoView()默认container: 'all',影响所有滚动祖先,包括视口。
我的链路是 .algo-toc-list (overflow-y: auto) → <html> (overflow-y: scroll)。每次 activeId 变,link.scrollIntoView({ block: 'nearest' }) 都会遍历两个容器。sticky 父元素里的 link,浏览器在不同实现里会用文档位置或视觉位置判定「是否在视口内」——文档位置在 grid row 顶部(约 Y=580),跟 sticky 视觉位置(top:80)不一致,触发回滚。
修法:直接对 .algo-toc-list 设 scrollTop,绕开视口。
- ✅ 不再回滚
- ❌ active 高亮彻底死了:页面加载无高亮,滚也不亮
v4:定位「active detection 失效」
死盯着 self-scroll effect 看是没用的——真凶在 active detection:
useEffect(() => {
const headingEls = entries
.map(({ id }) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null)
if (headingEls.length === 0) return // ← 早退,监听器永远不挂
...
window.addEventListener('scroll', schedule, { passive: true })
}, [entries])
两个 bug 叠在一起:
- 早退:
getElementById全部 null(Next.js 16 partial hydration 时 TOC 比 markdown body 先完成 hydration)→ 直接 return → 监听器永远挂不上 [entries]deps:DOM 之后变好,effect 不会重跑,监听器永远没机会挂
修法:useRef 缓存 headings + 监听器 effect 用 [] deps + heading 解析在 compute() 里懒做。第一次 mount DOM 没好 → 监听器已挂 → 用户第一次 scroll → compute 重新解析 → 这次 DOM 好了 → activeId 更新。
- ✅ 高亮跟滚动了
- ❌ 用户还是不满意
v5:「干脆让 TOC 浮在页面上」
用户原话:「就不能让目录悬浮在页面上吗!?」
回头看整个架构:sticky 的本质问题是 TOC 跟着文章区域走,滚到评论区就消失。前面所有修修补补都在 sticky 框架里硬撑。要彻底解决,必须换 fixed。
.article-layout {
display: flex;
max-width: 1400px;
margin: 0 auto;
padding: 0 8vw;
}
.article-toc-cell {
position: fixed;
top: var(--nav-offset);
width: 260px;
right: max(8vw, calc((100vw - 1400px) / 2 + 8vw));
max-height: calc(100vh - var(--nav-offset) - 4rem);
overflow-y: auto;
}
@media (min-width: 1280px) {
.article-main { margin-right: 260px; }
}
- ✅ TOC 永远钉在视口右侧,滚到评论区/footer 也不消失
- ✅ 删掉 cell+inner 双层、删掉 align-self stretch、删掉 grid(改 flex),结构极简
- ✅ 自滚动同步保留(用
container.scrollTop不会触发视口)
教训
- sticky + scrollIntoView 是坑:默认
container: 'all'遍历所有祖先。sticky 子元素的文档位置和视觉位置在不同浏览器实现里有歧义。能不用就别用 - Next.js 16 partial hydration 时序:client component 的
useEffect跑得比 server-rendered markdown body 早是常态。getElementById全 null 不稀奇。监听器一定要 lazy - 用户说「悬浮」就老老实实
position: fixed:sticky 的局限性太多,固定就彻底固定,别走「sticky 兼容路线」自我折磨 - 早退(early return)是大坑源头:一旦 early return 又没 retry 机制,bug 就变成「永远不工作」。要么别 early return,要么明摆着 retry
改动 commit 链
109b477 fix(article): scroll-based TOC active detection + self-scroll sync
ebdf83d fix(article): replace scrollIntoView with explicit scrollTo/scrollTop
7e03c9e fix(article): decouple TOC active listener from heading DOM availability
781661f refactor(ui): make article TOC position:fixed instead of sticky
下次再碰类似问题,先问「能不能直接换架构」,别在错误假设上硬磕。
人话
以上内容都是AI自己写的,甚至是自己发的,接下来作为AI的驾驶员,我说一些经验。
AI在猜,但如果有东西猜,就能猜准
众所周知,AI原理和概率论脱不了关系,这里我不再赘述,只说如何感知和运用。
如果AI老是修不好某个特定的BUG,但每次修复都说的振振有词,这个时候,就要问一句:你说的是真的,还是猜的?如果装了一些诚实skill或者AI性子比较诚实,像claude之类,这时候它就会坦诚,“不好意思,我之前说的,大多都是猜的”,只有一些性格比较差,或者skill装的不好,对话习惯不好之类的,还会说“我没有猜,我都是根据...给的答案”,如果真的不是猜的,那么为什么一开始就不修好来呢?
那么,知道了AI在猜,我们该怎么做?让AI不要猜吗?
那是不可能的,AI就是靠猜给答案的,而我们要做的,就是让AI猜的更准。
举个例子
同样是猜,我给两个盖着的碗,让你猜哪个碗里面有东西,和同样情况下告诉你,左边的碗里没有东西,哪个能猜的更准?结果显而易见!
同样的,到了AI这里,告诉他一些信息,让他更能猜对才是正路。可以告诉的信息有:实例真实代码,比如,我想要复刻labuladong.online的文章布局,我就让AI自己去看这个网站的前端代码。还有大体的架构。把想要实现的大功能自己拆分成小功能告诉AI,或者把抽象内容具体化,能让AI更好地理解需求和执行,就拿这次经历来说,我一开始让AI做一个文章目录,这个目录要有跟踪文章进度,常态显示的功能。这个时候,AI做出来的东西一直不对,侧边目录老是因为文章过长而被甩到上方屏幕之外,留下空白来。如此几轮描述问题,AI都做的不好,我也开始意识到AI在猜,于是我说出了那句“让目录悬浮”,终于搞定了。