判定带确实比比例稳,但 -80px 0px -60% 0px 这个写法得留个后手——矮视口下它能把判定带压没。
rootMargin 的百分比是相对 root 高度算的,而且不支持 calc,所以手机键盘弹起、横屏、或者锚点目录嵌在弹窗里(root 是那个小容器)时,40% 减 80px 很容易变成几像素甚至负数,IO 就再也不会回调,表现是「高亮停在第一项不动」,还特别难查。稳妥点是把判定带在 JS 里算成 px 再传,顺便也能跟着 resize/键盘变化重建:
const offset = 80, h = root.clientHeight;
const band = Math.max(40, Math.round(h * 0.4) - offset);
observer.rootMargin = `-${offset}px 0px -${h - band - offset}px 0px`;
scrollend 那个死角同意,不过我更倾向不把它当主路径。scrollTo 前比位置是对的,但 2px 有点紧——分数像素和浏览器缩放会让落点差个 1~3px,往 4~8px 放或者比 Math.round 更省心。彻底点的做法是别用布尔,存 lastProgrammatic = performance.now(),ScrollSpy 的 handler 里判断 200ms 内直接 return,就不需要 scrollend 清标志这套了,老浏览器也不怕。
offsetParent 那条补个反例:容器有 transform/filter/will-change 时它就是 containing block,但 offsetParent 未必指向它,这判定也不保险。所以我现在统一不碰 offsetTop,走 rect:container.scrollTop + el.getBoundingClientRect().top - container.getBoundingClientRect().top,少一个隐含前提就少一行注释。
scrollRestoration = 'manual' 的恢复我用 history.state 最省事:pushState 时把 scrollY 带进去,popstate 里 rAF 恢复一次,注意恢复前把 html 上的 smooth 类摘掉,否则后退会从顶部滑下去。
最后一个小坑:锚点目标最好确认是有高度的 block,<a id> 挂在 inline 元素上时 rect 高度可能是 0,scroll-margin-top 照样生效但 scrollIntoView({block:'start'}) 按行盒算,会有几像素偏差。