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

0 阅读23分钟

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

"闭包到底是什么?" 面试被问到闭包,你是不是只能背一句"函数能访问外部变量"?当 React 计数器卡在 1,你知道背后是闭包在作怪吗?

本文适合:想从"背概念"升级到"懂原理"的前端开发者。会从 JS 引擎执行机制讲起,一路讲到 React Hooks 闭包陷阱和 StrictMode。建议收藏 🔖,反复阅读。


3f1d694836a143c5a704ed603842fbe5.png

📑 目录


第一章 前置知识:JS 代码是怎么执行的

要理解闭包,必须先理解 JavaScript 引擎是如何执行代码的。这是地基,跳过它讲闭包就是空中楼阁。

1.1 编译与执行:两步走

很多人以为 JS 是"逐行解释执行"的。实际上,JS 引擎在执行代码之前会先编译——准确说是准备执行上下文的过程。

┌─────────────────────────────────────────────────────┐
│                  JS 代码执行流程                       │
│                                                       │
│   源代码  ──→  编译阶段(准备执行上下文)  ──→  执行阶段  │
│                  · 创建变量环境                      │
│                  · 创建词法环境                      │
│                  · 确定作用域链(outer 指针)          │
│                                                       │
└─────────────────────────────────────────────────────┘

编译阶段做了什么?

  • 扫描代码,识别所有变量声明(varletconst)和函数声明
  • 创建变量环境(Variable Environment):存放 var 声明和 function 声明
  • 创建词法环境(Lexical Environment):存放 letconst 声明(ES6 新增)
  • 确定 outer 指针:指向外层执行上下文(后面详细讲)

一句话:编译阶段搭好"工作台",执行阶段才真正运行代码。

1.2 执行上下文:函数运行时的"工作台"

每当一个函数被调用,JS 引擎就会为它创建一个执行上下文(Execution Context)。你可以把它理解为函数运行时的"工作台":

┌─────────────────────────────────────────────┐
│           执行上下文 (Execution Context)        │
│                                               │
│  ┌─────────────────────────────────────────┐  │
│  │ 变量环境 (Variable Environment)          │  │
│  │   var 声明的变量                         │  │
│  │   function 声明的函数                    │  │
│  │   arguments 对象                         │  │
│  └─────────────────────────────────────────┘  │
│                                               │
│  ┌─────────────────────────────────────────┐  │
│  │ 词法环境 (Lexical Environment)  [ES6]    │  │
│  │   let / const 声明的变量                 │  │
│  └─────────────────────────────────────────┘  │
│                                               │
│  ┌─────────────────────────────────────────┐  │
│  │ this 绑定                               │  │
│  └─────────────────────────────────────────┘  │
│                                               │
│  ┌─────────────────────────────────────────┐  │
│  │ outer(外部引用) ← 指向外层执行上下文    │  │
│  └─────────────────────────────────────────┘  │
│                                               │
└─────────────────────────────────────────────┘

注意最下面的 outer——它是闭包的关键。

1.3 变量环境与词法环境

ES6 之前只有变量环境。ES6 引入 let/const 后,新增了词法环境。它们的核心区别:

特性变量环境 (var)词法环境 (let/const)
声明方式varfunctionletconstclass
初始化时机编译阶段就初始化为 undefined编译阶段创建但不初始化(暂时性死区)
作用域函数级块级 {}
重复声明✅ 允许(会覆盖)❌ 报错 SyntaxError
console.log(a); // undefined(var 已初始化)
console.log(b); // ReferenceError(暂时性死区,let 未初始化)

var a = 1;
let b = 2;

1.4 调用栈:管理执行上下文的"栈"

JS 引擎用调用栈(Call Stack)来管理执行上下文的创建和销毁。函数调用就入栈,函数执行完就出栈:

function outer() {
  let x = 10;
  inner();
}

function inner() {
  let y = 20;
  console.log(x); // ???
}

outer();

执行过程:

调用栈变化:

  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 指针串起来的查找路径,就是作用域链。

变量查找路径(作用域链):

  inner 执行上下文
  │  没有 x?
  │
  ▼  outer
  outer 执行上下文
  │  找到了 x = 10 ✅
  │
  ▼  outer(如果还没找到,继续往上)
  global 执行上下文

完整代码演示:

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 指针在编译阶段就确定了,和运行时怎么调用无关。

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!
bar 的作用域链(在 foo 内部声明,outer 指向 foo):

  bar 执行上下文
  │  找 a
  ▼  outer
  foo 执行上下文 → 找到 a = 1 ✅
  │  如果没找到
  ▼  outer
  global 执行上下文

注意:bar 是在 baz 里调用的,但 outer 不指向 baz!
因为 bar 声明在 foo 里,词法作用域 = 声明时决定。

口诀:函数在哪里声明的,outer 就指向哪里。在哪里调用的,不重要。

理解了词法作用域和 outer 指针,就能理解闭包了——因为闭包的本质就是:outer 指向的执行上下文已经出栈了,但其中的某些变量还活着。


第三章 闭包:从冲突到解决

3.1 闭包为什么存在?——一个规则冲突

闭包不是 JavaScript 的"设计缺陷",而是为了解决一个规则冲突

┌───────────────────────────────────────────────────────┐
│                    规则冲突                             │
│                                                        │
│  规则 1:词法作用域                                     │
│    内部函数可以访问外部函数中声明的变量(通过 outer 指针)  │
│                                                        │
│  规则 2:函数调用完毕,执行上下文一定被销毁               │
│    函数执行完后,它的执行上下文从调用栈弹出,变量被回收     │
│                                                        │
│  ─────────────────────────────────────────────────────  │
│  冲突:如果内部函数在外部函数执行完之后才被调用,           │
│        outer 指向的上下文已经没了,变量找不到了!           │
└───────────────────────────────────────────────────────┘

怎么解决?

JS 引擎的做法:当内部函数引用了外部函数的变量,并且内部函数被返回/传递到了外部函数之外,引擎就把这些被引用的变量从栈内存转移到堆内存中,持久化保存。

这些被转移到堆内存中的变量集合,就是闭包。

3.2 闭包的"背包"机制——自由变量去哪了

我们用一个完整的例子,追踪变量在内存中的变化:

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() 被调用

调用栈:
  ┌─────────────────┐
  │ outer 上下文      │
  │   count = 0      │
  │   inner = <函数>  │
  └─────────────────┘
  ┌─────────────────┐
  │ global 上下文     │
  └─────────────────┘

Step 2:outer() 执行完毕,返回 inner 函数

正常情况下,outer 的执行上下文应该从调用栈弹出,count 被回收。但 inner 引用了 count

调用栈:                   堆内存:
  ┌─────────────┐         ┌──────────────────────┐
  │ global 上下文 │         │ 闭包(背包)           │
  │ fn = inner  │──引用──→│   count = 0           │
  └─────────────┘         │   ┌────────────────┐  │
                          │   │ inner 函数对象  │  │
                          │   │ outer → 闭包地址 │  │
                          │   └────────────────┘  │
                          └──────────────────────┘

"背包"比喻:outer 要走了(执行上下文销毁),但 inner 还需要用 count,所以 JS 引擎给 inner 背上一个背包,把 count 装进背包里放到堆内存中。inner 走到哪,背包跟到哪,里面的变量始终可用。

Step 3:fn() 被调用(即 inner 执行)

inner 通过自身的 outer 指针找到闭包的地址,从闭包中读取 count

inner 执行上下文
│  需要 count?
│
▼  outer → 指向闭包地址
闭包 { count: 0 }
│  找到 count = 0
│
▼  count++ → count = 1
│  console.log(1) ✅
│
▼  写回闭包 { count: 1 }  ← 闭包中的值被更新了

每次调用 fn(),inner 都通过 outer 指针访问闭包中的 count,读取 → 修改 → 写回。所以 count 能持续递增。

3.3 严格的闭包定义

在 JavaScript 中:

闭包 = 内部函数引用的外部函数的自由变量集合,在外部函数执行完毕后,依然保存在堆内存中,供内部函数后续调用时访问。

几个要点:

  • 自由变量:内部函数引用的、但不是在内部函数中声明的变量
  • 外部函数已执行完毕:执行上下文已从调用栈弹出
  • 变量集合:闭包不是"一个变量",而是被引用的所有变量的集合
  • 保存在堆内存:不在栈上,而在堆上(通过 outer 指针引用)
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 闭包的内存管理与垃圾回收

闭包的内存管理是一个容易被忽视但非常重要的话题。关键在于:引用闭包的变量是什么生命周期?

情况一:引用闭包的是全局变量
const fn = outer();  // fn 是全局变量,永远不会被销毁
fn(); fn(); fn();    // 闭包一直存在,count 永远不会被回收

后果:如果 fn 一直存在,闭包中的变量就一直占用内存。如果你不再需要它,但没有解除引用,就会内存泄漏

解决方案:不再使用时,手动置空引用:

const fn = outer();
fn(); // 用完了
fn = null;  // 解除引用 → 闭包失去所有引用 → GC 回收
情况二:引用闭包的是局部变量
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 HooksuseState/useEffect 底层依赖闭包记住状态本文第四章的核心
模块系统CommonJS / ES Modules 的私有作用域module.exports

一句话:闭包无处不在。只要你写了函数嵌套、回调、Hooks,你就已经在用闭包了。


第四章 React Hooks 中的过期闭包陷阱

理解了闭包的底层机制,现在来看 React 中最令人头疼的实际问题。

4.1 为什么 React 和闭包绑得这么紧

React 函数组件每次渲染都会重新执行。每次执行都是一次新的函数调用,创建新的执行上下文,产生新的闭包。

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

这是面试高频题,也是最容易踩的坑:

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,不会继续递增。

用执行上下文和闭包来理解

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 更新为 12 次渲染: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 三种解决方案

方案一:函数式更新(✅ 首选)
setCount(prev => prev + 1);

原理:函数式更新的 prev 参数由 React 内部的更新队列提供,永远是最新值,完全不依赖闭包中的外部变量。

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1);  // ✅ prev 永远是最新的,与闭包无关
  }, 1000);
  return () => clearInterval(timer);
}, []);

适用场景:绝大多数只需要基于上一个 state 计算新 state 的情况。最简洁、最推荐。

方案二:依赖数组加上 state
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 持有最新值
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变化不频繁时
useRefref.current = value可变引用绕过快照灵活需手动同步异步读最新值

第五章 React StrictMode 的状态陷阱

5.1 StrictMode 是什么

React.StrictMode 是 React 提供的开发模式辅助工具,用来提前发现潜在问题。

// 入口文件常见写法
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

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

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1);
  }, 1000);

  return () => clearInterval(timer);  // ✅ cleanup
}, []);

修复后的执行流程:

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)
addEventListenerremoveEventListener
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 · 执行上下文 · 作用域链