Skip to content

重学闭包

前言

当我们在讨论JavaScript的概念时,我们有两种视角来理解它们:静态视角与动态视角(或编译视角和运行视角)。

让我们以一个概念来举例:作用域

请看以下代码:

js
function func() { // A
  const a = 1
} // B
func()
console.log(a)

如果以静态视角来理解,从A到B的区间属于一个词法作用域,所谓词法作用域(也称静态作用域),指的是:未来运行时,引擎按照我们代码所框定的区域进行变量的绑定 。换句话说,仅仅通过分析代码,就可以知道,console.log(a) 无法获得正确的值,因为此处的 a 明显在声明变量 a 的区块外。

因此, 静态视角理解概念,无需在大脑中模拟一遍执行效果,就可以分析出结论。

当然,我们也可以通过动态视角来理解它。

在这个例子中, func 函数运行时,会初始化一个 Function Environment Record(函数环境记录) 对象,这个对象用来描述当前函数的词法环境。当函数执行结束时,执行上下文被销毁,同样,函数环境记录也会销毁。此时,通过 console.log(a) 所在的全局环境记录对象中,无法找到变量 a 的绑定,因此代码执行报错。

动态视角要求我们在大脑中执行代码,并按照运行时的状态来理解概念。

闭包的静态视角

js
function createIncFunc() {
  let a = 1
  return function inc() {
    console.log(a++)
  }
}
const inc = createIncFunc()
inc() // 1
inc() // 2
inc() // 3

在这个例子中,我们在 createIncFunc 函数的作用域内,声明并返回了另一个函数 inc ,因此我们断定 inc 函数会产生闭包,它可以访问上层作用域中变量 a 的值。

因此,如果从静态视角的概念理解闭包,我们可以简单归纳闭包的概念是:

当一个函数定义在另一个函数的作用域内会产生闭包,这个内部函数可以引用它的上层作用域中的变量。

或者简化为:

只要函数嵌套函数,就会产生闭包。

很遗憾的是,我只能说,这种静态视角对闭包的理解是肤浅的,是不得精髓的,仅仅理解到这个层面,只是解决了什么是闭包的问题,但没有解决为什么是闭包以及闭包如何运转的问题 ,要想更深入理解闭包,我们必须请动态视角出山了。

闭包的动态视角

在开启动态视角讲解前,请读者先阅读 执行上下文 章节,并确保对以下概念有所理解:

  • 执行栈(调用栈)
  • 词法环境与环境记录(Environment Record)

充分理解这两个概念就可以对闭包的原理进行彻底的解释了。让我们开始吧!

js
function createIncFunc() {
  let a = 1
  return function inc() {
    console.log(a++)
  }
}
const inc = createIncFunc()
inc() // 1
inc() // 2
inc() // 3

还是这个例子,让我们在大脑中模拟JavaScript引擎的工作:

  1. 构建全局环境,准备执行代码;
  2. 创建 全局执行上下文 ,调用栈开始执行第一行代码;
  3. 代码执行至 const inc = createIncFunc()createIncFunc 开始执行;
  4. 创建 函数执行上下文 (以下简称函数执行上下文A) ,该过程会创建一个函数环境记录(以下简称函数环境记录A)、关联Realm等,函数执行被推入执行栈,执行上下文A作为运行执行上下文(running execution context);
  5. 代码执行到 let a = 1 ,函数环境记录绑定变量 a
  6. 代码执行到 return 语句处,函数环境记录绑定函数 inc
  7. createIncFunc 函数执行完毕,函数执行被弹出执行栈,由于返回值被 inc 变量绑定,因此执行上下文A没有被销毁,而是被保持了!
  8. 代码执行到第一个 inc() ,创建函数执行上下文(以下简称执行上下文B),入执行栈,执行上下文B称为新的运行执行上下文;
  9. 代码执行到 console.log(a++) ,尝试在当前函数环境记录中查找变量 a ,没有找到;
  10. 继续在当前函数环境记录的 [[OuterEnv]] 中查找,找到了函数环境记录A中绑定的变量 a ,打印 a 并对其做自增操作,输出 1
  11. 代码执行到第二个 inc() ,重复步骤8-10,输出 2
  12. 代码执行到第三个 inc() ,重复步骤8-10,输出 3
  13. 所有代码执行完毕,函数执行上下文A销毁,闭包结束。

这个过程充分解释了闭包的产生、作用与销毁过程,因为内部函数被引用,导致外部函数的执行上下文没有销毁,当未来内部函数调用时,仍然可以获取到上层函数执行上下文中的数据,这就是闭包。

闭包的用途

闭包无处不在! ,闭包是典型的 不需要了解它,更不需要深入解释它,但是人人都会用它 的概念。考虑以下例子:

js
function clickSelf(el) {
  el.addEventListener('click', function() {
    console.log(el)
  })
}

clickSelf(document.body)

这是一段非常简单的代码,当用户点击某个元素时在控制台打印它。el.addEventListener('click', function() {...}) 这里的函数将会形成一个闭包,保证未来该函数执行时,仍然能获取到其上层函数作用域中的变量(在这个例子中是变量 el )。

js
;(function() {

  let count = 0

  function Logger() {

  }

  Logger.prototype.log = function() {
    console.log(this.el)
    console.log(`Click ${++count} times.`)
  }

  Logger.prototype.bind = function(el) {
    if (this.el) return
    count = 0
    this.el = el
    this.el.addEventListener('click', () => {
      this.log()
    })
  }

  window.Logger = Logger

})()

const logger = new Logger()
logger.bind(document.body)

在这个例子中, Logger.prototype.log 函数也生成了一个闭包,它引用了上层匿名函数中的 count 变量。