React 中空数组当做依赖 useEffect 只执行一次不准确

FreeGuideOnline 最新 2026-07-06

为什么 useEffect 依赖空数组只执行一次并不准确?

许多 React 新手认为 useEffect(() => {...}, []) 中的空依赖数组意味着副作用只在组件挂载时执行一次,并在卸载时清理一次。这个理解在大多数生产环境下看似有效,但实际上存在多种情况会导致这个副作用不止执行一次。如果代码没有做好相应的防御,这种“不准确”的假设可能会引发隐秘的 bug。

本教程将结合 React 的生命周期、严格模式以及状态更新机制,解释空数组依赖不精确的根本原因,并给出健壮的解决方案。


核心机制:useEffect 的执行时机

在深入边界情况之前,我们先回顾一下 React 中 useEffect 的基本执行规则:

依赖数组的状态 执行时机
无依赖(不传第二个参数) 每次组件渲染后都会执行
空数组 [] 理论上仅在组件挂载后执行一次,并在卸载时执行清理函数
包含变量的数组 [a, b] ab 在两次渲染之间发生变化时,执行清理函数后重新运行副作用

许多教程到此为止,但实际开发中,空数组的“一次性”行为会受到多种外部和内部因素干扰。


情况一:React 18 严格模式下的双次调用

从 React 18 开始,开发模式下的严格模式会对组件执行一次“附加的挂载-卸载-重新挂载”周期,以便暴露出副作用未正确清理的问题。这就是为什么你可能看到 useEffect 的 setup 函数被调用了两次,即使依赖是空数组。

function App() {
  useEffect(() => {
    console.log('Effect 执行'); // 开发模式会打印两次
    return () => {
      console.log('清理函数执行'); // 也会执行,用于模拟卸载
    };
  }, []);

  return <div>Hello</div>;
}

// 用 <React.StrictMode> 包裹后,控制台输出:
// Effect 执行
// 清理函数执行
// Effect 执行

为什么这样设计?
严格模式会故意重复挂载组件,以验证你的副作用是否支持“可重复挂载”。如果你在副作用中启动了全局订阅、事件监听或定时器,却没有在清理函数中正确销毁,第二次挂载就会导致内存泄漏或行为错乱。

重要启示:
空数组 [] 并不能保证副作用只运行一次,而是需要保证副作用具有正确的清理逻辑。也就是说,你应当假设你的副作用可能被执行多次,并且清理函数能够在下次执行前彻底重置状态。


情况二:组件因为父级渲染而意外重新挂载

“挂载”和“卸载”并不只在首次渲染时发生。如果父组件重新渲染并导致子组件的 key 发生变化,或者React 认为需要完全销毁并重建该组件,那么 useEffect 的空数组依赖同样会再次触发。

常见触发重新挂载的场景:

  • 在列表渲染中为子组件使用了不稳定的 key(例如 key={Math.random()}),导致每次列表更新时组件都被销毁并新建。
  • 通过条件渲染({show && <Child />})反复切换组件的存在状态。
  • 使用了第三方路由库,不同路由之间的组件即使 UI 相同也会经历卸载/挂载。
  • 动态变化的 CSS 动画或布局触发浏览器对 DOM 的重建。
function Parent() {
  const [count, setCount] = useState(0);
  return (
    <>
      {/* 每次点击按钮,Child 的 key 改变,强制重新挂载 */}
      <Child key={count} />
      <button onClick={() => setCount(c => c + 1)}>强制重新挂载</button>
    </>
  );
}

function Child() {
  useEffect(() => {
    console.log('Child 挂载,副作用运行');
    // 这里的副作用会被多次运行
  }, []);
  return <div>Child</div>;
}

在这种情况下,空数组依赖的“只执行一次”假设会失效。根本原因不在于依赖数组,而在于组件的挂载调用次数。


情况三:并发特性导致的恢复和丢弃

React 的并发模式允许渲染被中断、丢弃或恢复。当渲染被丢弃且状态被重置时,某些生命周期可能会多次启动或提前终止。尽管这不会直接影响已挂载组件中空依赖的 useEffect,但它会影响整个渲染过程,使得组件的挂载看起来比预期更频繁。

你无需在应用层关心每一次并发渲染的细节,但应意识到依赖“副作用只运行一次”来维护单例资源是危险的。应当在 useEffect 内部通过 ref 或模块级变量实现真正的“仅一次”行为(例如只初始化一次 WebSocket 连接),而不是依赖空数组本身。


如何写出健壮的空依赖副作用

既然空数组不可能在各种情况下都保证“只执行一次”,我们应该从代码设计上适应多次执行的可能。

1. 始终编写正确的清理函数

无论依赖数组是什么,所有在副作用中创建的订阅、计时器、事件绑定,都必须在清理函数中销毁。

useEffect(() => {
  const interval = setInterval(() => {
    console.log('tick');
  }, 1000);
  
  return () => {
    clearInterval(interval); // 保证旧的定时器不会叠加
  };
}, []);

即使严格模式故意重复挂载,组件最终也会得到唯一一个干净的定时器。

2. 用 ref 保证“真正只执行一次”

如果业务逻辑严格限制某个操作(如数据埋点、第三方 SDK 初始化)只能发生一次,即使在严格模式的双重挂载下也必须抑制第二次执行,可以使用 useRef 作为标记:

const onceRef = useRef(false);

useEffect(() => {
  if (onceRef.current) return;
  onceRef.current = true;

  // 这里的代码无论环境如何,只会在模块生命周期内执行一次
  doSomethingCritical();
}, []);

这种方法绕开了 React 的严格模式检测,但要慎用:它隐藏了可能的副作用清理问题,仅在确实需要抑制重复执行时采用。

3. 把副作用真正需要的变量放进依赖

很多开发者为了“节省”渲染次数而使用空数组,实际上变量却被副作用使用了。当你使用空数组却内部引用了 propsstate 时,不仅违反了 React 的 exhaustive-deps 规则,还会捕获到过期的闭包值。

// ❌ 错误:count 在空依赖下永远是初始值
useEffect(() => {
  const timer = setTimeout(() => console.log(count), 1000);
  return () => clearTimeout(timer);
}, []);

// ✅ 正确:把 count 加入依赖,或者使用函数式更新
useEffect(() => {
  const timer = setTimeout(() => console.log(count), 1000);
  return () => clearTimeout(timer);
}, [count]);

虽然这会让副作用运行多次,但这是 React 数据流的正确行为。如果副作用代价昂贵,可以通过防抖、节流或判断条件来优化。

4. 接受“可能执行多次”,设计幂等副作用

最好的策略是让副作用幂等:无论执行多少次,最终结果和状态都是一致的。典型的幂等操作包括:

  • 对同一资源的多次订阅被相互抵消(由清理函数退订前次订阅)。
  • 多次设置相同的 DOM 属性或全局状态覆盖,不会产生叠加效果。
  • 初始化只是设置一个当前值,而非累加。

总结

  • 空数组依赖 [] 不保证副作用只运行一次。严格模式、组件重新挂载、并发特性都可能导致多次执行。
  • React 18 严格模式通过故意重复挂载来检测未正确清理的副作用,这恰恰暴露了“只运行一次”的脆弱假设。
  • 正确做法是:总编写清理逻辑,让副作用支持重复执行;如必需一次性操作,使用 ref 标记;并遵循完整的依赖规则。
  • 代码越能容忍多次执行,在不同的渲染环境和优化策略下就越稳定。

记住:React 的 useEffect 是用来同步外部系统的,而不是用来模拟类组件生命周期的单次事件。将你的思维从“只做一次”转变为“总是正确开始并总是能够清理”,就能避免绝大多数与依赖数组相关的陷阱。