目录导航点击无响应、高亮跑偏——Scrollspy 硬编码 window 在内层滚动容器里全失效
自己实现了一个带活跃高亮的目录导航组件(Anchor / TOC),在独立 demo 页里跑得很顺。把它移进真正的文档站 app shell 之后,点击任意章节锚点——页面纹丝不动;滚动内容时,左侧高亮或是完全不更新,或是和实际阅读位置对不上。
翻了半天 CSS、检查了 z-index、确认了锚点 id 都挂好了——全无问题。症状的根源其实是一行写死的 window。
现象
- 点击 TOC 条目,页面不滚动。 组件调用
window.scrollTo({ top })来跳转,但实际上窗口本身没有滚动条——<main class="overflow-y-auto">才是真正的滚动区,window.scrollY永远是 0。 - 高亮更新滞后或对不上。 IntersectionObserver 使用默认
root(即 viewport),虽然内层元素滚动时 IO 偶尔还能触发,但rootMargin是针对错误的参照盒子算出来的,偏移量完全失准。
根因
组件在初始化时把两件事都写死成了 window / viewport:
// 点击跳转:写死 window
window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - offsetTop });
// 滚动侦测:root 为 null → viewport
new IntersectionObserver(callback, { rootMargin, threshold });
// ^^^^ 没传 root在 window 确实滚动的场景,这两行都正确。而一旦进入"fixed header + 侧边栏 + 内容区独立滚动"的 app shell 布局,window 就变成了一个永远不动的死参照。
孤立展示 demo 里,页面往往只有一个滚动轴(window),而真正的文档站 / 后台管理布局几乎都是 <main overflow-y-auto> 作内容区滚动器。在 demo 里通过测试的 window.scrollTo 放进真实壳子里即刻失效,而且没有任何报错——这个问题可以在代码库里潜伏很久,直到有人真的把组件用在正式页面上才暴露。
正解
给组件加一个可选的容器访问器 getContainer,缺省时退回 window,做到非破坏性兼容。
// prop 类型
getContainer?: () => HTMLElement | null;Scrollspy(IntersectionObserver)部分:
const root = getContainer?.() ?? null; // null = viewport(window 场景)
const observer = new IntersectionObserver(callback, { root, rootMargin, threshold });
// 把 getContainer 加入 useEffect 依赖数组点击跳转部分:
const container = getContainer?.() ?? null;
if (container) {
// 相对于容器顶部的偏移量,再补上容器已滚动的距离
const top =
el.getBoundingClientRect().top
- container.getBoundingClientRect().top
+ container.scrollTop
- offsetTop;
container.scrollTo({ top, behavior: "smooth" });
} else {
const top = el.getBoundingClientRect().top + window.scrollY - offsetTop;
window.scrollTo({ top, behavior: "smooth" });
}使用时用 useCallback 返回稳定引用,避免 IO effect 因函数引用变化反复重建:
const getContainer = useCallback(
() => document.querySelector<HTMLElement>("main.content-area"),
[]
);
<Anchor getContainer={getContainer} items={headings} />getContainer 同时传给 IntersectionObserver 的 root 和点击跳转逻辑,两者用同一个坐标系计算偏移——高亮和跳转就自然对齐了。同类滚动感知组件(Affix、BackTop)已采用相同 target / getContainer 约定,新写的组件从一开始就应当容器感知。
元教训:dogfooding 才能暴露布局假设
这个问题之所以能潜伏这么久,是因为组件从来只在孤立 demo 里展示,从未作为文档站本身的 TOC 使用。很多自建组件都隐含着"window 滚动 / 没有 fixed header / 全宽布局"的假设。把组件真正用进实际 app shell——而不是搭一个假场景——才是暴露这类问题的唯一手段。 demo 如果不能让组件的核心行为实际发生(比如"足够多的章节让 scrollspy 真正需要滚动"),它就不算在演示这个组件。
一句话外卖
任何依赖滚动位置的组件,初始化时就把
getContainer做成可选 prop,默认退回window——等到真实 app shell 里炸了再改,代价远高于提前预留一个参数。