滚动事件大概是前端里最常写、也最容易写坏的事件之一。随便打开一个线上项目十个页面里至少有八个的 scroll 监听是“裸奔”的没有清理、没有节流、回调里读到的 state 还是组件第一次渲染时的旧值。useOnWindowScroll 看着简单做对了却要处理不少边角——它把一个业务组件里的滚动副作用收敛成一行可复用的调用让“监听窗口滚动”这件事不再给你惹麻烦。这篇文章我会从设计取舍、源码实现到五个真实场景逐一拆解把我自己踩过的坑也一并写出来。1. 为什么滚动监听值得封装成一个 Hook1.1 那些年我们写过的“裸监听”很多人第一次写滚动监听是这样写的const App () { const [scrolled, setScrolled] useState(false); useEffect(() { const onScroll () setScrolled(window.scrollY 50); window.addEventListener(scroll, onScroll); return () window.removeEventListener(scroll, onScroll); }, []); return header className{scrolled ? header--scrolled : header} /; };这段代码在简单场景下没问题但页面一复杂问题就全冒出来两个组件同时需要滚动状态得各写一套监听回调里要用到最新的 props 或 state直接闭包捕获的话永远拿到旧值滚动一触发频繁 setState低端机上掉帧卡顿项目用了服务端渲染window 还没定义就直接崩。这些问题单独看都不大凑在一起就是灾难。所以像 useOnWindowScroll 这样的 hook 存在的意义不是让你少写几行代码而是把“监听窗口滚动”这件事里所有容易踩的坑一次性填平。你可以把它理解成一个交通管制员不急着自己冲出去而是等一批事件到了挑一个合适的时机放行同时保证规矩齐全、走后关门。1.2 这个 Hook 到底负责解决什么我整理了一下一个合格的窗口滚动 Hook 至少要承担五件事生命周期管理组件挂载时注册监听卸载时移除监听不留下内存泄漏。回调永远最新业务回调通过 ref 间接调用避免闭包捕获旧值。性能友好默认使用 passive 监听器配合 requestAnimationFrame 合并同一帧内的多次触发。SSR 安全在 window 不存在时提前返回不抛错。可选首次执行进入页面时就触发一次回调方便初始化滚动进度条这类 UI。用表格对比一下看得更清楚常见问题裸监听写法使用 useOnWindowScroll 后卸载后回调仍然触发需要手动 removeEventListener内部统一清理回调读到的 state 是旧的需要额外 useRef 或反复重订阅内部通过 ref 保证每次都是最新回调低端机滚动卡顿需要自己写节流或 RAF默认用 RAF 对齐渲染帧服务端渲染崩溃容易报 window is not defined内部判空提前 return进入页面时要立即执行一次需要手动调用回调开启 runOnMount 即可2. 三个关键设计取舍清理、最新回调与性能2.1 第一版事件绑定加清理所有 scroll hook 的起点都是最简单的封装function useOnWindowScroll(handler) { useEffect(() { window.addEventListener(scroll, handler); return () window.removeEventListener(scroll, handler); }, [handler]); }这段代码有一个明显问题useEffect 的依赖是 handler而 React 里每次渲染都会创建新的函数所以这个 effect 几乎每次渲染都会重新订阅。重订阅本身开销不大但如果 handler 内部依赖了组件最新状态旧监听被移除、新监听被挂上中间只要有一次没走清理就会累积出多个监听器滚动一次回调跑 N 遍。这也是我在线上项目里排查“滚动卡到爆炸”时见到最多的原因之一。2.2 stale closure 与 useRef 模式第二个问题是闭包捕获。假如你这么写const [page, setPage] useState(1); useOnWindowScroll(() { console.log(page); // 永远打印 1 setPage(page 1); });这里的 page 在 effect 创建时被快照了之后滚动回调里拿到的永远是初始值。有人会把 page 塞进 useEffect 的依赖数组里这确实能让问题消失但代价是每次 page 变化都重新绑定监听既不优雅也容易引入时序问题。正确的解法是把业务回调存进 refconst handlerRef useRef(handler); useEffect(() { handlerRef.current handler; });注意这个 effect 没有依赖数组它会在每次渲染后更新 ref。滚动监听的绑定只做一次但真正调用的永远是 handlerRef.current也就是最新的回调。这个模式在事件类 hook 里几乎是标配我后面所有的实现都建立在这个基础之上。2.3 passive 与 requestAnimationFrame 的性能组合滚动事件有多频繁主流浏览器帧率在 60fps 到 120fps用户快速滚动时一次滑动动作可能触发几十上百个 scroll 事件。如果每个事件都做 setState、读 layout主线程必然被塞满。这里有两个关键手段。第一个是 passive 监听器。给 addEventListener 的第三个参数传{ passive: true }相当于告诉浏览器“我不会在回调里调 preventDefault”浏览器就可以直接滚动不用等 JS 执行完。在 scroll 事件里调 preventDefault 本来就是徒劳的要拦截滚动应该拦截 wheel 或 touchmove所以这个选项几乎永远应该为 true。第二个手段是 requestAnimationFrame 合并。我习惯把同一帧内的多次 scroll 事件合并成一次回调可以理解成“批量打包发货”一秒钟滚动事件可能有几十个但我只保证在每一帧开始渲染前最多执行一次你的回调。这两个组合就是 performance 和 correctness 之间的最佳平衡点。加上清理函数里要记得 cancelAnimationFrame否则回调可能在组件卸载后的那一帧仍然执行。3. 完整源码实现与逐行解读3.1 基础版实现TypeScript 版我平时在项目里用的版本是这样一个 TypeScript 实现import { useEffect, useRef } from react; type ScrollHandler (event: UIEvent) void; interface UseOnWindowScrollOptions { passive?: boolean; runOnMount?: boolean; } export default function useOnWindowScroll( handler: ScrollHandler, options: UseOnWindowScrollOptions {} ): void { const { passive true, runOnMount false } options; const handlerRef useRef(handler); const frameRef useRefnumber | null(null); useEffect(() { handlerRef.current handler; }, [handler]); useEffect(() { if (typeof window undefined) return; const schedule (event: UIEvent) { if (frameRef.current ! null) return; frameRef.current requestAnimationFrame(() { frameRef.current null; handlerRef.current(event); }); }; window.addEventListener(scroll, schedule, { passive }); if (runOnMount) { schedule({ currentTarget: window } as UIEvent); } return () { window.removeEventListener(scroll, schedule); if (frameRef.current ! null) { cancelAnimationFrame(frameRef.current); frameRef.current null; } }; }, [passive, runOnMount]); return undefined; }3.2 逐段解读这个实现里最容易被忽略的是schedule函数里的 if 判断。滚动事件触发时如果frameRef.current不是 null说明这一帧已经安排过一次回调了直接返回不重复安排。直到 requestAnimationFrame 执行完把frameRef.current置回 null下一个事件才能再排。这就是“一帧最多一次”的核心逻辑。runOnMount的调用方式也是走schedule而不是直接调 handlerRef.current。因为初始化触发和滚动触发最好保持一致的行为都交给 RAF 调度避免同一个回调一个同步执行、一个异步执行导致时序不一致。清理函数里我额外加了 cancelAnimationFrame。组件卸载时这个帧回调不应该再执行否则 handler 里的 setState 会作用在一个已卸载的组件上虽然 React 18 之后不再警告但属于不必要的开销写清楚更稳妥。3.3 参数设计说明参数类型默认值说明handler(event: UIEvent) void必传滚动回调事件对象是原始 scroll eventpassivebooleantrue是否以 passive 模式监听默认开启runOnMountbooleanfalse挂载后是否立即触发一次进度条场景很需要如果你想用固定间隔节流而不是 RAF也可以给接口加一个 throttle 参数把 schedule 里的逻辑替换成时间戳判断if (throttle ! undefined) { const now Date.now(); if (now - lastRunRef.current throttle) { lastRunRef.current now; handlerRef.current(event); } }RAF 更适合 UI 联动场景因为它的执行时机和浏览器渲染帧对齐固定节流适合需要精确控制回调频率的场景比如埋点上报。两者没有绝对好坏按业务选即可。4. 实战场景从按钮到进度条的五种用法4.1 回到顶部按钮最典型的需求是滚动超过一定距离后显示“回到顶部”按钮function BackToTop() { const [visible, setVisible] useState(false); useOnWindowScroll(() { setVisible(window.scrollY 400); }); return ( button className{visible ? back-to-top--show : back-to-top} onClick{() window.scrollTo({ top: 0, behavior: smooth })} 返回顶部 /button ); }这段代码之所以简单可靠是因为每次滚动到达新的一帧setVisible 可以根据具体阈值做出判断。阈值 400 按需调整即可建议不要太小否则按钮出现得太频繁反而打扰阅读。4.2 阅读进度条阅读进度条是 useOnWindowScroll 配合 runOnMount 最好的例子function ScrollProgress() { const [progress, setProgress] useState(0); useOnWindowScroll(() { const doc document.documentElement; const scrollableHeight doc.scrollHeight - window.innerHeight; const ratio scrollableHeight 0 ? window.scrollY / scrollableHeight : 0; setProgress(Math.min(1, Math.max(0, ratio))); }, { runOnMount: true }); return ( div classNamescroll-progress style{{ transform: scaleX(${progress}) }} / ); }注意几个细节。第一用了 CSS transform 的 scaleX 而不是直接改 width因为 transform 不触发重排性能好很多。第二runOnMount 保证了刚进页面时进度条就正确不用等用户滚动一次才出现。第三scrollHeight 会随图片、字体加载而变首次计算可能不准确这是正常现象用户滚动一次后会修正。4.3 吸顶头部阴影导航栏在页面顶部时不需要阴影往下滚一点就加上这是很多后台页面都有的需求const [hasShadow, setHasShadow] useState(false); useOnWindowScroll(() { setHasShadow(window.scrollY 10); }); return header className{hasShadow ? header--shadow : header} /;这个场景最简单的写法就是调用一次 hook剩下全是样式问题。RAF 在这里的作用是避免在一帧内重复触发 setState减少 React 的渲染压力。4.4 触底加载更多无限滚动列表经常需要判断是否接近底部const [page, setPage] useState(1); const [loading, setLoading] useState(false); const [hasMore, setHasMore] useState(true); useOnWindowScroll(() { if (loading || !hasMore) return; const distance document.documentElement.scrollHeight - window.innerHeight - window.scrollY; if (distance 200) { setPage(p p 1); } });这里有个很容易踩的坑不能在兜底的判断里直接读 page 或 loading 当前值否则大概率拿到旧值。上面代码里 setPage 用了函数式更新完全绕开闭包问题。如果你的业务更复杂也可以把 loading、hasMore 都放进 ref 里统一管理。4.5 锚点导航高亮长文档页面右侧常见的“当前阅读到第几节”目录高亮也可以用这个 hook 实现const sections [ { id: intro, top: 0 }, { id: usage, top: 800 }, { id: api, top: 1800 }, ]; useOnWindowScroll(() { const y window.scrollY 80; let active sections[0].id; for (const s of sections) { if (y s.top) active s.id; else break; } setActiveId(prev (prev active ? prev : active)); });上面的setActiveId用函数式更新加了一个判断只有高亮项变化时才触发重渲染。这个优化对于长页面滚动非常重要否则滚一次就 setState 一次页面会明显发虚。4.6 一个额外的经验谈滚动类 UI 有个通病很多人喜欢直接在回调里 setState每帧都让 React 走一遍渲染流程哪怕只是改一个 transform 值。遇到这种情况我的习惯是“能用内置状态管理就直接用比如 CSS 变量的切换需要在滚动回调里高频改样式时直接操作 DOM ref 反而更省事”。这个 hook 只负责把滚动事件安全地交给你真正高频更新时你完全可以绕开 React 状态改成 style.transform 赋值。5. 踩坑实录滚动监听的常见问题与排查5.1 回调里读到的 state 永远是旧的这是滚动 hook 使用率最高的疑问。原因就是闭包快照。在 useEffect 注册监听时回调捕获了当时的 state 值之后每次调用都沿用这份快照。解法有两个一是用 setState 函数式更新二是把要读的变量同步到 refconst latestRef useRef({ loading, hasMore, }); useEffect(() { latestRef.current { loading, hasMore }; });这两个方案我建议按场景选简单判断逻辑用函数式更新就够了需要同时读多个变量的复杂逻辑就上 ref。5.2 滚动一次回调触发多次先确认是不是同一个 hook 在多个组件里各自注册了监听器。如果是每个组件各触发一次是正常行为。真正的问题场景是同一个组件里因为缺少清理导致监听器被重复挂载。排查方法很简单在回调第一行加一个计数器滚动一次如果计数增加了好几倍基本就是重复订阅。用我们实现的 hook因为 RAF 合并了同帧事件同一帧内即使多个 scroll 事件也只掉一次回调排除这个因素后再查组件是否被多次挂载。5.3 低端机上滚动手感黏滞这个要分清是 React 渲染瓶颈还是样式瓶颈。如果是 setState 导致每帧全量渲染先看能不能把 setState 改成上面提到的 DOM 直改如果是回调里读了 offsetTop、offsetHeight 这类值注意这些属性会强制同步重排建议把这些值缓存起来不要在滚动回调里反复读。页面上大量元素加了 box-shadow 或 filter 也可能加重滚动负担可以凭经验优先排查这两个方向。5.4 服务端渲染下 window is not defined我们的 hook 内部做了typeof window undefined判断所以 hook 本身不会崩。但如果你在组件顶层直接写了window.scrollY或者useState(window.innerWidth)SSR 时照样崩。解决办法是在 useEffect 里初始化这些值或者用typeof window ! undefined做一次判空再或者把初始状态保守地设为 0。5.5 移动端滚动事件不触发或触发异常移动端页面如果是在原生容器内滚动比如某个 div 设置了 overflow: auto那滚动事件发生在容器元素上而不是 window 上。这种场景应该用下一节的通用版 hook把 target 传成那个容器。另外移动端橡皮筋回弹效果会产生滚动位置回退如果你的业务逻辑严苛依赖于“向下滚动时必须增加”需要在逻辑里判断一下回到边界值的场景。5.6 passive 的边界passive: false在 scroll 事件里基本用不上。有同学为了拦截滚动去设置 passive: false结果滚动变得奇慢因为浏览器要等 JS 回调执行完才确定是否阻止默认行为。要拦截的是 wheel、touchmove 这类可以 preventDefault 的事件而不是 scroll。我们的 hook 默认传 true也强烈建议保持 true。6. 扩展思路从窗口滚动到任意容器滚动6.1 支持任意容器的通用版本窗口滚动只是容器滚动的一个特例。我项目里实际用的版本其实是一个泛化版 useOnScrolltarget 默认是 window但也支持传任意 DOM 元素function useOnScroll( handler: (event: Event) void, target: Window | HTMLElement | null window, options: UseOnWindowScrollOptions {} ): void { const targetRef useRef(target); useEffect(() { targetRef.current target; }, [target]); useEffect(() { if (typeof window undefined || !targetRef.current) return; const onScroll (event: Event) { handlerRef.current(event); }; targetRef.current.addEventListener(scroll, onScroll, { passive: true }); return () targetRef.current?.removeEventListener(scroll, onScroll); }, [target]); }想看某个容器的滚动距离时注意取值差异window 用window.scrollY普通元素用element.scrollTop。封装时可以让 callback 直接拿到滚动进度或者位置而业务侧不用关心 target 是谁。6.2 和 IntersectionObserver 怎么分工有很多时候你其实不需要感知滚动位置只需要知道“某个元素是否进入了视口”。这种情况用 IntersectionObserver 更合适它由浏览器底层实现不依赖 scroll 事件回调时机也能精确到交叉比例。触底加载、懒加载图片这类场景优先考虑 IntersectionObserver。而 useOnWindowScroll 更适合处理“滚动到什么位置”的连续状态比如进度条、吸顶阴影、回到顶部按钮。这两者不冲突一个管连续位置一个管进入视口按需配合。6.3 还能往上加什么我在这个 hook 基础上扩展过两个小能力。第一个是滚动方向判断用 ref 记住上一次 scrollY回调里对比即可const lastYRef useRef(0); useOnWindowScroll(() { const y window.scrollY; const dir y lastYRef.current ? down : up; lastYRef.current y; // dir 就是滚动方向 });第二个是滚动停止检测用 setTimeout 做 debounce在停止滚动 150 毫秒后触发一次回调。这类操作在深入阅读统计、吸附导航场景里都很有用。我自己在多个后台管理系统和营销页面里用过这个 hook 和它的泛化版本最深的体会是事件监听这类副作用天然适合封装成 hook但封装时一定要把清理、最新回调、性能这三件事做扎实。否则上线的不是便利是隐患。把窗口滚动这件事的边界想清楚之后以后任何 scroll 相关需求都能往这个框架里套这也是“序号 39”这个 hook 后面隐藏的价值所在。