如果你在 useEffect 内部使用了某个 prop 或 state,但没有把它放到依赖数组里,你会遇到 React 中最著名的 Bug ------ 闭包陷阱 (Stale Closure) 。
这意味着:你的 Effect 只能"看见"旧的数据,永远看不见新的数据。
1. 为什么"必须"放?(原理演示)
看这个经典的错误例子:
JavaScript
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // ❌ 永远打印 0
}, 1000);
return () => clearInterval(id);
}, []); // ⚠️ 空数组:意思是"只在挂载时运行一次"
}
发生了什么?
- 第一次渲染 (Mount):
count是 0。useEffect执行,创建了一个定时器。这个定时器捕获了当时 的count(也就是 0)。 - 第二次渲染: 用户点了按钮,
count变成了 1。 - React 检查依赖: 依赖数组是
[](空的),跟上次一样。 - React 决定: "既然依赖没变,那我就不重新运行 Effect 了。"
- 结果: 旧的定时器还在跑,它手里的
count依然是第一次渲染时的那个 0 。它永远不知道外面count已经变了。
这就是"对 React 撒谎"的代价。
2. 但是,如果你把它们放进去...
JavaScript
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]); // ✅ 加进去了
现在的行为:
count变了 (0 -> 1)。- React 发现依赖变了。
- 清除旧的定时器。
- 运行 新的 Effect,创建新的定时器(捕获新的
count1)。
问题来了: 定时器被不断重置,这可能不是你想要的(比如会导致计时不准)。
3. 如何"既不撒谎,又不重置"?
可以使用 useRef 和 useEffectEvent。这两个工具存在的意义,就是为了合法地把变量从依赖数组里拿出来。
方法 A:使用函数式更新 (如果是 setState)
如果你只是想修改状态,不需要读取它:
JavaScript
useEffect(() => {
const id = setInterval(() => {
// ✅ 不需要依赖 count,因为 prev 永远是 React 传给你的最新值
setCount(prev => prev + 1);
}, 1000);
}, []); // ✅ 空数组是安全的
方法 B:使用 useRef (逃生舱)
如果你需要读取值,但不想触发 Effect 重跑:
JavaScript
const countRef = useRef(count);
// 每次渲染都同步最新值
useEffect(() => { countRef.current = count });
useEffect(() => {
const id = setInterval(() => {
// ✅ 读 ref,永远是最新的,且 ref 不需要放进依赖
console.log(countRef.current);
}, 1000);
}, []); // ✅ 安全
方法 C:使用 useEffectEvent (最新标准)
我们在上一个问题里用到的方法:
JavaScript
const onTick = useEffectEvent(() => {
console.log(count); // ✅ 在这里读最新值
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, 1000);
}, []); // ✅ 安全
总结
-
官方规则 (ESLint): 凡是用到的响应式数据(props, state, context),必须 全部填入依赖数组。不要试图通过欺骗 linter (
// eslint-disable) 来解决逻辑问题。 -
后果: 如果不填,代码会引用旧值(闭包陷阱)。
-
正确做法: 如果你不希望某个变量导致 Effect 重新运行,不要简单地把它从数组里删掉,而是应该:
- 用
useRef把它包起来。 - 或者用
useEffectEvent把它隔离开。 - 或者检查是否可以移出 Effect。
- 用