前端入门18-JavaScript进阶之做用域链

声明

本系列文章内容所有梳理自如下几个来源:javascript

做为一个前端小白,入门跟着这几个来源学习,感谢做者的分享,在其基础上,经过本身的理解,梳理出的知识点,或许有遗漏,或许有些理解是错误的,若有发现,欢迎指点下。前端

PS:梳理的内容以《JavaScript权威指南》这本书中的内容为主,所以接下去跟 JavaScript 语法相关的系列文章基本只介绍 ES5 标准规范的内容、ES6 等这系列梳理完再单独来说讲。java

正文-做用域链

做用域一节中,咱们介绍了变量的做用域分两种:全局和函数内,且函数内部能够访问外部函数和全局的变量。git

咱们也介绍了,每一个函数被调用时,会建立一个函数执行上下文 EC,EC 里有个变量对象 VO 属性,函数内部操做的局部变量就是来源于 VO,但 VO 只保存当前上下文的变量,那么函数内部又是如何能够访问到外部函数的变量以及全局变量的呢?github

本篇就是来说讲做用域链的原理,理清楚这些理所固然的基础知识的底层原理。web

先来看个例子,再看些理论,最后结合理论再回过头分析例子。数组

var num = 0;
var sum = -1;
function a() {
    console.log(num);  //1. 输出什么
    var b = function () {
        console.log(num++);
    }
    var num = 1;
    b();  //2. 输出什么
    console.log(sum); //3. 输出什么
    return b;
}

var c = function(num) {
    var d = a();
    d();  //4. 输出什么
}

c(10);

当执行了最后一行代码时,会有四次输出,每次都会输出什么,能够先想一想,而后再继续看下去,对比下你的答案是否正确。数据结构

理论

做用域链的原理仍是跟执行上下文 EC 有关,执行上下文 EC 有个做用域链属性(Scope chain),做用域链是个链表结构,链表中每一个节点是一个 VO,在函数内部嵌套定义新函数就会多产生一个节点,节点越多,函数嵌套定义越深。闭包

因为做用域链本质上相似于 VO,也是执行上下文的一个属性,那么,它的建立时机天然跟 EC 是同样的,即:全局代码执行时的解析阶段,或者函数代码执行时的解析阶段。函数

每调用一次函数执行函数体时,js 解释器会通过两个阶段:解析阶段和执行阶段;

调用函数进入解析阶段时主要负责下面的工做:

  1. 建立函数上下文
  2. 建立变量对象
  3. 建立做用域链

建立变量对象的过程在做用域一节中讲过了,主要就是解析函数体中的声明语句,建立一个活动对象 AO,并将函数的形参列表、局部变量、arguments、this、函数对象自身引用添加为活动对象 AO 的属性,以便函数体代码对这些变量的使用。

而建立做用域链的过程,主要作了两件事:

  1. 将当前函数执行上下文的 VO 放到链表头部
  2. 将函数的内部属性 [[Scope]] 存储的 VO 链表拼接到 VO 后面

ps:[[]] 表示 js 解释器为对象建立的内部属性,咱们访问不了,也操做不了。

两个步骤建立了当前函数的做用域链,而当函数体的代码操做变量时,优先到做用域链的表头指向的 VO 寻找,找不到时,才到做用域链的每一个节点的 VO 中寻找。

那么,函数的内部属性 [[Scope]] 存储的 VO 链表是哪里赋值的?

这部分工做也是在解析阶段进行的,只不过是外层函数被调用时的解析阶段。解析阶段会去解析当前上下文的代码,若是碰到是变量声明语句,那么将该变量添加到上下文的 VO 对象中,若是碰到的是函数声明语句,那么会将当前上下文的做用域链对象引用赋值给函数的内部属性 [[Scope]]。但若是碰到是函数表达式,那 [[Scope]] 的赋值操做须要等到执行阶段。

因此,函数的内部属性 [[Scope]] 存储着外层函数的做用域链,那么当每次调用函数时,建立函数执行上下文的做用域链属性时,直接拼接外层函数的做用域链和当前函数的 VO,就能够达到以函数内部变量优先,依照嵌套层次寻找外层函数变量的规则。

这也是为何,明明函数的做用域链是当函数调用时才建立,但却依赖于函数定义的位置的缘由。由于函数调用时,建立的只是当前函数执行上下文的 VO。而函数即便没被调用,只要它的外层函数被调用,那么外层函数建立执行上下文的阶段就会顺便将其做用域链赋值给在它内部定义的函数。

分析

var num = 0;
var sum = -1;
function a() {
    console.log(num);  //1. 输出:undefined 
    var b = function () {
        console.log(num++);
    }
    var num = 1;
    b();  //2. 输出:1 
    console.log(sum); //3.输出:-1 
    return b;
}

var c = function(num) {
    var d = a();
    d();  //4. 输出:2
}

c(10);

1.当第一次执行全局代码时,首先建立全局执行上下文EC

因此,当进入执行阶段,开始执行全局代码时,全局变量已经所有添加到全局 EC 的 VO 里的,这也就是变量的提早声明行为,并且对于全局 EC 来讲,它的做用域链就是它的 VO,同时,由于解析过程当中遇到了函数声明语句,因此在解析阶段就建立了函数 a 对象(a:<function> 表示 a 是一个函数对象),也为函数 a 的内部属性 [[Scope]] 赋值了全局 EC 的做用域对象。

2.全局代码执行到 var c = function(num) 语句时

相应的全局变量在执行阶段进行了赋值操做,那么,赋值操做实际操做的变量就是对全局 EC 的 VO 里的相对应变量的操做。

3.当全局代码执行到 c(10),调用了函数 c 时

也就是说,在 c 函数内部代码执行以前,就为 c 函数的执行建立了 c 函数执行上下文 EC,这个过程当中,会将形参变量,函数体声明的变量都添加到 AO 中(在函数执行上下文中,VO 的具体表现为 AO),同时建立 arguments 对象,肯定函数内 this 的指向,因为这里的普通函数调用,因此 this 为全局对象。

最后,会建立做用域链,赋值逻辑用伪代码表示:

Scope chain = c函数EC.VO -> c函数内部属性[[Scope]]

           = c函数EC.VO -> 全局EC.VO

图中用数组形式来表示做用域链,实际数据结构并不是数组,因此,对于函数 c 内部代码来讲,变量的来源依照优先级在做用域链中寻找。

4.当函数 c 内部执行到 var d = a(); 调用了 a 函数时

一样,调用 a 函数时,也会为函数 a 的执行建立一个函数执行上下文,a 函数跟 c 函数同样定义在全局代码中,因此在全局 EC 的建立过程当中,已经为 a 函数的内部属性 [[Scope]] 赋值了全局 EC.VO,因此 a 函数 EC 的做用域链一样是:a函数EC.VO -> 全局EC.VO。

也就是做用域链跟函数在哪被调用无关,只与函数被定义的地方有关。

5.执行 a 函数内部代码

接下去开始执行 a 函数内部代码,因此第一行执行 console.log(num) 时,须要访问到 num 变量,去做用域链中依次寻找,首先在 a函数EC.VO 中找到 num:undefined,因此直接使用这个变量,输出就是 undefined。

6.执行 var b = function()

接下去执行了 var b = function (),建立了一个函数对象赋值给 b,同时对 b 函数的内部属性 [[Scope]] 赋值为当前执行上下文的做用域链,因此 b 函数的内部属性 [[Scope]]值为:a函数EC.VO -> 全局EC.VO

7.接下去执行到 b(),调用了b函数,因此此时

一样,也为 b 函数的执行建立了函数执行上下文,而做用域链的取值为当前上下文的 VO 拼接上当前函数的内部属性 [[Scope]] 值,这个值在第 6 步中计算出来。因此,最终 b 函数 EC 的做用域:

b函数EC.VO -> a函数EC.VO -> 全局EC.VO

8.接下去开始执行函数b的内部代码:console.log(num++);

因为使用到 num 变量,开始从做用域链中寻找,首先在 b函数EC.VO 中寻找,没找到;接着到下个做用域节点 a函数EC.VO 中寻找,发现存在 num 这个变量,因此 b 函数内使用的 num 变量是来自于 a 函数内部,而这个变量的取值在上述介绍的第 7 步时已经被赋值为 1 了,因此这里输出1。

同时,它还对 num 进行累加1操做,因此当这行代码执行结束,a 函数 EC.VO 中的 num 变量已经被赋值为 2 了。

9.b 函数执行结束,将 b 函数 EC 移出 ECS 栈,继续执行栈顶a函数的代码:console.log(sum);

因此这里须要使用 sum 变量,一样去做用域链中寻找,首先在 a函数EC.VO 中并无找到,继续去 全局EC.VO 中寻找,发现 sum 变量取值为 -1,因此这里输出-1.

10.a 函数也执行结束,将 a 函数 EC 移出 ECS 栈,继续执行 c 函数内的代码:d()

因为 a 函数将函数 b 做为返回值,因此 d() 其实是调用的 b 函数。此时:

这里又为 d 函数建立了执行上下文,因此到执行阶段执行代码:console.log(num++); 用到的 num 变量沿着做用域链寻找,最后发现是在 a函数EC.VO 中找到,且此时 num 的值为第 8 步结束后的值 2,这里就输出 2.

到这里你可能会疑惑,此时 ECS 栈内,a函数EC 不是被移出掉了吗,为什么 d 函数建立 EC 的做用域链中还包括了 a函数EC

这里就涉及到闭包的概念了,留待下节闭包讲解。

总结

若是要从原理角度理解:

  • 变量的做用域机制依赖于执行上下文,全局代码对应全局执行上下文,函数代码对应函数执行上下文
  • 每调用一次函数,会建立一次函数执行上下文,这过程当中,会解析函数代码,建立活动对象 AO,将函数内声明的变量、形参、arguments、this、函数自身引用都添加到AO中
  • 函数内对各变量的操做其实是对上个步骤添加到 AO 对象内的这些属性的操做
  • 建立执行上下文阶段中,还会建立上下文的另外一个属性:做用域链。对于函数执行上下文,其值为当前上下文的 VO 拼接上当前函数的内部属性 [[Scope]],对于全局执行上下文,其值为上下文的 VO。
  • 函数内部属性 [[Scope]] 存储着它外层函数的做用域链,是在外层函数建立函数对象时,从外层函数的执行上下文的做用域链复制过来的值。
  • 总之,JavaScript 中的变量之因此能够在定义后被使用,是由于定义的这些变量都被添加到当前执行上下文 EC 的变量对象 VO 中了,而之因此有全局和函数内两种做用域,是由于当前执行上下文 EC 的做用域链属性的支持。也能够说一切都依赖于执行上下文机制。

那么,若是想通俗的理解:

  • 函数内操做的变量,若是在其内部没定义,那么在其外层函数内寻找,若是尚未找到,继续往外层的外层函数内寻找,直到外层是全局对象为止。
  • 这里的外层函数,指的是针对于函数声明位置的外层函数,而不是函数调用位置的外层函数。做用域链只与函数声明的位置有关系。

你们好,我是 dasu,欢迎关注个人公众号(dasuAndroidTv),公众号中有个人联系方式,欢迎有事没事来唠嗑一下,若是你以为本篇内容有帮助到你,能够转载但记得要关注,要标明原文哦,谢谢支持~ dasuAndroidTv2.png