主要是理清执行上下文、作用域链和变量对象的关系
1.执行上下文
简而言之,执行上下文就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中运行任何的代码都是在执行上下文中运行。
执行上下文类型:
执行栈:
执行栈,在其他编程语言中也被叫做调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。
当 JavaScript 引擎首次读取你的脚本时,它会创建一个全局执行上下文并将其推入当前的执行栈。每当发生一个函数调用,引擎都会为该函数创建一个新的执行上下文并将其推到当前执行栈的顶端。
引擎会运行执行上下文在执行栈顶端的函数,当此函数运行完成后,其对应的执行上下文将会从执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文。
让我们通过下面的代码示例来理解这一点:
let a = 'Hello World!';
function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}
function second() {
console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');
当上述代码在浏览器中加载时,JavaScript 引擎会创建一个全局执行上下文并且将它推入当前的执行栈。当调用 first() 函数时,JavaScript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。
当在 first() 函数中调用 second() 函数时,Javascript 引擎为该函数创建了一个新的执行上下文并将其推到当前执行栈的顶端。当 second() 函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行上下文,即 first() 函数的执行上下文。
当 first() 函数执行完成后,它的执行上下文从当前执行栈中弹出,上下文控制权将移到全局执行上下文。一旦所有代码执行完毕,Javascript 引擎把全局执行上下文从执行栈中移除。
2.执行上下文是如何被创建的(以及其中的作用域和变量对象)
到目前为止,我们已经看到了 JavaScript 引擎如何管理执行上下文,现在就让我们来理解 JavaScript 引擎是如何创建执行上下文的。
执行上下文分两个阶段创建:1)创建阶段; 2)执行阶段
执行上下文建立过程:
1、在任意的 JavaScript 代码被执行前,执行上下文处于创建阶段。在创建阶段中总共发生了三件事情:
2、执行阶段
解释器执行代码的伪逻辑:
我们以实例来讲解函数执行上下文的VO/AO:
VO
function foo(i){
var a = 'hello'
var b = function(){}
function c(){}
}
foo(22)
此时的函数上下文:
ECObj = {
scopChain: {...}, // 作用域链
variableObject: { //变量对象VO
arguments: {
0: 22,
length: 1
},
i: 22,
c: pointer to function c()
a: undefined,
b: undefined
},
this: { ... } //this
}
正如我们看到的,在上下文创建阶段,VO的初始化过程如下(该过程是有先后顺序的:函数的形参>>函数声明>>变量声明):
总结:有顺序,是函数形参、函数声明和变量声明,而且函数声明相同会覆盖,变量声明相同会忽略。
AO
正如我们看到的,创建的过程仅负责处理定义属性的名字,而并不为他们指派具体的值,当然还有对形参/实参的处理。一旦创建阶段完成,执行流进入函数并且激活/代码执行阶段,看下函数执行完成后的样子:
ECObj = {
scopeChain: { ... },
variableObject: {
arguments: {
0: 22,
length: 1
},
i: 22,
c: pointer to function c()
a: 'hello',
b: pointer to function privateB()
},
this: { ... }
}
3.作用域链
在执行上下文的作用域中查找变量的过程被称为标识符解析(indentifier resolution),这个过程的实现依赖于函数内部另一个同执行上下文相关联的对象——作用域链。**作用域链是一个有序链表,其包含着用以告诉JavaScript解析器一个标识符到底关联着哪一个变量的对象。**而每一个执行上下文都有其自己的作用域链Scope。
所以,作用域链是执行上下文和变量对象之间桥梁。
作用域链Scope其实就是对执行上下文EC中的变量对象VO|AO有序访问的链表。能按顺序访问到VO|AO,就能访问到其中存放的变量和函数的定义。
作用域链,是由当前环境与上层环境的一系列变量对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。
作用域链:就是一个区域,包含了变量、常量、函数等定义信息和赋值信息,以及这个区域内代码书写的结构信息,作用域可以嵌套。
作用域其实由两部分组成:
1.记录作用域变量信息(变量、常量和函数等统称为变量)和代码结构信息的东西,称为Environment Record。
2.一个引用__outer__
,这引用指向当前作用域的父作用域。全局作用域的__outer__
为null。
Scope定义如下:Scope = AO|VO + [[Scope]]
其中,AO始终在Scope的最前端,不然为啥叫活跃对象呢。即:Scope = [AO].concat([[Scope]]);
当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
这里主要来讲Scope和[[scope]]
Scope就是作用域链,作用域链Scope其实就是对执行上下文EC中的变量对象VO|AO有序访问的链表。能按顺序访问到VO|AO,就能访问到其中存放的变量和函数的定义。
Scope定义如下:Scope = AO|VO + [[Scope]]
其中,AO始终在Scope的最前端,不然为啥叫活跃对象呢。即:Scope = [AO].concat([[Scope]]);
因为[[scope]]在函数创建的时候,就保存在函数中,所以作用域链在函数创建时就已经有了。
那么[[scope]]是什么呢?
[[Scope]]是一个包含了所有上层变量对象的分层链,它属于当前函数上下文,并在函数创建的时候,保存在函数中。
[[Scope]]是在函数创建的时候保存起来的——静态的(不变的),只有一次并且一直都存在——直到函数销毁。 比方说,哪怕函数永远都不能被调用到,[[Scope]]属性也已经保存在函数对象上了。
var x=10;
function f1(){
var y=20;
function f2(){
return x+y;
}
}
以上示例中,f2的[[scope]]属性可以表示如下:
var x=10;
function f1(){
var y=20;
function f2(){
return x+y;
}
}
以上示例中,f2的[[scope]]属性可以表示如下:
f2.[[scope]]=[
f2OuterContext.VO
]
而f2的外部EC的所有上层变量对象包括了f1的活跃对象f1Context.AO,再往外层的EC,就是global对象了。
所以,具体我们可以表示如下:
f2.[[scope]]=[
f1Context.AO,
globalContext.VO
]
对于EC执行环境是函数来说,那么它的Scope表示为:
functionContext.Scope=functionContext.AO+function.[[scope]]
注意,以上代码的表示,也体现了[[scope]]和Scope的差异,Scope是EC的属性,而[[scope]]则是函数的静态属性。
(由于AO|VO在进入执行上下文和执行代码阶段不同,所以,这里及以后Scope的表示,我们都默认为是执行代码阶段的Scope,而对于静态属性[[scope]]而言,则是在函数声明时就创建了)
对于以上的代码EC,我们可以给出其Scope的表示:
exampelEC={
Scope:[
f2Context.AO+f2.[[scope]],
f1.context.AO+f1.[[scope]],
globalContext.VO
]
}
下面,让我们以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的。
函数创建
函数的作用域在函数定义的时候就决定了。
这是因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链!
function foo() {
function bar() {
...
}
}
函数创建时,各自的[[scope]]为:
foo.[[scope]] = [
globalContext.VO
];
bar.[[scope]] = [
fooContext.AO,
globalContext.VO
];
函数激活
当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。(VO/AO添加到作用前端)
这时候执行上下文的作用域链,我们命名为 Scope:
Scope = [AO].concat([[Scope]]);
至此,作用域链创建完毕。
我们再来看一个示例:
var a = 20;
function test() {
var b = a + 10;
function innerTest() {
var c = 10;
return b + c;
}
return innerTest();
}
test();
在上面的例子中,全局,函数test,函数innerTest的执行上下文先后创建。我们设定他们的变量对象分别为VO(global),VO(test), VO(innerTest)。而innerTest的作用域链,则同时包含了这三个变量对象,所以innerTest的执行上下文可如下表示。
innerTestEC = {
VO: {...}, // 变量对象
scopeChain: [VO(innerTest), VO(test), VO(global)], // 作用域链
}
我们可以直接用一个数组来表示作用域链,数组的第一项scopeChain[0]为作用域链的最前端,而数组的最后一项,为作用域链的最末端,所有的最末端都为全局变量对象。
很多人会误解为当前作用域与上层作用域为包含关系,但其实并不是。以最前端为起点,最末端为终点的单方向通道我认为是更加贴切的形容。如图。
注意,因为变量对象在执行上下文进入执行阶段时,就变成了活动对象,这一点在上一篇文章中已经讲过,因此图中使用了AO来表示。Active Object
是的,作用域链是由一系列变量对象组成,我们可以在这个单向通道中,查询变量对象中的标识符,这样就可以访问到上一层作用域中的变量了。
4.VO(变量对象)/AO(活动对象)
AO其实就是被激活的VO,两个其实是一个东西。
变量对象(Variable object)是说JS的执行上下文中都有个对象用来存放执行上下文中可被访问但是不能被delete的函数标示符、形参、变量声明等。它们会被挂在这个对象上,对象的属性对应它们的名字对象属性的值对应它们的值但这个对象是规范上或者说是引擎实现上的不可在JS环境中访问到活动对象。
激活对象(Activation object)有了变量对象存每个上下文中的东西,但是它什么时候能被访问到呢?就是每进入一个执行上下文时,这个执行上下文儿中的变量对象就被激活,也就是该上下文中的函数标示符、形参、变量声明等就可以被访问到了。
5.代码示例捋清执行上下文、作用域链和变量对象
var scope = "global scope";
function checkscope(){
var scope2 = 'local scope';
return scope2;
}
checkscope();
执行过程如下:
1.checkscope 函数被创建,保存作用域链到内部属性[[scope]]【作用域链】
checkscope.[[scope]] = [
globalContext.VO
];
2.执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈【执行上下文】
ECStack = [
checkscopeContext,
globalContext
];
3.checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链【作用域链】
checkscopeContext = {
Scope: checkscope.[[scope]],
}
4.第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明【变量对象】
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
}
}
5.第三步:将活动对象压入 checkscope 作用域链顶端
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: [AO, [[Scope]]]
}
6.准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: 'local scope'
},
Scope: [AO, [[Scope]]]
}
7.查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
ECStack = [
globalContext
];
6.变量和函数提前
(function() {
console.log(typeof foo); // 函数指针
console.log(typeof bar); // undefined
var foo = 'hello',
bar = function() {
return 'world';
};
function foo() {
return 'hello';
}
}());
1、为什么我们能在foo声明之前访问它?
回想在VO的创建阶段,我们知道函数在该阶段就已经被创建在变量对象中。所以在函数开始执行之前,foo已经被定义了。
2、Foo被声明了两次,为什么foo显示为函数而不是undefined或字符串?
我们知道,在创建阶段,函数声明是优先于变量被创建的。而且在变量的创建过程中,如果发现VO中已经存在相同名称的属性,则不会影响已经存在的属性。
因此,对foo()函数的引用首先被创建在活动对象里,并且当我们解释到var foo时,我们看见foo属性名已经存在,所以代码什么都不做并继续执行。
3、为什么bar的值是undefined?
bar采用的是函数表达式的方式来定义的,所以bar实际上是一个变量,但变量的值是函数,并且我们知道变量在创建阶段被创建但他们被初始化为undefined,这也是为什么函数表达式不会被提升的原因。
7.总结
1、EC分为两个阶段,创建执行上下文和执行代码。
2、每个EC可以抽象为一个对象,这个对象具有三个属性,分别为:作用域链Scope,VO|AO(AO,VO只能有一个)以及this。
3、函数EC中的AO在进入函数EC时,确定了Arguments对象的属性;在执行函数EC时,其它变量属性具体化。
4、函数EC中的Scope在进入函数EC时创建,用来有序访问该EC对象AO中的变量和函数。
5、函数的[[scope]]属性在函数创建时就已经确定,并保持不变。
6、EC创建的过程是由先后顺序的:参数声明 > 函数声明 > 变量声明
参考文章:
https://segmentfault.com/a/1190000009035308
https://juejin.im/entry/599e949251882524472239c4
https://juejin.im/entry/599e949251882524472239c4
https://juejin.im/post/5ac301d151882510fd3fcf3a
https://www.jianshu.com/p/a6d37c77e8db
https://www.jianshu.com/p/21a16d44f150
https://segmentfault.com/a/1190000009522006#articleHeader3
http://www.cnblogs.com/wangfupeng1988/tag/javascript/default.html?page=2
https://zhuanlan.zhihu.com/p/48590085
https://segmentfault.com/a/1190000009041008
https://segmentfault.com/a/1190000009035308
https://segmentfault.com/a/1190000000533094#articleHeader8
https://www.jianshu.com/p/3114a3e0a818
https://www.jianshu.com/p/05641baa5134
https://www.jianshu.com/p/765a6de0b649