React 组件中使用两个 useEffect 处理滚动和路由切换是否合理?
结论
同一个组件里使用两个 useEffect 是合理的。滚动监听和路由切换时的重置由不同条件触发,分开处理更清楚。
建议用 useRef 保存滚动位置,只用 useState 控制 Header 是否隐藏。这样不用在每次滚动时更新两个位置状态,也不用随着位置变化反复移除、添加监听器。
原代码的问题在哪里?
设置 state 后,当前回调仍然读到旧值
原代码在滚动事件中执行:
setScrollPrev(scrollCurrent);
setScrollCurrent(window.scrollY);
if (scrollCurrent - scrollPrev > 0) {
// 隐藏 Header
}
调用 setter 会请求 React 更新状态,但当前回调中的 scrollCurrent 和 scrollPrev 不会因此改变。这次比较用的是回调捕获的旧值,而不是本次事件的 window.scrollY 和上一次位置,因此方向判断可能滞后。
滚动位置变化会让监听器重新注册
监听滚动的 Effect 依赖 [scrollCurrent, scrollPrev]。这些状态变化后,React 会先运行清理函数,再执行 Effect,重新注册监听器。
清理函数可以避免监听器不断累积,但这些重复注册可以省去。滚动位置只供下一次事件读取,不需要参与 JSX 渲染,适合存入 useRef。
重置数值并不会清除隐藏类
路由切换时,原代码只把两个位置状态设为 0,没有移除通过 classList.add() 添加的 header-hidden。如果没有后续滚动事件,隐藏类就可能一直保留。
另外,共享的路由布局通常在子路由切换时保持挂载。组件保持挂载时仍然可能重新渲染;useLocation() 可以让组件响应 location 变化,无须依赖卸载或刷新页面来恢复 Header。
推荐实现
保留两个 Effect:一个在 pathname 改变时恢复显示,另一个负责注册和清理滚动监听。
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
function Header() {
const { pathname } = useLocation();
const previousScrollY = useRef(0);
const [hidden, setHidden] = useState(false);
useEffect(() => {
previousScrollY.current = window.scrollY;
setHidden(false);
}, [pathname]);
useEffect(() => {
previousScrollY.current = window.scrollY;
const handleScroll = () => {
const currentScrollY = window.scrollY;
const previous = previousScrollY.current;
if (currentScrollY === previous) {
return;
}
previousScrollY.current = currentScrollY;
setHidden(currentScrollY > 0 && currentScrollY > previous);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return (
<div
id="site-header"
className={`header flex w-full items-center justify-between p-2 border-b-2 border-(--sub) sticky${hidden ? " header-hidden" : ""}`}
>
<p>Header Content</p>
</div>
);
}
export default Header;
如果项目尚未定义对应样式,可以参考:
.header {
position: sticky;
top: 0;
transition: transform 160ms ease;
}
.header.header-hidden {
transform: translateY(-100%);
}
已有样式时,按实际布局调整即可。
为什么这样能减少不必要的更新?
previousScrollY.current 可以在事件中同步读取和修改,修改它不会触发组件渲染。监听器每次都能拿到最近一次滚动位置,也不需要把位置加入 Effect 的依赖数组。
hidden 直接决定 JSX 中是否出现 header-hidden,因此应该使用 state。持续向下滚动时,即使多次调用 setHidden(true),相同的布尔值通常也不会触发新的有效渲染。
这里可以使用空依赖数组,因为滚动回调使用的是稳定的 ref 对象和 state setter,没有读取需要随渲染更新的普通状态值。如果以后在回调中加入 props 或其他 state,需要重新检查依赖。
路由切换时还要注意什么?
[pathname]只响应路径变化。如果 query 或 hash 变化时也需要恢复显示,应根据需求补充对应依赖。- 重置滚动记录不会滚动页面。示例用实际的
window.scrollY建立新的比较基准,没有调用window.scrollTo()。 - 如果应用在导航后执行滚动恢复或程序化滚动,也可能触发滚动事件。这份实现会继续按照滚动方向判断;如果要求忽略导航产生的滚动,需要结合应用现有的滚动管理逻辑处理。
- 这里假设滚动发生在
window。如果页面使用独立的滚动容器,需要监听该容器,并读取它的scrollTop。
备注:内容仅供参考。
