前言
我们平时日常开发只是单纯地使用 JavaScript 和调用 Web API,对 V8 的理解还停留在表面,并不了解 V8 这个“黑盒”内部是如何工作的。
只有搞清楚这个问题,才能写出性能更好、更优雅的 JavaScript 代码。同时,了解 JavaScript 的执行原理,也能让我们能好理解 Babel 的词法分析和语法分析原理、ESLint 的语法检查机制、React.js 和 Vue 前端框架的底层实现,以后再面对新的技术和框架,也能以不变应万变。
什么是V8?
V8 是 Google 开源的 JavaScript 引擎,被广泛应用于各种 JavaScript 执行环境,比如 Chrome 浏览器、Node.js、Electron 以及 Deno 等。
V8怎么执行JS代码的?
当 V8 执行 JavaScript 源码时,首先解析器会把源码解析为抽象语法树(Abstract Syntax Tree),解释器(Ignotion)再将 AST 翻译为字节码,一边解释一边执行。
在此过程中,解释器会记特定代码片段的运行次数,如果代码运行次数超过某个阈值,那么该段代码就被标记为热代码(hot code),并将运行信息反馈给优化编译器(TurboFan)。
优化编译器根据反馈信息,优化并编译字节码,最终生成优化后的机器码,这样当该段代码再次执行时,解释器就直接使用优化机器码执行,不用再次解释,大大提高了代码运行效率。
这种在运行时编译代码的技术也被称为 JIT(即时编译),通过 JIT 可以极大提升 JavaScript 代码的执行性能。
本文我们先来介绍 V8 的解析器。
解析器(Parser)如何把源码转换成 AST?
要让 V8 执行我们编写的源码,就要将源码转换成 V8 能理解的格式。V8 会先把源码解析为一个抽象语法树(AST),这是用来表示源码的树形结构的对象,这个过程称为解析(Parsing),主要由 V8 的 Parser 模块实现。然后, V8 的解释器会把 AST 编译为字节码,一边解释一边执行。
解析和编译过程的性能非常重要,因为 V8 只有等编译完成后才能运行代码(现在我们先关注 V8 中解析过程的实现)。
整个解析过程可分为两部分。
-
词法分析:将字符流转换为
tokens,字符流就是我们编写的一行行代码,token是指语法上不能再分割的最小单位,可能是单个字符,也可能是字符串,图中的Scanner就是V8的词法分析器。 -
语法分析:根据语法规则,将
tokens组成一个有嵌套层级的抽象语法结构树,这个树就是AST,在此过程中,如果源码不符合语法规范,解析过程就会终止,并抛出语法错误。图中的Parser和Pre-Parser都是V8的语法分析器。
词法分析
在 V8 中,Scanner 负责接收 Unicode 字符流,并将其解析为 tokens,提供给解析器使用。比如 var a = 1; 这行代码,经过词法分析后的 tokens 就是下面这样:
[
{
"type": "Keyword",
"value": "var"
},
{
"type": "Identifier",
"value": "a"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "Numeric",
"value": "1"
},
{
"type": "Punctuator",
"value": ";"
}
]
可以看到, var a = 1; 这样一行代码包括 5 个 tokens:
- 关键字
var - 标识符
name - 赋值运算符
= - 分割符
;
语法分析
接下来, V8 的解析器会通过语法分析,根据 tokens 生成 AST, var a = 1; 这行代码生成的 AST 的 JSON 结构如下所示:
{
"type": "Program",
"start": 0,
"end": 10,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 10,
"declarations": [
{
"type": "VariableDeclarator",
"start": 4,
"end": 9,
"id": {
"type": "Identifier",
"start": 4,
"end": 5,
"name": "a"
},
"init": {
"type": "Literal",
"start": 8,
"end": 9,
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
}
],
"sourceType": "module"
}
你可以在astexplorer.net/中观察源码通过 Parser 转换后的 AST 的结构。
但是,对于一份 JavaScript 源码,如果所有源码在执行前都要完全经过解析才能执行,那必然会面临以下问题。
- 代码执行时间变长:一次性解析所有代码,必然会增加代码的运行时间。
- 消耗更多内存:解析完的
AST,以及根据AST编译后的字节码都会存放在内存中,必然会占用更多内存空间。 - 占用磁盘空间:编译后的代码会缓存在磁盘上,占用磁盘空间。
所以,现在主流 JavaScript 引擎都实现了延迟解析(Lazy Parsing)。
延迟解析
延迟解析的思想很简单:在解析过程中,对于不是立即执行的函数,只进行预解析(Pre Parser),只有当函数调用时,才对函数进行全量解析。
进行预解析时,只验证函数语法是否有效、解析函数声明、确定函数作用域,不生成 AST,而实现预解析的,就是 Pre-Parser 解析器。
function foo(a, b) {
var res = a + b;
return res;
}
var a = 1;
var c = 2;
foo(1, 2);
由于 Scanner 是按字节流从上往下一行行读取代码的,所以 V8 解析器也是从上往下解析代码。当 V8 解析器遇到函数声明 foo 时,发现它不是立即执行,所以会用 Pre-Parser 解析器对其预解析,过程中只会解析函数声明,不会解析函数内部代码,不会为函数内部代码生成 AST。
然后 Ignition 解释器会把 AST 编译为字节码并执行,解释器会按照自上而下的顺序执行代码,先执行 var a = 1; 和 var a = 2; 两个赋值表达式,然后执行函数调用 foo(1, 2) ,这时 Parser 解析器才会继续解析函数内的代码、生成 AST,再交给 Ignition 解释器编译执行。
结语
以上就是关于 V8 在运行 JavaScript 时,解析器的相关知识,如有不对欢迎各位大佬指正。
参考:《V8是如何执行JavaScript代码的?》- 老蒋