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

显示模式

登录
ARCHIVE DOCUMENTJS

我将【闭包】讲给你听!

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/102-我将【闭包】讲给你听!
本文目录8 个章节
  1. 一、什么是闭包
  2. 二、函数引用外部变量后的闭包
  3. 三、循环中闭包让人很意外
  4. 四、闭包与性能
  5. 五、小结
  6. 图片来源与授权风险
  7. 原文归属
  8. 参考链接

我将【闭包】讲给你听!

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

闭包生命周期示意图

图片来源:原文首图(原目录 image.png 已转换为本地 WebP),已改为稳定相对路径。原图转载授权未核实,公开发布前请确认许可。

一、什么是闭包

MDN 对闭包的概括是:函数和对其周围状态(词法环境)的引用绑定在一起,这样的组合就是闭包;它让内层函数可以访问外层函数的作用域。ECMAScript 更精确的描述是,函数对象创建时会保存定义位置的词法环境引用,函数对象的 [[Environment]] 指向相应的 Environment Record。

从入门角度可以记住四点,但需要把原文的几处说法校正一下:

  1. 函数对象创建时会记录定义位置的词法环境;函数不必先执行、返回或“逃逸”才有环境引用。
  2. 闭包不是一个和函数并列的执行上下文。函数对象、闭包所指向的环境、调用函数时创建的执行上下文,是不同层次的概念。
  3. 环境记录保存标识符绑定,函数通过环境链查找自由变量;不是把作用域中的所有值复制进一个背包。
  4. 调用函数时会创建这一次调用的执行上下文;函数声明/表达式求值时已经创建函数对象。返回函数常常让闭合环境继续可达,所以更容易观察到闭包。

Environment Record 和执行上下文是规范抽象,不要求实现真的创建与图中一模一样的盒子。引擎可以优化未被观察到的变量,但必须保持可观察的绑定语义。

函数对象、调用和环境不是同一件事

下面的代码适合对应原文首图理解:

var baz

;(function () {
  var foo = 1

  baz = function () {
    return foo
  }
})()

console.log(baz())
// 1

逐步看:

  1. IIFE 的函数对象在函数表达式求值时已经创建。
  2. 调用 IIFE 时,才创建这一次 IIFE 调用的执行上下文。
  3. 内部函数对象创建时,把包含 foo 的词法环境记录到 [[Environment]]
  4. IIFE 返回后,调用上下文结束,但 baz 仍然引用内部函数,所以 foo 所在环境仍然可达。
  5. baz() 创建的是一次新的函数执行上下文,不是一个新的“函数实例”;只要 baz 仍可达,函数对象和闭合环境就不会因为某一行结束而立即销毁。

如果执行 baz = null,并且没有其他引用,相关对象和环境之后才可能变成可回收对象,具体 GC 时间由引擎决定。

函数对象与闭包环境示意图

图片来源:原文函数创建配图,已本地化。原图来自第三方文章资源,转载授权未核实;图片仅作教学示意。

二、函数引用外部变量后的闭包

function makeAdder() {
  let sum = 0

  return function (y) {
    sum += y
    return sum
  }
}

const add1 = makeAdder()
const add2 = makeAdder()

console.log(add1(1))
console.log(add1(1))

console.log(add2(2))
console.log(add2(2))

输出为:

1
2
2
4

分析这段代码:

  1. 调用 makeAdder() 一次,会创建一套新的 sum 绑定和一个新的返回函数对象。
  2. 返回函数的 [[Environment]] 指向这次调用的环境;makeAdder 的执行上下文结束后,该环境因返回函数仍然可达而继续存活。
  3. add1 的两次调用读写第一次 makeAdder()sum,所以得到 1、2
  4. add2 来自第二次 makeAdder() 调用,它拥有另一套 sum 绑定,所以得到 2、4
  5. sum 是活绑定,不是闭包中保存的一份不可变值。通过 sum += y 修改后,下一次调用会读取修改后的绑定。

变量查找也不是“先查本地,再特殊检查闭包”。函数执行上下文会从自己的 LexicalEnvironment 开始,沿 [[OuterEnv]] 链查找;这条链恰好包含 makeAdder 的环境和更外层环境。

makeAdder 的闭包环境示意图

图片来源:原文 makeAdder 配图,已本地化。原图来自第三方文章资源,转载授权未核实;图片仅作教学示意。

三、循环中闭包让人很意外

var:多个回调共享一个绑定

function starfunc() {
  const tipText = [
    'hello tom',
    'hello jerry',
    'hello jack',
  ]

  for (var i = 0; i < tipText.length; i += 1) {
    var item = tipText[i]
    setTimeout(() => {
      console.log(item)
    }, 100)
  }
}

starfunc()

常见输出是三次 hello jack。原因不是每个闭包保存了当时的值副本,而是:

  1. var item 的绑定属于 starfunc 的函数作用域。
  2. 三个箭头函数分别创建,但都通过自己的环境链访问同一个 item 绑定。
  3. 循环每轮给同一个绑定重新赋值;循环结束后绑定的值是 hello jack
  4. timer task 稍后运行回调,回调读取的是当时仍然存在的共享绑定,因此三次都看到最后的值。

setTimeout 不会让回调在当前调用栈里“等待”,也不存在 ECMAScript 规范中的 Event Table:宿主注册 timer,达到延迟阈值后才有资格安排一个 task,回调开始运行时才进入 JavaScript 执行上下文。100ms 是调度下限,不是精确执行时刻。

var 循环共享绑定示意图

图片来源:原文 var 循环配图,已本地化。原图来自第三方文章资源,转载授权未核实;请确认图示的再发布许可。

let:每轮使用不同的迭代绑定

现代写法如下:

function starfuncWithLet() {
  const tipText = [
    'hello tom',
    'hello jerry',
    'hello jack',
  ]

  for (let i = 0; i < tipText.length; i += 1) {
    const item = tipText[i]
    setTimeout(() => {
      console.log(item)
    }, 100)
  }
}

starfuncWithLet()
// hello tom
// hello jerry
// hello jack

for (let ...) 的规范语义会为需要的迭代创建独立的每轮绑定,块内的 const item 也在每轮形成对应绑定。因此三个回调访问的是三个不同的 item,而不是三个值副本塞进同一个闭包。这里变化的是词法环境/绑定,不是“每轮创建一个可见的函数执行上下文”。

如果把 let i 提到循环外,再用普通的 for (; i < 3; i += 1),回调仍可能共享同一个外层 i;关键是每轮绑定的创建位置和语法语义,而不是关键字名字本身。

let 循环每轮绑定示意图

图片来源:原文 let 循环配图,已本地化。原图来自第三方文章资源,转载授权未核实;图片仅作教学示意。

四、闭包与性能

原文提醒“构造函数内给每个实例创建方法”可能产生重复函数对象,这个观察在实例很多时有价值,但不能推出“闭包必然性能差”。是否有成本取决于捕获状态、对象数量、引擎优化和实际访问热点。

下面的构造函数会为每个实例创建两个自有函数:

function MyObject(name, message) {
  this.name = String(name)
  this.message = String(message)

  this.getName = function () {
    return this.name
  }

  this.getMessage = function () {
    return this.message
  }
}

如果方法只依赖 this,可以放到原型上,让多个实例共享同一个函数对象:

function MyObject(name, message) {
  this.name = String(name)
  this.message = String(message)
}

MyObject.prototype.getName = function () {
  return this.name
}

MyObject.prototype.getMessage = function () {
  return this.message
}

现代 class 的普通实例方法也位于原型上:

class ModernObject {
  constructor(name, message) {
    this.name = String(name)
    this.message = String(message)
  }

  getName() {
    return this.name
  }

  getMessage() {
    return this.message
  }
}

如果方法需要捕获每个实例的私有词法状态,闭包可能正是有意的封装方式;也可以考虑 #private 字段。原型方法不保证在任何引擎、任何调用场景下都更快,应该用对象数量、内存快照和基准测试判断。

五、小结

  • 闭包是函数对象与定义位置词法环境的组合,不是独立的执行上下文。
  • var 循环的回调共享一个函数作用域绑定;let 循环可以产生每轮独立绑定。
  • timer 是宿主调度的 task,不会让回调在当前调用栈中等待。
  • 外层函数返回后,执行上下文结束;若返回函数仍引用环境,环境中的绑定仍然可用。
  • 闭包可以实现私有状态、函数工厂和部分应用;是否使用应由数据封装和生命周期决定。
  • 原型方法常用于共享只依赖 this 的行为,但不能用“闭包一定慢”或“原型一定快”这样的绝对结论替代测量。

图片来源与授权风险

五张配图均已从原文资源改为 images/102-image-01.webpimages/102-image-05.webp 的相对引用,正文不再依赖第三方热链。原作者、原站点和第三方图床的转载授权均未由仓库确认;公开发布前请确认图片许可,必要时替换为自绘图。

原文归属

作者:哇喔WEB。历史来源:SegmentFault 原文《我将【闭包】讲给你听!》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。

参考链接

原作者和来源信息沿用原文的文章归属;本地资源仅用于稳定展示,不代表对原图或原文的版权取得。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS