返回博客

Affix 吸顶栏「逃跑」:中间滚动容器里 scroll 事件不冒泡

做了一个自研的 Affix 组件,逻辑很直接:用 position: fixed 把内容钉在视口,通过 getBoundingClientRect 实时计算坐标,滚动时重新测量。在大多数页面测试都很正常,但一旦放到 app-shell 布局里(顶栏固定、正文区域 overflow-y: auto 独立滚动),问题就来了——往下滚动之后,吸顶栏根本不动,定在原来的视口坐标,像幽灵一样飘出卡片边界。如果页面有多张卡片,滚动之后屏幕上会出现好几根横条乱散在四处。

现象

  • 触发吸顶后,吸顶栏位置正确。
  • 滚动页面的中间容器(<main class="h-dvh overflow-y-auto">),吸顶栏纹丝不动,但容器本身已经向上移动了几百像素。
  • getBoundingClientRect 返回的占位符坐标随容器一起变了,但 Affix 的 top 值还停在上一次计算结果。
  • 控制台断点验证:scroll 回调在滚动中间容器时根本没有被调用

根因

scroll 事件不冒泡。这是 DOM 规范中明确写死的:

The scroll event does not bubble, except for scroll on document.

组件里注册监听的写法是:

targetEl.addEventListener("scroll", onScroll);
window.addEventListener("scroll", onScroll); // bubble 阶段

在 app-shell 布局中,真正发生滚动的是 <main> 这个中间元素——它夹在 targetEl(卡片内容)和 window 之间。<main> 滚动时,事件不向上冒泡,所以:

  • targetEl 上的监听器:没触发(滚动的是 <main>,不是 target 内部)。
  • window 上的 bubble 监听器:也没触发(scroll 不冒泡)。

结果:组件完全不知道页面已经滚动,fixed 坐标永远停在初次测量的位置。

坑:scroll 不冒泡,bubble 阶段 window 监听器对中间容器哑火

只要存在 overflow:auto / overflow-y:auto 的中间祖先元素,window.addEventListener('scroll', fn)(bubble 阶段)就永远收不到这个中间容器的滚动事件。 这类布局在现代 app-shell(左侧导航固定、右侧内容区独立滚动)里极为普遍,自研固定位置组件踩到这个坑的概率很高。

正解

scroll 虽然不冒泡,但会在捕获阶段从 window 向下传播到目标元素。在 window 上挂一个 capture: true 的监听器,可以拦截页面上任何元素的滚动——无论是 window 本身、中间 <main>,还是更深层的 overflow:auto 容器。

function schedule() {
  requestAnimationFrame(measure); // rAF 节流,避免每帧强制 layout
}
 
// capture: true —— 捕获阶段,能拦截任意滚动源
window.addEventListener("scroll", schedule, { capture: true, passive: true });
window.addEventListener("resize", schedule);
 
// 清理时必须传同样的 capture: true,否则 removeEventListener 无法匹配
return () => {
  window.removeEventListener("scroll", schedule, { capture: true });
  window.removeEventListener("resize", schedule);
};

这一个监听器取代了原来分散在 targetElwindow(bubble)上的两个监听器,逻辑更简洁,覆盖更完整。

正解:capture: true 一个监听器搞定所有滚动源

window.addEventListener('scroll', fn, { capture: true }) 在捕获阶段工作,能接收页面上任意元素触发的 scroll——中间容器、深层滚动区、window 本身全部覆盖,不需要枚举所有可能的滚动祖先。

验证方法

修复前后可以用以下片段在控制台验证固定栏与容器的相对距离是否保持不变:

const container = document.querySelector("main.overflow-y-auto");
const bar = [...container.querySelectorAll("div")]
  .find(d => getComputedStyle(d).position === "fixed");
 
const before = bar.getBoundingClientRect().top - container.getBoundingClientRect().top;
 
container.scrollTop += 160;
 
requestAnimationFrame(() => requestAnimationFrame(() => {
  const after = bar.getBoundingClientRect().top - container.getBoundingClientRect().top;
  console.assert(after === before, "gap must stay constant"); // 修复前会跳变约 160px
}));

注意事项

  • removeEventListener 必须传相同的 { capture: true },否则注册的是两个不同的监听器,旧的不会被移除,内存泄漏。
  • 另一个形似的坑:祖先元素有 transform / filter / backdrop-filter 时,position: fixed包含块会从视口变成该祖先元素,导致固定栏坐标系整体偏移——这是不同根因(包含块污染,不是 scroll 信号丢失),需要分别诊断。
  • 如果不需要跨容器钉住,改用 position: sticky 可以彻底规避这类 scroll 信号问题;但需要跨容器固定的场景(Ant Design 风格的 Affix),capture 方案是正确路径。

一句话外卖

scroll 事件不冒泡——在 app-shell 布局里监听中间容器滚动,唯一可靠的方式是 window.addEventListener('scroll', fn, { capture: true })