问题场景
你遇到过这样的 Bug 吗?页面每隔几秒轮询接口获取最新数据,但始终拿到的都是第一次请求的结果:
tsx
function PollingComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', count); // 永远是 0!
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<button onClick={() => setCount(c => c + 1)}>
点击 +1(当前:{count})
</button>
);
}
无论你点多少次按钮,定时器里打印的 count 始终是初始值 0。这就是 React 里最经典的 闭包陷阱(Closure Trap / Stale Closure)。
类似的场景还有:
setTimeout/setInterval回调中读取不到最新的 stateuseCallback定义的函数内总是拿到旧值useEffect清理函数中引用了过期的 props- 事件监听器内部状态永远停留在绑定时刻
原因分析
什么是闭包?
JavaScript 闭包是指函数可以「记住」它被创建时的作用域环境。React 的每次渲染都会创建独立的函数作用域和闭包:
tsx
// 第一次渲染:count = 0
function Component() {
const count = 0; // 本次渲染的 count
const log = () => {
console.log(count); // 闭包捕获了 0
};
// ...
}
// 第二次渲染:count = 1(新的作用域)
function Component() {
const count = 1; // 新的 count
const log = () => {
console.log(count); // 闭包捕获了 1
};
// ...
}
核心原因:useEffect 的空依赖数组
tsx
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 捕获的是「创建 effect 时」的 count
}, 1000);
return () => clearInterval(timer);
}, []); // ✅ 依赖数组为空 → effect 只运行一次
// ❌ 但回调中的闭包永远绑定在第一次渲染的 count 上
[]表示 effect 只在挂载时运行一次- 回调函数的闭包捕获了**首次渲染的 **``count`
- 后续 state 更新触发了重渲染,但 effect 没有重新执行,闭包依然引用旧值
useCallback 同样有坑
tsx
// ❌ 闭包陷阱:handleClick 永远引用旧的 count
const handleClick = useCallback(() => {
doSomething(count);
}, []); // 依赖数组为空
// ✅ 正确写法
const handleClick = useCallback(() => {
doSomething(count);
}, [count]);
解决方案(含实操代码)
方案一:正确声明依赖(最推荐)
把依赖值加入 useEffect / useCallback 的依赖数组,让它们随 state 变化重新创建:
tsx
function PollingComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', count); // ✅ 每次 count 变化都重新创建,拿到最新值
}, 1000);
return () => clearInterval(timer);
}, [count]); // ✅ 关键:把 count 加入依赖
return (
<button onClick={() => setCount(c => c + 1)}>
点击 +1(当前:{count})
</button>
);
}
⚠️ 注意事项 :如果 count 变化频繁,定时器会被反复销毁创建。对于高频场景可以用方案二。
方案二:使用 ref 保存最新值
useRef 返回的对象在组件的整个生命周期中引用不变,修改 .current 不会触发重渲染:
tsx
function PollingComponent() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// 每次渲染后同步 ref
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', countRef.current); // ✅ 从 ref 读取,永远最新
}, 1000);
return () => clearInterval(timer);
}, []); // ✅ 依赖为空,定时器只创建一次
// ...
}
这是一种典型的 ref 透传 模式,适合定时器、WebSocket 等不希望频繁重启的场景。
方案三:函数式更新(适用于 state 更新)
如果闭包中只是更新 state(不需要读取当前值做逻辑判断),可以用函数式更新:
tsx
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// ✅ 不依赖 count,而是通过函数参数拿到最新值
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
方案四:useReducer 派发 action
对于复杂的状态逻辑,useReducer 的 dispatch 引用稳定,且 reducer 函数在组件外部,闭包干净:
tsx
const initialState = { count: 0, step: 1 };
function reducer(state, action) {
switch (action.type) {
case 'tick':
return { ...state, count: state.count + state.step };
case 'setStep':
return { ...state, step: action.payload };
default:
return state;
}
}
function Timer() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const timer = setInterval(() => {
dispatch({ type: 'tick' }); // ✅ dispatch 稳定,reducer 外部定义无闭包问题
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{state.count}</div>;
}
方案五:ESLint 规则自动拦截
安装 eslint-plugin-react-hooks 并开启 exhaustive-deps 规则,它会在编译期就提醒你漏掉了依赖:
json
// .eslintrc.json
{
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}
看到黄色波浪线就该意识到:这里可能有闭包陷阱。
要点总结
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 正确声明依赖 | 大多数场景 | 简单直观,符合 React 设计哲学 | 可能引起频繁重执行 |
| useRef 透传 | 定时器、WebSocket、事件监听 | 避免 effect 重复销毁创建 | 需要额外保持 ref 同步 |
| 函数式更新 | 纯 state 更新 | 不依赖外部变量,干净优雅 | 仅适用于 setState 场景 |
| useReducer | 复杂状态逻辑 | dispatch 稳定,逻辑集中 | 学习成本稍高 |
| ESLint 规则 | 所有场景 | 从源头拦截,防范于未然 | 需要工具配置 |
一句话口诀: 闭包捕获的是「创建时的快照」而非「最新的值」------牢记这一点,绝大多数闭包陷阱都能一眼看穿。