「JavaScript深入」理解闭包

858 阅读3分钟

在《你不知道的Javascript》中,有一道题

for(var i=0; i < 6; i++) { 
    setTimeout(function(){ 
        console.log(i); 
    },0); 
}

答案是6个6。

这道题粗略的解释是i属于全局作用域,在异步定时器里,也引用的是相同作用域中的i,当定时器启动时,i已经变成了6,所以打印出来的结果并不是我们预期的0、1、2、3、4、5。

那么有没有更加专业(zhuangbility)一点的说法呢?有的,我们可以从执行上下文和闭包的角度来解题。

今天我们来深入说一说其中的原理

闭包是什么

首先我们需要知道闭包究竟是什么东西。

MDN中对于闭包的解释是这样的:

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

这么专业的解释肯定看不懂啦,我们来看看它给的例子:

function init() {
    var name = "Mozilla"; // name 是一个被 init 创建的局部变量
    function displayName() { // displayName() 是内部函数,一个闭包
        alert(name); // 使用了父函数中声明的变量
    }
    displayName();
}
init();

以上代码非常简单,它的大概意思就是displayName函数里并没有name这个变量,它引用了外层作用域中的name,那么displayName就是一个闭包

所以我们用大白话定义一下:闭包 = 函数 + 访问外层作用域的变量。

执行上下文与闭包的关系

要明白执行上下文与闭包的关系,首先我们需要写一下上面例子中的执行上下文过程:

1、创建全局上下文,压栈

ECStack=[globalContext]

2、全局上下文初始化完成

globalContext={
   VO:{  //全局变量对象中有个init函数变量声明
      init:referance to function init
   },
   Scope:[globalContext.VO],
   this:globalContext.VO
}

同时init函数内部有个[[scope]]属性保存了globalContext.VO

3、调用函数,创建函数上下文后压栈

ECStack=[globalContext,initContext]

4、进入上下文,此时函数并没执行,初始化函数上下文,将[[scope]]内部属性复制给函数上下文中的Scope属性,并将当前上下文中的AO(活动变量对象)放到最前面

initContext={
   AO:{
      arguments:{
         length:0
      },
      name:undefined,
      displayName:referance to function displayName
   },
   Scope:[AO,globalContext.VO],
   this:undefined
}

5、函数代码执行,变量对象完成赋值

initContext={
   AO:{
      arguments:{
         length:0
      },
      name:Mozilla,
      displayName:referance to function displayName
   },
   Scope:[AO,globalContext.VO],
   this:undefined
}

6、遇到displayName代码,此时内部生成[[scope]]属性保存外层作用域层级链

displayName.[[scope]]=[initContext.AO,globalContext.VO]

7、开始调用displayName函数,创建函数上下文,压栈

ECStack=[globalContext,initContext,displayNameContext]

8、进入函数上下文,初始化displayNameContext,把活动对象压入作用域链中

displayNameContext={
   AO:{
      arguments:{
         length:0
      },
   }
   Scope:[AO,initContext.AO,globalContext.VO]
}

9、函数执行,完成变量对象赋值,然后找到外层的name,打印

10、开始弹栈

// 先弹displayName的上下文
ECStack=[globalContext,initContext]

// 再弹init的上下文
ECStack=[globalContext]

以上就是执行上下文的过程,而displayName之所以能够访问到外层作用域中的name,就是因为displayName中的Scope属性,里面保存了initContext和全局上下文中的变量对象。

displayName.Scope=[AO,initContext.AO,globalContext.VO]

有了这个属性,即使initContext被弹出执行栈了,displayName同样可以获取到它的变量对象。

这就是闭包的底层逻辑。同时也是闭包和执行上下文的关系

回到开始

现在我们来重新审视一下最开始的代码

for(var i=0; i < 6; i++) { 
    setTimeout(function(){ 
        console.log(i); 
    },0); 
}

当执行function函数时,此时的执行上下文有什么呢?全局上下文变量对象中有一个i

globalContext.VO={
   i:6
}

我们可以修改一下代码使它变成我们想要的0、1、2、3、4、5

1、第一种改法

for(var i=0; i < 6; i++) { 
    setTimeout(function(i){ 
        console.log(i); 
    },0,i); 
}

使用这种方法,会让setTimeout函数传ifunction,此时匿名函数第一次执行时它的上下文中是这样的

functionContext={
   AO:{
      arguments:{
         0:0,  // 实参中的 i
         length:1
      },
      i:0,
   }
  ...
}

函数调用时,i已经被定时器当成参数传递给匿名函数了。第一次匿名函数执行时i为0。

2、第二种改法

for (var i = 0; i < 6; i++) {
  (function (i) {
    setTimeout(() => {
      console.log(i);
    }, 0);
  })(i);
}

类似第一种改法,只是此时i保存在外层立即执行函数的变量对象里面

外层匿名函数Context = {
    AO: {
        arguments: {
            0: 0,
            length: 1
        },
        i: 0
    }
}

内层匿名函数的Scope属性为

[AO,外层匿名函数Context.AO,globalContext.VO]

内层匿名函数会顺着作用域链查找i,找到外层匿名函数Context.AO,取到i的值

3、第三种改法

for(let i=0; i < 6; i++) { 
    setTimeout(function(){ 
        console.log(i); 
    },0); 
}

使用let声明不会将i挂在全局变量对象下(跟var声明不是同一个)。此时开辟了不同的变量对象。

定时器参数函数Context={
   AO:{
      length:0
   },
   Scope:[AO,无名氏作用域变量对象,globalContext.VO]
}

于是定时器参数函数function顺着Scope属性找到对应的i

github

最后推广一下本人长期维护的 github 博客

1.从学习到总结,记录前端重要知识点,涉及 Javascript 深入、HTTP 协议、数据结构和算法、浏览器原理、ES6、前端技巧等内容。

2.在 README 中有目录可对应查找相关知识点。

如果对您有帮助,欢迎star、follow。

地址在这里