本文目录
夜班值班员下班时,不会在门口钉一张“我当时正在做第几步”的纸条给全公司看。他留给接班同事的,是写着柜号、密码和待办事项的备忘签——只有拿着这张签的人,还能打开那只柜子。
闭包也类似:外层函数返回后,调用栈上的那一帧可以结束;只要内层函数仍可能被调用,它与创建时词法环境之间的可达关系就会保留,里面的绑定因此继续存在。
第一章说过闭包,这一章追问“保存”的是什么
第一章里我们区分过:闭包保存的是能到达哪些绑定,调用栈记录当前有哪些调用尚未完成。很多困惑来自把两者混成“闭包把栈帧原封不动存进冰箱”。
这一章把词法环境、绑定、生命周期和常见陷阱放在一起,补完这条基础线。读 Vue 响应式、事件监听、模块私有状态时,背后都是同一套“名字在哪里查找、何时仍可达”的规则。
先猜输出:循环里的函数为什么总打印同一个数
const handlers = []
for (var i = 0; i < 3; i += 1) {
handlers.push(() => console.log(i))
}
handlers[0]()
handlers[1]()
handlers[2]()若你熟悉 var,可能猜三次都是 3。因为 var 没有块级作用域,整个循环共享同一个 i 绑定;循环结束时 i 已是 3,三个函数读到的都是它。
改成 let:
for (let i = 0; i < 3; i += 1) {
handlers.push(() => console.log(i))
}会依次打印 0、1、2。每次迭代,循环体都会为 i 创建新的词法绑定;每个箭头函数捕获的是那一次迭代的环境,而不是循环结束后唯一的那个 i。
这不是“let 魔法”,而是词法作用域规则与循环语义共同作用的结果。for (let i ...) 在规范里会为每次迭代准备新的词法环境;var 则始终指向函数或全局里的同一个绑定。箭头函数没有自己的 this 和 arguments,但会捕获定义处的外层词法环境,因此特别适合作为循环里推入数组的短回调。
词法环境:名字在哪里查找
执行函数时,引擎需要回答“这里的 x 是谁”。规则是沿词法作用域向外找:先看当前环境,再看外层函数环境,直到全局。
const base = 10
function outer() {
const bonus = 2
function inner() {
return base + bonus
}
return inner
}
const fn = outer()
console.log(fn()) // 12outer 返回时,它的调用已经结束,栈帧可以弹出。但 inner 仍持有创建时的词法环境,因此还能读到 bonus。base 则来自更外层的绑定。
闭包不是把 outer 的栈帧压进仓库,而是让 inner 的环境引用保持可达。只要 fn 还在,bonus 这个绑定就不会被回收。
闭包保存绑定,不保存返回瞬间的值快照
function createCounter(start) {
let value = start
return {
read: () => value,
add: (n) => {
value += n
},
}
}
const counter = createCounter(1)
console.log(counter.read()) // 1
counter.add(4)
console.log(counter.read()) // 5read 和 add 共享同一个 value 绑定。若再次调用 createCounter(100),会得到另一套独立环境。这比“函数记住了一个数字”更准确,也能解释为什么多个闭包有时共享状态、有时互不影响。
若内层只读外层常量,逻辑一样:读的是绑定,不是复印纸。
与调用栈分工:同步返回 vs 异步回调
同步调用:
function checkout() {
const receipt = pay()
return receipt
}pay 返回时,结果通过 return 直接交回 checkout 的调用表达式。此时 checkout 的帧仍在栈上。
异步登记:
function checkout() {
const status = '待支付'
setTimeout(() => {
console.log(status)
}, 0)
}登记 setTimeout 的调用结束后,checkout 帧可以弹出。稍后回调运行时是新的调用,它访问 status,靠的是词法环境,而不是“checkout 还在栈底等着”。
判断一段代码是否仍处在原调用的同步路径里,可以问:它能否用普通 return 把结果交给上一行表达式?能,多半还在同一调用链;不能,就是另一条时间线上的恢复。第四章、第十章讨论的定时器与 await,都是在问“恢复时谁保存了状态”;闭包回答的是“词法上谁还能被读到”。
防抖与节流:闭包在业务里的典型形状
function debounce(fn, wait) {
let timer = 0
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), wait)
}
}返回的函数闭包着 timer 绑定。每次输入都会重置定时器,但不需要把 timer 挂到全局。debounce 调用结束后,外层帧已返回;只要返回的包装函数仍被事件系统持有,timer 就继续存在。
这类工具函数说明:闭包最常见的用途之一,就是在不污染全局的前提下保存少量可变状态。
模块、私有状态与全局污染
ES 模块顶层也是一个词法环境:
// store.js
let cache = new Map()
export function get(key) {
return cache.get(key)
}
export function set(key, value) {
cache.set(key, value)
}cache 不挂到 window,外部只能通过导出函数访问。这就是模块作用域提供的“文件级闭包”。第十四章的 live binding 与这里的私有 cache 可以同时成立:导出的是函数绑定,函数体闭包着模块内的 cache。
经典模式里,IIFE 也曾用来模拟私有字段:
const api = (() => {
let secret = 0
return {
inc() {
secret += 1
},
read() {
return secret
},
}
})()现代项目更倾向模块,但闭包机制相同:内部绑定对外不可直接命名,只能通过返回的接口操作。
生命周期:闭包也可能让内存留得更久
闭包本身不是泄漏,它只是让环境在需要时继续存在。若把大对象或 DOM 节点放进长期存活的闭包,而闭包又被全局事件、定时器或单例持有,那些对象就会比预期更晚被回收。
function bindHuge(logger, hugeData) {
button.addEventListener('click', () => {
logger(hugeData.length)
})
}即使 logger 只用 hugeData.length,只要闭包仍可达,整个 hugeData 数组通常也一起被留住。修复方向是只闭包必要字段:
const size = hugeData.length
button.addEventListener('click', () => logger(size))或在不再需要时 removeEventListener。事件监听、观察者、缓存 Map 的键若间接引用 DOM,是前端里常见的“闭包链过长”来源。排查时问:这个回调是否还必须存在?它闭包着哪些绑定?
暂时性死区:绑定存在,尚未初始化
function demo() {
console.log(typeof value)
let value = 1
}typeof value 在 let 声明之前仍会抛出 ReferenceError,而不是得到 'undefined'。因为词法绑定已经创建,但在执行到声明行之前处于未初始化状态——这就是暂时性死区(TDZ)。
闭包讨论里常忽略 TDZ:内层函数可以闭包外层 let,但若在外层初始化完成前就去读,照样报错。循环里的 let 每次迭代产生新绑定,也解释了为什么监听器能记住不同的 i;var 没有 per-iteration 绑定,于是一个共享的 i 被所有闭包读到。
多个闭包共享同一环境
function createPair() {
let shared = 0
return {
inc: () => { shared += 1 },
read: () => shared,
}
}
const pair = createPair()
pair.inc()
pair.inc()
console.log(pair.read()) // 2inc 和 read 闭包着同一套外层环境,因此看到同一个 shared。若 createPair() 调用两次,会得到两套互不影响的环境。工厂函数、React 早期模块模式、Vue 组合式里返回的一组函数,底层都是这个结构。
这也说明“闭包泄漏”往往不是某一个函数的问题,而是一组长期存活的回调共同抓住了一份大环境。断开泄漏,有时只要让其中一个引用释放,有时要整体移除监听器或销毁组件实例。
箭头函数与词法 this:别和闭包混成一条
const button = document.querySelector('button')
const obj = {
label: '提交',
bind() {
button.addEventListener('click', () => {
console.log(this.label)
})
},
}
obj.bind()箭头函数回调里的 this 取自 bind 执行时的 obj,不是按钮。它捕获的是词法上的 this,与闭包捕获词法绑定类似,但规则不同:改外层 this 绑定方式(例如把 bind 改成 const self = this 的普通函数)结果会变。
读代码时分开问:这个函数需要闭包哪些变量绑定?需要固定哪个 this?箭头函数只帮你解决后者的一半问题,不会自动替你管理 DOM 生命周期。
最后把第一章与本章并在一起看:调用栈回答“现在执行到哪一层调用”;词法环境回答“这个名字指向哪份绑定”;闭包回答“外层调用结束后,哪份绑定仍被内层函数拴住”。三条线合在一起,才足以解释同步错误堆栈、异步回调读旧变量、模块私有状态与 Worker 消息里该传什么。
容易踩的坑:别把闭包当成“栈的复印件”
第一种误解是“闭包 = 调用栈没弹干净”。栈记录当前控制流;闭包记录词法可达性。异步回调执行时,外层同步调用往往早已结束,调试器却可能为了因果链显示“从哪里安排”,那不等于同步栈仍在。
第二种是“闭包会让所有局部变量永远存在”。只有仍被可达的内层函数引用到的绑定才会延长生命周期;未被引用的局部变量,外层返回后可以被回收。
第三种是在 var 循环里制造一堆监听器,却以为“每次循环都复制了当时的 i”。应改用 let,或显式用 IIFE / 参数创建新绑定:
for (var i = 0; i < 3; i += 1) {
handlers.push(((index) => () => console.log(index))(i))
}第四种是把闭包和 this 绑在一起混背。闭包解决“读哪个词法绑定”;this 由调用方式决定。箭头函数没有自己的 this,它会捕获定义时的 this,那是另一条规则线。
面试里常见的“闭包面试题”往往考循环加 var,或考 setTimeout 打印序列。解题时不要只背答案,先画词法环境:有几个绑定、回调何时创建、执行时读的是哪个环境。第一章的调用栈图负责同步路径,本章的环境图负责异步回调里仍能读到的名字。
Vue 3 的 ref、computed 依赖收集,本质也是函数执行时记住访问了哪些响应式绑定,下次绑定变化时再通知相关副作用函数重跑。那是框架在词法环境与依赖图上的扩展,不是另一套与闭包无关的魔法。读完本章,再回头看响应式文章,会更容易分清“保存的是绑定关系”还是“保存的是 DOM 节点本身”。
下一步:闭包管变量,this 管“当前对象”
本章把词法环境与闭包铺开了,但第一章还留过另一条线:普通函数的 this 不由定义位置决定,而主要由调用方式决定。闭包回答“能读到哪个名字”;this 回答“这次调用代表谁”。
以后遇到“变量从哪来的、为什么还能读到”,可以先问:这是词法绑定还是调用栈上的临时帧?内层函数是否仍可达,从而延长外层环境?异步恢复时,是旧栈继续,还是新调用读取旧环境?能把这些问题分开,再读事件监听、模块私有状态或框架响应式,会少很多只背结论的环节,也更稳。
下一章从会议室话筒说起,拆开默认、隐式、显式与 new 绑定,以及箭头函数为何能保住外层 this。