🔥 从执行上下文到 React 陷阱------彻底搞懂 JavaScript 闭包
"闭包到底是什么?" 面试被问到闭包,你是不是只能背一句"函数能访问外部变量"?当 React 计数器卡在 1,你知道背后是闭包在作怪吗?
本文适合:想从"背概念"升级到"懂原理"的前端开发者。会从 JS 引擎执行机制讲起,一路讲到 React Hooks 闭包陷阱和 StrictMode。建议收藏 🔖,反复阅读。

📑 目录
- [第一章 前置知识:JS 代码是怎么执行的](#第一章 前置知识:JS 代码是怎么执行的 "#%E7%AC%AC%E4%B8%80%E7%AB%A0-%E5%89%8D%E7%BD%AE%E7%9F%A5%E8%AF%86js-%E4%BB%A3%E7%A0%81%E6%98%AF%E6%80%8E%E4%B9%88%E6%89%A7%E8%A1%8C%E7%9A%84")
- [1.1 编译与执行:两步走](#1.1 编译与执行:两步走 "#11-%E7%BC%96%E8%AF%91%E4%B8%8E%E6%89%A7%E8%A1%8C%E4%B8%A4%E6%AD%A5%E8%B5%B0")
- [1.2 执行上下文:函数运行时的"工作台"](#1.2 执行上下文:函数运行时的"工作台" "#12-%E6%89%A7%E8%A1%8C%E4%B8%8A%E4%B8%8B%E6%96%87%E5%87%BD%E6%95%B0%E8%BF%90%E8%A1%8C%E6%97%B6%E7%9A%84%E5%B7%A5%E4%BD%9C%E5%8F%B0")
- [1.3 变量环境与词法环境](#1.3 变量环境与词法环境 "#13-%E5%8F%98%E9%87%8F%E7%8E%AF%E5%A2%83%E4%B8%8E%E8%AF%8D%E6%B3%95%E7%8E%AF%E5%A2%83")
- [1.4 调用栈:管理执行上下文的"栈"](#1.4 调用栈:管理执行上下文的"栈" "#14-%E8%B0%83%E7%94%A8%E6%A0%88%E7%AE%A1%E7%90%86%E6%89%A7%E8%A1%8C%E4%B8%8A%E4%B8%8B%E6%96%87%E7%9A%84%E6%A0%88")
- [第二章 作用域链与词法作用域](#第二章 作用域链与词法作用域 "#%E7%AC%AC%E4%BA%8C%E7%AB%A0-%E4%BD%9C%E7%94%A8%E5%9F%9F%E9%93%BE%E4%B8%8E%E8%AF%8D%E6%B3%95%E4%BD%9C%E7%94%A8%E5%9F%9F")
- [2.1 作用域:变量查找的"管辖范围"](#2.1 作用域:变量查找的"管辖范围" "#21-%E4%BD%9C%E7%94%A8%E5%9F%9F%E5%8F%98%E9%87%8F%E6%9F%A5%E6%89%BE%E7%9A%84%E7%AE%A1%E8%BE%96%E8%8C%83%E5%9B%B4")
- [2.2 作用域链:outer 指针串联的查找路径](#2.2 作用域链:outer 指针串联的查找路径 "#22-%E4%BD%9C%E7%94%A8%E5%9F%9F%E9%93%BEouter-%E6%8C%87%E9%92%88%E4%B8%B2%E8%81%94%E7%9A%84%E6%9F%A5%E6%89%BE%E8%B7%AF%E5%BE%84")
- [2.3 词法作用域:静态的,声明时就决定了](#2.3 词法作用域:静态的,声明时就决定了 "#23-%E8%AF%8D%E6%B3%95%E4%BD%9C%E7%94%A8%E5%9F%9F%E9%9D%99%E6%80%81%E7%9A%84%E5%A3%B0%E6%98%8E%E6%97%B6%E5%B0%B1%E5%86%B3%E5%AE%9A%E4%BA%86")
- [第三章 闭包:从冲突到解决](#第三章 闭包:从冲突到解决 "#%E7%AC%AC%E4%B8%89%E7%AB%A0-%E9%97%AD%E5%8C%85%E4%BB%8E%E5%86%B2%E7%AA%81%E5%88%B0%E8%A7%A3%E5%86%B3")
- [3.1 闭包为什么存在?------一个规则冲突](#3.1 闭包为什么存在?——一个规则冲突 "#31-%E9%97%AD%E5%8C%85%E4%B8%BA%E4%BB%80%E4%B9%88%E5%AD%98%E5%9C%A8%E4%B8%80%E4%B8%AA%E8%A7%84%E5%88%99%E5%86%B2%E7%AA%81")
- [3.2 闭包的"背包"机制------自由变量去哪了](#3.2 闭包的"背包"机制——自由变量去哪了 "#32-%E9%97%AD%E5%8C%85%E7%9A%84%E8%83%8C%E5%8C%85%E6%9C%BA%E5%88%B6%E8%87%AA%E7%94%B1%E5%8F%98%E9%87%8F%E5%8E%BB%E5%93%AA%E4%BA%86")
- [3.3 严格的闭包定义](#3.3 严格的闭包定义 "#33-%E4%B8%A5%E6%A0%BC%E7%9A%84%E9%97%AD%E5%8C%85%E5%AE%9A%E4%B9%89")
- [3.4 闭包的内存管理与垃圾回收](#3.4 闭包的内存管理与垃圾回收 "#34-%E9%97%AD%E5%8C%85%E7%9A%84%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6")
- [3.5 闭包的缺点与注意事项](#3.5 闭包的缺点与注意事项 "#35-%E9%97%AD%E5%8C%85%E7%9A%84%E7%BC%BA%E7%82%B9%E4%B8%8E%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9")
- [3.6 闭包的常见用途](#3.6 闭包的常见用途 "#36-%E9%97%AD%E5%8C%85%E7%9A%84%E5%B8%B8%E8%A7%81%E7%94%A8%E9%80%94")
- [第四章 React Hooks 中的过期闭包陷阱](#第四章 React Hooks 中的过期闭包陷阱 "#%E7%AC%AC%E5%9B%9B%E7%AB%A0-react-hooks-%E4%B8%AD%E7%9A%84%E8%BF%87%E6%9C%9F%E9%97%AD%E5%8C%85%E9%99%B7%E9%98%B1")
- [4.1 为什么 React 和闭包绑得这么紧](#4.1 为什么 React 和闭包绑得这么紧 "#41-%E4%B8%BA%E4%BB%80%E4%B9%88-react-%E5%92%8C%E9%97%AD%E5%8C%85%E7%BB%91%E5%BE%97%E8%BF%99%E4%B9%88%E7%B4%A7")
- [4.2 经典案例:setInterval 计数器永远卡在 1](#4.2 经典案例:setInterval 计数器永远卡在 1 "#42-%E7%BB%8F%E5%85%B8%E6%A1%88%E4%BE%8Bsetinterval-%E8%AE%A1%E6%95%B0%E5%99%A8%E6%B0%B8%E8%BF%9C%E5%8D%A1%E5%9C%A8-1")
- [4.3 三种解决方案](#4.3 三种解决方案 "#43-%E4%B8%89%E7%A7%8D%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88")
- [4.4 三种方案速查对比](#4.4 三种方案速查对比 "#44-%E4%B8%89%E7%A7%8D%E6%96%B9%E6%A1%88%E9%80%9F%E6%9F%A5%E5%AF%B9%E6%AF%94")
- [第五章 React StrictMode 的状态陷阱](#第五章 React StrictMode 的状态陷阱 "#%E7%AC%AC%E4%BA%94%E7%AB%A0-react-strictmode-%E7%9A%84%E7%8A%B6%E6%80%81%E9%99%B7%E9%98%B1")
- [5.1 StrictMode 是什么](#5.1 StrictMode 是什么 "#51-strictmode-%E6%98%AF%E4%BB%80%E4%B9%88")
- [5.2 经典案例:计数器每次 +2](#5.2 经典案例:计数器每次 +2 "#52-%E7%BB%8F%E5%85%B8%E6%A1%88%E4%BE%8B%E8%AE%A1%E6%95%B0%E5%99%A8%E6%AF%8F%E6%AC%A1-2")
- [5.3 修复:加上 cleanup](#5.3 修复:加上 cleanup "#53-%E4%BF%AE%E5%A4%8D%E5%8A%A0%E4%B8%8A-cleanup")
- [第六章 useEffect Cleanup 速查表](#第六章 useEffect Cleanup 速查表 "#%E7%AC%AC%E5%85%AD%E7%AB%A0-useeffect-cleanup-%E9%80%9F%E6%9F%A5%E8%A1%A8")
- [第七章 面试回答模板与追问](#第七章 面试回答模板与追问 "#%E7%AC%AC%E4%B8%83%E7%AB%A0-%E9%9D%A2%E8%AF%95%E5%9B%9E%E7%AD%94%E6%A8%A1%E6%9D%BF%E4%B8%8E%E8%BF%BD%E9%97%AE")
- [第八章 终极记忆卡片](#第八章 终极记忆卡片 "#%E7%AC%AC%E5%85%AB%E7%AB%A0-%E7%BB%88%E6%9E%81%E8%AE%B0%E5%BF%86%E5%8D%A1%E7%89%87")
第一章 前置知识:JS 代码是怎么执行的
要理解闭包,必须先理解 JavaScript 引擎是如何执行代码的。这是地基,跳过它讲闭包就是空中楼阁。
1.1 编译与执行:两步走
很多人以为 JS 是"逐行解释执行"的。实际上,JS 引擎在执行代码之前会先编译 ------准确说是准备执行上下文的过程。
sql
┌─────────────────────────────────────────────────────┐
│ JS 代码执行流程 │
│ │
│ 源代码 ──→ 编译阶段(准备执行上下文) ──→ 执行阶段 │
│ · 创建变量环境 │
│ · 创建词法环境 │
│ · 确定作用域链(outer 指针) │
│ │
└─────────────────────────────────────────────────────┘
编译阶段做了什么?
- 扫描代码,识别所有变量声明(
var、let、const)和函数声明 - 创建变量环境 (Variable Environment):存放
var声明和function声明 - 创建词法环境 (Lexical Environment):存放
let、const声明(ES6 新增) - 确定 outer 指针:指向外层执行上下文(后面详细讲)
一句话:编译阶段搭好"工作台",执行阶段才真正运行代码。
1.2 执行上下文:函数运行时的"工作台"
每当一个函数被调用,JS 引擎就会为它创建一个执行上下文(Execution Context)。你可以把它理解为函数运行时的"工作台":
javascript
┌─────────────────────────────────────────────┐
│ 执行上下文 (Execution Context) │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 变量环境 (Variable Environment) │ │
│ │ var 声明的变量 │ │
│ │ function 声明的函数 │ │
│ │ arguments 对象 │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 词法环境 (Lexical Environment) [ES6] │ │
│ │ let / const 声明的变量 │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ this 绑定 │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ outer(外部引用) ← 指向外层执行上下文 │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────┘
注意最下面的 outer------它是闭包的关键。
1.3 变量环境与词法环境
ES6 之前只有变量环境。ES6 引入 let/const 后,新增了词法环境。它们的核心区别:
| 特性 | 变量环境 (var) | 词法环境 (let/const) |
|---|---|---|
| 声明方式 | var、function |
let、const、class |
| 初始化时机 | 编译阶段就初始化为 undefined |
编译阶段创建但不初始化(暂时性死区) |
| 作用域 | 函数级 | 块级 {} |
| 重复声明 | ✅ 允许(会覆盖) | ❌ 报错 SyntaxError |
js
console.log(a); // undefined(var 已初始化)
console.log(b); // ReferenceError(暂时性死区,let 未初始化)
var a = 1;
let b = 2;
1.4 调用栈:管理执行上下文的"栈"
JS 引擎用调用栈(Call Stack)来管理执行上下文的创建和销毁。函数调用就入栈,函数执行完就出栈:
js
function outer() {
let x = 10;
inner();
}
function inner() {
let y = 20;
console.log(x); // ???
}
outer();
执行过程:
sql
调用栈变化:
Step 1 Step 2 Step 3 Step 4
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ │ │ inner │ │ │ │ │
│ │ │ outer │ │ outer │ │ │
│ outer │ │ global│ │ global│ │ global│
└───────┘ └───────┘ └───────┘ └───────┘
global 入栈 outer 入栈 inner 出栈 outer 出栈
inner 入栈 (执行完毕) (执行完毕)
outer 执行完
关键问题 :inner() 执行时,它的执行上下文里并没有 x。那 console.log(x) 怎么找到 x 的?靠的就是 outer 指针 + 作用域链。
第二章 作用域链与词法作用域
2.1 作用域:变量查找的"管辖范围"
作用域决定了代码的哪些部分可以访问哪些变量。JavaScript 有三种作用域:
| 作用域类型 | 关键字 | 范围 | 示例 |
|---|---|---|---|
| 全局作用域 | 最外层 | 整个脚本 | const API = 'https://...' |
| 函数作用域 | function |
函数体内 | function fn() { var x = 1; } |
| 块级作用域 | {} + let/const |
代码块内 | if (true) { let y = 2; } |
注意 :块级作用域是 ES6 新增的,只有
let和const才会创建块级作用域。var不受{}限制。
2.2 作用域链:outer 指针串联的查找路径
每个执行上下文中都有一个 outer 指针,指向外层的执行上下文。当当前上下文找不到某个变量时,就会沿着 outer 指针向外层查找,一层一层往外找,直到全局执行上下文。
这条由 outer 指针串起来的查找路径,就是作用域链。
sql
变量查找路径(作用域链):
inner 执行上下文
│ 没有 x?
│
▼ outer
outer 执行上下文
│ 找到了 x = 10 ✅
│
▼ outer(如果还没找到,继续往上)
global 执行上下文
完整代码演示:
js
const name = 'global'; // 全局作用域
function outer() { // outer 执行上下文
const name = 'outer'; // outer 的局部变量
function inner() { // inner 执行上下文
const name = 'inner'; // inner 的局部变量
console.log(name); // 先在 inner 里找 → 找到 'inner'
}
inner();
}
outer();
变量查找规则:
- 先在当前执行上下文中找
- 找不到就通过 outer 指针去外层执行上下文找
- 一层一层往外找,直到全局执行上下文
- 全局也找不到 →
ReferenceError
outer 是怎么确定的? 不是看函数在哪里被调用,而是看函数在哪里被声明。这就是下一节的"词法作用域"。
2.3 词法作用域:静态的,声明时就决定了
词法作用域 (Lexical Scope)是指:作用域是由代码中函数声明的位置来决定的,而不是函数在哪里被调用。
换句话说:outer 指针在编译阶段就确定了,和运行时怎么调用无关。
js
function foo() {
const a = 1;
function bar() { // bar 声明在 foo 内部
console.log(a); // outer 指向 foo 的执行上下文
}
return bar;
}
function baz() {
const a = 2;
foo()(); // 在 baz 里调用 bar,但 bar 的 outer 仍然指向 foo
}
baz(); // 打印 1,不是 2!
sql
bar 的作用域链(在 foo 内部声明,outer 指向 foo):
bar 执行上下文
│ 找 a
▼ outer
foo 执行上下文 → 找到 a = 1 ✅
│ 如果没找到
▼ outer
global 执行上下文
注意:bar 是在 baz 里调用的,但 outer 不指向 baz!
因为 bar 声明在 foo 里,词法作用域 = 声明时决定。
口诀 :函数在哪里声明 的,outer 就指向哪里。在哪里调用的,不重要。
理解了词法作用域和 outer 指针,就能理解闭包了------因为闭包的本质就是:outer 指向的执行上下文已经出栈了,但其中的某些变量还活着。
第三章 闭包:从冲突到解决
3.1 闭包为什么存在?------一个规则冲突
闭包不是 JavaScript 的"设计缺陷",而是为了解决一个规则冲突:
sql
┌───────────────────────────────────────────────────────┐
│ 规则冲突 │
│ │
│ 规则 1:词法作用域 │
│ 内部函数可以访问外部函数中声明的变量(通过 outer 指针) │
│ │
│ 规则 2:函数调用完毕,执行上下文一定被销毁 │
│ 函数执行完后,它的执行上下文从调用栈弹出,变量被回收 │
│ │
│ ───────────────────────────────────────────────────── │
│ 冲突:如果内部函数在外部函数执行完之后才被调用, │
│ outer 指向的上下文已经没了,变量找不到了! │
└───────────────────────────────────────────────────────┘
怎么解决?
JS 引擎的做法:当内部函数引用了外部函数的变量,并且内部函数被返回/传递到了外部函数之外,引擎就把这些被引用的变量从栈内存转移到堆内存中,持久化保存。
这些被转移到堆内存中的变量集合,就是闭包。
3.2 闭包的"背包"机制------自由变量去哪了
我们用一个完整的例子,追踪变量在内存中的变化:
js
function outer() {
let count = 0; // 自由变量(被内部函数引用的外部变量)
return function inner() {
count++;
console.log(count);
};
}
const fn = outer(); // outer 执行完毕
fn(); // 1
fn(); // 2
fn(); // 3
Step 1:outer() 被调用
ini
调用栈:
┌─────────────────┐
│ outer 上下文 │
│ count = 0 │
│ inner = <函数> │
└─────────────────┘
┌─────────────────┐
│ global 上下文 │
└─────────────────┘
Step 2:outer() 执行完毕,返回 inner 函数
正常情况下,outer 的执行上下文应该从调用栈弹出,count 被回收。但 inner 引用了 count!
sql
调用栈: 堆内存:
┌─────────────┐ ┌──────────────────────┐
│ global 上下文 │ │ 闭包(背包) │
│ fn = inner │──引用──→│ count = 0 │
└─────────────┘ │ ┌────────────────┐ │
│ │ inner 函数对象 │ │
│ │ outer → 闭包地址 │ │
│ └────────────────┘ │
└──────────────────────┘
"背包"比喻 :outer 要走了(执行上下文销毁),但 inner 还需要用
count,所以 JS 引擎给 inner 背上一个背包,把count装进背包里放到堆内存中。inner 走到哪,背包跟到哪,里面的变量始终可用。
Step 3:fn() 被调用(即 inner 执行)
inner 通过自身的 outer 指针 找到闭包的地址,从闭包中读取 count:
ini
inner 执行上下文
│ 需要 count?
│
▼ outer → 指向闭包地址
闭包 { count: 0 }
│ 找到 count = 0
│
▼ count++ → count = 1
│ console.log(1) ✅
│
▼ 写回闭包 { count: 1 } ← 闭包中的值被更新了
每次调用 fn(),inner 都通过 outer 指针访问闭包中的 count,读取 → 修改 → 写回。所以 count 能持续递增。
3.3 严格的闭包定义
在 JavaScript 中:
闭包 = 内部函数引用的外部函数的自由变量集合,在外部函数执行完毕后,依然保存在堆内存中,供内部函数后续调用时访问。
几个要点:
- 自由变量:内部函数引用的、但不是在内部函数中声明的变量
- 外部函数已执行完毕:执行上下文已从调用栈弹出
- 变量集合 :闭包不是"一个变量",而是被引用的所有变量的集合
- 保存在堆内存:不在栈上,而在堆上(通过 outer 指针引用)
js
function createCounter() {
let count = 0; // 自由变量 A
let name = '计数器'; // 自由变量 B(即使 inner 没用到,现代引擎可能会优化掉)
return {
increment() { count++; }, // inner1 引用 count
getName() { return name; }, // inner2 引用 name
getCount() { return count; } // inner3 引用 count
};
}
const counter = createCounter();
// createCounter 执行完后,闭包包含 { count, name }
// 三个内部函数通过各自的 outer 指针共享同一个闭包
3.4 闭包的内存管理与垃圾回收
闭包的内存管理是一个容易被忽视但非常重要的话题。关键在于:引用闭包的变量是什么生命周期?
情况一:引用闭包的是全局变量
js
const fn = outer(); // fn 是全局变量,永远不会被销毁
fn(); fn(); fn(); // 闭包一直存在,count 永远不会被回收
后果 :如果 fn 一直存在,闭包中的变量就一直占用内存。如果你不再需要它,但没有解除引用,就会内存泄漏。
解决方案:不再使用时,手动置空引用:
js
const fn = outer();
fn(); // 用完了
fn = null; // 解除引用 → 闭包失去所有引用 → GC 回收
情况二:引用闭包的是局部变量
js
function doSomething() {
const fn = outer(); // fn 是局部变量
fn();
fn();
// doSomething 执行完 → fn 出栈 → 闭包失去引用 → GC 自动回收
}
doSomething();
结果 :doSomething 执行完后,fn 出栈,闭包没有其他引用了,GC 会自动回收闭包中的变量。不需要手动清理。
总结
| 闭包引用者 | 生命周期 | 是否需要手动清理 | 内存泄漏风险 |
|---|---|---|---|
| 全局变量 | 永久 | ✅ 需要 fn = null |
⚠️ 高 |
| 局部变量 | 函数作用域 | ❌ 自动回收 | ✅ 低 |
| 事件监听器/定时器 | 直到移除 | ✅ 需要 cleanup | ⚠️ 高 |
| React useEffect 回调 | 直到 cleanup | ✅ 必须写 cleanup | ⚠️ 高 |
3.5 闭包的缺点与注意事项
闭包虽然强大,但也有代价:
1. 内存占用
闭包会保持对外部作用域的引用,导致本该被回收的变量无法被及时释放。如果闭包持续存在(比如全局变量持有的回调),外部作用域中的对象就无法被垃圾回收,导致调用栈的内存占用持续增加。
2. 性能开销
每次创建闭包时,都有额外的上下文开销------需要将变量从栈内存复制到堆内存、维护 outer 指针等。在频繁创建闭包的场景下(如大量循环中的回调),可能会影响性能。
3. 但不必过度担心
现代 JS 引擎(V8 等)会做大量优化:
- 如果闭包中的某些变量实际上没有被引用,引擎可能会优化掉,不放进闭包
- 对于短生命周期的闭包,引擎可能会直接在栈上处理,不需要转移到堆
- 只有真正被长期持有的闭包才会带来明显的内存影响
最佳实践 :不要因为"怕内存泄漏"就不用闭包。正确做法是------确保你创建的闭包有合理的生命周期:该 cleanup 的 cleanup,该解除引用的解除引用。
3.6 闭包的常见用途
闭包不是奇技淫巧,它是 JavaScript 最核心的特性之一,无处不在:
| 用途 | 说明 | 示例 |
|---|---|---|
| 数据私有化 | 外部无法直接访问变量,只能通过暴露的方法操作 | 模块模式、工厂函数 |
| 函数工厂 | 批量生成带固定参数的函数 | createMultiplier(2) → (x) => x * 2 |
| 缓存/Memoization | 通过闭包持有计算结果,避免重复计算 | 递归优化、请求缓存 |
| 回调与事件 | 回调函数通过闭包记住上下文 | addEventListener、setTimeout |
| React Hooks | useState/useEffect 底层依赖闭包记住状态 |
本文第四章的核心 |
| 模块系统 | CommonJS / ES Modules 的私有作用域 | module.exports |
一句话:闭包无处不在。只要你写了函数嵌套、回调、Hooks,你就已经在用闭包了。
第四章 React Hooks 中的过期闭包陷阱
理解了闭包的底层机制,现在来看 React 中最令人头疼的实际问题。
4.1 为什么 React 和闭包绑得这么紧
React 函数组件每次渲染都会重新执行。每次执行都是一次新的函数调用,创建新的执行上下文,产生新的闭包。
css
第 1 次渲染 Counter():
执行上下文 A:{ count: 0, effect 回调: fn_A }
fn_A 的闭包 → { count: 0 }
第 2 次渲染 Counter():
执行上下文 B:{ count: 1, effect 回调: fn_B }
fn_B 的闭包 → { count: 1 }
问题 :fn_A 和 fn_B 是两个不同的函数 ,各自有独立的闭包。如果某个地方(比如 setInterval)持有了 fn_A 的引用,它读到的永远是 count: 0------因为 fn_A 的闭包里只有它被创建时的"快照"。
这就是过期闭包(Stale Closure):拿着旧照片认人------state 已经变了,但旧闭包里的照片没变。这个英文术语在 Stack Overflow 和英文文档中出现频率极高,面试和日常排障都用得上。
4.2 经典案例:setInterval 计数器永远卡在 1
这是面试高频题,也是最容易踩的坑:
jsx
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 永远打印 0
setCount(count + 1); // 永远设成 1,不会递增
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
运行结果 :页面上 count 永远显示 1,不会继续递增。
用执行上下文和闭包来理解:
scss
第 1 次渲染:Counter() 执行
├─ 执行上下文 A 创建
│ count = 0, setCount = <函数>
├─ useEffect 创建 setInterval
│ setInterval 的回调 fn_A 被创建
│ fn_A 的闭包 → { count: 0 } ← 快照!
├─ useEffect 依赖数组 [] 为空,effect 只执行这一次
└─ 执行上下文 A 出栈
但 fn_A 的闭包保留在堆内存中 { count: 0 }
1 秒后:fn_A 执行
├─ 读闭包中的 count → 0
├─ setCount(0 + 1) = 1 → 触发第 2 次渲染
└─ count 更新为 1
第 2 次渲染:Counter() 再次执行
├─ 执行上下文 B 创建
│ count = 1
├─ 但 setInterval 的回调还是 fn_A!
│ fn_A 的闭包仍然是 { count: 0 } ← 没变!
└─ 执行上下文 B 出栈
1 秒后:fn_A 又执行
├─ 读闭包中的 count → 还是 0
├─ setCount(0 + 1) = 1 → count 永远是 1 → 卡死 💀
根本原因 :setInterval 持有了 fn_A 的引用。fn_A 的闭包捕获了第 1 次渲染时的 count = 0。后续渲染虽然创建了新的执行上下文(count = 1),但 fn_A 的闭包没有更新------它手里拿的还是旧照片。
4.3 三种解决方案
方案一:函数式更新(✅ 首选)
jsx
setCount(prev => prev + 1);
原理 :函数式更新的 prev 参数由 React 内部的更新队列提供,永远是最新值,完全不依赖闭包中的外部变量。
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // ✅ prev 永远是最新的,与闭包无关
}, 1000);
return () => clearInterval(timer);
}, []);
适用场景:绝大多数只需要基于上一个 state 计算新 state 的情况。最简洁、最推荐。
方案二:依赖数组加上 state
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer); // 重要!必须清理旧 timer
}, [count]); // count 变了 → 清理旧 effect → 用新闭包创建新 effect
原理 :每次 count 变化,effect 先执行 cleanup(清除旧的 setInterval,释放旧闭包),再用新的 count 值创建新的 setInterval(新的闭包)。
缺点:每次 state 变化都会重建 effect,频繁操作时性能不佳。
方案三:useRef 持有最新值
jsx
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count; // 每次渲染同步更新 ref(这是同步赋值,不是副作用)
useEffect(() => {
const timer = setInterval(() => {
setCount(countRef.current + 1); // 从 ref 读最新值
}, 1000);
return () => clearInterval(timer);
}, []);
原理 :useRef 返回的是一个可变对象 ({ current: ... }),它在组件的整个生命周期中保持同一个引用 (存放在堆内存中,不随渲染销毁)。每次渲染时通过 countRef.current = count 同步最新值,回调里通过 countRef.current 读取------绕过了闭包的快照限制。
与闭包的本质区别:
- 闭包中的变量是值拷贝(每次渲染的快照)
useRef.current是引用(始终指向同一个堆对象,值可变)
适用场景:在异步回调、事件监听等场景中需要读取"最新值"且不方便用函数式更新时。
4.4 三种方案速查对比
| 方案 | 核心写法 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 函数式更新 | setX(prev => prev + 1) |
React 队列提供最新值 | 最简洁 | 只能基于上一个值计算 | ✅ 首选 |
| 依赖数组 | }, [count]) |
重建 effect + 新闭包 | 语义明确 | 频繁重建 effect | 变化不频繁时 |
| useRef | ref.current = value |
可变引用绕过快照 | 灵活 | 需手动同步 | 异步读最新值 |
第五章 React StrictMode 的状态陷阱
5.1 StrictMode 是什么
React.StrictMode 是 React 提供的开发模式辅助工具,用来提前发现潜在问题。
jsx
// 入口文件常见写法
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
重要 :StrictMode 只在开发环境生效,生产环境不会有任何影响,放心用。
在开发模式下,StrictMode 会故意执行以下操作:
- ✅ 组件渲染函数调用两次(检测纯函数副作用)
- ✅
useEffect执行两次(挂载 → 卸载 → 再挂载) - ✅
useState/useReducer的 updater 函数(如prev => prev + 1)调用两次 - ✅ 等等...
目的:逼你写出正确的 cleanup 函数,确保组件能够在挂载/卸载之间安全切换。
⚠️ 版本差异 :effect 双调用是 React 18+ 的行为。React 17 的 StrictMode 只会双调用渲染函数,不会双调用 effect。升级到 React 18 后突然出现的"定时器跑两次"问题,大概率是缺少 cleanup。
5.2 经典案例:计数器每次 +2
jsx
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// ❌ 没有 cleanup!
}, []);
return <div>{count}</div>;
}
注意这里用了函数式更新(解决了过期闭包问题),但没有 cleanup。在 StrictMode 下的执行流程:
StrictMode 第 1 次挂载:
→ 创建 timer1,每秒 +1 ✅
StrictMode 模拟卸载:
→ 没有 cleanup 函数 → timer1 没被清除,还在跑!
StrictMode 第 2 次挂载:
→ 又创建 timer2,每秒 +1
结果:timer1 + timer2 同时运行 → 每秒 +2 💀
如果你不做开发,你甚至发现不了这个 Bug------因为生产环境没有 StrictMode,一切正常。但一旦你依赖了不 cleanup 的副作用,换一个场景就可能出问题。
5.3 修复:加上 cleanup
jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer); // ✅ cleanup
}, []);
修复后的执行流程:
scss
StrictMode 第 1 次挂载:
→ 创建 timer1
StrictMode 模拟卸载:
→ 执行 cleanup → clearInterval(timer1) → timer1 停了 ✅
StrictMode 第 2 次挂载:
→ 创建 timer2(唯一运行的定时器)
结果:只有 timer2 运行 → 每秒 +1 ✅
第六章 useEffect Cleanup 速查表
一个简单的原则:effect 里创建了什么,cleanup 就要清理什么。
| effect 里创建的资源 | 对应的 cleanup |
|---|---|
setInterval(id) |
clearInterval(id) |
setTimeout(id) |
clearTimeout(id) |
addEventListener |
removeEventListener |
subscribe() |
unsubscribe() |
new WebSocket() |
socket.close() |
new IntersectionObserver() |
observer.disconnect() |
new MutationObserver() |
observer.disconnect() |
fetch / axios 请求 |
controller.abort()(AbortController) |
requestAnimationFrame(id) |
cancelAnimationFrame(id) |
口诀:有创建就有销毁,有订阅就有取消,有监听就有移除。
第七章 面试回答模板与追问
主问题:请解释一下 React 中的闭包陷阱?
参考回答:
React 函数组件每次渲染都会重新执行,生成新的闭包。闭包捕获的是本次渲染时 的 state 值(可以理解为"快照")。如果某个回调(如
setInterval、事件处理函数)是在旧渲染周期创建的,且没有随着 state 更新而重新创建,它读到的就是旧值。后果 :连续
setState只生效一次、定时器不递增、请求参数是旧的等。解决方案有三种:
- 函数式更新
setX(prev => prev + 1)------最推荐,不依赖外部变量- 依赖数组加 state------让 effect 随 state 变化重建
- useRef 持有最新值------通过可变引用绕过闭包快照
此外,React StrictMode 在开发模式下会双调用 effect(挂载→卸载→挂载),用来检查 cleanup 是否正确。如果 effect 里创建了资源却没写 cleanup,就会出现资源泄漏(如定时器重复创建)。所以 useEffect 里创建的资源一定要配套 cleanup 函数。
追问 Q1:闭包是怎么产生的?为什么 React 特别容易遇到闭包问题?
闭包产生的原因是词法作用域规则和执行上下文销毁规则的冲突。词法作用域要求内部函数能访问外部函数的变量,但函数执行完后上下文要销毁。JS 引擎的解决方案是将被引用的自由变量转移到堆内存中(闭包),通过 outer 指针让内部函数继续访问。
React 特别容易遇到是因为:函数组件每次渲染都重新执行,每次执行都创建新的闭包。
useEffect持有的回调可能持有旧渲染的闭包,而useEffect的执行时机和渲染是异步的,很容易出现回调引用过期快照的问题。
追问 Q2:useCallback 能避免过期闭包吗?
不能。
useCallback只是缓存了函数引用,减少不必要的重建,但它捕获的 state 仍然是创建时的值。真正解决过期闭包要用函数式更新或useRef。
追问 Q3:函数式更新和 useRef 方案有什么本质区别?
函数式更新是让 React 内部 维护最新的 state 值(通过队列机制),调用方不需要关心当前值是什么。
useRef则是你自己在组件层维护一个"活引用",手动同步和读取。前者更声明式,后者更灵活但更命令式。
追问 Q4:React 19 对 StrictMode 行为有变化吗?
React 19 沿用了 React 18 的 StrictMode 双调用策略,没有根本性变化。但 React 19 引入了
use()hook 和新的并发特性,写 cleanup 的习惯依然重要。
第八章 终极记忆卡片
| 概念 | 一句话记忆 |
|---|---|
| 执行上下文 | 函数运行时的"工作台",包含变量环境、词法环境、outer 指针 |
| 作用域链 | outer → outer → ... → global,变量查找的完整路径 |
| 词法作用域 | outer 在声明时就决定了,和在哪里调用无关 |
| 闭包 | 外部函数销毁后,自由变量被转移到堆内存中,通过 outer 指针持续访问 |
| 过期闭包 | 拿着旧照片认人------state 变了,但旧闭包的照片没变 |
| 函数式更新 | setX(prev => prev + 1)------永远拿到最新值,闭包克星 |
| useRef | 可变的"活页笔记本",引用不变,内容可变,绕过闭包快照 |
| StrictMode | 故意双调用 effect,逼你写 cleanup |
| Cleanup 原则 | 创建了什么,就要在 return 里清理什么 |
写在最后
闭包不是什么神秘的东西------它就是 JS 引擎为了解决"词法作用域 vs 执行上下文销毁"这个规则冲突而设计的机制。自由变量被转移到堆内存,通过 outer 指针持续访问,这就是闭包的全部。
但在 React 的渲染模型下,闭包的"记忆"特性会和函数组件的"每次重新创建"产生微妙的化学反应:每次渲染产生新的闭包,旧闭包中的变量就成了"过期快照"。
理解了闭包的底层机制(执行上下文 → outer 指针 → 堆内存 → 垃圾回收),再加上 StrictMode 双调用的"体检机制",你就能从容应对 React 中绝大多数状态相关的"玄学 Bug"。
下次遇到 state 不更新的问题,先问自己三个问题:
- 这个回调是在哪次渲染创建的? 它的闭包是哪次的快照?
- 这个回调的引用还被谁持有? 是不是
setInterval或事件监听器还拽着旧的? - 我有没有写 cleanup? 如果资源是 StrictMode 双调用创建的,旧的清理了吗?
想通了,Bug 就迎刃而解了。
📚 参考资料
- React 官方文档 - useEffect
- React 官方文档 - StrictMode
- 《你不知道的 JavaScript》(上卷)- 第一章 作用域、第二章 词法作用域、第五章 闭包
- A Complete Guide to useEffect - Dan Abramov
- JavaScript Visualized: Scope (Chain) - Lydia Hallie
如果这篇文章对你有帮助,欢迎点赞 👍 收藏 🔖 关注,后续会持续分享 React 深度解析系列文章。
🏷️ 推荐标签 :
React·Hooks·useEffect·闭包·Stale Closure·StrictMode·前端面试·JavaScript·执行上下文·作用域链