K 的一隅

JavaScript JavaScript 核心机制

闭包到底保存了什么:词法环境与生命周期

从值班员留下的备忘签收说起,弄清闭包、词法环境、绑定与调用栈的分工,以及循环、模块与内存中的常见陷阱。

9 分钟阅读 更新于 2026-07-24
本文目录

夜班值班员下班时,不会在门口钉一张“我当时正在做第几步”的纸条给全公司看。他留给接班同事的,是写着柜号、密码和待办事项的备忘签——只有拿着这张签的人,还能打开那只柜子。

闭包也类似:外层函数返回后,调用栈上的那一帧可以结束;只要内层函数仍可能被调用,它与创建时词法环境之间的可达关系就会保留,里面的绑定因此继续存在。

第一章说过闭包,这一章追问“保存”的是什么

第一章里我们区分过:闭包保存的是能到达哪些绑定,调用栈记录当前有哪些调用尚未完成。很多困惑来自把两者混成“闭包把栈帧原封不动存进冰箱”。

这一章把词法环境、绑定、生命周期和常见陷阱放在一起,补完这条基础线。读 Vue 响应式、事件监听、模块私有状态时,背后都是同一套“名字在哪里查找、何时仍可达”的规则。

先猜输出:循环里的函数为什么总打印同一个数

js
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

js
for (let i = 0; i < 3; i += 1) {
  handlers.push(() => console.log(i))
}

会依次打印 012。每次迭代,循环体都会为 i 创建新的词法绑定;每个箭头函数捕获的是那一次迭代的环境,而不是循环结束后唯一的那个 i

这不是“let 魔法”,而是词法作用域规则与循环语义共同作用的结果。for (let i ...) 在规范里会为每次迭代准备新的词法环境;var 则始终指向函数或全局里的同一个绑定。箭头函数没有自己的 thisarguments,但会捕获定义处的外层词法环境,因此特别适合作为循环里推入数组的短回调。

词法环境:名字在哪里查找

执行函数时,引擎需要回答“这里的 x 是谁”。规则是沿词法作用域向外找:先看当前环境,再看外层函数环境,直到全局。

js
const base = 10

function outer() {
  const bonus = 2

  function inner() {
    return base + bonus
  }

  return inner
}

const fn = outer()
console.log(fn()) // 12

outer 返回时,它的调用已经结束,栈帧可以弹出。但 inner 仍持有创建时的词法环境,因此还能读到 bonusbase 则来自更外层的绑定。

闭包不是把 outer 的栈帧压进仓库,而是让 inner 的环境引用保持可达。只要 fn 还在,bonus 这个绑定就不会被回收。

闭包保存绑定,不保存返回瞬间的值快照

js
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()) // 5

readadd 共享同一个 value 绑定。若再次调用 createCounter(100),会得到另一套独立环境。这比“函数记住了一个数字”更准确,也能解释为什么多个闭包有时共享状态、有时互不影响。

若内层只读外层常量,逻辑一样:读的是绑定,不是复印纸。

与调用栈分工:同步返回 vs 异步回调

同步调用:

js
function checkout() {
  const receipt = pay()
  return receipt
}

pay 返回时,结果通过 return 直接交回 checkout 的调用表达式。此时 checkout 的帧仍在栈上。

异步登记:

js
function checkout() {
  const status = '待支付'
  setTimeout(() => {
    console.log(status)
  }, 0)
}

登记 setTimeout 的调用结束后,checkout 帧可以弹出。稍后回调运行时是新的调用,它访问 status,靠的是词法环境,而不是“checkout 还在栈底等着”。

判断一段代码是否仍处在原调用的同步路径里,可以问:它能否用普通 return 把结果交给上一行表达式?能,多半还在同一调用链;不能,就是另一条时间线上的恢复。第四章、第十章讨论的定时器与 await,都是在问“恢复时谁保存了状态”;闭包回答的是“词法上谁还能被读到”。

防抖与节流:闭包在业务里的典型形状

js
function debounce(fn, wait) {
  let timer = 0
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), wait)
  }
}

返回的函数闭包着 timer 绑定。每次输入都会重置定时器,但不需要把 timer 挂到全局。debounce 调用结束后,外层帧已返回;只要返回的包装函数仍被事件系统持有,timer 就继续存在。

这类工具函数说明:闭包最常见的用途之一,就是在不污染全局的前提下保存少量可变状态。

模块、私有状态与全局污染

ES 模块顶层也是一个词法环境:

js
// 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 也曾用来模拟私有字段:

js
const api = (() => {
  let secret = 0
  return {
    inc() {
      secret += 1
    },
    read() {
      return secret
    },
  }
})()

现代项目更倾向模块,但闭包机制相同:内部绑定对外不可直接命名,只能通过返回的接口操作。

生命周期:闭包也可能让内存留得更久

闭包本身不是泄漏,它只是让环境在需要时继续存在。若把大对象或 DOM 节点放进长期存活的闭包,而闭包又被全局事件、定时器或单例持有,那些对象就会比预期更晚被回收。

js
function bindHuge(logger, hugeData) {
  button.addEventListener('click', () => {
    logger(hugeData.length)
  })
}

即使 logger 只用 hugeData.length,只要闭包仍可达,整个 hugeData 数组通常也一起被留住。修复方向是只闭包必要字段:

js
const size = hugeData.length
button.addEventListener('click', () => logger(size))

或在不再需要时 removeEventListener。事件监听、观察者、缓存 Map 的键若间接引用 DOM,是前端里常见的“闭包链过长”来源。排查时问:这个回调是否还必须存在?它闭包着哪些绑定?

暂时性死区:绑定存在,尚未初始化

js
function demo() {
  console.log(typeof value)
  let value = 1
}

typeof valuelet 声明之前仍会抛出 ReferenceError,而不是得到 'undefined'。因为词法绑定已经创建,但在执行到声明行之前处于未初始化状态——这就是暂时性死区(TDZ)。

闭包讨论里常忽略 TDZ:内层函数可以闭包外层 let,但若在外层初始化完成前就去读,照样报错。循环里的 let 每次迭代产生新绑定,也解释了为什么监听器能记住不同的 ivar 没有 per-iteration 绑定,于是一个共享的 i 被所有闭包读到。

多个闭包共享同一环境

js
function createPair() {
  let shared = 0
  return {
    inc: () => { shared += 1 },
    read: () => shared,
  }
}

const pair = createPair()
pair.inc()
pair.inc()
console.log(pair.read()) // 2

incread 闭包着同一套外层环境,因此看到同一个 shared。若 createPair() 调用两次,会得到两套互不影响的环境。工厂函数、React 早期模块模式、Vue 组合式里返回的一组函数,底层都是这个结构。

这也说明“闭包泄漏”往往不是某一个函数的问题,而是一组长期存活的回调共同抓住了一份大环境。断开泄漏,有时只要让其中一个引用释放,有时要整体移除监听器或销毁组件实例。

箭头函数与词法 this:别和闭包混成一条

js
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 / 参数创建新绑定:

js
for (var i = 0; i < 3; i += 1) {
  handlers.push(((index) => () => console.log(index))(i))
}

第四种是把闭包和 this 绑在一起混背。闭包解决“读哪个词法绑定”;this 由调用方式决定。箭头函数没有自己的 this,它会捕获定义时的 this,那是另一条规则线。

面试里常见的“闭包面试题”往往考循环加 var,或考 setTimeout 打印序列。解题时不要只背答案,先画词法环境:有几个绑定、回调何时创建、执行时读的是哪个环境。第一章的调用栈图负责同步路径,本章的环境图负责异步回调里仍能读到的名字。

Vue 3 的 refcomputed 依赖收集,本质也是函数执行时记住访问了哪些响应式绑定,下次绑定变化时再通知相关副作用函数重跑。那是框架在词法环境与依赖图上的扩展,不是另一套与闭包无关的魔法。读完本章,再回头看响应式文章,会更容易分清“保存的是绑定关系”还是“保存的是 DOM 节点本身”。

下一步:闭包管变量,this 管“当前对象”

本章把词法环境与闭包铺开了,但第一章还留过另一条线:普通函数的 this 不由定义位置决定,而主要由调用方式决定。闭包回答“能读到哪个名字”;this 回答“这次调用代表谁”。

以后遇到“变量从哪来的、为什么还能读到”,可以先问:这是词法绑定还是调用栈上的临时帧?内层函数是否仍可达,从而延长外层环境?异步恢复时,是旧栈继续,还是新调用读取旧环境?能把这些问题分开,再读事件监听、模块私有状态或框架响应式,会少很多只背结论的环节,也更稳。

下一章从会议室话筒说起,拆开默认、隐式、显式与 new 绑定,以及箭头函数为何能保住外层 this