🔥 从执行上下文到 React 陷阱——彻底搞懂 JavaScript 闭包

🔥 从执行上下文到 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 指针)          │
│                                                       │
└─────────────────────────────────────────────────────┘

编译阶段做了什么?

  • 扫描代码,识别所有变量声明(varletconst)和函数声明
  • 创建变量环境 (Variable Environment):存放 var 声明和 function 声明
  • 创建词法环境 (Lexical Environment):存放 letconst 声明(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)
声明方式 varfunction letconstclass
初始化时机 编译阶段就初始化为 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 新增的,只有 letconst 才会创建块级作用域。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();

变量查找规则

  1. 先在当前执行上下文中找
  2. 找不到就通过 outer 指针去外层执行上下文找
  3. 一层一层往外找,直到全局执行上下文
  4. 全局也找不到 → 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 通过闭包持有计算结果,避免重复计算 递归优化、请求缓存
回调与事件 回调函数通过闭包记住上下文 addEventListenersetTimeout
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_Afn_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 只生效一次、定时器不递增、请求参数是旧的等。

解决方案有三种:

  1. 函数式更新 setX(prev => prev + 1)------最推荐,不依赖外部变量
  2. 依赖数组加 state------让 effect 随 state 变化重建
  3. 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 不更新的问题,先问自己三个问题:

  1. 这个回调是在哪次渲染创建的? 它的闭包是哪次的快照?
  2. 这个回调的引用还被谁持有? 是不是 setInterval 或事件监听器还拽着旧的?
  3. 我有没有写 cleanup? 如果资源是 StrictMode 双调用创建的,旧的清理了吗?

想通了,Bug 就迎刃而解了。


📚 参考资料


如果这篇文章对你有帮助,欢迎点赞 👍 收藏 🔖 关注,后续会持续分享 React 深度解析系列文章。


🏷️ 推荐标签React · Hooks · useEffect · 闭包 · Stale Closure · StrictMode · 前端面试 · JavaScript · 执行上下文 · 作用域链

相关推荐
秋天的一阵风1 小时前
⚡2026 年了,十万级表格还只会「虚拟滚动」?难怪你的页面照样卡顿
前端·javascript·面试
志尊宝2 小时前
Vue3 零基础每日笔记(029):插槽 slot 三连——默认、具名、作用域一次讲透
前端·javascript·vue.js·vue·前端开发
铁皮饭盒2 小时前
网页端, 40mb离线模型, 自动抠图, 不用 Python,不用服务器,不用 API Key, 不用显卡
前端·javascript·后端
福兮说2 小时前
在浏览器里写一个“够用就好“的 Excel 公式求值器
前端·javascript·excel·编译原理
志尊宝2 小时前
Vue3 零基础每日笔记(027):组件上的 v-model——让自定义组件也能双向绑定
前端·javascript·vue.js·笔记·html5
云浪3 小时前
从 0 手写一个 MCP Server:让 Copilot 调用 Tool 完成四则运算
javascript·node.js·mcp
尾善爱看海12 小时前
Vue 面试收官篇:SSR、性能优化落地、30 道高频面试题精讲(附标准答案)
前端·javascript·vue.js·面试·vue
李明卫杭州17 小时前
React 复合事件系统对比解析
前端·javascript·react.js
LEE18 小时前
前端转型全栈 01:数据建模,前端最大的盲区
前端·javascript·后端