JS 基础回归03:闭包

200 阅读8分钟

闭包一直是一个令广大 JS 开发者迷惑的概念,这一篇,我将一一走近作用域、作用域链、执行上下文,揭开它们的神秘面纱,以轻松理解闭包到底是个什么玩意。

开始学习闭包的时候,总是容易陷入到寻找精准定义的误区中去,但是,单一地为闭包下定义,是很难理解的,因为之所以会出现闭包这个东西,和前面我们提到的这些概念息息相关:执行上下文、作用域、作用域链,理解了这些东西,才能理解闭包。

作用域和作用域链

在任何编程语言中,作用域都是核心概念,变量在作用域内找到归属,可以在其中自由活动,可以说,作用域是变量的领地,领地之外的人无法干涉和改变其活动。JS基于词法作用域规则,每个变量的作用域在其定义时就被确定。函数是 JS 中唯一拥有局部块级作用域的容器,函数的参数和内部的变量在局部作用域内部可访问和操作。函数的作用域同样在其定义时就被确定。

当局部作用域出现与外层作用域同名变量时,会出现属性遮蔽的情况,会优先访问到局部作用域的变量。

const name = 'Alice'
function sayHi() {
  const name = 'Fan'
  console.log(name) // Fan
}
sayHi()

每一个函数都有自己的局部作用域,当多个函数嵌套定义时,他们的作用域也就会随之嵌套,形成一个作用域链。

JS 的变量查找,就是沿着作用域链进行。

const name = 'Fan'

function sayName() {
  console.log(name) // Fan
  const age = 25
  function sayAge() {
    console.log(age) // 25
  }
  sayAge()
}

sayName()

在上面这个例子中,两个函数sayAgesayName 在创建时,形成嵌套关系,它们都会创建自己的作用域,这些作用域形成一个作用域链:

sayAge -> sayName -> 全局作用域

sayAge 在查找 age 这个变量时,会从最里层的作用域找起,可以看到,自己的作用域内部并没有声明变量 age,它会沿着作用域链向上,到最近的 sayName 中查找,在这里得到了 age 变量。

可以看到,作用域链的查找方式和原型链的查找方式十分类似,区别在于,原型链查找不到时返回一个 undefined,而作用域链会报出 ReferenceError.

如果在 sayName 中也找不到,就会继续向上,到全局作用域,这里也找不到,就会返回一个 ReferenceError。

事实上,我们可以从技术上为理解闭包,在计算机科学中,闭包指的是这样一种机制:不管在哪里被调用,一个函数,可以访问到其作用域链上外层作用域中任意位置的变量。

明确作用域链的工作机制后,就很好理解函数的执行上下文了。

执行上下文

我们通过函数声明来定义一个函数,该函数会提升到所在作用域的最顶部,确保该函数在作用域内的任何位置都可以被调用:

sayHi('Fanyj') // hi, Fanyj

function sayHi(name) {
  console.log('hi, ' + name)
}

执行上面代码,会打印出“hi, Fanyj”。可以看到,我们在函数定义之前调用函数,函数成功执行。为什么会这样?

是因为,每一个函数在被执行的时候,会初始化一个执行上下文环境,这个环境里包含以下几个东西:

  • this 关键字
  • 函数声明
  • arguments:实参对象(被调用时传入的参数,未调用时为空)
  • 函数内部的变量、形参(未被赋值)等
  • 对当前作用域链的引用(包含访问到的外层作用域的变量)

注意上面的第5项内容,“对当前作用域的引用”,正是这个操作,完成了函数与外部作用域的链接,形成了作用域链。

我们讲,JS 中的所有函数都可以叫做闭包,因为它们都拥有对当前作用域链的引用。

到了这里,一切还很正常,闭包就是保留外部作用域链的引用,而使得闭包可以访问作用域链上的所有变量而已,但是,当这个特性被花样使用之时,它的强大才能被显示出来(同时也带来了迷惑)。

但在 JS 中,谈起闭包的时候,更偏向于这样一种现象,指由于函数保留了对于作用域链的引用,所以,在任何位置调用函数,都可以访问其作用域中的变量,哪怕这个变量表面上是私有的。

闭包到底复杂在哪里?

先看一个例子:

const name = 'Alice'
function bar() {
  const name = 'Fan'
  function sayName() {
    console.log(name)
  }

  return sayName
}

const func = bar()
func()

试思考,func 被调用时,会输出什么呢?

上面这种情况,把一个内部函数作为另一个函数的返回值返回出去,并在外部进行调用,这正是闭包的一个经典用法。这种情况下,很容易会认为,既然函数在全局环境被调用,其访问的 name 也将会在全局作用域内查找,返回值将是 Alice。

但在上面的例子中,神奇的事情发生了,我们在全局作用域访问到了位于 bar 函数局部作用域的变量 name,所以答案仍然是:Fan。发生了什么?不是说,变量只可以在作用域范围内被访问吗,那 bar 中的变量 name,为什么可以在全局作用域访问呢?

如果你理解前面关于作用域的描述,就能够很容易判断出正确结果,在前面我们提到:

变量的作用域在其定义时就被确定

也就是说,在函数内部的变量,它会根据定义时产生的作用域链进行查找确定,和它在何处调用没有任何关系。

我们换个角度来理解。在正常情况下,之所以在作用域外部访问不到内部的变量,是因为函数返回的时候,如果没有其他引用,函数执行上下文中对于作用域的引用会被垃圾回收机制回收(防止内存滥用)。所以,很多人的疑惑点在这里出现,当 bar 执行完毕,其作用域被回收,那变量 name 也就自然被回收了,那为什么还能被访问到呢?

问题当然出现在返回值上,因为返回值 sayName 是一个函数,该函数拥有外层到全局作用域的全部引用,正是这个引用,阻止了外层作用域被回收。

我们提到过,每一个函数每次被调用的时候,都会初始化一个新的执行上下文环境,这个环境中,包含了对作用域链的引用,作用域链是在函数定义时就确定的。也就是说,内部嵌套函数的执行环境和外部函数的执行环境是没有关系的,它们都拥有独立的执行上下文。哪怕同一个函数被多次调用,同样会创建多个独立的执行上下文。所以,外部函数执行完毕,其打算回收 name,但内部嵌套函数进行变量查找时,其走的路线是自己的上下文对象中对于作用域链的引用。

所以说,最常见的对于闭包的描述是这样的:闭包指函数可以访问其所在词法作用域,保留着对上层作用域的引用,哪怕它在作用域外执行

再如果对闭包有疑惑,请默念以下三句话:

  1. 作用域和作用域链在函数定义时就确定了
  2. 每个函数在每一次调用时,都会创建属于自己的执行上下文
  3. 执行上下文中包含了对于变量作用域链的引用

事实上,只要函数在其定义的作用域外被调用(常见的形式有两种:函数作为返回值,函数作为参数传递),闭包就出现了。

所以说,哪怕你根本没法解释闭包,但我相信在你的代码中,闭包已经悄悄出现很多次了。

闭包在一些 JS 库中大规模应用,例如最典型的用法是 JS 模块,在一个 JS 模块中,其所有的功能被封装在内部,只向外部暴露一个接口,外界可以通过这个接口访问模块内部的数据

闭包的性能问题和安全问题

很明显,闭包阻止了内存回收机制对于作用域的回收,保持着对于作用域内活动对象的引用,其必定会带来多余的内存开销。所以,要避免大量使用闭包。

同时,由于闭包的存在,可以在作用域外访问内部的变量,本应该销毁的变量仍然存在,这就导致了内存泄露风险。

以上就是我对于闭包的理解。

参考资料

  1. 【推荐】深入理解javascript原型和闭包
  2. 【Book】《JavaScript权威指南》
  3. 【Book】《你不知道的JavaScript》