什么是闭包陷阱?
让我们从一道经典的题目说起。观察以下代码,判断页面上的数字如何变化:
- A. 页面上的数字每秒增加 1
- B. 页面上的数字始终为 0
JSX
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInteral(() => {
setCount(count + 1); // 🚨 危险操作!
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
正确答案是 B,数字永远定格在0!因为在 useEffect
的回调函数中,count
被捕获在闭包中,每次定时器执行时,count
都使用的是初始值 0,不会更新。
这里的解释太抽象,无法理解?没关系,要理解这个"陷阱",我们需要穿越到故事的起点------从最基础的 JavaScript 的闭包 说起。
JavaScript 中的闭包
什么是闭包?
闭包(Closure)是 JavaScript 中的一个核心特性:当一个函数内部定义了另一个函数,并且内部函数引用了外部函数的变量时,内部函数会"记住"这些变量,即使外部函数已经执行完毕。
让我们举个简单例子进行说明:
JavaScript
function outer() {
const x = 10;
function inner() {
console.log(x); // 内部函数引用了外部变量 x
}
return inner;
}
const innerFunc = outer();
innerFunc(); // 输出 10
在上面的这段代码中,即使 outer()
已经执行完毕,但 innerFunc
(即 inner
函数)仍然能访问到 outer
中的变量 x
。这就是闭包的作用。
事实上,闭包的本质是 函数与其词法环境的绑定关系,具体表现:
- 内部函数可以访问外部函数作用域的变量
- 外部函数执行完毕后,闭包仍保留变量引用
闭包如何"记住"变量?
JavaScript 的闭包会记住 变量在闭包创建时的值。如果变量后续被修改,闭包中引用的仍然是它"当时看到"的值。
简单来说,闭包"捕获"的是变量的引用,而不是值。
以下面 闭包"记住"旧值 为例:
JavaScript
function createCounter() {
let count = 0;
return function() {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 输出 1
counter(); // 输出 2
在上面的代码中,闭包(返回的函数)一直引用同一个 count
变量,所以每次调用 counter()
时,count
的值会被修改。
因此,我们可以这么说:闭包中的变量 count
是动态引用的,如果 count
被修改,闭包会拿到最新的值。
React 函数组件的闭包陷阱
React 函数组件的工作原理
在 React 中,一个函数组件其实就是一个JavaScript函数。因此,在函数组件中声明的函数,对于同样在函数组件中声明的变量(无论是普通变量,还是使用useState钩子声明的变量),都会产生相应的闭包,因而会产生闭包陷阱。
在继续阅读之前,让我们思考下面代码执行的真实结果:
- A. 点击按钮时控制台始终打印0
- B. 每次点击都会打印最新值(如1,2,3...)
JSX
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
// 这个 effect 中的 count 是当前闭包的值
console.log(count);
}, []);
return <button onClick={() => setCount(count + 1)}>Click</button>;
}
正确答案是 A。我们可以对整个流程进行分解:按钮点击-->更新count
值-->组件重新渲染-->整个函数会被重新执行,生成新的闭包环境。
问题出现的原因
组件每次渲染都是独立的闭包 :每次重新渲染时,count
是一个新的变量,属于当前这次渲染的闭包。
类比生活中的例子,想象你拍了一张照片(闭包),照片中的你穿着红色衣服。后来你换了一件蓝色衣服,但照片中的你依然是红色。闭包就像这张照片,记录的是某个瞬间的状态。
结合最初代码分析
还记得文章开头的示例代码吗? 让我们重新解析:
JSX
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 问题:总是输出 0
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
上面的代码究竟发生了什么?
① 第一次渲染:
count
的初始值是0
。useEffect
执行,启动定时器,定时器的回调函数捕获了此时的闭包,其中的count
是0
。
② 1秒后:
- 定时器回调尝试执行
setCount(count + 1)
,此时count
是闭包中的0
,所以更新后的count
变为1
。
③ 组件重新渲染:
- 新的闭包被创建,新的
count
是1
。 - 但旧的定时器回调仍然引用旧的闭包(
count
是0
),所以下一次执行时,仍然会执行setCount(0 + 1)
,导致count
永远卡在1
。
总结
在 React 函数组件中,当满足下面条件时,即会形成闭包陷阱:
- 持久化引用:在闭包中创建长期存活的引用(如定时器、事件监听)
- 跨渲染周期:后续操作访问旧闭包中的状态值
- 依赖数组未正确声明 :未在依赖数组(如
useEffect
的第二个参数)中声明所有引用的变量,从而导致副作用函数持续引用旧闭包中的值。
但是,闭包机制当然不是一无是处,它是React函数组件的核心优势,有着天然的状态隔离性、明确的依赖关系。