前言
我们平时日常开发只是单纯地使用 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代码的?》- 老蒋