写 React Hooks 的时候,你有没有遇到过这种"灵异事件":
明明天在这个组件里 setCount 已经加到飞起了,界面上的数字也在跳动,但是 setInterval 或者是 useEffect 里的 console.log 打印出来的,却永远是初始值 0?
这时候你会怀疑人生:"是我眼花了,还是 React 坏了?"
其实 React 没坏,你只是掉进了**"闭包陷阱" (Stale Closure)**。今天咱们就借一段简单的代码,扒一扒这个坑的底裤,顺便看看怎么优雅地爬出来。
案发现场:诡异的"时间冻结"
让我们先看看这段经典的"受害者"代码。这是很多同学(包括刚开始写 Hooks 的我)都会写出的逻辑:
JavaScript
javascript
import { useEffect, useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
// ❌ 这是一个典型的闭包陷阱现场
useEffect(() => {
const timer = setInterval(() => {
// 这里的 count 永远是 0,仿佛时间被冻结了
console.log('Current count:', count);
}, 1000);
return () => clearInterval(timer);
}, []); // 👈 罪魁祸首在这里:空依赖数组
return (
<>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>count + 1</button>
</>
);
}
现象描述
当你运行这段代码,点击按钮让 count 增加时:
- 界面(UI) :显示
1, 2, 3...(正常更新,说明 State 确实变了)。 - 控制台(Console) :
Current count: 0...Current count: 0... (像复读机一样)。

为什么会这样?
要理解这个问题,首先要修正一个心智模型:每一次渲染(Render),都是一次独立的"快照"。
-
第一次渲染 (Mount) :
- React 创建了组件,此时
count = 0。 useEffect执行。因为它依赖是[],所以它只在第一次渲染时执行。setInterval被创建。关键点来了: 这个定时器的回调函数是在count为0的那个闭包作用域里定义的。它捕获了那一刻的count(也就是 0)。
- React 创建了组件,此时
-
第二次渲染 (点击按钮后) :
- React 再次执行组件函数,
count变成了1。 - 但是!
useEffect的依赖数组是空的,React 认为"没必要重新运行这个 Effect"。 - 于是,那个旧的 定时器(Mount 时创建的)依然在坚强地活着。它手里紧紧攥着的,依然是第一次渲染时的旧变量
0。
- React 再次执行组件函数,
简单来说:你的组件 UI 已经活在 2026 年了,但那个定时器还活在 2023 年,它根本不知道外面的世界变了。这就是 JS 词法作用域与 React Hooks 机制碰撞出的"火花"。
怎么爬出陷阱?
既然知道了是因为"引用了旧变量",那想要实现如下图片效果,思路就很清晰了:要么让 Effect 重新执行,要么用某种方式穿透闭包。

方法一:诚实地告诉 React 你的依赖(官方推荐)
这就是修复后的代码逻辑,也是最符合 React 数据流直觉的写法:
JavaScript
javascript
useEffect(() => {
const timer = setInterval(() => {
// ✅ 此时能读到最新的 count
console.log('Current count:', count);
}, 1000);
// 每次 effect 重新执行之前 都会执行上一次的清理函数
return () => clearInterval(timer);
}, [count]); // 👈 把 count 加入依赖数组
原理分析: 一旦把 [count] 加入依赖数组,逻辑就变了:
count变了 ->useEffect发现依赖变了。- React 先执行
cleanup函数(clearInterval),杀掉旧的定时器。 - React 执行新的
useEffect,创建一个新的定时器。 - 这个新 定时器是在当前渲染闭包里创建的,所以它捕获的是最新 的
count。
潜在问题: 虽然 Bug 修好了,但带来了性能抖动 。如果 count 变化很快(比如动画),定时器会被频繁地 创建 -> 销毁 -> 创建。如果定时器间隔很短,这可能会导致计时不准。
方法二:函数式更新
如果你只是想让 count 加 1,而不关心在 setInterval 里打印日志,可以用函数式更新:
JavaScript
scss
useEffect(() => {
const timer = setInterval(() => {
// ✅ prev 永远是 React 内部拿到的最新状态,不需要依赖 count
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
这能解决 UI 更新问题,但解决不了"在定时器里获取最新值打印"的问题。
方法三:终极大法 useRef
如果你既不想让定时器频繁重启(保持依赖为 []),又想在回调里拿到最新的值,useRef 是最佳选择。
为什么? 因为 useRef 返回的 ref 对象在组件的整个生命周期内保持引用不变 ,但它的 .current 属性是可变的。这就像一个挂在墙上的白板,无论房间(闭包)怎么换,白板还是那一块,上面的字随时能改。
JavaScript
scss
// 1. 创建一个 ref
const countRef = useRef(count);
// 2. 每次渲染都把最新的 count 写入 ref
// 这一步确保 ref.current 永远是最新的
countRef.current = count;
useEffect(() => {
const timer = setInterval(() => {
// 3. ✅ 永远读取 ref 里的最新值
// 这里的闭包引用的是 countRef 对象本身,这个对象是永远不变的
console.log('Current count:', countRef.current);
}, 1000);
return () => clearInterval(timer);
}, []); // 👈 依赖依然是空,定时器稳如泰山,不会重启!
这也是知名 Hooks 库 ahooks 中 useInterval 的核心实现原理。
总结
React 闭包陷阱本质上是 JavaScript 闭包机制 与 React 声明式编程 之间的一种"沟通误会"。
- 陷阱成因 :
useEffect、useCallback等 Hooks 的依赖数组写少了,导致内部函数引用了旧的渲染闭包中的变量。 - 基础解法:补全依赖数组(但要注意副作用的频繁执行)。
- 进阶解法 :使用
useRef作为"逃生舱",在不重启 Effect 的情况下,透过闭包读取最新状态。