技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

我从来不理解 JavaScript 闭包,直到有人这样向我解释它

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/101-我从来不理解JavaScript闭包,直到有人这样向我解释它
本文目录8 个章节
  1. 准备:执行上下文是什么
  2. 词法作用域:按定义位置找变量
  3. 返回函数的函数
  4. 最后,一个闭包:计数器
  5. 闭包并不只在返回函数时出现
  6. 小结:背包比喻的精确版本
  7. 原文归属
  8. 参考链接

我从来不理解 JavaScript 闭包,直到有人这样向我解释它

Category(分类): JavaScript Status: 已整理

为了保证可读性,本文保留意译和背包比喻,但把规范抽象与引擎实现分开说明。

正如标题所说,JavaScript 闭包对我来说一直有点神秘。看过很多文章,在工作中也使用过闭包,却没有真正把“函数、作用域和调用”联系起来。下面仍然从执行上下文、词法作用域、返回函数和计数器开始。

准备:执行上下文是什么

代码运行时需要一组规范状态,ECMAScript 把它抽象为执行上下文。执行上下文包含当前代码执行所需的词法环境、变量环境、函数状态等;它是规范模型,不要求对应某个具体的“内存盒子”或机器调用栈。

对普通同步函数,可以先用这个教学模型理解:

  1. 调用函数时,创建这一次调用的函数执行上下文。
  2. 参数、局部声明和当前执行状态进入相应的环境记录。
  3. 执行上下文被推入执行上下文栈,函数体按顺序运行。
  4. 函数正常返回、抛出异常或完成其他控制流后,上下文不再继续执行。

到达 } 是正常完成的一种方式,但 throw 等 abrupt completion 也会结束函数;生成器和 async function 还可能挂起后恢复。所以下面关于“入栈、返回、离开”的描述,主要针对普通同步函数的正常路径。

函数执行上下文结束后会从执行上下文栈中移除,但这不等于其中的词法环境立即被物理删除。若仍有返回的函数、对象、任务等可达引用,环境中的绑定必须继续存在;只有在相关对象和环境不可达后,它们才可能被垃圾回收,GC 的具体时间由引擎决定。

基础例子

let a = 3

function addTwo(x) {
  const ret = x + 2
  return ret
}

const b = addTwo(a)
console.log(b)

可以这样理解:

  1. 在当前脚本的全局词法环境中建立 a 绑定,并赋值为 3
  2. 函数声明会创建函数对象并把它绑定到 addTwo;函数体此时没有被调用。
  3. 调用 addTwo(a) 时,先在当前环境中读取 a,得到 3
  4. 这次调用创建 addTwo 的函数执行上下文,参数 x 得到 3
  5. const ret 先创建为未初始化绑定(处于 TDZ),计算初始化表达式后直接初始化为 5
  6. 函数返回 ret,上下文结束,调用结果 5 被赋给 b

这里的“变量离开上下文”是教学说法。xret 没有被其他可达函数捕获时,相关状态通常可以回收;规范并不要求引擎在某一行结束时立即释放一块可见内存。

词法作用域:按定义位置找变量

看下面的代码:

let val1 = 2

function multiplyThis(n) {
  const ret = n * val1
  return ret
}

const multiplied = multiplyThis(6)
console.log('example of scope:', multiplied)

multiplyThis 找不到 val1 时,会沿函数定义时建立的外层词法环境链寻找,而不是沿调用者的局部变量寻找。ECMAScript 用 Environment Record 和 [[OuterEnv]] 表示这条链,函数对象在创建时保存定义环境的 [[Environment]] 引用。

下面的反例能排除“动态作用域”的误解:

let value = 'global'

function read() {
  return value
}

function caller() {
  let value = 'caller'
  return read()
}

console.log(caller())
// global:read 按定义位置查找,不会借用 caller 的局部 value

在经典 Script 中,顶层 let 是全局词法绑定,通常不等同于 globalThis.value;在 ESM 中,顶层绑定属于模块环境。这个差异不改变“按定义位置查找”的核心。

返回函数的函数

原文的例子先返回一个只使用自身参数的函数:

let val = 7

function createAdder() {
  function addNumbers(a, b) {
    return a + b
  }

  return addNumbers
}

const adder = createAdder()
const sum = adder(val, 8)
console.log('example of function returning a function:', sum)

这段代码当然可以输出 15,但 addNumbers 没有读取 createAdder 的局部变量,因此它还没有展示闭包捕获外层状态的关键效果。改成下面这样更直观:

function createAdder(x) {
  return function addNumbers(y) {
    return x + y
  }
}

const add5 = createAdder(5)
console.log(add5(2))
// 7

createAdder(5) 创建一次调用环境,其中有绑定 x = 5;返回的函数对象的 [[Environment]] 指向这个环境。调用 add5(2) 时,当前函数环境中有 y,再沿外层环境链找到 x。这就是闭包在实际代码中的作用。

严格说,闭包不是“调用者上下文”,也不是把每个值复制出来的背包。背包是帮助记忆的比喻;更准确的说法是:函数对象保存定义时的词法环境引用,标识符解析沿环境链进行,捕获的是绑定而不是固定值快照;绑定是否可变取决于 letconst 等声明。

最后,一个闭包:计数器

function createCounter() {
  let counter = 0

  const increment = function () {
    counter += 1
    return counter
  }

  return increment
}

const increment = createCounter()
const c1 = increment()
const c2 = increment()
const c3 = increment()
console.log('example increment:', c1, c2, c3)

输出是:

example increment: 1 2 3

常见的错误推演

有一种错误解释是:调用 increment() 时,本地和全局都找不到 counter,于是引擎会给它一个默认数值并创建新的局部变量。这个解释不适用于这段代码:

  • countercreateCounter() 的那次调用环境中已经声明并初始化为 0
  • increment 函数对象创建时保存了指向该环境的 [[Environment]] 引用。
  • 如果所有环境都找不到一个标识符,读取它会抛出 ReferenceError,不会自动创建局部变量。
  • 非严格脚本中对未声明标识符的赋值可能污染全局对象,那是历史兼容行为,也不是闭包机制。

按活绑定理解

  1. createCounter() 的第一次调用创建一个新的 counter 绑定,值为 0
  2. 返回的 increment 函数保存这个环境引用;函数执行上下文结束后,环境因仍然可达而继续存活。
  3. 第一次调用 increment() 沿环境链读到同一个 counter 绑定,把它改为 1
  4. 第二次和第三次调用继续读写同一个绑定,因此得到 23
  5. 这里保存的是活绑定,不是每次调用时复制一个值,也不是每次调用都创建一个新的函数实例。

如果再调用一次 createCounter(),会得到另一个独立的 counter 绑定:

const add1 = createCounter()
const add2 = createCounter()

console.log(add1(), add1())
console.log(add2(), add2())
// 1 2
// 1 2

两个返回函数不共享各自工厂调用中的绑定;同一个工厂调用也可以返回多个函数,让它们共同访问同一环境。

这就是闭包:函数对象与其定义位置的词法环境组合在一起。 函数调用时创建的是新的函数执行上下文;闭包环境和函数对象不是同一个“上下文”。

闭包并不只在返回函数时出现

在全局或模块作用域中创建的函数也有定义环境引用,只是它主要指向全局/模块环境,通常不需要特别强调。只有当函数访问了外层局部绑定,或者该函数被返回、传递给任务、保存到对象中时,闭包保留状态的效果才特别明显。

部分应用是一个简洁例子:

const addX = (x) => (n) => n + x
const addThree = addX(3)

console.log(addThree(4))
// 7

addThree 访问的 x 来自 addX(3) 的环境,参数 n 则来自 addThree(4) 这次调用。箭头函数不是闭包的必要条件,普通函数也有同样的词法环境机制。

小结:背包比喻的精确版本

我仍然喜欢背包的比喻:函数被创建时,好像带上了定义位置的环境。只是要记住几件事:

  • 背包不是值的副本;严格说是函数对象的 [[Environment]] 环境引用。
  • 环境里的绑定是活的,多个闭包可能共享一个绑定。
  • 调用函数会创建一次新的执行上下文,但不会创建一个新的函数实例。
  • 外层函数返回后,上下文不再执行;如果返回函数仍可达,闭合环境仍然可访问。
  • 不可达对象和环境可能被 GC 回收,但回收时机不可预测。
  • 闭包不是必然的性能问题。是否保留状态、创建多少函数和对象,应该结合场景、内存快照与基准测试判断。

原文归属

原文转载/意译来源:SegmentFault《我从来不理解 JavaScript 闭包,直到有人这样向我解释它》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。

参考链接

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS