初步理解

1. 提出问题:消失的计数器

假设你要写一个点击计数器,要求: count 变量不能是全局变量(防止被其他代码改乱),但每次调用函数时,它又能累加。

你可能会尝试这样写:

问题: 怎样让 count 既能“私有化”,又能“记住”上次的值?

2. 闭包的初级理解:函数自带的“小背包”

要解决上面的问题,我们需要在函数里面再返回一个函数

为什么这能行?

3. 进阶理解:词法作用域与生存期

闭包不是 JS 特意制造的“功能”,而是 作用域规则 的自然产物:

  1. 词法作用域:函数寻找变量时,是根据它定义的位置来找,而不是执行的位置。
  2. 变量生存期:只要闭包函数还在被引用,它“背包”里的变量就一直存在于内存中。

4. 结合 React:大名鼎鼎的“闭包陷阱”

在 React 中,闭包最常出现在 useEffectuseCallback 中,这就是著名的 过期闭包 (Stale Closure) 问题。

经典案例:

现象: 屏幕上的数字会从 0 变成 1,然后永远停在 1。console.log打印出来的count,永远是0。 解释:

如何解决?

  1. 添加依赖项:让 useEffectcount 改变时重新运行,创建带新“背包”的新闭包。
  2. 使用函数式更新setCount(prev => prev + 1)。这样不需要捕获外部变量,直接操作最新状态。

总结

 

进一步拆解

好的,我们这就开始闭包的“拆解之旅”。

第一阶段:观察现象(由表及里)

在 JS 中,函数不仅仅是执行一段代码,它还带有一个隐形的“记忆背包”。

请看下面这段代码并思考:

image-20260507142304433

🤔 问题 1: factory("Alice") 执行完毕后,它的局部变量 nameintro 按理说应该被销毁了。那么当你随后运行 greetAlice() 时,它为什么还能知道 name 是 "Alice"?

第二阶段:揭秘本质(词法作用域)

这就是闭包的奥秘:闭包 = 函数 + 该函数创建时所处的环境

  1. 环境捕获:当你在一个函数内部定义另一个函数时,内部函数会“记住”它出生时的周围环境(变量)。
  2. 生命周期延长:只要内部函数还活着(比如被赋值给了 greetAlice),它背着的“背包”(环境)就不会被垃圾回收。

💡 核心理解: 闭包让函数拥有了“私有内存”。

第三阶段:实战挑战(React/Next.js 中的“坑”)

在 React 中,我们经常用闭包,但如果不理解它,就会遇到“过期闭包” (Stale Closure)

请看这个 Next.js/React 组件:

🤔 问题 2:

  1. 用户先点击“弹窗”按钮。
  2. 在 3 秒内,用户又快速点击了“加1”按钮 5 次(此时屏幕显示数字为 5)。
  3. 等 3 秒到了,弹出的对话框里显示的数字是 0 还是 5?

为什么是 0?(拆解过程)

  1. 快照(Snapshot):当你点击“弹窗”按钮时,handleAlertClick 函数开始执行。
  2. 创建闭包setTimeout 接收了一个匿名函数。这个函数就是一个闭包,它背起了一个“小背包”,里面装的是点击那一刻的 count
  3. 引用 vs 副本:在 JS 中,基本类型(如数字)在闭包中就像一张“照片”。虽然你后面点击加 1 改变了组件状态,导致组件重新渲染,但 那个 3 秒后的定时器回调函数依然拿着 3 秒前的那张照片

第四阶段:更复杂的挑战(引出 useRef)

在 Next.js 或 React 开发中,如果你确实需要弹窗显示最新的数字(即显示 5),该怎么办?

开发者通常会使用 useRef 来打破闭包的限制。

请看这段代码改动:

🤔 问题 3: 为什么使用 countRef.current 就能拿到最新的 5,而直接用 count 却不行?从“闭包背包”的角度思考,countRef 这个对象在闭包里发生了什么变化?

(提示:闭包背着的是“箱子本身”,还是“箱子里面的东西”?)

为什么?

这里的关注点应该在于alert里面的countRef.current,而不是countRef.current = count。既然之前的例子中,当handleAlertClick执行的时候,闭包里面的变量值count = 0,那么这个例子中,countRef.current此时也是0啊,为什么最后是5呢?

  1. count(基本类型):背着的是“照片”

当你使用 count 时,闭包就像拍了一张照片放进背包。

  1. useRef 就像是一个固定在路边的公告栏

这种写法很奇怪,我感觉这不符合react 单向数据流的规则。

 

你的直觉非常敏锐!你说得对,这种写法确实“很不 React”。

在 React 的哲学里,这种写法违反了 “单向数据流” unidirectional data flow 和 “声明式编程” declarative 的原则。让我们拆解一下为什么这种写法很怪,以及“正经”的 React 该怎么写。

为什么这种写法很“怪”?

  1. 手动同步(副作用) 代码中有一行 countRef.current = count;。这种在渲染函数体内部直接修改外部变量(Ref)的操作,实际上是一种隐蔽的副作用。React 期望渲染函数是“纯”的(Pure),这种写法破坏了纯函数原则。
  2. 数据双重来源 你同时维护了 count(State)和 countRef(Ref)。当你需要显示数字时用 State,需要避开闭包时用 Ref。这让逻辑变得混乱:“我到底该信谁?”

“正经”的 React 解决办法是什么?

如果你想在 3 秒后获取点击那一刻的最新值,React 官方推荐的姿势通常有以下两种:

方案 A:接受闭包(这是 React 的默认行为)

React 认为,如果你点击时的 count 是 0,那么 3 秒后弹出 0 是符合逻辑的,因为那个弹窗是“属于”当时那个时刻的。如果你想更新,你应该让 useEffect 监听 count

方案 B:使用函数式更新(处理 State 本身)

如果你是为了在定时器里修改状态(而不是弹窗),你应该用:

方案 C:将逻辑封装进自定义 Hook(高级做法)

如果你真的遇到了极其复杂的闭包问题(比如在写一个复杂的倒计时或音频播放器),我们会写一个 useLatest 钩子来封装这种“怪异”:

这种写法依然使用了 Ref,但它被封装在 useEffect 里,符合 React 对副作用管理的规范。

为什么这比之前的写法“正规”?

  1. 符合副作用规范:之前的写法是在渲染过程中直接修改 ref.current,这在 React 的并发模式(Concurrent Mode)下可能会出问题。方案 C 把修改操作放在了 useEffect 里,这是 React 官方推荐的处理外部变量同步的地方。
  2. 职责分离:组件不需要关心 ref 是如何同步的,它只需要调用 useLatest 拿到那个“永远最新的引用”即可。
  3. 类型安全:在 TypeScript 中,这种封装可以很好地支持泛型。

适用场景

当你遇到以下情况时,useLatest 是神药:

总结

第四阶段的例子之所以“怪”,是因为它是为了向你演示闭包原理而刻意构造的“手术刀式”代码。

在实际开发中:

第五阶段:终极理解——闭包与内存泄露

这是理解闭包的最后一环。既然闭包能让变量“长生不老”,那如果用得不好,就会导致内存被占满。

看下面这个例子(常见于 Next.js 的服务端轮询或长连接场景):

🤔 最终问题: 虽然 myClosure 这个函数只访问了巨大数组的第一个元素,但整个拥有 100 万个元素的 massiveData 数组会从内存中消失吗?为什么?

 

我的答案是:不会消失,因为 myClosure 并没有执行完成。

答案不准确。

 

只要 myClosure 这个变量还在,数组就不会消失。 即使你没有执行它,或者执行完了一次,它依然存在。

这里的关键点不是“执行完”,而是“引用还存在”

  1. 生存期绑定:因为 myClosure 引用了那个内部函数,而那个函数又引用了 massiveData
  2. 垃圾回收(GC)的逻辑:JS 的垃圾回收器会想:“我能删掉 massiveData 吗?不行,因为 myClosure 以后可能还会被调用,它一旦被调用,就需要用到这个数组。”
  3. 哪怕只用一点点:哪怕你的闭包里只写了 console.log(massiveData[0]),JS 引擎通常也会把整个数组保存在内存里,因为它属于那个“背包”环境的一部分。

最后的“毕业小练习”

Next.jsuseEffect 中,如果你写了下面这段代码,不加清理函数(cleanup),为什么会被称为内存泄漏?

我的答案:因为handler函数里面使用了data变量,存在闭包,即使组件卸载之后,handler作为变量还存在于内存中。

我的答案没有抓住最准确的点,最准确的点应该是window。

 

  1. 外部引用(关键点):你通过 window.addEventListenerhandler 交给了浏览器窗口对象(window)。

  2. 根级引用window 是全局对象,只要页面没关闭,window 就一直存在。

  3. 引用链条

    • window 引用了 handler 事件监听器。
    • handler 作为一个闭包,它的“背包”里引用了 data
    • 即使 React 组件已经卸载(Unmount),只要你不手动执行 removeEventListener,这个链条就断不开。

结果:那个巨大的 data 就这样悄悄地驻留在内存里,哪怕用户已经跳转到了其他页面。

 

闭包的应用

其实这个问题是我最关心的问题,因为一旦有个概念很难理解,我会先看这个概念的具体应用是什么,如果和我无关,我就完全没有兴趣了。

这么多年的经验下来,我发现初级程序员是不需要管闭包的概念的,我做了这么多年的项目,自己基本上没有写过闭包的函数。即使我确实写过deepClone相关的函数,但也是照着别人抄的,里面有闭包,我根本就不理解,项目OK就行了。

但是学习了react之后,发现函数要想用的好,还是需要理解一些基本概念,函数肯定不只是在里面写一个简单的方法就行了的,各种各样的抽象方法才是区分初级和高级程序员的分水岭。

那么闭包具体有哪些应用呢?

 

作为从 Vue 转型 React/Next.js 的开发者,你产生这种疑惑很正常,因为 Vue 的响应式系统(Proxy/DefineProperty)把很多闭包的操作给“自动化”了,让你不需要直接面对它。

但在 React 尤其是现代 React (Hooks) 中,闭包不再是某个特定的“功能”,而是整个框架运行的基石。在 React 项目中,闭包几乎无处不在:

1. 它是 React Hooks 的底层实现原理

React 并没有什么魔法能让 useState 记住你的值。每当组件重新渲染时,函数组件会被重新执行。

2. 它是处理“异步一致性”的神器

这是 React 相对于 Vue (class-based) 的一大优势。

上面这个例子好像看不出有什么影响,其实是例子不对,应该拿下面这个例子来说明:

“给不同的人发消息”,没有闭包(快照)就会出大问题。

致命影响的例子:聊天发送系统

假设你在写一个聊天 App,你快速切换聊天对象并发送消息。

  1. React 的闭包模式(正确):
  1. Vue 或 Ref 模式(最新值引用):

 

react例子:

操作顺序:

  1. 选中 Alice -> 点击发送(3秒倒计时开始)。
  2. 立即点击“选 Bob”(recipient 状态变了)。

结果: 3秒后,弹窗显示 "Message sent to Alice"

结论: 逻辑正确。因为“发送”动作属于点击那一刻的上下文。

 

vue例子

操作顺序:

  1. 选中 Alice -> 点击发送。
  2. 立即点击“选 Bob”。

结果: 3秒后,弹窗显示 "Message sent to Bob"

结论: 逻辑错误! 你本来想给 Alice 打招呼,结果消息发给了 Bob。

 

为什么 Vue 开发者通常不觉得这是个问题?

在 Vue 开发中,如果开发者意识到需要“定格”某个值,他们必须手动进行快照处理:

深度对比:React vs Vue 在这里的“哲学”区别

特性React (函数式/快照)Vue (响应式/引用)
默认行为捕获快照。每个渲染周期都是独立的,异步回调默认记住“过去”。追踪最新。变量指向内存地址,异步回调默认看到“现在”。
异步安全性。天然防止“发错人”的逻辑错误,不需要额外处理。需留意。如果不手动保存副本,高频操作下容易出现数据错位。
直觉感受“那一刻的 UI 是什么样的,逻辑就是什么样的。”“数据现在是什么值,逻辑拿到的就是什么值。”

3. 高阶组件 (HOC) 与 柯里化 (Currying)

在 Next.js 或 React 的逻辑复用中,闭包经常用来创建“定制化函数”:

4. 封装私有变量 (模块化模式)

虽然现代 ESM 模块已经很流行,但在编写复杂的逻辑库(如特定的动画引擎或状态管理工具)时,闭包依然是实现真正私有变量的唯一手段。

5. 函数记忆化 (Memoization)

当你使用 useMemo 或实现自定义缓存函数时,闭包用来存储之前的计算结果。并且cache是私有变量,外界无法修改cache,更加安全。

总结:Vue vs React 的闭包感