共计 6803 个字符,预计需要花费 18 分钟才能阅读完成。
前言
本文内容主要涵盖了执行上下文栈、执行上下文、变量对象、函数变量提升等内容。
众所周知,JavaScript
是单线程编程语言,同一时间只能做一件事情,程序执行顺序由上而下,程序的执行主要依托 JavaScript
引擎,JavaScript
引擎也并非一行一行的分析执行代码,而是一段一段的分析执行。
可执行代码
JavaScript
引擎执行的代码当然是可执行代码,在 JavaScript
中可执行代码有三类:全局代码、函数代码以及 Eval
代码。
javaScript 运行原理
JavaScript
程序的执行主要分语法检查和运行两个阶段,语法检查包括词法分析和语法分析,目的是将 JavaScript
高级语言程序转成抽象语法树。
语法检查完成后,到了执行阶段,执行阶段包括预解析和执行,预解析首先会创建执行上下文(本文重点),将语法检查正确后生成的抽象语法树复制到当前执行上下文中,然后做属性填充,对语法树当中的变量名、函数声明以及函数的形参进行属性填充。最后就是执行。
JavaScript
运行原理会在后面的文章输出,不是本文的重点,本文只需知道程序运行的大致是怎样的过程,执行上下文何时创建。
执行上下文栈
何为执行上下文栈???
在 JavaScript
解释器运行阶段(预解析)还维护了一个栈,用于管理执行上下文。在执行上下文栈中,全局执行上下文处于栈底,顶部为当前的执行上下文。当顶部的执行完成,就会弹出栈,类似于数据结构中的栈,每当有当前的执行上下文执行完就会从栈顶弹出,这种管理执行上下文的栈叫做执行上下文栈。
一个执行上下文可以激活另一个执行上下文,类似于函数调用另一个函数,可以一层一层的调用下去。
激活其它执行上下文的某执行上下文被称为调用者(caller
),被激活的执行上下文被称为被调用者(callee
)。一个执行上下文即可能是调用者也有可能是被调用者。
当一个 caller
激活了一个 callee
时,caller
会暂停自身的执行,将控制权交给 callee
,此时该callee
被放进执行上下文栈,称为进行中的上下文,当这个 callee
上下文结束后,把控制权交还给它的 caller
,caller
会在刚才暂停的地方继续执行。在这个 caller
结束后,会继续触发其他的上下文。
执行上下文栈在 JavaScript
中可以数组模拟:
ECStack = [];
当浏览器首次载入脚本,会默认先进入到全局执行上下文,位于执行上下文栈的最底部,此时全局代码会开始初始化,初始化生成相应的对象和函数,在全局上下文执行的过程中可能会激活一些其他的方法(如果有的话),然后进入它们的执行上下文,并将元素压入执行上下文栈中。可以把所有的程序执行看作一个执行上下文栈,栈的顶部是正在激活的上下文。如下表所示:
EC stack | |
---|---|
Active EC | |
… | |
EC | |
Global EC |
在程序结束之前,ECStack
最底部永远是globalContext
:
ECStack = [globalContext];
看看下面实例一,是一个怎么的过程:
// 实例一
function bar() {console.log('bar');
}
function foo() {bar();
}
foo();
当执行一个函数时,会创建一个执行上下文并压入执行上下文栈中,当函数执行完毕,就将该执行上下文弹出执行上下文栈。
// 创建执行上下文栈
ECStack = [];
// foo() 创建该函数执行上下文并压入栈中
ECStack.push(<foo> functionContext);
// foo()中调用了 bar(),创建 bar()执行上下文并压入栈中
ECStack.push(<bar> functionContext);
// bar()执行完毕弹出
ECStack.pop();
// foo()执行完毕弹出
ECStack.pop();
执行上下文(Execution Context)
执行上下文在程序运行的预解析阶段创建,预解析也就是代码的真正的执行前,可以说是代码执行前的准备工作,即创建执行上下文。
执行上下文有何用,主要做了三件事:
- this 绑定;
- 创建变量对象;
- 创建作用域链。
this
、作用域
和作用域链
也是 JavaScript
中很重要的知识点,后面的文章会详细的输出。
何为执行上下文?
执行上下文理解为是执行环境的抽象概念,当 JavaScript
代码执行一段可执行代码时,都会创建对应的执行上下文,一个执行上下文可以抽象的理解为object
,都包括三个重要属性:
executionContext: {
variable object:vars, functions, arguments
scope chain: variable object + all parents scopes
thisValue: context object
}
全局代码
全局代码不包含函数内代码,在初始化阶段,执行上下文栈底部有一个全局执行上下文:
ECStack = [globalContext];
函数代码
当进入函数代码时,函数执行,创建该函数执行上下文并压入栈中。需要注意的是函数代码不包含内部函数代码。
ECStack = [
<xxx> functionContext
...
<xxx> functionContext
globalContext
];
Eval 代码
eval(...)
有些陌生,平时也很少用到,eval(...)
函数可以接受一个字符串为参数,并将其中的内容视为好像在书写就存在于程序中这个位置的代码。
换句话说,可以在你写的代码中用程序生成代码并运行,就好像是写在那个位置的一样。
eval('var x = 10');
(function foo() {eval('var y = 20');
})();
console.log(x); // 10
console.log(y); // 'y is not defined'
上面实例执行过程:
ECStack = [globalContext];
// eval('var x = 10')进栈
ECStack.push(
evalContext,
callingContext: globalContext
);
// eval 出栈
ECStack.pop();
// foo funciton 进栈
ECStack.push(<foo> functionContext);
// eval('var y = 20') 进栈
ECStack.push(
evalContext,
callingContext: <foo> functionContext
);
// eval 出栈
ECStack.pop();
// foo 出栈
ECStack.pop();
变量对象
变量对象(variable object
)是与执行上下文相关的数据作用域(scope of data
),是与上下文相关的特殊对象,用与存储被定义在上下文中的变量(variables
)和函数声明(function declarations
)。变量对象是一个抽象的概念,不同的上下文,它表示使用不同的对象。
全局变量对象
全局变量对象是全局上下文的变量对象。全局变量对象就是全局对象,为啥这么说:
全局对象(
Global object
) 是在进入任何执行上下文之前就已经创建了的对象;这个对象只存在一份,它的属性在程序中任何地方都可以访问,全局对象的生命周期终止于程序退出那一刻。
- 全局对象初始创建阶段将
Math
、String
、Date
、parseInt
作为自身属性,等属性初始化,同样也可以有额外创建的其它对象作为属性(其可以指向到全局对象自身)- 在
DOM
中,全局对象的window
属性就可以引用全局对象自身- 可以通过全局上下文的
this
来访问全局对象,同样也可以递归引用自身- 当访问全局对象的属性时通常会忽略掉前缀,全局对象是不能通过名称直接访问的
global = {
Math: <...>,
String: <...>,
Date: <...>,
...
window: global
}
console.log(Math.random()); // 当访问全局对象的属性时通常会忽略掉前缀;初始创建阶段将 Math 等作为自身属性
console.log(this.Math.random()); // 通过 this 来访问全局对象
console.log(this) // window 通过全局上下文的 this 来访问全局对象
var a = 1;
console.log(this.a); // 1
console.log((window.a); // 1 全局对象有 window 属性指向自身
console.log(a); // 1 当访问全局对象的属性时通常会忽略掉前缀
this.window.b = 2;
console.log(this.b); // 2
上面的全局对象的定义和变量对像的定义对比,能知道全局变量对象就是全局对象,简单的说,因为变量对象用于存储被定义在上下文中的变量和函数声明,全局对象在进入任何执行上下文前就已经创建了,同样存储着在全局范围内定义的变量和函数声明。
需要注意的是全局上下文的变量对象允许通过 VO
属性名称来间接访问,原因就是全局变量对象就是全局对象,在其他上下文中是不能直接 VO
对象。
全局变量对象 VO
会有下列属性:
- 函数声明(
FunctionDeclaration
,FD
) - 所有的变量声明(
var
,VariableDeclaration
) - 不存在所谓的函数形参
函数上下文变量对象(Variable object
)
当进入执行上下文时,VO
包含来下列属性:
- 函数形参,属性名就是参数名,其值是实参值,若没有传递的参数,其值为
undefined
; - 函数声明(
FunctionDeclaration
,FD
),由名称和对应值组成一个变量对象的属性被创建;如果变量对象已经存在相同属性名称,则完全替换这个属性。 - 所有的变量声明(
var
,VariableDeclaration
),由名称和对应值(undefined
)组成一个变量对象的属性被创建;如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。
function foo(a) {
var b = 2;
var c = function() {};
function bar() {console.log('bar');
}
}
foo(10);
当进入带有参数 10 的 foo
函数执行上下文时,VO
:
VO = {
a: 10,
bar: <reference to FunctionDeclaration 'bar'>,
b: undefined,
c: undefined
}
在函数声明过程中,如果变量对象已经存在相同的属性名称,则完全替换这个属性:
function foo(a) {console.log(a);
function a() {}
}
foo(10) // function a(){}
在变量声明过程中,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性
// 与参数名同名
function foo(a) {console.log(a);
var a = 20;
}
foo(10) // 10
// 与函数名同名
function bar(){console.log(a)
var a = 10
function a(){}
}
bar() // 'function a(){}'
VO
创建过程中,函数形参的优先级是高于函数的声明的,结果是函数体内部声明的 function a(){}
覆盖了函数形参 a
的声明,因此最后输出 a
是一个function
。
从上面的实例说明,函数声明比变量声明的优先级高,在定义的过程中不会被变量覆盖,除非是赋值:
function foo(a){
var a = 10
function a(){}
console.log(a)
}
foo(20) // 10
function bar(a){
var a
function a(){}
console.log(a)
}
bar(20) // 'function a(){}'
活动对象(Activation object
)
活动对象想必大家对这个概念都不陌生,但是容易和变量对象混淆。
活动对象就是函数上下文中的变量对象,只是不同阶段的不同叫法,在创建函数执行上下文阶段,变量对象被创建,变量对象的属性不能被访问,此时的函数还没有执行,当函数来到执行阶段,变量对象被激活,变成了活动对象,并且里面的属性都能访问到,开始进行执行阶段的操作。
// 执行阶段
VO -> AO
function foo(a) {
var b = 2;
var c = function() {};
function bar() {console.log('bar');
}
}
foo(10);
VO = {
arguments: {
0: 10,
length: 1
},
a: 10,
bar: <reference to FunctionDeclaration 'bar'>,
b: undefined,
c: undefined
}
AO = {
arguments: {
0: 10,
length: 1
},
a: 10,
bar: <reference to FunctionDeclaration 'bar'>,
b: 2,
c: reference to FunctionExpression "c"
}
调用函数时,会为其创建一个 Arguments
对象,并自动初始化局部变量 arguments
,指代该Arguments
对象。所有作为参数传入的值都会成为 Arguments 对象的数组元素。
简洁的总结下上面的内容:
- 全局上下文的变量对象是全局对象;
- 函数上下文的变量对象初始化只包含
Arguments
对象; - 在进入执行上下文时会给变量对象添加形参、函数声明及变量声明等属性;
- 在代码执行,可以通过赋值修改变量对象的属性。
提升
提升一个很常见的话题,是面试中经常被问到的一部分,函数声明优先级比变量声明高,这句话应该是大部分同学都会回答,为啥,上面的内容已经很好的做出了解释。看下面实例:
function test() {console.log(foo); // function foo(){}
console.log(bar); // undefined
var foo = 'Hello';
console.log(foo); // Hello
var bar = function () {return 'world';}
function foo() {return 'hello';}
}
test();
// 创建阶段
VO = {
arguments: {length: 0},
foo: <reference to FunctionDeclaration 'foo'>, // 解释了第一个输出是 foo 引用,函数声明优先变量被创建,同名属性不会被干扰,在函数还没有被调用前已经被创建了,即能输出 foo 的引用
bar: undefined // 解释了第二个输出是 undefined,函数表达式还是只是一个变量声明,不是函数声明,不会被提升
}
// 执行阶段
VO -> OV
OV = {
arguments: {length: 0},
foo: 'Hello', // 这里解释了为什么第三个输出值为‘Hello’,做了赋值操作
bar: reference to FunctionExpression "bar"
}
// 实例真实的执行顺序
function test() {function foo() {return 'hello';}
}
var foo;
var bar;
console.log(foo);
console.log(bar);
foo = 'Hello';
console.log(foo);
bar = function () {return 'world';}
}
需要注意的是变量提升只存在使用 var
关键字声明变量,如果是使用 let
声明变量不存在变量提升。
声明变量的作用域限制在其声明位置的上下文中,在上下文被创建的阶段时创建了,如果没有声明的变量总是全局的,并且是在执行阶段将赋值给未声明的变量的值被隐式创建为全局变量,可以通过 delete
操作符删除,声明变量不可以。
function foo() {console.log(a); // Uncaught ReferenceError: a is not defined;a 不存在 VO 中
a = 1;
console.log(a);
}
foo();
function bar() {
a = 1;
console.log(a); // 1 可以在全局变量中找到 a 的值
}
bar();
c = 10;
console.log(delete c); // true
var d = 10;
console.log(delete d); // false
如果清楚上下文相关的内容,提升的问题很好的能解答,在学习中我们还是需要了解一些底层的知识,这样有助我们更好的进步。
结语
文章如有不正确的地方欢迎各位大佬指正,也希望有幸看到文章的同学也有收获,一起成长!
——本文首发于个人公众号———
最后,欢迎大家关注我的公众号,一起学习交流。