React(NextJS)中window滚动事件无法获取最新状态问题求解
问题根源
这是React Hooks典型的闭包陈旧值问题:空依赖数组的useEffect仅在组件首次挂载时执行一次,此时绑定到window上的scroll事件回调,会被闭包锁定在首次渲染的上下文里,回调内部引用的router、isComplete、isFetching等所有状态,永远是组件挂载时的初始值,后续状态更新不会同步到已绑定的回调函数中。
如果直接移除空依赖数组、不做清理逻辑,会在每次相关状态更新时新增一个scroll监听器,旧监听器不会自动销毁,最终出现事件触发次数远超预期的问题。
解决方案
方案1:ref同步最新状态(性能优先推荐)
这个方案不需要频繁解绑/重绑滚动事件,通过ref挂载所有回调需要访问的动态值,让事件回调永远能读到最新状态,完全避开闭包问题,是滚动这类高频事件场景下的最优实现。
import { useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/router'; // 组件内核心逻辑 const router = useRouter(); const infiniteScroll = useRef(null); // 新建ref存储滚动回调需要用到的所有动态值 const latestStateRef = useRef({ isComplete: false, isFetching: false, routerQuery: router.query }); // 每次相关状态更新时,同步最新值到ref useEffect(() => { latestStateRef.current = { isComplete, isFetching, routerQuery: router.query }; }, [isComplete, isFetching, router.query]); const handleScroll = useCallback(() => { const el = infiniteScroll.current; if (!el) return; const rect = el.getBoundingClientRect(); const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const viewportWidth = window.innerWidth || document.documentElement.clientWidth; const isVisible = rect.top >= 0 && rect.left >= 0 && rect.bottom <= viewportHeight && rect.right <= viewportWidth; // 从ref中读取最新状态,不存在闭包陈旧问题 const { isComplete: latestIsComplete, isFetching: latestIsFetching, routerQuery } = latestStateRef.current; if (isVisible && !latestIsComplete && !latestIsFetching) { const params = { ...routerQuery }; params.page = params.page ? (parseInt(params.page) + 1).toString() : '1'; router.replace({ query: params }, undefined, { scroll: false }); } }, [router]); // 仅在组件挂载、卸载时绑定/解绑一次事件,不会重复绑定 useEffect(() => { window.addEventListener('scroll', handleScroll, { passive: true }); return () => window.removeEventListener('scroll', handleScroll); }, [handleScroll]);
优化提示:给scroll事件添加
passive: true配置可以避免事件回调阻塞浏览器滚动渲染,大幅提升页面滚动流畅度;另外不要直接修改router.query这个只读对象,操作前先做浅拷贝,避免触发隐式异常。
方案2:useEffect全依赖+清理函数
如果不想引入额外ref,可以把事件回调定义在useEffect内部,把所有回调用到的状态加入依赖数组,同时在effect的返回函数里每次重绑前先移除旧监听器,保证同一时间只有一个生效的监听器。这个方案逻辑更直观,但因为每次状态变化都会重绑事件,高频滚动场景下性能略差于方案1。
useEffect(() => { const handleScroll = () => { const el = infiniteScroll.current; if (!el) return; const rect = el.getBoundingClientRect(); const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const viewportWidth = window.innerWidth || document.documentElement.clientWidth; const isVisible = rect.top >=0 && rect.left >=0 && rect.bottom <= viewportHeight && rect.right <= viewportWidth; if (isVisible && !isComplete && !isFetching) { const params = { ...router.query }; params.page = params.page ? (parseInt(params.page) + 1).toString() : '1'; router.replace({ query: params }, undefined, { scroll: false }); } }; window.addEventListener('scroll', handleScroll, { passive: true }); // 每次effect重跑前、组件卸载时,移除上一次绑定的监听器 return () => window.removeEventListener('scroll', handleScroll); // 把所有回调内用到的外部值全部加入依赖数组 }, [isComplete, isFetching, router, infiniteScroll.current]);
常见避坑点
- 不要试图通过在滚动回调里触发强制重渲染的方式拿最新值,会导致滚动过程中组件频繁重绘,引发页面卡顿
- 不要用脱离React生命周期的全局变量存储状态,多组件实例共存时会出现状态串扰
- 全窗口滚动场景不需要给容器设置固定高度加
overflow-y: scroll,直接监听window的scroll事件就是符合需求的原生实现
内容的提问来源于stack exchange,提问作者Nina Horne
相关产品推荐
相关产品推荐

