假设你要写一个点击计数器,要求: count 变量不能是全局变量(防止被其他代码改乱),但每次调用函数时,它又能累加。
你可能会尝试这样写:
xfunction createCounter() { let count = 0; count++; console.log(count);}createCounter(); // 输出 1createCounter(); // 输出 1(哎呀,每次都被重置成 0 了)问题: 怎样让 count 既能“私有化”,又能“记住”上次的值?
要解决上面的问题,我们需要在函数里面再返回一个函数:
xxxxxxxxxxfunction counterCreator() { let count = 0; // 这个变量在父函数的作用域里 return function() { count++; // 子函数使用了父函数的变量 console.log(count); };}const myCounter = counterCreator(); myCounter(); // 输出 1myCounter(); // 输出 2为什么这能行?
count)会被垃圾回收机制销毁。myCounter),JS 引擎会认为:“这个 count 以后还要用,不能销毁!”。闭包不是 JS 特意制造的“功能”,而是 作用域规则 的自然产物:
在 React 中,闭包最常出现在 useEffect 或 useCallback 中,这就是著名的 过期闭包 (Stale Closure) 问题。
经典案例:
xxxxxxxxxxfunction Timer() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { // 这里的箭头函数是一个闭包 // 它捕获的是这个定时器创建那一刻的 count 值(即 0) console.log("当前 count:", count); setCount(count + 1); }, 1000); return () => clearInterval(timer); }, []); // 依赖项为空,意味着定时器只在挂载时创建一次 return <div>{count}</div>;}
现象: 屏幕上的数字会从 0 变成 1,然后永远停在 1。console.log打印出来的count,永远是0。 解释:
useEffect 里的函数在组件第一次渲染时创建。[],这个闭包永远只记得它出生时的 count 是 0。setCount(0 + 1),所以结果永远是 1。如何解决?
useEffect 在 count 改变时重新运行,创建带新“背包”的新闭包。setCount(prev => prev + 1)。这样不需要捕获外部变量,直接操作最新状态。
好的,我们这就开始闭包的“拆解之旅”。
在 JS 中,函数不仅仅是执行一段代码,它还带有一个隐形的“记忆背包”。
请看下面这段代码并思考:
xxxxxxxxxxfunction factory(name) { const intro = "我是"; return function() { console.log(intro + name); };}const greetAlice = factory("Alice");const greetBob = factory("Bob");greetAlice(); // 输出什么?greetBob(); // 输出什么?
🤔 问题 1:
当 factory("Alice") 执行完毕后,它的局部变量 name 和 intro 按理说应该被销毁了。那么当你随后运行 greetAlice() 时,它为什么还能知道 name 是 "Alice"?
这就是闭包的奥秘:闭包 = 函数 + 该函数创建时所处的环境。
greetAlice),它背着的“背包”(环境)就不会被垃圾回收。 💡 核心理解: 闭包让函数拥有了“私有内存”。
在 React 中,我们经常用闭包,但如果不理解它,就会遇到“过期闭包” (Stale Closure)。
请看这个 Next.js/React 组件:
xxxxxxxxxx'use client';import { useState } from 'react';export default function Counter() { const [count, setCount] = useState(0); const handleAlertClick = () => { // 假设用户点击按钮后,3秒后弹窗 setTimeout(() => { alert('当前的数字是: ' + count); }, 3000); }; return ( <div> <p>数字: {count}</p> <button onClick={() => setCount(count + 1)}>点我加1</button> <button onClick={handleAlertClick}>点击并在3秒后弹窗</button> </div> );}🤔 问题 2:

为什么是 0?(拆解过程)
handleAlertClick 函数开始执行。setTimeout 接收了一个匿名函数。这个函数就是一个闭包,它背起了一个“小背包”,里面装的是点击那一刻的 count。在 Next.js 或 React 开发中,如果你确实需要弹窗显示最新的数字(即显示 5),该怎么办?
开发者通常会使用 useRef 来打破闭包的限制。
请看这段代码改动:
xxxxxxxxxx'use client';import { useState, useRef } from 'react';export default function Counter() { const [count, setCount] = useState(0); const countRef = useRef(0); // 每次 count 变化,同步更新 ref countRef.current = count; const handleAlertClick = () => { setTimeout(() => { // 注意这里! alert('Ref 里的数字是: ' + countRef.current); }, 3000); }; return ( <div> <p>数字: {count}</p> <button onClick={() => setCount(count + 1)}>加1</button> <button onClick={handleAlertClick}>弹窗</button> </div> );}🤔 问题 3:
为什么使用 countRef.current 就能拿到最新的 5,而直接用 count 却不行?从“闭包背包”的角度思考,countRef 这个对象在闭包里发生了什么变化?
(提示:闭包背着的是“箱子本身”,还是“箱子里面的东西”?)

为什么?
这里的关注点应该在于alert里面的countRef.current,而不是countRef.current = count。既然之前的例子中,当handleAlertClick执行的时候,闭包里面的变量值count = 0,那么这个例子中,countRef.current此时也是0啊,为什么最后是5呢?
count(基本类型):背着的是“照片”当你使用 count 时,闭包就像拍了一张照片放进背包。
0。5,但背包里的照片不会自动更新。0。useRef 就像是一个固定在路边的公告栏。countRef 的引用地址)。这种写法很奇怪,我感觉这不符合react 单向数据流的规则。
你的直觉非常敏锐!你说得对,这种写法确实“很不 React”。
在 React 的哲学里,这种写法违反了 “单向数据流” unidirectional data flow 和 “声明式编程” declarative 的原则。让我们拆解一下为什么这种写法很怪,以及“正经”的 React 该怎么写。
为什么这种写法很“怪”?
- 手动同步(副作用): 代码中有一行
countRef.current = count;。这种在渲染函数体内部直接修改外部变量(Ref)的操作,实际上是一种隐蔽的副作用。React 期望渲染函数是“纯”的(Pure),这种写法破坏了纯函数原则。- 数据双重来源: 你同时维护了
count(State)和countRef(Ref)。当你需要显示数字时用 State,需要避开闭包时用 Ref。这让逻辑变得混乱:“我到底该信谁?”“正经”的 React 解决办法是什么?
如果你想在 3 秒后获取点击那一刻的最新值,React 官方推荐的姿势通常有以下两种:
方案 A:接受闭包(这是 React 的默认行为)
React 认为,如果你点击时的
count是 0,那么 3 秒后弹出 0 是符合逻辑的,因为那个弹窗是“属于”当时那个时刻的。如果你想更新,你应该让useEffect监听count。方案 B:使用函数式更新(处理 State 本身)
如果你是为了在定时器里修改状态(而不是弹窗),你应该用:
xxxxxxxxxxsetCount(prev => prev + 1); // 永远能拿到最新的 prev方案 C:将逻辑封装进自定义 Hook(高级做法)
如果你真的遇到了极其复杂的闭包问题(比如在写一个复杂的倒计时或音频播放器),我们会写一个
useLatest钩子来封装这种“怪异”:xxxxxxxxxx// useLatest.tsimport { useRef, useEffect } from 'react';// 这个 Hook 接收任何值,并返回一个永远指向该值最新版本的 Reffunction useLatest<T>(value: T) {const ref = useRef(value);// 关键:每次渲染后,都会执行这个 useEffect// 从而把最新的 value 同步到 ref.current 中useEffect(() => {ref.current = value;});return ref;}export default useLatest;xxxxxxxxxximport { useState } from 'react';import useLatest from './useLatest'; // 引入刚才写的 Hookexport default function TimerApp() {const [count, setCount] = useState(0);// countRef.current 永远是最新的 countconst countRef = useLatest(count);const handleAlert = () => {setTimeout(() => {// 即使在 3 秒后执行,也能通过 Ref 拿到那一刻的最新的值alert(`State 中的旧值 (闭包): ${count} \nRef 中的新值 (通过 Hook): ${countRef.current}`);}, 3000);};return (<div className="p-4"><h1 className="text-xl">计数器: {count}</h1><buttononClick={() => setCount(count + 1)}className="bg-blue-500 text-white px-4 py-2 m-2">加 1</button><buttononClick={handleAlert}className="bg-green-500 text-white px-4 py-2 m-2">3秒后弹窗</button></div>);}这种写法依然使用了 Ref,但它被封装在
useEffect里,符合 React 对副作用管理的规范。
为什么这比之前的写法“正规”?
- 符合副作用规范:之前的写法是在渲染过程中直接修改
ref.current,这在 React 的并发模式(Concurrent Mode)下可能会出问题。方案 C 把修改操作放在了useEffect里,这是 React 官方推荐的处理外部变量同步的地方。- 职责分离:组件不需要关心
ref是如何同步的,它只需要调用useLatest拿到那个“永远最新的引用”即可。- 类型安全:在 TypeScript 中,这种封装可以很好地支持泛型。
适用场景
当你遇到以下情况时,
useLatest是神药:
- 定时器/订阅:需要在异步回调中获取最新状态,又不想频繁销毁/重建定时器。
- 避免 useEffect 频繁执行:你的某个函数依赖了状态,但你不想因为这个状态的变化而重新运行整个
useEffect(把函数存入useLatest,然后useEffect依赖项设为空)。总结
第四阶段的例子之所以“怪”,是因为它是为了向你演示闭包原理而刻意构造的“手术刀式”代码。
在实际开发中:
- 如果你想尊重闭包:就用 State,接受它是“快照”的事实。
- 如果你想打破闭包:通常意味着你的业务逻辑可能需要
useRef或者需要重新检查useEffect的依赖项数组。
这是理解闭包的最后一环。既然闭包能让变量“长生不老”,那如果用得不好,就会导致内存被占满。
看下面这个例子(常见于 Next.js 的服务端轮询或长连接场景):
xxxxxxxxxxfunction createLeakyFunction() { const massiveData = new Array(1000000).fill('💣'); // 一个巨大的数组 return function() { console.log("我只用了一下这个小数据:" + massiveData[0]); };}const myClosure = createLeakyFunction();🤔 最终问题:
虽然 myClosure 这个函数只访问了巨大数组的第一个元素,但整个拥有 100 万个元素的 massiveData 数组会从内存中消失吗?为什么?
我的答案是:不会消失,因为 myClosure 并没有执行完成。
答案不准确。
只要 myClosure 这个变量还在,数组就不会消失。 即使你没有执行它,或者执行完了一次,它依然存在。
这里的关键点不是“执行完”,而是“引用还存在”:
myClosure 引用了那个内部函数,而那个函数又引用了 massiveData。massiveData 吗?不行,因为 myClosure 以后可能还会被调用,它一旦被调用,就需要用到这个数组。”console.log(massiveData[0]),JS 引擎通常也会把整个数组保存在内存里,因为它属于那个“背包”环境的一部分。在 Next.js 的 useEffect 中,如果你写了下面这段代码,不加清理函数(cleanup),为什么会被称为内存泄漏?
xxxxxxxxxxuseEffect(() => { const data = fetchLargeData(); // 假设获取了大量数据 const handler = () => console.log(data); window.addEventListener('scroll', handler); // 忘记写 return () => window.removeEventListener('scroll', handler);}, []);我的答案:因为handler函数里面使用了data变量,存在闭包,即使组件卸载之后,handler作为变量还存在于内存中。
我的答案没有抓住最准确的点,最准确的点应该是window。
外部引用(关键点):你通过 window.addEventListener 把 handler 交给了浏览器窗口对象(window)。
根级引用:window 是全局对象,只要页面没关闭,window 就一直存在。
引用链条:
window 引用了 handler 事件监听器。handler 作为一个闭包,它的“背包”里引用了 data。removeEventListener,这个链条就断不开。结果:那个巨大的 data 就这样悄悄地驻留在内存里,哪怕用户已经跳转到了其他页面。
其实这个问题是我最关心的问题,因为一旦有个概念很难理解,我会先看这个概念的具体应用是什么,如果和我无关,我就完全没有兴趣了。
这么多年的经验下来,我发现初级程序员是不需要管闭包的概念的,我做了这么多年的项目,自己基本上没有写过闭包的函数。即使我确实写过deepClone相关的函数,但也是照着别人抄的,里面有闭包,我根本就不理解,项目OK就行了。
但是学习了react之后,发现函数要想用的好,还是需要理解一些基本概念,函数肯定不只是在里面写一个简单的方法就行了的,各种各样的抽象方法才是区分初级和高级程序员的分水岭。
那么闭包具体有哪些应用呢?
作为从 Vue 转型 React/Next.js 的开发者,你产生这种疑惑很正常,因为 Vue 的响应式系统(Proxy/DefineProperty)把很多闭包的操作给“自动化”了,让你不需要直接面对它。
但在 React 尤其是现代 React (Hooks) 中,闭包不再是某个特定的“功能”,而是整个框架运行的基石。在 React 项目中,闭包几乎无处不在:
React 并没有什么魔法能让 useState 记住你的值。每当组件重新渲染时,函数组件会被重新执行。
count 能保持? 因为 React 内部使用了linked list来存储状态,而你每个组件的渲染其实都形成了一个闭包,这个闭包访问了 React 内部那个特定索引下的状态。这是 React 相对于 Vue (class-based) 的一大优势。
Vue 的逻辑:由于 Vue 依赖 this(或者 Composition API 里的 ref 对象),它的值始终是“最新的”。这听起来很好,但在异步场景下(如发送请求或延迟弹窗),如果你在请求发出后改变了数据,请求回来后的回调会拿到新数据而不是发起请求时的数据。
React 的闭包逻辑:每一个渲染都有它自己的 props和 state 快照。
上面这个例子好像看不出有什么影响,其实是例子不对,应该拿下面这个例子来说明:
“给不同的人发消息”,没有闭包(快照)就会出大问题。
致命影响的例子:聊天发送系统
假设你在写一个聊天 App,你快速切换聊天对象并发送消息。
- React 的闭包模式(正确):
操作流:
- 你给 张三 写了“你好”,点发送(异步请求开始,闭包记住了
recipient = 张三)。- 你立刻点击左侧列表,切换到了 李四 的聊天窗口。
3秒后:
- 请求完成,消息成功发给了 张三。
- 结果:符合逻辑。 虽然你现在人在李四的窗口,但刚才那句“你好”是发给张三的。
- Vue 或 Ref 模式(最新值引用):
操作流:
- 你给 张三 写了“你好”,点发送(异步请求只记住了“去看一眼当前选中的人”)。
- 你在 3 秒内切换到了 李四。
3秒后:
- 请求完成,它看了一眼“当前选中的人”,发现是李四。
- 结果:灾难! 你的“你好”发给了李四,而张三没收到消息
react例子:
xxxxxxxxxxfunction ChatApp() {const [recipient, setRecipient] = useState("Alice");const handleSend = () => {// 点击发送时,这个函数“捕获”了当前的 recipient 是 Aliceconst message = `Hello ${recipient}`;setTimeout(() => {// 3秒后,虽然 state 变了,但闭包里存的还是 Alicealert(`Message sent to ${recipient}: ${message}`);}, 3000);};return (<div><p>当前聊天对象: <b>{recipient}</b></p><button onClick={() => setRecipient("Alice")}>选 Alice</button><button onClick={() => setRecipient("Bob")}>选 Bob</button><hr /><button onClick={handleSend}>发送给 {recipient}</button></div>);}操作顺序:
- 选中 Alice -> 点击发送(3秒倒计时开始)。
- 立即点击“选 Bob”(
recipient状态变了)。结果: 3秒后,弹窗显示 "Message sent to Alice"。
结论: 逻辑正确。因为“发送”动作属于点击那一刻的上下文。
vue例子
xxxxxxxxxx<script setup>import { ref } from 'vue';const recipient = ref('Alice');const handleSend = () => {const currentMsg = `Hello ${recipient.value}`; // 字符串在这一刻拼接,记住了 AlicesetTimeout(() => {// 【关键差异】:recipient.value 是响应式的// 3秒后,它会去读最新的值。如果你已经切换到了 Bob,这里就是 Bob。alert(`Message sent to ${recipient.value}: ${currentMsg}`);}, 3000);};</script><template><div><p>当前聊天对象: <b></b></p><button @click="recipient = 'Alice'">选 Alice</button><button @click="recipient = 'Bob'">选 Bob</button><hr /><button @click="handleSend">发送给 </button></div></template>操作顺序:
- 选中 Alice -> 点击发送。
- 立即点击“选 Bob”。
结果: 3秒后,弹窗显示 "Message sent to Bob"。
结论: 逻辑错误! 你本来想给 Alice 打招呼,结果消息发给了 Bob。
为什么 Vue 开发者通常不觉得这是个问题?
在 Vue 开发中,如果开发者意识到需要“定格”某个值,他们必须手动进行快照处理:
xxxxxxxxxxconst handleSend = () => {// 必须手动把当前值存入一个局部变量,利用 JS 函数的作用域闭包const recipientAtThatTime = recipient.value;setTimeout(() => {alert(`Sent to ${recipientAtThatTime}`);}, 3000);};深度对比:React vs Vue 在这里的“哲学”区别
特性 React (函数式/快照) Vue (响应式/引用) 默认行为 捕获快照。每个渲染周期都是独立的,异步回调默认记住“过去”。 追踪最新。变量指向内存地址,异步回调默认看到“现在”。 异步安全性 高。天然防止“发错人”的逻辑错误,不需要额外处理。 需留意。如果不手动保存副本,高频操作下容易出现数据错位。 直觉感受 “那一刻的 UI 是什么样的,逻辑就是什么样的。” “数据现在是什么值,逻辑拿到的就是什么值。”
在 Next.js 或 React 的逻辑复用中,闭包经常用来创建“定制化函数”:
权限控制:你可以写一个函数 withRole(role),它返回另一个处理页面逻辑的函数。内部函数通过闭包记住了 role 参数。
事件传参:在渲染列表时,我们经常这样写:
xxxxxxxxxx<button onClick={() => handleDelete(item.id)}>删除</button>这里的匿名箭头函数就是一个闭包,它为每个按钮“抓取”并锁定了属于它自己的 item.id。
虽然现代 ESM 模块已经很流行,但在编写复杂的逻辑库(如特定的动画引擎或状态管理工具)时,闭包依然是实现真正私有变量的唯一手段。
start 和 stop 方法,而这两个方法共享同一个闭包里的计时器变量,外部完全无法修改这个计时器。当你使用 useMemo 或实现自定义缓存函数时,闭包用来存储之前的计算结果。并且cache是私有变量,外界无法修改cache,更加安全。
xxxxxxxxxxfunction memoize(fn) { const cache = {}; // 闭包里的私有缓存 return function(arg) { if (cache[arg]) return cache[arg]; return cache[arg] = fn(arg); };}deepClone 或封装工具类)而主动掏出的“高级武器”。onClick、每一个 useEffect、每一个自定义 Hook,本质上全是在利用闭包来管理数据的生存周期。