news 2026/10/18 11:36:16

从零手写 useOnWindowScroll:React 滚动监听 Hook 的封装与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手写 useOnWindowScroll:React 滚动监听 Hook 的封装与性能优化

做前端这些年,有个特别容易被低估的能力,叫“把重复代码收进自定义 Hook 里”。在我整理的高频 Hook 清单里,useOnWindowScroll 恰好排在第 39 个——一个专门监听 window 滚动事件的小工具。它解决的问题非常聚焦:回到顶部按钮、吸顶导航、滚动进度条、阅读进度提示,这些场景统统离不开 window 的 scroll 事件。页面里每多一个滚动关联组件,如果都裸写一遍 addEventListener,那你很快就会被重复绑定、忘记清理、内存泄漏这几个老问题缠上。

useOnWindowScroll 的用法就一行:传入滚动回调,Hook 内部负责绑定、更新、卸载,组件代码立刻干净不少。这篇文章我会从零手写这个 Hook,把每个设计点掰开讲清楚,包括为什么用 ref 存回调、为什么 cleanup 里的 capture 参数容易埋雷、为什么要配合 passive 和 rAF 做性能优化。适合刚接触自定义 Hook 的 React 开发者,也适合想把自己项目里的滚动监听代码收拢成工具的老手。

1. 为什么要把它封装成一个开箱即用的 Hook

1.1 原生监听的三座大山

很多新手写滚动相关功能,第一个版本通常长这样:

useEffect(() => { window.addEventListener('scroll', handleScroll); }, []);

看起来没毛病,但问题在后面。第一,组件一旦涉及多个滚动功能,每个组件里都要重复这段代码,文件之间互相复制粘贴,改个监听参数就要全局搜索。第二,如果忘记在 return 里移除监听,组件卸载后回调依然会被触发,滚动一多,控制台就开始刷 “Can't perform a React state update on an unmounted component”。第三,回调函数在每次渲染时都是新引用,如果你把它写进依赖数组,React 会觉得“这个副作用又变了”,于是反复移除和绑定监听,性能损耗虽然不大,但调试时很容易让人怀疑人生。

这三个问题本质上都是“副作用生命周期管理”问题。React 的哲学是:副作用应该交给 useEffect,由它统一负责绑定和清理。但原生 API 不会替你记住应该怎么收尾,它只会老老实实把监听从绑定的那一刻一直保留到移除。所以我们需要一个自定义 Hook,把“绑定 + 清理 + 更新”三件事一次性封装好。

1.2 React 的约定:副作用就该交给 useEffect

在 React 函数组件里,每次渲染都会重新执行组件函数,而 useEffect 的依赖数组决定了副作用何时重建。useOnWindowScroll 的核心思路很简单:把 window.addEventListener 放进某个 useEffect,把对应的 removeEventListener 放进这个 effect 的 cleanup 函数。

这样设计的好处是,组件卸载时 React 会自动执行 cleanup,监听器被移除,内存泄漏的可能性从源头被掐掉。如果你在依赖数组里声明了需要更新的回调,React 会在下一次渲染时先清理旧监听,再注册新监听,保证回调永远是最新的。

我见过有人在组件里自己维护一个“是否已卸载”标志位,或者在 unmount 时手动调用移除函数,这些都是冗余操作。只要把绑定放进 effect、把移除放进 cleanup,React 的生命周期机制会替你收好尾。

1.3 什么时候用它,什么时候直接上现成库

市场上已经有一些成熟的滚动监听 Hook,比如某些组件库自带的 useScroll,它返回 scrollY、scrollX、滚动方向等等一系列状态,开箱即用。但这类库往往还附带一堆你可能用不上的能力,包体积也不小。

我的建议是:如果你的需求只是“监听 window 滚动,然后自己处理业务逻辑”,比如判断 scrollY 超过 400px 就显示回到顶部按钮,那 useOnWindowScroll 这个十行级别的小 Hook 完全够用,而且你可以完全掌控它的行为。如果需求是“要在任意 DOM 元素内监听滚动、需要滚动方向、需要节流配置、还要跟 IntersectionObserver 联动”,那直接选择一个成熟的滚动状态库更划算。自己写容易把边界情况搞复杂,交给社区维护的库反而更省心。

2. 手写 useOnWindowScroll 的完整实现

2.1 第一版:十行代码的最小实现

先把最直观的版本写出来:

import { useEffect } from 'react'; export function useOnWindowScroll(handler, { capture = false, passive = true } = {}) { useEffect(() => { window.addEventListener('scroll', handler, { capture, passive }); return () => { window.removeEventListener('scroll', handler, { capture }); }; }, [handler, capture, passive]); }

这个版本已经能跑。它支持传入回调 handler,也支持透传 capture 和 passive。依赖数组里包含这三个变量,任何一项变化都会触发重绑定,行为是准确的。

但实际用起来有个麻烦:你得保证 handler 本身是稳定引用。如果调用方写的是:

useOnWindowScroll(() => { setShowBackTop(window.scrollY > 400); });

那么这个箭头函数在每次父组件渲染时都是新对象,依赖数组里的 handler 每次都变,于是 effect 每次渲染都会先移除再绑定。这段时间里如果恰好发生滚动,可能会出现一次回调丢失,也可能导致监听频繁重建。

2.2 进阶版:用 ref 解决 handler 反复变更

为了让调用方可以放心传内联函数,我们需要把 handler 的引用问题消化在 Hook 内部。方案是使用 useRef 维护一个“最新回调”的盒子:

import { useEffect, useRef } from 'react'; export function useOnWindowScroll(handler) { const handlerRef = useRef(handler); useEffect(() => { handlerRef.current = handler; }, [handler]); useEffect(() => { const onScroll = (event) => { handlerRef.current(event); }; window.addEventListener('scroll', onScroll, { passive: true }); return () => { window.removeEventListener('scroll', onScroll); }; }, []); }

这个版本的思路值得细品。第一个 effect 负责在每次组件渲染后把最新的 handler 写进 ref,第二个 effect 只执行一次,把真正稳定的 onScroll 函数注册到 window。由于 onScroll 永远引用 handlerRef.current,所以它拿到的始终是最新的回调,但监听器本身不需要重新注册。

调用方从此可以放心写:

useOnWindowScroll(() => { setShowBackTop(window.scrollY > 400); });

父组件再怎么重渲染,监听器也只会绑定一次,干净利落。

2.3 cleanup 里的暗坑:add 和 remove 的参数必须一一对应

这个坑我踩过不止一次。第一版代码里,addEventListener 时传了{ capture: true, passive: true },removeEventListener 时如果只传window.removeEventListener('scroll', onScroll),没有传 capture,那监听器根本移除不掉。

原因在于浏览器的监听器匹配规则:removeEventListener 需要至少匹配事件的类型、回调函数引用和 capture 布尔值。capture 传 false 和传 undefined 在某些浏览器的行为并不完全一致,稳妥的做法是让 remove 的参数和 add 保持一致,或者干脆都用默认值。

写 Hook 时,我习惯把事件名、回调、capture 值统一存成常量,这样 add 和 remove 天然对称。

2.4 参数设计:capture、passive 与按需启停

除了 handler,useOnWindowScroll 最常见的三个可配置项是 capture、passive 和 enabled。我一般按这个表格来设计参数:

参数名类型默认值说明
handlerfunction必填滚动时触发的回调,建议直接用内联箭头函数
capturebooleanfalse是否在捕获阶段触发,通常不需要改为 true
passivebooleantrue告知浏览器不会调用 preventDefault,提升滚动性能
enabledbooleantrue是否启用监听,方便在不需要时一键停用

capture 在 scroll 监听里基本用不到。window 上的滚动事件监听,默认冒泡阶段触发已经足够,把 capture 设为 true 反而容易和其他元素的捕获监听产生干扰。passive 则建议保持默认 true,浏览器会因此允许优化滚动相关事件的处理,尤其对移动端滚动流畅度有帮助。

enabled 参数可以这样加:

export function useOnWindowScroll(handler, { enabled = true } = {}) { useEffect(() => { if (!enabled) return; const onScroll = () => handlerRef.current(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, [enabled]); }

停用监听比在回调里加 if 判断更彻底,因为 enabled 为 false 时根本不会注册监听。

3. 真实项目里踩过的 5 个坑

3.1 滚动事件“不触发”:绝大多数是绑定时机的问题

有朋友跟我说,他调用了 useOnWindowScroll,但回调一次都不执行。我让他先检查组件本身有没有渲染。这是一个容易被忽略的点:如果 Hook 被调用在某个条件渲染的组件里,而这个组件在首屏还没挂载,那监听器自然还没注册。等用户开始滚动时,组件倒是挂载了,但监听器直到组件卸载前都只会注册一次,不会补上“历史滚动位置”。

另外一个经典问题是,组件挂载时用户已经处于页面中部,但监听器只在滚动发生后触发回调,导致比如“回到顶部按钮”一开始不该显示却显示了,或者一开始该显示却没显示。解决方案是在组件挂载后手动补一次回调调用:

useEffect(() => { handlerRef.current(); }, []);

这样首屏就会同步一次当前滚动状态,之后再交给滚动事件实时更新。

3.2 组件卸载后的 setState 警告

这个问题在 React 16 和 17 里很常见,18 之后警告弱化了,但内存泄漏风险依然在。表现是组件已经卸载,滚动监听却没有移除,导致回调里执行了 setState,React 警告 “Can't perform a React state update on an unmounted component”。

根因就是 cleanup 没有写,或者 cleanup 的 capture 参数不匹配导致移除失败。我排查时的套路是:先在控制台打印 addEventListener 和 removeEventListener 是否成对执行,再用 DevTools 的 Performance 面板录制一段滚动,看滚动事件回调是不是还在触发。绝大多数情况,问题都出在 add 和 remove 的参数不对称。

3.3 滚动卡顿:passive、rAF 与节流的组合拳

滚动事件的触发频率非常高,移动端低端机型一秒钟可以触发六七十次。如果每次触发都执行复杂的计算或多次 setState,页面会明显掉帧。第一个优化是 passive:true,告诉浏览器这个监听器不会调用 preventDefault,浏览器可以放心地走合成器线程优化滚动,不打断主线程工作。第二个优化是配合 requestAnimationFrame 做节流,让回调最多每帧执行一次,我一般在 4.3 小节里展开这块。

第三个容易被忽略的点是 setState 的调用姿势。每次滚动都 setState 同一个布尔值,React 会做浅对比,值没变化时不触发重新渲染,这个还好。但如果你每次滚动都滚动条位置写入 state,比如进度条数值,那每次都会触发重渲染。方案是把滚动条数值写进 ref,然后用 CSS 变量或直接操作 DOM 更新视觉,避免 React 重渲染的成本。

3.4 StrictMode 下监听器翻倍了

React 18 的 StrictMode 在开发环境下会故意让 effect 执行两次:挂载、清理、再挂载。这么做是为了暴露副作用里的清理逻辑缺陷。如果你的 effect 只写了 addEventListener,没有写清理函数,开发模式下就会看到两个监听器同时存在,滚动一次回调执行两次。

这不是 bug,而是 React 在模拟组件的卸载再挂载。只要你的 cleanup 正确移除监听器,第二次挂载时监听器会重新注册,最终只会有一份监听。所以第一次遇到这种情况时不要慌,把它当作检查 cleanup 写没写对的机会。

3.5 SSR 场景下 window is not defined

服务端渲染时,组件代码会在 Node 环境执行,window 并不存在。如果你在函数体顶层直接访问 window,比如用 window.scrollY 初始化某个 state,就会抛出 ReferenceError。正确的做法是把所有依赖 window 的代码放进 useEffect,因为 useEffect 只在客户端渲染完成后执行,而函数体本身在服务端和客户端都会执行。

我们的 useOnWindowScroll 已经天然规避了这个问题,因为 window.addEventListener 发生在 effect 内部。但如果你在组件里这样写:

const [scrollY, setScrollY] = useState(window.scrollY);

那就踩雷了。要改成:

const [scrollY, setScrollY] = useState(0); useEffect(() => { setScrollY(window.scrollY); }, []);

虽然会有一个从 0 到真实值的闪烁过程,但至少不会在服务端崩溃。

4. 适用场景与扩展思路

4.1 回到顶部按钮和滚动进度条

回到顶部按钮是最典型的 useOnWindowScroll 场景。代码很朴素:

const [showBackTop, setShowBackTop] = useState(false); useOnWindowScroll(() => { setShowBackTop(window.scrollY > 400); });

滚动进度条也类似,区别在于要把滚动位置转换成百分比:

const [progress, setProgress] = useState(0); useOnWindowScroll(() => { const total = document.documentElement.scrollHeight - window.innerHeight; setProgress(total > 0 ? window.scrollY / total : 0); });

然后通过 style 里的宽度或 transform 把 progress 渲染出来。这两个场景都是典型的“依赖具体滚动位置”的 UI,很适合用这个 Hook。

4.2 吸顶导航与移动端交互栏

吸顶导航的判断逻辑是 scrollY 是否超过某个阈值。超过之后给导航加 fixed 定位或者添加阴影类名,我的习惯是把类名切换和样式变化分开处理,避免每次滚动都触发 React 渲染。做法是把 “是否吸顶”存进 state,只有状态切换时才触发渲染:

const [isSticky, setIsSticky] = useState(false); useOnWindowScroll(() => { const next = window.scrollY > 80; setIsSticky((prev) => (prev !== next ? next : prev)); });

这里用函数式更新和守卫判断,确保每次滚动最多只有一次 setState 有效变更。移动端的“下滑隐藏底部栏、上滑显示底部栏”也是同一个思路,只是还要记录上一次滚动方向,判断时需要参考上次的 scrollY 值。

4.3 交给 requestAnimationFrame 做滚动节流

如果滚动回调本身要做一些比较重的视觉更新,比如读取元素位置、修改 transform、计算视差偏移,那可以给 useOnWindowScroll 做一个 rAF 增强版:

import { useEffect, useRef } from 'react'; export function useOnWindowScrollRaf(handler) { const ticking = useRef(false); const handlerRef = useRef(handler); useEffect(() => { handlerRef.current = handler; }, [handler]); useEffect(() => { const onScroll = () => { if (ticking.current) return; ticking.current = true; requestAnimationFrame(() => { handlerRef.current(); ticking.current = false; }); }; window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, []); }

原理很简单:ticking 作为一个锁,保证一帧内只会有一个 rAF 回调排队,从而把高频滚动压到跟屏幕刷新率一致。相比之下,setTimeout 节流会出现时间间隔和滚动事件堆积的问题,rAF 的优势是天然和浏览器渲染帧对齐,不会产生额外延迟感。

4.4 与 IntersectionObserver 的分工

写滚动监听久了会发现,很多场景其实不需要监听 scroll。比如图片懒加载、组件进入视口的曝光统计、无限滚动加载更多,这些用 IntersectionObserver 更合适。它由浏览器底层实现,性能和精度都比在 scroll 回调里手动计算 getBoundingClientRect 好得多,还不需要关心节流和清理。

但如果你的 UI 要依赖“滚动位置的绝对值”,比如进度条、滚动阈值切换状态、恢复滚动位置,那 IntersectionObserver 帮不上忙,必须依赖 scroll 监听。我的经验是:先问自己“这个交互要不要知道当前到底滚了多少像素”,需要就上 useOnWindowScroll,只需要知道“某个元素在不在视口里”,直接上 IntersectionObserver。把这两者的边界划清楚,代码既轻量又好维护。

最后分享一个我实际用下来的选择标准:如果只是给组件加一个回到顶部按钮,或者搞一个吸顶导航,useOnWindowScroll 的十行实现完全够用,不要为了这点需求引入一整个滚动库。要是你还想同时管理 scrollY、scrollLeft、滚动方向,甚至要求节流频率可配置,那就直接找一个成熟封装好的 useScroll 类型库,省得自己维护边界情况。两种方案我都放进过生产项目,前者轻、快、可控,后者省心、功能全,关键别在一个自定义 Hook 里既要又要,把职责收窄,用得才顺手。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 11:35:06

内容生成前的资料整备:避免“无标题”错误的项目提交指南

抱歉,我注意到您提供的输入内容中缺少“项目标题”和“项目正文”等关键信息。当前输入中标题为“【无标题】”,也没有可用的热搜词和网络搜索内容,我无法基于空内容生成有价值的博文。请您按以下格式重新提供项目信息:项目标题: …

作者头像 李华
网站建设 2026/10/18 11:35:00

机器人竞赛硬件设计实战:从供电到CAN总线的核心要点

1. 从零吃透一套机器人竞赛硬件讲义:这份V0.2.1到底讲了什么如果你正在准备机器人竞赛,或者刚加入某个做移动机器人平台的团队,大概率会遇到一个很现实的问题:软件算法写得再漂亮,板子一上电就烧、电机一给指令就抖、通…

作者头像 李华
网站建设 2026/10/18 11:33:22

基于以太坊的车险理赔系统:智能合约如何解决理赔慢与骗保问题

简介:这份资料面向计算机相关专业学生与开发者,围绕基于以太坊区块链的车险理赔系统展开,覆盖Solidity智能合约、web3.js交互、Vue前端与node.js部署脚本等完整技术链路。项目适合用作毕业设计、课程设计或入门区块链DApp开发的实战素材&…

作者头像 李华
网站建设 2026/10/18 11:31:16

油井生产动态预测模型选型:从ARIMA到深度学习的对比实践

简介:这份源码包面向石油工程师、数据科研人员及深度学习入门者,针对油井生产动态预测这一关键难题,系统集成了CNN、RNN、LSTM、Self-Attention与Seq2Seq等多种主流模型的实现,并附有完整对比分析过程。包体共232个文件&#xff0…

作者头像 李华
网站建设 2026/10/18 11:30:26

从Vibe Coding到实战:用AI从零做出一个习惯打卡小程序

最近社区里冒出个高频词“vibe coding”,意思是“靠感觉编程”——你不用一板一眼抠语法,而是用大白话不断跟AI描述想法、反馈体验、调整方向,让AI写代码,你主要管节奏和决策。我给自己定了个小目标:用AI“vibe”出一个…

作者头像 李华
网站建设 2026/10/18 11:29:49

AIS解码全链路解析:从NMEA句子到船位经纬度的协议还原技术

简介:面向AIS(船舶自动识别系统)开发与学习的小型资料包,适合需要快速理解VHF信号接收、ITU-R M.1371报文解码以及AIS数据解析的工程师或爱好者。资源共3个文件,包含2个txt文本和1个cpp源文件,压缩包仅5KB&…

作者头像 李华