掌握JavaScript面试:什么是闭包?

5,756

前言

在学习闭包之前,我们要先了解下作用域与词法作用域。掌握了之后,只需一步就可以最终了解闭包。

作用域

定义

在JavaScript中,变量的可访问性由作用域管理。作用域由函数或代码块创建。变量只能在定义它的函数或代码块内使用。超出范围则不可访问

这个例子中的count变量属于foo函数创建的作用域,在该作用域内可访问。如果从外部访问count会引发报错,正常情况下执行完foo函数后他所拥有的这个作用域就会被主动释放和销毁。

变量隔离

我们可以把作用域理解为一种空间策略,每个函数拥有自己的私有作用域,它对变量进行了隔离,控制了变量的可访问性,因此不同的作用域可以具有相同名称的变量而不冲突。

分别在foo()bar()作用域定义count变量不会发生冲突。

作用域嵌套

我们可以把innerFunc()嵌套在外部函数outerFunc()中,外部作用域中的outerVar变量在内部作用域中是可访问的。

小结

  1. 作用域可以嵌套
  2. 外部作用域的变量可以在内部作用域内部访问
  3. 如果外部作用域中也找不到,最终会去全局作用域中查找,从内至外层层查找形成作用域链

扩展: 我们把内部作用域可以访问到外部作用域,而外部无法访问内部的行为叫做作用域继承,可以把全局作用域比喻成一个大房子,里面有很多层小房间(函数作用域或者块作用域),各自拥有自己的钥匙🔑。可以从里面访问外面,但是无法从外面访问里面,并且相互之间无法访问

词法作用域

JavaScript采用的是词法作用域,词法作用域(静态作用域)就是定义在词法阶段的作用域,之所以称为词法(或静态)是因为引擎(在词法化时)仅通过查看JavaScript源代码而不执行代码即可确定作用域的嵌套范围,因此当词法分析器处理代码时会保持作用域不变。

const myGlobal = 0;

function func() {
  const myVar = 1;
  console.log(myGlobal); // "0"

  function innerOfFunc() {
    const myInnerVar = 2;
    console.log(myVar, myGlobal); // "1 0"

    function innerOfInnerOfFunc() {
      console.log(myInnerVar, myVar, myGlobal); // "2 1 0"
    }

    innerOfInnerOfFunc();
  }

  innerOfFunc();
}

func();
  • innerOfInnerOffFunc()的词法作用域由innerOfUNC()、func()和全局作用域组成。在innerOfInnerOffFunc()中,可以访问变量myInnerVar、myVar和myGlobal。

  • innerFunc()的词法作用域由func()和全局作用域组成。在innerOfFunc()中,可以访问变量myVar和myGlobal。

  • func()的词法作用域只包含全局作用域。在func()中,可以访问变量myGlobal。

说明: 词法作用域意味着变量的可访问性由源代码中嵌套范围内变量的位置决定,在内部作用域内可以访问其外部作用域的变量。这样的作用域结构为引擎执行时提供了足够的位置信息,引擎可按照逐级向上查找规则来找到相应的变量。

简单的例子:

var value = 1;
function foo() {
    console.log(value);
}
function bar() {
    var value = 2;
    foo();
}
bar(); // 1

在js中输出结果为1,这就是词法作用域的效果,这里bar()调用foo()foo()并不是在bar()中寻找value变量,而是先查询自身作用域,找不到后根据作用域链查找机制直接去查询了全局。(作用域查找会在找到第一个匹配的标识符时停止)

扩展: eval()with可以“欺骗“词法作用域,会导致代码运行变慢,不要使用它们 需要明确的是,事实上,javascript并不具有动态作用域,它只有词法作用域,简单明了,但是this机制某种程度上很像动态作用域

闭包

前面理解了词法作用域允许静态访问外部作用域的变量。距离闭包仅一步之遥!

让我们回顾一下这个示例:

function outerFunc() {
  let outerVar = 'I am outside!';

  function innerFunc() {
    console.log(outerVar); // "I am outside!"
  }

  innerFunc();
}

outerFunc();

现在我们知道在innerFunc()作用域内可以从词法作用域访问变量outerVar,这里innerFunc()调用发生在其词法作用域(outerFunc()的作用域)内。

innerFunc()修改到其词法作用域之外(outerFunc()之外)调用。innerFunc()还能访问outerVar吗?

让我们对代码片段进行调整:

function outerFunc() {
  let outerVar = 'I am outside!';

  function innerFunc() {
    console.log(outerVar); // "I am outside!"
  }

  return innerFunc;
}

const myInnerFunc = outerFunc();
myInnerFunc();

现在innerFunc()在其词法作用域之外执行,但是innerFunc()仍然可以从其词法作用域访问outerVar,即使是在词法作用域之外执行。也就是说innerFunc()从其词法作用域捕获(又称记忆)变量outerVar

换句话说,innerFunc()是一个闭包,因为它在词法作用域内捕获了变量outerVar

正常来说,当outerFunc函数执行完毕之后,其作用域是会被销毁的,然后垃圾回收器会释放那段内存空间。而闭包却很神奇的将outerFunc的作用域存活了下来,innerFunc依然持有该作用域的引用,这个引用就是闭包。 到这里我们已经完成了理解闭包的最后一步:

闭包是一个函数,它从定义它的地方记住变量,形成一个私有的作用域,保护里边的私有变量不受外界的干扰,除了保护私有变量外,还可以存储一些内容,而不管它以后在哪里执行,所以无论通过哪种方式将内部的函数传递到所在的词法作用域以外,它都会持有对原始作用域的引用,无论在何处执行这个函数都会使用闭包。

识别闭包的经验法则:如果你在一个函数中看到一个外来变量(没有在函数内部定义),那么这个函数很可能是一个闭包,因为外来变量被捕获了。

在前面的代码片段中可以看出innerFunc()闭包中的外来变量outerVar是从outerFunc()作用域捕获的。

要观察闭包中所记录的环境变量值,可以从浏览器的控制台中看到,像上面的实例如果用浏览器控制台中加入中断点后观察可以看到下面的图像: 图中可以看到innerFunc作用域(Scope)会出现一种名称为Closoure(闭包)的变量值,这是可以观察闭包中的环境变数值的方式。

小结

某个函数在定义时的词法作用域之外的地方被调用,闭包可以使该函数极限访问定义时的词法作用域。

扩展:通俗地讲闭包就是在一个函数里边再定义一个函数。这个内部函数一直保持有对外部函数中作用域的访问权限(小房间一直可以有大房子的访问权限)。

闭包的作用

  1. 访问其他函数内部变量
  2. 保护变量不被内存回收机制回收
  3. 避免全局变量被污染 方便调用上下文的局部变量 加强封装性

比如vue2中definePropertydep实例的引用,让我们继续用一些例子来说明为什么闭包是有用的。

闭包示例

事件处理程序

let countClicked = 0;

myButton.addEventListener('click', function handleClick() {
  countClicked++;
  myText.innerText = `You clicked ${countClicked} times`;
});

这个实例中文本将更新显示单击次数。单击按钮时,handleClick()在DOM代码内部的某个远离定义的地方执行。

但是作为一个闭包,handleClick()从词法作用域捕获countClicked,并在单击发生时更新它。更重要的是,myText也被捕获了。

回调

从词法作用域捕获变量在回调中很有用。

function foo(message, time) {
  setTimeout(function callback() {
    console.log(message); // Hello, World!
  }, 1000);
}
foo('Hello, World!'1000);

之所以callback()是闭包,是因为它捕获了变量message

闭包经典使用场景

闭包还有个相当经典的场景,它在面试中经常会被提及,那就是for循环中的函数。

var arr = [];
for(var i = 0; i< 5; i++){
      arr[i] = function(){
           return i;
      }
 }

 arr[0](); // 5

在这个问题中,i 这个变量是被共享的。当循环结束之后,i 已经变成5了。所以arr0输出的是5。要解决问题,需要在定义arr[i]的匿名函数时,就需要记住 i 的值,而不能让它一直是变动的。用闭包的思路是让i在每次迭代的时候,都产生一个私有的作用域,在这个私有的作用域中保存当前i的值,每个作用域的i是独立不影响的,也就避免了共用 i,而 i 到最后都是5的问题。代码如下。

var arr = [];
 for(var i = 0i5i++){
      arr[i] = (function(i){
           return function(){
                return i;
           }
      })(i)
 }

 arr[0](); // 0

终极解决方案,这是 ES6 中的知识,因为之前在 JS 中是没有块级作用域的概念的,到了 ES6 中就有了,Let 声明的变量就可以更好的解决上述问题。

var arr = [];
for (let i = 0; i < 5; i++) {
  arr[i] = function () {
    return i;
  };
}

arr[0](); // 0

总结🥇

作用域决定了JavaScript中变量的可访问性。主要包括函数作用域和块作用域。

词法作用域允许函数作用域从外部作用域静态访问变量。

最后,闭包是从其词法作用域捕获变量的函数。用简单的话来说,闭包会记住从定义它的地方开始的变量,无论它在哪里执行。

闭包捕获事件处理程序,回调中的变量。它们用于函数式编程。

扩展 🏆

如果你觉得本文对你有帮助,可以查看我的其他文章❤️:

👍 Web开发应了解的5种设计模式🍊

👍 10个简单的技巧让你的 vue.js 代码更优雅🍊

👍 经典面试题!从输入URL到页面展示你还不赶紧学起来?🍊

👍 浅谈SSL协议的握手过程🍊

👍 解锁this、apply、call、bind新姿势🍊