JavaScript 闭包是什么?为什么变量没有被释放,什么时候会内存泄漏?
闭包是函数与它所处词法环境的组合,使函数能够访问定义位置外层的变量。外层函数返回以后,只要相关函数及环境仍然可达,需要的变量就可能继续存在。
闭包不是把当时所有变量都拍成一份快照。多个函数可以共享同一个变量,读取时看到后续修改;不同工厂调用又可以形成独立状态。
这本身是正常能力,常用于封装状态和回调。内存泄漏的问题,是不再需要的数据仍被长期引用。例如全局事件监听器或计时器保留了回调,回调又保留面板数据。
因此,排查时要看引用链,并在生命周期结束时取消监听、计时器和订阅,而不是见到闭包就改写。
函数返回了,为什么还能记住计数?
先看一个能运行的例子
下面是同一个计数器的 TS 和 Python 写法。两者都能说明“返回的函数保留了访问外层状态的能力”,但不代表两种语言的内存实现完全相同。
TypeScript
functionmakeCounter(){letcount=0;return()=>{count+=1;returncount;};}consta=makeCounter();constb=makeCounter();console.log(a(),a(),b());// 1、2、1Python
defmake_counter():count=0defincrease():nonlocalcount count+=1returncountreturnincrease a=make_counter()b=make_counter()print(a(),a(),b())# 1 2 1以 TS 这段代码为例,调用makeCounter()时创建 count 和内部函数。外层返回后,a 仍然指向内部函数,内部函数需要访问那次调用中的 count,所以这份状态仍然能使用。
第二次调用创建了另一份状态,b 不会继承 a 已经增加的数字。MDN 的闭包指南解释了函数与词法环境的关系。
它记住的是变量关系,不是冻结的旧值
如果两个内部函数共同访问同一次调用里的 count,一个增加,另一个读取,就会看到增加后的值。
因此,说“闭包捕获变量”通常比说“复制变量值”更准确。对于具体语言和写法,还要看作用域规则。
常见的循环题也是这样,var不创建每次迭代独立的块级绑定,多个回调可能读到同一个最后值;用let时,每次迭代会有对应绑定,结果就不同。
不要把这种题背成“闭包总能保存当时的值”,因为它恰好考的是多个回调有没有共享同一个变量。
正常保留和泄漏,差在哪里?
计数器还在使用,count 保留下来是必要的状态。没有它,计数功能就不能继续。
但假设页面有一个大型图表面板,创建时注册了 window 上的事件监听器。面板关闭后,监听器没有移除,回调还在,回调所需的图表数据也可能仍在。
从垃圾回收的角度,它仍然可达;从产品的角度,它已经不该继续占用。这才是泄漏要关注的关系。
也不是外层定义过的所有对象都一定被完整保留。引擎可能优化,具体要看实际引用、代码与分析工具,不能靠源码里“在同一作用域”就计算内存占用。
清理要清掉长期保留的入口
移除监听器时,需要使用对应的事件类型、回调引用和相关选项。重新写一个长得一样的箭头函数,不是原来的回调,通常移除不了。
计时器需要取消,订阅需要退订。只把局部变量设成 null,如果长期入口仍然持有真正的数据或另一个引用,也解决不了问题。
实际排查可以反复打开、关闭面板,观察堆快照中的 retained size 与 retaining path。看是谁仍然保留它,再判断那条引用是否应该存在。
这样得到的是能验证的清理方案,而不是“闭包会泄漏,所以不用闭包”的结论。
面试官继续追问
有循环引用,就一定回收不了吗?
现代 JavaScript 垃圾回收通常能够处理不可达的循环。关键是它是否仍然从根对象可达,不是两个对象是否互相指着。
把回调注册到按钮上,按钮删除了就一定没问题吗?
要看还有谁引用按钮、回调和相关数据。如果按钮与监听关系整体已不可达,可以被回收;如果全局注册表仍持有它们,就可能继续留下。
闭包和全局变量,哪个更好?
闭包可以把状态限制在需要它的函数组中,减少全局暴露。但仍然要管理生命周期。封装方式不同,不代表自动获得零泄漏保证。
面试速记卡
- 闭包,函数保留对定义位置相关词法环境的访问。
- 状态,同一环境可以共享,不同调用可以独立。
- 回收,看是否可达,不看外层函数是否已经返回。
- 泄漏,已经不需要的数据,仍被长期入口保留。
- 清理,取消监听、计时器和订阅,检查真实引用链。
我是程序员Sunday。本文收录于 sunday面试指南,原文与配图可继续阅读。