JavaScript 编译执行
作者:Seiya
时间:2019年06月03日
编译原理基础
尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。
传统编译语言的编译流程
- 分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为 词法单元。
分词与词法分析
分词(tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的, 主要差异在于 词法单元 的识别是通过 有状态
还是 无状态
的方式进行的。
注意:
空格是否会被当作词法单元,取决于空格在 这门语言中是否具有意义。
- 解析/语法分析(Parsing)
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
- 代码生成
将 AST 转换为可执行代码的过程称被称为代码生成。
注意
这个过程与语言、目标平台等息息相关。
JavaScript 代码运行的三个阶段
浏览器首先按顺序加载由 <script>
标签分割的 JavaScript 代码块,加载代码块完毕后,立刻进入以下三个阶段。然后再按顺序查找下一个代码块,再继续执行以下三个阶段,无论是外部脚本文件(不异步加载)还是内部脚本代码块,都是一样的原理,并且都在同一个全局作用域中。
词法阶段
编译原理部分介绍了,大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)。词法化的过程会对源代码中的字符进行检查,如果出现不正确,则向外抛出一个语法错误,如果语法正确,则进入预编译阶段。如果是有状态的解析过程,还会赋予单词语义。
预编译阶段
之前的文章有介绍,V8 引擎有一个很重要的特点,就是“延迟”思想,这使得很多 JavaScript 代码的编译直到被调用的时候才会发生。此阶段会创建变量对象、确定作用域链以及 this 指向。更详细的预编译细节,后续的文章会继续进行讨论,这篇文章仅仅梳理大概的流程。
执行阶段
一般我们使用 JavaScript 引擎的方式:拿到一段 JavaScript 代码,将它传递给 JavaScript 引擎,并要求它执行。然而执行 JavaScript 代码并非一锤子买卖,宿主环境中遇到一些事件时,会继续把一段代码传递给 JavaScript 引擎去执行。
这样我们就形成了对 JavaScript 引擎的感性认知:一个 JavaScript 引擎会常驻与内存中,它等待我们(宿主)把 JavaScript 代码传递给它执行。
实际上,JavaScript 引擎的处理方式远比这个认知更为复杂,前面关于引擎的相关文章也有所介绍,JavaScript 代码传递的过程并不是发生在宿主和引擎之间,而是发生在引擎内部,JavaScript 引擎会在需要的时候编译并执行一段代码。
JavaScript 编译执行
函数声明提升:
无论函数调用和声明的位置是前是后,系统总会把函数声明移到调用前面;
变量声明提升:
无论变量调用和声明的位置是前是后,系统总会把声明移到调用前,注意仅仅只是声明,所以值是 undefined;
我们通过一个示例,来观察 JavaScript 代码的编译执行过程:
<script>
var a = 1;
console.log(a);
function test(a) {
console.log(a);
var a = 123;
console.log(a);
function a() {};
console.log(a);
var b = function() {};
console.log(b);
function d() {};
}
var c = function() {
console.log("I at C function");
}
console.log(c);
test(2);
</script>
第一次编译阶段过程分析:
页面产生便创建了GO全局对象(Global Object)(也就是window对象);
第一个脚本文件加载(也就是上面的 JavaScript 代码);
脚本加载完毕后,分析语法是否合法(词法阶段);
开始预编译,查找变量声明,作为全局对象属性,值赋予undefined;查找函数声明,作为全局对象属性,值赋予函数体;
此阶段过程可以抽象为:
GlobalObject/window = { a: undefined, c: undefined, test: function(a) { console.log(a); var a = 123; console.log(a); function a() {}; console.log(a); var b = function() {}; console.log(b); function d() {} } }
简单描述就是创建全局对象(全局作用域),加载 JavaScript 代码。词法分析通过后,开始查找变量声明作为全局对象属性,也就是示例中的变量
a
和c
,并赋值undefined
。最后查找函数声明作为全局对象属性,也就是test()
,并赋予函数体。
第一次执行阶段过程分析:
从全局对象属性中查找变量
a
,并为其赋值为1
;执行 console.log 语句,从全局对象属性中查找变量
a
,并取出它的值进行打印;从全局对象属性中查找变量
c
,并为其赋值;执行 console.log 语句,从全局对象属性中查找变量
c
,并取出它的值进行打印;从全局对象属性中查找函数
test()
,并开始预编译;
此阶段过程可以抽象为:
GlobalObject/window = { a: 1, c: function (){ console.log("I at C function"); } test: function(a) { console.log(a); var a = 123; console.log(a); function a() {}; console.log(a); var b = function() {}; console.log(b); function d() {} } }
此阶段执行结果如下:
// console.log(a) 1 // console.log(c) ƒ (){ console.log("I at C function"); }
第二次编译阶段过程分析:
创建AO活动对象(Active Object);
查找函数形参及函数内变量声明,形参名及变量名作为AO对象的属性,值为 undefined;
实参、形参相统一,实参值赋给形参;
查找函数声明,函数名作为AO对象的属性,值为函数引用;
此阶段过程可以抽象为:
// 首先,查找形参和变量声明,值赋予undefined(注意变量a既是全局变量,也是形参和函数体内部的变量) AO = { a:undefined, b:undefined } // 其次,将实参值赋值给形参 AO = { a:2, b:undefined } // 之后,查找函数声明,值赋予函数体,所以变量 a 的值被覆盖 AO = { a:function a() {}, b:undefined, d:function d() {} }
第二次执行阶段过程分析:
执行 console.log 语句,从活动对象属性中查找变量
a
,并取出它的值进行打印;从活动对象属性中查找变量
a
,并为其赋值为123
;执行 console.log 语句,从活动对象属性中查找变量
a
,并取出它的值进行打印;执行 console.log 语句,从活动对象属性中查找变量
a
,并取出它的值进行打印;从活动对象属性中查找变量
b
,并为其赋值(赋值函数体);执行 console.log 语句,从活动对象属性中查找变量
b
,并取出它的值进行打印;
此阶段执行结果如下:
// console.log(a) ƒ a() {} // console.log(a) 123 // console.log(a) 123 // console.log(b) ƒ () {}
注意:
预编译阶段发生变量声明和函数声明,没有初始化行为(匿名函数不参与预编译); 只有在解释执行阶段才会进行变量初始化 ;