前端18-JavaScript作用域链(进阶)

声明

本系列文章内容全部梳理自以下几个来源:

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

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

正文-作用域链

作用域一节中,我们介绍了变量的作用域分两种:全局和函数内,且函数内部可以访问外部函数和全局的变量。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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。而函数即使没被调用,只要它的外层函数被调用,那么外层函数创建执行上下文的阶段就会顺便将其作用域链赋值给在它内部定义的函数。

分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 的作用域对象。

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

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

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

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

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

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

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

图中用数组形式来表示作用域链,实际数据结构并非数组,所以,对于函数 c 内部代码来说,变量的来源依照优先级在作用域链中寻找。

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

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

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

  1. 执行 a 函数内部代码

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

  1. 执行 var b = function()

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

  1. 接下去执行到 b(),调用了b函数,所以此时:

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

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

  1. 接下去开始执行函数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 了。

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

所以这里需要使用 sum 变量,同样去作用域链中寻找,首先在 a函数EC.VO 中并没有找到,继续去 全局EC.VO 中寻找,发现 sum 变量取值为 -1,所以这里输出-1.

  1. 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 的作用域链属性的支持。也可以说一切都依赖于执行上下文机制。

那么,如果想通俗的理解:

  • 函数内操作的变量,如果在其内部没定义,那么在其外层函数内寻找,如果还没有找到,继续往外层的外层函数内寻找,直到外层是全局对象为止。
  • 这里的外层函数,指的是针对于函数声明位置的外层函数,而不是函数调用位置的外层函数。作用域链只与函数声明的位置有关系。
请叫我大苏 wechat
您的支持将鼓励我继续创作!