闭包:从「能跑」到「可控」——用执行上下文把它讲透,再把泄漏风险压住
引言:为什么团队里闭包总是「会用但讲不清」
闭包几乎是每个前端都“用过”的能力:回调、事件处理、节流防抖、柯里化、状态缓存……到处都是它。但一到排查线上内存飙升、解释“为什么变量没被回收”、或者评审里讨论“这个写法会不会泄漏”,就容易陷入两种极端:
- 把闭包当玄学:只记住“函数套函数 + 引用外部变量”,但不知道底层到底发生了什么。
- 把闭包当洪水猛兽:遇到闭包就怕泄漏,动不动“全局变量/单例就是坏”。
这篇文章的目标很明确:用“执行上下文 + AO/GO + 可达性”把闭包拆开讲清楚,再落到工程实践:哪些写法会导致内存常驻、怎么定位、怎么释放、怎么验证。很多内容需要配合内存图理解(图片必须保留),建议边看边对照图。
目录
-
- 脉络:为什么闭包在 JS 里如此关键
-
- 闭包到底是什么:定义、自由变量与词法绑定
-
- 从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)
-
- 闭包形成的关键:[[scope]] / parentScope 为什么能“跨出上下文”
-
- 内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收
-
- 闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”
-
- 如何释放:最小化作用域、解除引用、弱引用思路
-
- 性能与排查:用浏览器工具定位闭包导致的内存/耗时
-
- 引擎优化:闭包里“没用到的变量”会怎样(V8 优化)
-
- 进阶边界:多个闭包实例彼此独立;为什么
null能断引用而undefined不行
- 进阶边界:多个闭包实例彼此独立;为什么
-
- 实战建议:团队落地清单 & 指标验证
-
- 总结:关键结论 + 下一步建议
f 脉络探索:闭包为什么值得被「认真对待」
闭包是 JavaScript 中一个非常容易让人迷惑的知识点:它既是语言表达力的源泉,也可能成为内存与可维护性的风险点。很多经典资料对它评价极高——因为它背后牵扯的是一整套“词法作用域 + 执行上下文 + 垃圾回收”的体系。
![]()
** 图7-1 《你不知道的JavaScript》上卷中对闭包的评语**
把这张图放在开头的意义是:闭包不是一个“语法点”,而是理解 JS 运行机制的入口。当你能把闭包讲清楚,很多“JS 为什么这样设计”的问题都会连起来。
本章小结(可迁移的经验)
- 闭包不是“函数套函数”的表象,而是 词法作用域如何在运行时被保留。
- 闭包相关的争论,往往不是“对错”,而是 讨论口径(广义/狭义)不同。
- 真正工程风险来自:闭包让某些对象变成“长期可达” ,从而影响 GC。
一、闭包到底是什么:定义、自由变量与词法绑定
1.1 闭包的概念定义:把“感觉”变成“可证明”
闭包并不是 JavaScript 独有。计算机科学中,闭包(Closure)又称词法闭包(Lexical Closure),是一种在支持头等函数的语言中实现词法绑定的技术:闭包在实现上可以理解为“函数 + 关联环境(自由变量的绑定) ”的组合结构。
在 JavaScript 语境下,可以用更工程化的表达:
闭包 = 一个函数 + 该函数在定义时可访问的外层作用域引用(自由变量所在的词法环境)。
这里最关键的是两个词:
- 自由变量:跨出自己作用域、来自外层作用域的变量(“不是我家里的变量,但我能用”)。
- 词法解析阶段确定:函数“能访问哪些变量”,在**代码写出来那一刻(定义时)**就决定了,而不是调用时随机决定。
一个常用的工作记忆法: “函数定义时就把外层环境‘锁’住了” 。后续你把函数拿到哪里调用,它都沿着当初锁定的链去找变量。
1.2 广义 vs 狭义:团队沟通时要先统一口径
社区对“什么算闭包”常见两种口径:
- 广义:JS 里“函数”几乎都带着词法作用域信息,因此都可以叫闭包。
- 狭义(更严谨) :只有当函数实际捕获并使用外层变量,才讨论“闭包带来的效果”(比如变量驻留)。
下面这段代码就体现了差异点:它能访问 name,但是否把它视为“闭包”(严格意义)看你站哪种口径:
// 可以访问 name:test 算闭包(广义)
// 有访问到 name:test 算闭包(更严谨,讨论“闭包效果”更有意义)
var name = "放寒假了";
function test() {
console.log(name);
}
test();
本章小结(落地清单)
- 团队讨论闭包前,先明确口径:讨论“闭包机制”还是“闭包效果(变量驻留)” 。
- 记住闭包核心:定义时锁定词法环境,不是调用时决定。
- 所谓“自由变量”,本质就是:跨作用域访问的变量。
二、从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)
闭包很容易被讲成“概念”,但工程上要做到“可控”,一定要落到执行过程:调用栈如何创建执行上下文?AO/GO 什么时候创建?引用链怎样形成可达性?
2.1 先看一个最小高阶函数:返回函数指针发生了什么
function foo() {
// bar 预解析,前面有讲过
function bar() {
console.log("小吴");
}
return bar;
}
var fn = foo();
fn();
// 小吴
关键点:调用函数会创建执行上下文(Execution Context) 。执行上下文创建前,会先创建对应的 AO(Activation Object) :用于存放形参、局部变量、函数声明等。
2.2 内存图:从“栈/堆”视角理解 fn = foo() 的意义
建议把这一段当成闭包全篇的“底座”,后面所有闭包/泄漏/回收都在重复这个结构。
![]()
** 图7-2 高阶函数执行前**
![]()
** 图7-3 foo函数调用阶段内存图**
![]()
** 图7-4 foo函数中的bar函数调用阶段内存图**
把图 7-2 ~ 7-4 用一句话串起来:
-
foo()调用时创建foo的执行上下文与 AO; -
bar函数对象存在于堆上,foo的 AO 里保存了它的引用; -
return bar让全局fn指向bar的函数对象地址; - 后续
fn()本质是在执行bar()。
你可以把它想象成:return 返回的不是函数体,而是“函数对象的指针” 。fn 接住这个指针后,就和 bar 的生命周期绑定了。
本章小结(落地清单)
- AO 是“函数即将执行前”创建的,不是定义函数时就创建(避免无谓开销)。
-
return function的本质是:返回堆上函数对象的引用。 - 后续
fn()执行的是“那块函数对象”,而不是重新生成一份。
三、闭包形成的关键:为什么 [[scope]] / parentScope 能让变量跨出上下文
上一章只是“返回了函数”。闭包真正“神奇”的点在于:外层函数执行完了,内层函数还能访问外层变量。
来看这个例子
function foo() {
var name = "why";
function bar() {
console.log("小吴", name);
}
return bar;
}
var fn = foo();
fn();
// 小吴 why
如果只背概念会说:“bar 引用了 foo 的变量 name,所以形成闭包”。但工程上更重要的是 “它凭什么引用得到?” ——答案是:函数对象内部会保存定义时的外层作用域引用(常被描述为 [[scope]] / parentScope) 。
3.1 发生了什么:把“访问外层变量”写成一条可执行的查找链
bar() 执行时要查 name:
- 先查自己的 VO/AO(
bar的活动对象)——没有。 - 沿着函数对象记录的
parentScope(也就是foo的 AO)继续查——找到了。 - 输出
why。
也就是说,闭包并不是“让变量不销毁”的魔法,而是:
bar的函数对象握住了foo的 AO 引用,使得这块 AO 对 GC 来说一直是“可达的”。
![]()
** 图7-5 bar函数中的name形成闭包内存图**
3.2 常见误区:闭包 ≠ 执行上下文永远不销毁
一个容易混淆的点:执行上下文(FEC)会销毁,但 AO 是否可回收 取决于有没有被外界引用链保持可达。
-
foo()的执行上下文从调用栈弹出,这是必然的; - 但
foo的 AO 如果被bar的parentScope引用着,并且bar又被fn引用着,那么它就仍然可达,无法回收; - “闭包效果”来自这条引用链,而不是来自“执行上下文不销毁”。
本章小结(落地清单)
- 闭包的底层抓手是:函数对象持有
parentScope(词法环境引用) 。 - “变量没被回收”不是因为执行上下文不弹栈,而是因为 对象仍可达。
- 解释闭包时,把“查找链”讲出来,团队沟通会更一致。
四、内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收
4.1 普通函数:执行完就“自由变量不自由”了
function foo() {
var name = "xiaowu";
var age = 20;
}
function test() {
console.log("test");
}
foo();
test();
![]()
** 图7-6 foo与test函数执行前的初始化表现**
这张图强调:全局 GO 中保存的是函数对象引用;函数对象里保存了 parentScope 指向 GO;调用时创建执行上下文与 AO。
![]()
** 图7-7 foo函数和test函数的内存图执行过程**
![]()
** 图7-8 foo的执行上下文销毁前后对比**
关键结论在图 7-8:foo 执行结束,AO 里 name/age 没有被任何外部引用链持有,于是变为不可达,被回收。这就是“自由变量没能真的自由”。
4.2 闭包函数:AO 被外部引用链锁住,变量驻留
function foo() {
var name = "xiaowu";
var age = 20;
function bar() {
// 引用了外层变量,形成闭包
console.log("这是我的名字", name);
console.log("这是我的年龄", age);
}
return bar;
}
var fn = foo();
fn();
// 这是我的名字 xiaowu
// 这是我的年龄 20
![]()
** 图7-9 闭包执行前内存图**
![]()
** 图7-10 foo函数执行内存图**
![]()
** 图7-11 bar的函数执行上下文**
![]()
** 图7-12 bar脱离捕捉时的上下文,自由变量依旧存在**
图 7-12 是闭包“可解释”的关键画面:
-
fn -> bar函数对象(全局根对象可达) bar函数对象 -> parentScope -> foo 的 AO- 因为这条链存在,所以
foo AO仍可达,name/age仍可达
本章小结(落地清单)
- 普通函数执行完:AO 通常不可达 → 回收。
- 闭包能驻留变量:本质是 AO 被函数对象的 parentScope 引用,并且函数对象又被根对象引用。
- 是否回收,归根到底看:从根对象出发是否可达(标记清除的核心判断)。
五、闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”
闭包会让变量驻留,但驻留 ≠ 泄漏。工程上判断泄漏的标准非常朴素:
本该释放、却因为不必要的引用链而长期可达的内存,占用不断增长或长时间不下降。
在闭包语境里,常见泄漏模式就是:返回的函数被长期保存(全局数组/缓存/事件回调/定时器),导致其捕获的外层 AO 一直可达。
六、如何释放:最小化闭包作用域、解除引用、弱引用思路
6.1 解决策略三件套
- 最小化闭包作用域:只捕获必要数据(不要把整坨对象/大数组/DOM 节点顺手闭包进去)。
- 解除引用:用完就断开引用链,让对象从根不可达。
- 弱引用(WeakMap/WeakSet) :对“缓存类”场景非常有效(不会阻止 GC)。
6.2 “解除引用”的标准写法:把 fn 指向 null
// 内存泄漏解决方法
function foo() {
var name = "xiaowu";
var age = 20;
function test() {
console.log("这是我的名字", name);
console.log("这是我的年龄", age);
}
return test;
}
var fn = foo();
fn();
// 解除引用:断开 root -> fn -> 函数对象 -> AO 的链
fn = null; // 注意:置 null 不会立刻回收,会在后续 GC 周期中回收
![]()
** 图7-13 fn指向bar的指针**
![]()
** 图7-14 fn指向bar的指针置为null**
图 7-14 的“孤岛”是你需要在脑子里形成的肌肉记忆:
只要根对象到不了这块内存,它迟早会被回收(标记清除的可达性判断)。
本章小结(落地清单)
- 释放闭包的核心动作:断开根对象到函数对象的引用链(常见是置
null/ 移除监听 / 清理数组缓存)。 - 设计闭包时先问一句: “我真的需要捕获整个对象/大数组/DOM 吗?”
- 缓存场景优先考虑:WeakMap/WeakSet(避免“缓存越用越大”)。
七、闭包泄漏案例:大对象被闭包捕获,内存与耗时如何爆炸
为了把问题讲“刺痛”,用一个极端但很真实的例子:闭包捕获一个大数组。
function createFnArray() {
// 创建一个长度为1024*1024的数组,往里面每个位置填充1.观察占了多少的内存空间(int类型,整数1占4个字节byte)
// 4byte*1024=4kb,再*1024为4mb,占据的空间是4M × 100 + 其他的内存 = 400M+
// 在js里面不管是整数类型还是浮点数类型,看起来都是数字类型,这个时候占据的都是8字节,但是js引擎为了提高空间的利用率,对很多小的数字是用不到8个字节(byte)的,8字节 = 2的64次方,所以8字节是很大的,现在的js引擎大多数都会进行优化,对小的数字类型,在V8中称为Smi,小数字 2的32次方
var arr = new Array(1024 * 1024).fill(1);
return function () {
console.log(arr.length);
};
}
var arrayFn = createFnArray();
![]()
** 图7-15 闭包泄露案例**
如果你把 createFnArray() 创建出来的函数持续保存(比如 push 进数组),引用链会不断叠加:
![]()
** 图7-16 引用叠加,闭包无法释放**
7.1 用性能工具看“泄漏”长什么样
在浏览器 Performance 面板勾选 Memory,刷新/执行后,你会看到脚本耗时显著升高:
![]()
** 图7-17 闭包的性能检测**
7.2 释放后的对比:不一定立刻回收,但趋势会回来
function createFnArray() {
var arr = new Array(1024 * 1024).fill(1);
return function () {
console.log(arr.length);
};
}
var arrayFns = [];
for (var i = 0; i < 100; i++) {
// createFnArray() // 不接收就会很快变成不可达
arrayFns.push(createFnArray());
}
setTimeout(() => {
arrayFns = null; // 关键:断开引用链
}, 2000);
![]()
** 图7-18 性能提升效果**
你还能通过调用树看到耗时主要来源于闭包相关逻辑:
![]()
** 图7-19 闭包耗时来源**
本章小结(落地清单)
- 闭包泄漏常见触发器:闭包捕获大对象 + 长期保存闭包引用(数组/缓存/事件/定时器)。
- Performance 勾选 Memory:关注 脚本耗时 + 内存曲线是否持续上升。
- “置 null”不保证立刻回收,但能保证:后续 GC 周期具备回收条件。
八、引擎优化:闭包里“没用到的变量”会怎样(V8)
一个很实用的问题:闭包让外层 AO 不回收,那 AO 里没用到的属性会不会也一直占着?
例子:闭包只用 name,没有用 age:
function foo() {
var name = "why";
var age = 18;
function bar() {
debugger;
console.log(name);
}
return bar;
}
var fn = foo();
fn();
![]()
** 图7-20 V8引擎优化效果(未使用变量被销毁)**
继续在 debugger 暂停时验证:name 能访问,age 可能因为未使用被优化掉:
![]()
** 图7-21 debugger检测未使用的age变量是否真被回收**
这点对工程实践的启示非常直接:
- 规范上你可以认为“闭包会保留整个 AO”;
- 但引擎实现会做逃逸分析/变量提升优化等,减少无用变量占用;
- 不要依赖这种优化写代码:它是实现细节,不是稳定契约(尤其跨引擎/跨版本)。
本章小结(落地清单)
- V8 可能回收闭包外层 AO 中“未被使用的变量”(实现优化)。
- 工程判断别靠“引擎可能帮我优化”,仍以 引用链是否可达 为主。
- 评审时更关注:闭包是否捕获了不必要的大对象/DOM/业务上下文。
九、进阶边界:多个闭包实例彼此独立;为什么 null 能断引用而 undefined 不行
9.1 多个闭包实例:互不影响,释放也只释放自己的那份
同一个 foo() 调两次,得到的是两套独立的 AO 与函数对象:
function foo() {
var name = "小吴";
var age = 18;
function bar() {
console.log(name);
console.log(age);
}
return bar;
}
var fn = foo();
fn();
var baz = foo();
fn = null; // 只会释放 fn 对应的那一套引用链
baz();
这条结论在工程里特别重要:你清理了一个引用,不代表全局都释放了。如果你把闭包存进多个地方(例如多个数组、多个事件回调、多个缓存),就需要逐个断链。
9.2 为什么 null 可以解除引用,而 undefined 不行?
这里有一个值得思考的问题:为什么 null 可以解除引用,而 undefined 不行?
从“引用链”的角度看:
-
null是一个明确的“空值”,把变量指向空处,等价于 把这条引用边砍掉。 -
undefined更多表达“未初始化/缺省值”,它依然是一个值;更关键的是,在很多语义下它并不被用作“主动断链”的表达(团队代码规范也通常不推荐用undefined表达释放)。
工程建议:释放引用请用
null(语义清晰、团队共识强、便于 code review 与静态检查)。
本章小结(落地清单)
- 每次调用外层函数,都会创建一套新的 AO/函数对象:闭包实例彼此独立。
- 释放引用只影响对应那条链:你清理一个,不会自动清理所有。
- 断链用
null:表达“我主动释放”,比undefined更清晰。
十、实战建议:把“闭包可控”落到团队工程规范里
下面给一份可以直接放进团队“代码评审 checklist / 性能排查 SOP”的清单。
10.1 评审 Checklist(闭包相关)
-
捕获内容最小化:闭包里只引用必要字段,避免把整个
props/state/context/大对象捕获进去。 - 避免捕获 DOM 节点:尤其是长生命周期的闭包(事件回调/单例缓存)捕获 DOM,会让节点难以回收。
- 长生命周期容器要可清理:全局数组、Map 缓存、事件总线、定时器回调——都要有对应的清理路径。
-
组件/页面卸载必须断链:移除事件监听、取消订阅、清理定时器、清空缓存引用(
= null)。 - 缓存优先 WeakMap:key 是对象的缓存(如 DOM 节点、组件实例)优先 WeakMap,减少“缓存常驻”。
10.2 排查 SOP(内存/性能)
- Performance 勾选 Memory:复现操作,观察内存曲线是否持续上升(不回落)。
- 录制并看调用树:定位高耗时函数是否来自闭包创建/大对象捕获。
- 缩小复现:把闭包引用容器(数组/缓存)逐步置
null,观察趋势变化(不是立刻回收,但趋势会变)。 - 检查引用链:谁在持有闭包?(全局变量、单例模块、事件总线、定时器、DOM 监听器最常见)
10.3 指标验证(建议团队共用)
- 内存指标:关键页面操作 5 分钟后,JS Heap 是否可稳定回落到阈值区间
- 性能指标:关键交互的 Long Task 次数/总耗时是否下降
- 回归验证:增加“卸载/切页/重复进入”压测脚本,验证引用链不会累积
总结:关键结论 + 团队落地建议
关键结论(背下来就够用)
- 闭包的本质是:函数对象持有定义时的外层作用域引用(parentScope/词法环境) ,从而让外层 AO 继续可达。
- 是否回收不看“函数执行没执行完”,只看 从根对象出发是否可达(标记清除的核心)。
- 闭包造成的风险不是“用了闭包”,而是:闭包捕获了不该长期驻留的对象,并且闭包引用被长期持有。
- 释放闭包的关键动作是:断开引用链(置
null、移除监听、清空容器、取消订阅等)。 - 引擎可能优化未使用变量(如 V8),但工程上不要依赖实现细节,仍以引用链分析为准。
下一步建议(怎么在团队里真正落地)
- 把“闭包评审 checklist”加入 PR 模板:涉及事件、缓存、定时器、订阅时必须勾选清理项。
- 建立 1~2 个“典型泄漏 demo”用于 onboarding:让新人用 Performance/Memory 亲手看见“可达性”是什么。
- 在关键业务页引入定期压测(重复进入/退出/滚动/筛选等),用指标验证“内存可回落”。
- 对缓存策略做统一约束:对象 key 的缓存优先 WeakMap;全局数组缓存必须提供清理 API。
只要团队能把闭包从“语法点”升级成“引用链与可达性”的共识,闭包就会从“玄学”变成“可控工具”。