难道是 React 的问题?深入理解函数组件中的闭包陷阱
在 React 函数组件开发中,我们常常会遇到所谓的“闭包问题”。最经典的案例之一就是延迟执行时访问的状态不是最新值。

在这个例子中,当我们点击“延迟弹出”按钮并启动一个 3 秒后执行的 alert 时,如果在此期间不断点击让 count 自增,最终弹窗显示的仍然是初始值(如 0)。
这真的是 React 的问题吗?其实不然。要理解这一现象,我们需要明确两个核心概念:
- 函数是特殊的对象
- 函数的作用域是静态作用域(词法作用域)
一、函数是特殊的对象
JavaScript 中的函数不仅仅是可执行的逻辑单元,它还是一个对象,拥有属性和内部槽(internal slots)。其中,[[Scopes]] 属性记录了函数创建时所处的词法环境,也就是它能访问的所有变量的集合。
我们可以通过改造 handleClick 来验证这一点:
const handleClick = () => {
const fn = () => {
alert(count);
};
console.dir(fn);
setTimeout(fn, 3000);
};
查看控制台输出的 fn 对象,可以看到其 [[Scopes]] 中捕获了当前作用域下的 count 变量:

此时 count 的值为 0,因此 fn 的闭包环境就固定了这个值。
二、静态作用域:闭包的根源
JavaScript 采用静态作用域(也称词法作用域),即函数的作用域在定义时就已经确定,不会因为调用时的环境而改变。
当我们点击 setCount 更新状态时,整个 Test 函数组件会重新执行(re-render),生成新的作用域。但之前通过 setTimeout 注册的 fn 函数,仍然是第一次渲染时创建的函数实例,它所引用的 count 依然是旧作用域中的值。
为了更直观地观察这一点,我们可以延迟打印 fn 的信息:
const handleClick = () => {
const fn = () => {
alert(count);
};
setTimeout(() => {
console.dir(fn);
fn();
}, 3000);
};

可以看到,3 秒后执行时,fn 的 [[Scopes]] 依然是第一次渲染时的环境,count 的值没有更新。
三、解决方案:使用 useRef 打破闭包限制
既然问题出在闭包捕获了旧值,那么解决方案就是使用一个在整个组件生命周期中保持不变的引用——useRef。
useRef 返回的对象在组件的整个生命周期中是同一个引用,其 .current 属性可以随时读取和修改,且不会触发重新渲染。
const Test = function () {
const [update, setUpdate] = useState(0); // 用于触发重渲染
const count = useRef(0); // 使用 ref 存储可变值
const handleClick = () => {
const fn = () => {
alert(count.current); // 读取最新值
};
setTimeout(() => {
console.dir(fn);
fn();
}, 3000);
};
const handleAddClick = () => {
count.current += 1;
setUpdate(!update); // 触发 UI 更新
};
return (
<>
<button onClick={handleAddClick}>{count.current}</button>
<button onClick={handleClick}>弹出</button>
</>
);
};

此时,无论何时点击“延迟弹出”,3 秒后弹窗显示的都是最新的 count.current 值。
查看 fn 的 [[Scopes]]:

虽然 count 的引用没有变,但通过 .current 可以访问到最新的值,实现了“实时”读取。
✅ 核心结论:React 的“闭包问题”本质上是 JavaScript 函数作用域机制与函数组件频繁重渲染共同导致的结果,而非 React 本身的缺陷。
四、谨慎使用 useCallback:避免过时的函数引用
由于闭包的存在,useCallback 的使用需要格外谨慎。如果依赖项数组(dependency array)未正确设置,可能会缓存一个捕获了过时状态的函数。
案例:空依赖数组导致状态滞留
const Test = function () {
const [count, setCount] = useState<number>(0);
const handleClick = useCallback(() => {
setCount(count + 1);
}, []); // 空依赖数组
return <div onClick={handleClick}>click me {count}</div>;
};

现象:无论点击多少次,count 最多只增加 1。
原因:useCallback 在第一次渲染时创建了一个函数,该函数捕获了 count 的初始值 0。由于依赖数组为空,这个函数在整个组件生命周期中都不会更新,因此每次执行都是 setCount(0 + 1)。
我们可以通过命名函数并打印其作用域来验证:
const handleClick = useCallback(function a() {
console.dir(a); // 查看函数 a 的 [[Scopes]]
setCount(count + 1);
}, []);

可以看到,函数 a 的作用域中 count 始终为 0。
正确做法
- 正确设置依赖项:如果函数依赖
count,应将其加入依赖数组:const handleClick = useCallback(() => { setCount(count + 1); }, [count]); - 使用函数式更新:避免依赖具体状态值:
const handleClick = useCallback(() => { setCount(prev => prev + 1); }, []); // 此时可以安全使用空依赖 - 使用高级 Hook:如
ahooks提供的useMemoizedFn,它能自动处理函数更新,避免闭包陷阱。
总结:理解闭包机制是掌握 React 函数组件的关键。合理使用 useRef 和 useCallback,并注意依赖项的管理,才能写出高效、无 bug 的 React 应用。