JavaScript 脚本编译与执行过程简述
由于上一篇文章对作用域、执行上下文等写得过于详细,而且参杂了很多内容,看起来并不好理解,因此就简化一下。
在网页里,JavaScript 脚本是插入在 <script>
标签内的(内联或外部引入)。
<script>
// some JavaScript code...
</script>
<script>
// some JavaScript code...
</script>
<script src="xxx"></script>
一般情况下,脚本是按顺序一块一块地执行的(一个 <script>
标签为一块)。只有执行完一块,才会执行下一块的。如果当前块有语法错误或其他错误导致当前块终止执行,不会影响下一块的执行。
对于外部引入的 JavaScript 脚本,若设置
<script>
标签的defer
、async
属性会影响脚本的加载顺序,不会按着编写顺序执行。而这些属性对于内联脚本是没有作用的。
要让 JavaScript 代码运行起来,包括编译阶段和执行阶段。每一块脚本的运行都包含这两个过程。请注意,不是将“所有块”编译完再执行的。
编译阶段:
词法分析:将源码分解成很多个有意义的 token
语法分析:通篇检查当前块的语法是否有误,若无,将 tokens 转换为 AST
代码生成:将 AST 转换为 JS 引擎可执行代码
执行阶段:
创建执行上下文
初始化执行上下文
代码执行
JS 引擎的优化工作是在编译阶段完成的,例如 V8 引擎。若编译阶段检查出语法有误,将会终止当前块的后续阶段。
待编译阶段完成后,接着进入执行阶段。而执行阶段可以分为两个步骤:
- 进入执行上下文
- 代码执行
进入执行上下文步骤,可以理解为,真正一行一行执行代码的前期准备工作:
JS 引擎创建一个叫做:执行上下文栈(Execution Context Stack,ECS)的东西。顾名思义,就是用了维护执行上下文的。
最先进入的肯定是全局代码,这个执行上下文被称为:全局上下文。首先插入 ECS,因此 ECS 栈底永远是全局上下文。后面调用函数时,会进入函数上下文,该上下文也会插入 ECS。
每进入一个执行上下文,都会做一些初始化工作。
待准备工作完成后,就会进入代码执行步骤,即一行一行地去执行代码。
大家可能疑惑的地方,应该是“前期准备工作”的具体过程是怎样的?
执行上下文栈
进入全局代码,ECS 插入 GlobalContext,后面每调用一个函数就往 ECS 栈顶插入 FunctionalContext,函数执行完 FunctionalContext 又会从栈顶删除......周而复始的过程。ESC 栈底永远保留着 GlobalContext。直到页面销毁,它才会被删除,ESC 的使命也就结束了,被 JS 引擎清空。
ECS = [
FunctionalContext // 栈顶
...
FunctionalContext
GlobalContext // 栈底
]
前面提到上下文:GlobalContext(全局上下文)、FunctionalContext(函数上下文)。
执行上下文
其实 JavaScript 中有三种执行上下文:
全局上下文:
全局下都是属于全局上下文。
函数上下文:
顾名思义,就是 JavaScript 函数内的执行上下文
eval 上下文:
就是 eval('some code...'),尽可能不要用,会影响性能、欺骗词法作用域
每个执行上下文,可以看作是一个 JavaScript 对象,它有三个重要属性:
Variable Object:
即变量对象,简称 VO。我们声明的变量、函数就是放在 VO 中的。
Scope Chain:
即作用域链,简称 Scope。它包含了当前上下文 VO,以及各父级的 VO。查找变量就是根据这链条去查询的。
This:
即 this 对象,这是一个很灵活的对象,跟函数调用相关。
}
执行上下文的初始化工作,就是确定这些属性的一些值。这个“初始化”的过程也常被称为“预编译”,网上很多文章都有这个说法。
举例
上面的执行阶段的过程,举例会更好地说明。
var a = 'global'
function foo() {
var a = 'local'
function bar() {
console.log(a)
}
bar()
}
foo() // "local"
当编译过程之后,进入执行阶段。JS 创建一个执行上下文栈 ECStack,总是先进入全局上下文,全局上下文被插入到执行上下文栈:
ECStack = [ GlobalContext ]
接着对 GlobalContext 进行初始化:
GlobalContext = {
VO: window,
Scope: [ GlobalContext.VO ],
this: GlobalContext.VO
}
// 注意,不同宿主环境顶层对象是不一样的。
// 浏览器下为 window 对象,Node 中为 global 对象。
// 本文以浏览器为例,因此直接写了 window 对象。
初始化的同时 foo
函数也被创建,会保存当前上下文的作用域链到函数内部的 [[scope]]
属性中。该属性可以通过 console.dir(foo)
查看。
foo.[[scope]] = [ GlobalContext.VO ]
当全局上下文初始化完成之后,开始执行全局代码,当进行到 foo()
时,JS 引擎又会创建 foo
函数执行上下文,并插入 ECStack 栈顶。
ECStack = [ GlobalContext, FunctionalContext<foo> ]
跟着,会进入 foo
函数上下文,该上下文也会进行初始化工作:
- 复制
foo
函数[[scope]]
属性创建作用域链。- 用
arguments
创建活动对象 AO。- 初始化活动对象,即 AO 按顺序加入:形参、函数声明、变量声明。若存在同名情况,函数声明会覆盖形参,变量声明会被忽略。
- 将 AO 对象加入
foo
作用域链顶端。
FunctionalContext<foo> = {
AO: {
arguments: {
length: 0
},
a: undefined,
bar: ƒ bar()
},
Scope: [ GlobalContext.VO, AO ],
this: undefined // this 是在函数执行的时候才确定下来的,foo 函数的 this 的值跟作用域链没有关系
}
初始化完成,开始执行 foo
函数体代码,AO 对象也会更新。当执行到 bar()
函数时,又将会进入 bar
函数上下文,并进行初始化工作:
ECStack = [ GlobalContext, FunctionalContext<foo>, FunctionalContext<bar> ]
FunctionalContext<bar> = {
AO: {
arguments: {
length: 0
}
},
Scope: [ GlobalContext.VO, FunctionalContext<foo>.VO, AO ],
this: undefined
}
初始化完成,开始执行 bar
函数代码,在 console.log(a)
中,会查找 a
变量。
按作用域链顺序查找,先从当前上下文的 AO 中查找,若查找不到再往上一层 AO 中查找...直到全局上下文的 VO 中查找,若再查找不到就会抛出引用错误(ReferenceError)。
AO -> FunctionContext<foo>.AO -> GlobalContext.VO
很明显,a
变量将会在 FunctionContext<foo>.AO
中查找到,其值为 "local"
,因此打印结果就是 "local"
。
待 bar()
执行完毕,bar
函数上下文会被从栈顶删除。
ECStack.pop(FunctionalContext<bar>)
又将执行权交还给 foo
函数上下文,它也将会执行完毕,也会从栈顶移除。
ECStack.pop(FunctionalContext<foo>)
然后又交还给全局上下文
ECStack = [ GlobalContext ]
至此,这个程序执行完毕。当页面销毁时,ECStack 才会被清空。