手写面试题五:闭包和词法环境

699 阅读8分钟

转载请注明原文链接。原文链接

手写面试题系列是我为了准备当下和以后的面试而编写的文章系列,当然对于前端小伙伴也有帮助。我建议读完之后,自己动手敲代码或者手写一遍才能更好地掌握。

参考文献:

  1. 闭包 - MDN
  2. Lexical Environment — The hidden part to understand Closures
  3. 【译】 理解 JavaScript 中的执行上下文和执行栈

在上一篇文章 手写面试题四:执行上下文、执行栈和词法环境的末尾,我解释了词法环境的概念,也提及的闭包,但是没有展开去讲清楚。在这篇文章,我就来带领窥探一下闭包的奥秘。

一、什么是闭包?

先看一下MDN的官方解释:

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

1. 个人解读

首先,我来分析一下官方解释的含义。从第一句话开始:一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。

把第一句话再简化一点来说就是:一个函数和它的词法环境的引用组合起来就是闭包。即:函数fn1的闭包 = fn1 + fn1的词法环境的引用。我们知道,函数一旦被定义,其实它的词法环境就已经确定了,所以一个函数必然是存在闭包的

再看下一句话:也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

我开始接触闭包这个概念时,就认为一个内层函数中访问到其外层函数的作用域的情况才算是闭包。但是这句话的意思是,闭包具有一个特性:闭包可以让你可以在一个内层函数中访问到其外层函数的作用域

再看最后一句话:在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

最后一句话,佐证了我对第一句话的解释,也就是说:只要函数创建就会产生闭包

2. 结论

上面大段的解释,只是为了向读者说明闭包到底是什么。因为我以前对闭包的认知也是错误的,为了避免读者和我以前有一样认知,所以花些篇幅去解释。

下面,给出闭包官方含义的结论:

  1. 闭包 = 函数 + 函数词法环境的引用;
  2. 闭包具有一个特性:可以在一个内层函数中访问到其外层函数的作用域;
  3. 闭包在函数创建的时候创建,意味着只要函数被定义,就一定产生闭包,与它是否访问外层函数的作用域没关系;

二、函数的词法环境与闭包

闭包的特性就是可以在内层函数访问外层函数的作用域。要想弄清楚闭包为什么有这个特性就不得不提及词法环境这个概念。

词法环境由2个部分组成,一是环境记录器,二是外部环境的引用

环境记录器是存储变量和函数声明的实际位置,更具体点就是存储了函数的变量、函数和参数列表(arguments对象,可以把函数参数理解为函数内部声明的变量,给函数参数传值就是给参数赋值,但函数参数的作用域仍属于函数内部); 外部环境的引用意味着它可以访问其父级词法环境(作用域);

通俗点解释就是,在词法环境中,环境记录器记录保存了执行上下文中的变量和函数的实际值,外部环境的引用使得该执行上下文可以沿着作用域链访问父级的作用域。

用伪代码来表示就是:

LexicalEnvironment: {
	// 环境记录器:记录函数内部定义的变量(let、const关键字声明的)、函数和参数列表
    EnvironmentRecord: {
      Type: "Object",
      // 在这里绑定标识符
    }
    // 对外部环境的引用
    outer: <null>
}

由于函数的词法环境存储着对外部环境的引用,所以使得函数可以访问函数外层作用域中的变量和函数。又由于函数的词法环境是在函数定义的时候就确定了,所以函数可以访问的外部环境的层级在函数定义的时候就已经确定了,可以理解为:函数的能访问外部环境的层级在定义时已经确定,不会因为函数的调用方式而发生变化

这句话很绕,而且很容易与函数this的指向取决于函数的调用方式产生混淆,读者需要花费一定的时间去理解和消化。

举个简单的例子说明:

const num = 10;
function fn1() {
	console.log(num);
}

function fn2() {
	const num = 20;
	fn1();
}

fn2(); // 输出 10

例子中,尽管函数fn1()在函数fn2()内部被调用,但是输出的num仍旧是全局环境中的num的值;改变fn1()调用的方式并不会改变fn1()可以访问的上层作用域的层级,因为这些事在函数定义的时候就确定了的。

三、闭包的常见使用示例

闭包的特性使得函数可以访问到外层作用域。如果一个内层函数访问外层函数中的变量,外层函数已经执行完毕,但是内层函数还没执行完毕时,内层函数想要访问外层函数中的变量,变量会直到内层函数执行完时它的存储空间才会被收回。

1. 在定时器中
(function autorun() {
    const num = 100
    setTimeout(function log() {
        console.log(num)
    }, 1000)
    console.log('autorun 执行完毕')
})()

输出结果为: autorun 执行完毕 1000

在autorun执行完毕1秒后,内部函数log会输出num的值100。也就是说,在函数autorun()执行完毕后,函数log()仍然可以访问到外层函数autorun()中的变量num。

2. 在事件处理中
(function autorun(){
    const num = 100;
    $("#btn").on("click", function log(){
      console.log(num);
    });
    console.log('autorun 执行完毕')
})();

同样的输出结果。因为点击事件也是延迟出发的,所以在autorun()执行完毕后才会调用函数log()。

在上面的2个例子中,我们可以看到,log() 函数在外层函数autorun()执行完毕后仍旧可以访问内层函数中的变量。

如果内层函数访问了外层函数中的变量,那么变量的生命周期取决于内层的生命周期。被内层函数引用的外部作用域中的变量将一直存活直到闭包函数被销毁。如果一个变量被多个内层函数所引用,那么直到所有的内层函数被垃圾回收后,该变量才会被销毁。

3. 闭包与循环

内层函数访问外层函数中的变量时,访问的事外层函数中变量的引用,而不会拷贝外层函数中变量的值。如在循环中使用:

(function initEvents(){
  for(var i=1; i<=10; i++){
    setTimeout(() => {
      (function showNumber(){
        console.log(i)
      })() 
    }, 100);
  }
})()

函数showNumber()会在for循环执行完毕后再执行,所以i会最后再自增一次,i为11, 由于i通过关键字var定义, 它会被变量提升至全局作用域,所以showNumber取到的自始至终是同一个i的引用,所以结果不变,再加上循环会在 i = 10之后再运行一次i++,所以10次输出结果相同,都是11。

在这里,如果把var换成let,那么每一个i都会被单独定义一次,这时,每个showNumber引用的i都不同,所以输出的结果会是1-10。

4. 闭包的性能考量

如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。 由于闭包的特性,可以访问到外层函数,但是这种操作需要上溯作用域链,造成不必要的消耗。

另外由于内层函数访问外层函数中的变量会导致外层函数中的这些变量在外层函数没有执行完毕时,变量不会销毁,使得存储空间一直得不到回收,造成性能问题。

所以如无必要,不应该大量的使用闭包。