React 中空数组当做依赖 useEffect 只执行一次不准确
为什么 useEffect 依赖空数组只执行一次并不准确?
许多 React 新手认为 useEffect(() => {...}, []) 中的空依赖数组意味着副作用只在组件挂载时执行一次,并在卸载时清理一次。这个理解在大多数生产环境下看似有效,但实际上存在多种情况会导致这个副作用不止执行一次。如果代码没有做好相应的防御,这种“不准确”的假设可能会引发隐秘的 bug。
本教程将结合 React 的生命周期、严格模式以及状态更新机制,解释空数组依赖不精确的根本原因,并给出健壮的解决方案。
核心机制:useEffect 的执行时机
在深入边界情况之前,我们先回顾一下 React 中 useEffect 的基本执行规则:
| 依赖数组的状态 | 执行时机 |
|---|---|
| 无依赖(不传第二个参数) | 每次组件渲染后都会执行 |
空数组 [] |
理论上仅在组件挂载后执行一次,并在卸载时执行清理函数 |
包含变量的数组 [a, b] |
当 a 或 b 在两次渲染之间发生变化时,执行清理函数后重新运行副作用 |
许多教程到此为止,但实际开发中,空数组的“一次性”行为会受到多种外部和内部因素干扰。
情况一: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. 把副作用真正需要的变量放进依赖
很多开发者为了“节省”渲染次数而使用空数组,实际上变量却被副作用使用了。当你使用空数组却内部引用了 props 或 state 时,不仅违反了 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 是用来同步外部系统的,而不是用来模拟类组件生命周期的单次事件。将你的思维从“只做一次”转变为“总是正确开始并总是能够清理”,就能避免绝大多数与依赖数组相关的陷阱。