React滚动位置Hook导致滚动时组件持续重渲染如何优化
你的认知确实有偏差,当前的持续重渲染是完全符合React运行规则的预期行为
根本原因很简单:你写的useScrollPosition内部,每次scroll事件触发都会执行setScrollPosition更新Hook自身的state。只要调用了这个Hook的组件,不管你后续拿这个返回值做什么判断,只要Hook里的state变了,组件就会先触发重渲染。
你后面写的useEffect阈值判断,是组件已经因为scrollPosition变化完成重渲染之后才会执行的逻辑,根本拦不住前面的重渲染流程——实际运行逻辑是:滚动触发→Hook更新state→组件重渲染→effect执行判断阈值,发现没跨10px就不更新scrolled,不会触发第二次渲染而已,但是第一次无意义的重渲染已经发生了。
问题根源
你把连续变化的滚动位置存在了通用Hook的state里,所有用这个Hook的组件,都会被迫接收每一次滚动的位置更新,哪怕组件根本不需要知道精确的滚动距离,只关心「有没有超过10px」这种二值状态。
可落地的优化方案
- 方案一:把阈值判断下沉到Hook内部,从源头挡住无意义的state更新
如果你的常用场景就是判断滚动是否跨过固定阈值,可以直接改造Hook支持传入阈值参数,只有状态真的发生变化的时候才更新state,不要把每次滚动的精确位置都抛给组件。
改造后的Hook代码:
import { useLayoutEffect, useState } from 'react'; const useScrollPosition = (threshold) => { // 传了阈值就直接存布尔值,没传就存原始滚动距离 const [scrollValue, setScrollValue] = useState(() => { const yOffset = window.pageYOffset; return typeof threshold === 'number' ? yOffset > threshold : yOffset; }); useLayoutEffect(() => { const handleScroll = () => { const yOffset = window.pageYOffset; const newVal = typeof threshold === 'number' ? yOffset > threshold : yOffset; // 只有值和之前不一样才调用setState,完全一样就不触发更新 setScrollValue(prev => prev === newVal ? prev : newVal); }; // 加passive: true提升滚动性能,告诉浏览器不会阻止默认滚动行为 window.addEventListener('scroll', handleScroll, { passive: true }); return () => window.removeEventListener('scroll', handleScroll); }, [threshold]); return scrollValue; }; export default useScrollPosition;
对应业务组件可以直接简化,删掉多余的scrolled状态和对应的effect:
const Component = () => { // 直接拿到是否滚动超过10px的布尔值,只有跨阈值的瞬间才会触发重渲染 const scrolled = useScrollPosition(10); return ( <div className={clsx(style.element, { [style.elementScrolled]: scrolled, })} > {children} </div> ); };
- 方案二:如果要保留Hook返回全量滚动位置的通用能力,改成单例订阅模式
如果有的场景需要拿精确滚动位置,有的场景只需要阈值判断,可以把scroll监听做成全局单例,整个应用只注册一次scroll事件,组件按需订阅自己需要的派生值,只有派生值变化的时候才触发组件更新:
import { useLayoutEffect, useState, useRef } from 'react'; // 全局单例相关变量,整个应用共用一个scroll监听 const listeners = new Set(); let isBound = false; const triggerListeners = () => { const yOffset = window.pageYOffset; listeners.forEach(listener => listener(yOffset)); }; const bindGlobalScroll = () => { if (isBound) return; window.addEventListener('scroll', triggerListeners, { passive: true }); isBound = true; }; const unbindGlobalScroll = () => { if (listeners.size > 0) return; window.removeEventListener('scroll', triggerListeners); isBound = false; }; const useScrollPosition = (selector = val => val) => { const [value, setValue] = useState(() => selector(window.pageYOffset)); // 用ref存最新的选择器函数,避免因为选择器变化重新绑定监听 const latestSelector = useRef(selector); latestSelector.current = selector; useLayoutEffect(() => { bindGlobalScroll(); const listener = (yOffset) => { const newVal = latestSelector.current(yOffset); setValue(prev => prev === newVal ? prev : newVal); }; listeners.add(listener); return () => { listeners.delete(listener); unbindGlobalScroll(); }; }, []); return value; }; export default useScrollPosition;
使用的时候灵活度很高:需要全量滚动位置就直接const y = useScrollPosition(),需要判断阈值就传选择器const isScrolled = useScrollPosition(y => y > 10),需要判断是否触底就传const isBottom = useScrollPosition(y => window.innerHeight + y >= document.body.scrollHeight - 20),所有场景下都只有返回值真的变化的时候才会触发组件重渲染,而且全局只有一个scroll监听,性能比每个组件自己绑监听好很多。
内容的提问来源于stack exchange,提问作者Alex

