PAYATHON 2026

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。

备注:内容仅供参考。