AOVOGO 爬坑

246 阅读15分钟

一 执行上下文EC和变量对象VO

  1. EC:Execution Context,中文翻译执行上下文,也有翻译成执行环境的。
  2. VO:Variable object,中文翻译变量对象。
  3. 这两个概念很重要,涉及到作用域以及作用域链的原理。

1 执行上下文 EC

  1. 可以通俗的把它理解成一个对象,对象名 EC,表示代码的执行上下文。
  2. 在 JavaScript 中,每当要执行不同类型的代码时,就会创建一个执行上下文 EC。
  • 全局代码
  • 函数代码
  1. 全局代码就会被执行,那么在全局代码执行前就会先创建一个全局的执行上下文 EC,之后每调用一次函数,要执行函数内的代码时,会再创建一个函数执行上下文 EC。
  2. 而每次创建一个执行上下文时,都会将其放入一个栈结构,这个栈就称为执行上下文栈(ECS),也有翻译成执行环境栈。
  3. ECS这个栈底一直是全局执行上下文,直到 js 文件代码执行结束。全局代码执行过程中,每调用一次函数,新创建一个函数执行上下文,就放入栈内。
  4. 因此,栈顶就表示当前执行的代码,如果栈顶是全局执行上下文,表示正在执行全局代码;如果栈顶是函数执行上下文,表示正在执行函数内的代码。当函数执行结束时,这个函数执行上下文就从栈中移出。
  5. 将 EC 理解成一个对象的话,它有两个属性,一个是变量对象(VO),另一个是作用域链(Scope Chain)。
  6. 用途 1 保存变量 2 变量提升

2 变量对象VO

  1. 全局对象就是 window,所以全局执行上下文的变量对象 VO = window。
  2. 在函数执行上下文中,因为变量对象 VO 是要保存当前上下文中所有的变量,一个函数内的变量包括:形参变量、局部变量、自身函数对象引用变量、arguments、this。
  3. 针对函数执行上下文,为了保存这些变量,特意创建了一个对象,称它为活动对象 AO,函数内所需的变量就都保存在 AO 中,所以在函数执行上下文中,变量对象 VO 的具体表现也就是 AO。
  4. 小结:变量对象 VO 是一个抽象概念,用于保存当前执行上下文中所有的变量。所以在全局执行上下文中,因为全局对象已经保存着当前上下文所有的变量,所以 VO 在这里的具体实现就是全局对象。在函数执行上下文中,由于要保存函数形参、局部变量、自身函数对象引用变量、arguments、this,所以新创建了一个叫活动对象 AO 来保存,此时 VO 的具体实现就是 AO。

3 作用域链

  1. 作用域链本质上,其实是将有嵌套层次关系的执行上下文的 VO 拼接起来。
  2. 当出现函数内再嵌套函数时,此时作用域链就会比较长:内层函数执行上下文的 VO –> 外层函数执行上下文的 VO –> 全局执行上下文 VO。
  3. 作用域链是如何将有嵌套层次的执行上下文的 VO 拼接起来,需要借助函数对象的内部属性 [[Scope]],[[]]表示执行引擎为对象创建的内部属性,我们访问不了,也操作不了。
Scope = VO + All Parent VOs
Eg: scopeChain = [ [VO] + [VO1] + [VO2] + [VO n+1] ];

二 作用域

1 实际编程中,建议不要过多的使用全局变量,有多种方法可以避免:

  • 使用一个全局对象来作为命名空间,将其余不在函数体内部定义的变量,作为该全局对象的属性来定义使用。

    var NUM = {}
    NUM.len = 1
    function () {
    	console.log(NUM.len)
    }
    
    
  • 使用一个立即执行的函数来作为临时命名空间,函数执行结束释放临时命名空间。

    (function () {
    	var n = 1
    	function a() {
    		console.log(n)
    	}
    	a()
    })()
    
  • 如果临时命名空间内的部分变量需要供外部使用,一可以将这部分变量添加到作为命名空间的全局对象上的属性,二可以利用闭包的特性,返回一个新建的对象,为该对象添加一些接口可访问这部分变量。

2 变量的声明提前原理

var i = 0
function A(){
	console.log(i) // undefined
	for(var i = 0; i<1; i++){}
	console.log(i) // 1
}
A()

提升理论

  1. 在全局执行上下文中,VO 的具体表现是全局对象;在函数执行上下文中,VO 的具体表现是 AO,AO 存储着函数内的变量:形参、局部变量、函数自身引用、this、arguments。
  2. js 解释器会分两个过程:解析阶段、执行代码阶段。
  • 解析阶段:其实本质上就是创建一个执行上下文,这个阶段会解析当前上下文内的代码,将声明的变量都保存到 VO 对象上。
  • 执行代码阶段:就是代码实际运行期,当运行到相对应的变量的赋值语句时,就会将具体的属性值写入 VO 对象上保存的对应变量。
  1. 也就是说,在执行代码阶段,代码实际运行时,js 解释器已经解析了一遍上下文内的代码,并创建了执行上下文,且为其添加了一个 VO 属性,在 VO 对象上添加了上下文内声明的所有变量,这就是变量的声明提前行为。而之后函数体内对各变量的操作,其实是对 VO 上保存的变量进行操作了。

提升分析

var i = 0
function A(){
	console.log(i) // undefined
	for(var i = 0; i<1; i++){}
	console.log(i) // 1
}
A()
  1. 解释器第一次执行这份代码,那么当执行全局代码时,首先进入全局执行上下文的解析阶段:
  • 1 解析代码创建的全局执行上下文 EC
  • 2 创建VO,并给VO 添加属性 i A
  • 3 忽略其他过程
  • 4 将创建的全局EC放入 ECS 执行栈

  1. 当实际开始执行第一行全局代码时,js解释器经过了解析阶段已经做了如上的工作,得到了一些基本的信息。之后便是执行全局代码,如果执行的代码是访问全局变量,那么直接读取全局 EC 中 VO 里的对应变量;如果是对全局变量赋值操作,那么写入全局 EC 中的 VO 里对应变量的属性值。
  2. 如果执行的代码是调用某个函数,此时就会为这个函数的执行创建一个函数执行上下文,那么这个过程同样需要两个阶段:解析阶段和执行阶段。
  3. 当执行A()
  • 解析A() 函数内代码,并创建函数执行上下文,A函数EC
  • 创建AO,并为其添加属性
  • 省略其他
  • 将创建的A函数EC放入ECS执行栈
  1. 执行函数 A ,第一行输出才会输出 undefined,因为变量的声明提前特性在调用函数时创建函数执行上下文的过程中,已经解析了函数内的声明语句,并将这些变量添加到函数上下文 EC 的 AO 中了。

三 作用域链

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

作用域理论

  1. 作用域链的原理还是跟执行上下文 EC 有关,执行上下文 EC 有个作用域链属性(Scope chain),作用域链是个链表结构,链表中每个节点是一个 VO,在函数内部嵌套定义新函数就会多产生一个节点,节点越多,函数嵌套定义越深。
Scope = VO + All Parent VOs
Eg: scopeChain = [ [VO] + [VO1] + [VO2] + [VO n+1] ];
  1. 调用函数进入解析阶段时主要负责下面的工作:

    • 1 创建函数上下文 EC
    • 2 创建变量对象 VO AO
    • 3 创建作用域链
  2. 创建作用域链的过程,主要做了两件事:

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

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

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

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

    function f1 () {
    	function f2 () {
    		
    	}
    }
    f1()
    // 如果碰到的是函数声明语句,那么会将当前上下文的作用域链对象引用赋值给函数的内部属性 [[Scope]]
    f1执行,碰见声f2,将当前作用域链对象的引用Scope(f1的) 赋值给 f2 的[[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: 表示 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

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.

总结

  • 变量的作用域机制依赖于执行上下文,全局代码对应全局执行上下文,函数代码对应函数执行上下文
  • 每调用一次函数,会创建一次函数执行上下文,这过程中,会解析函数代码,创建活动对象 AO,将函数内声明的变量、形参、arguments、this、函数自身引用都添加到AO中
  • 函数内对各变量的操作实际上是对上个步骤添加到 AO 对象内的这些属性的操作
  • 创建执行上下文阶段中,还会创建上下文的另一个属性:作用域链。对于函数执行上下文,其值为当前上下文的 VO 拼接上当前函数的内部属性 [[Scope]],对于全局执行上下文,其值为上下文的 VO。
  • 函数内部属性 [[Scope]] 存储着它外层函数的作用域链,是在外层函数创建函数对象时,从外层函数的执行上下文的作用域链复制过来的值。
  • 总之,JavaScript 中的变量之所以可以在定义后被使用,是因为定义的这些变量都被添加到当前执行上下文 EC 的变量对象 VO 中了,而之所以有全局和函数内两种作用域,是因为当前执行上下文 EC 的作用域链属性的支持。也可以说一切都依赖于执行上下文机制。
  • 函数内操作的变量,如果在其内部没定义,那么在其外层函数内寻找,如果还没有找到,继续往外层的外层函数内寻找,直到外层是全局对象为止。
  • 这里的外层函数,指的是针对于函数声明位置的外层函数,而不是函数调用位置的外层函数。作用域链只与函数声明的位置有关系。