滚动事件大概是前端里最常写、也最容易写坏的事件之一。随便打开一个线上项目,十个页面里至少有八个的 scroll 监听是“裸奔”的:没有清理、没有节流、回调里读到的 state 还是组件第一次渲染时的旧值。useOnWindowScroll 看着简单,做对了却要处理不少边角——它把一个业务组件里的滚动副作用收敛成一行可复用的调用,让“监听窗口滚动”这件事不再给你惹麻烦。这篇文章我会从设计取舍、源码实现到五个真实场景逐一拆解,把我自己踩过的坑也一并写出来。
1. 为什么滚动监听值得封装成一个 Hook
1.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 变化都重新绑定监听,既不优雅也容易引入时序问题。
正确的解法是把业务回调存进 ref:
const 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 = useRef<number | 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 event |
| passive | boolean | true | 是否以 passive 模式监听,默认开启 |
| runOnMount | boolean | false | 挂载后是否立即触发一次,进度条场景很需要 |
如果你想用固定间隔节流而不是 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 className="scroll-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 函数式更新,二是把要读的变量同步到 ref:
const 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 支持任意容器的通用版本
窗口滚动只是容器滚动的一个特例。我项目里实际用的版本其实是一个泛化版 useOnScroll,target 默认是 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 后面隐藏的价值所在。