作为前端开发,JavaScript 是我们的核心武器。但你是否好奇,我们编写的 JavaScript 代码在浏览器中究竟是如何被执行的呢?这背后的关键角色就是 V8 引擎。深入理解 V8 引擎执行 JavaScript 代码的过程,不仅能让我们写出更高效的代码,还能帮助我们排查疑难杂症。今天,就让我们一起深入 V8 引擎的内部,揭开 JavaScript 代码执行的神秘面纱。
我们编写的 JavaScript 代码,对于计算机的 CPU 来说,就像是一门外语,CPU 只能理解由 0 和 1 组成的机器码。为了让 JavaScript 代码能够在机器上运行,就需要一个 “翻译官” 来把 JavaScript 代码转换成机器码。
编程语言大致可以分为编译型和解释型。编译型语言(如 C、C++)在程序执行之前,会经过编译器的编译过程,将代码一次性翻译成机器能读懂的二进制文件,之后每次运行程序,直接执行这个二进制文件就行。而解释型语言(如 JavaScript),在每次运行时,都需要通过解释器对程序进行动态解释和执行。
生成 AST
词法分析
在 V8 引擎拿到 JavaScript 代码后,首先会进行词法分析。这一步就像是把一篇文章拆分成一个个单词,词法分析器会将一行行的代码分解成一个个最小的、不可再分的单元,我们称之为 token。
let name = 'zs'
console.log(name)
比如,对于代码let name = 'zhangsan';,词法分析器会将其分解为let(关键字类型的 token)、name(标识符类型的 token)、=(运算符类型的 token)、'zhangsan'(字符串字面量类型的 token)。这些 token 是代码的最小语义单元,它们携带了代码的基本信息。
flowchart LR
A["let name = 'zhangsan';"] --> B[词法分析器]
B --> C["Token 1: let\nType: 关键字"]
B --> D["Token 2: name\nType: 标识符"]
B --> E["Token 3: =\nType: 运算符"]
B --> F["Token 4: 'zs'\nType: 字符串"]
语法分析
有了 token 之后,接下来就是语法分析阶段。语法分析器会将这些 token 按照 JavaScript 的语法规则,构建成一棵抽象语法树(AST)。AST 是代码的一种结构化表示,它以树状结构展示了代码的语法结构,每个节点都代表代码中的一个语法结构,比如变量声明、函数调用、表达式等。
还是以let name = 'zhangsan'; console.log(name);这段代码为例,生成的 AST 大致是这样的:有一个根节点,根节点下有两个子节点,一个代表变量声明let name = 'zhangsan',另一个代表函数调用console.log(name)。变量声明节点又包含了变量名name和初始值'zhangsan'等信息;函数调用节点包含了函数名console.log和参数name等信息。
flowchart TD
A["Tokens: let, name, =, 'zhangsan'"] --> B[语法分析器]
B --> C["AST节点: VariableDeclaration"]
C --> D["kind: let"]
C --> E["declarations: VariableDeclarator"]
E --> F["id: Identifier\nname: name"]
E --> G["init: Literal\nvalue: 'zs'"]
生成AST树
AST 对于编译器和解释器来说至关重要,因为后续的代码优化、生成字节码等操作,都依赖于这棵抽象语法树。这里不得不提一下 Babel,它的工作原理就是先将 ES6 的代码解析生成 ES6 的 AST,然后对这个 AST 进行一系列转换操作,将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后再根据 ES5 的 AST 生成 ES5 代码,从而实现让现代 JavaScript 代码在老版本浏览器中也能运行的目的。
graph TD
A[Program] --> B[VariableDeclaration]
A --> C[ExpressionStatement]
B --> D["kind: let"]
B --> E[VariableDeclarator]
E --> F[Identifier: name]
E --> G[Literal: 'zs']
C --> H[CallExpression: console.log]
H --> I[MemberExpression]
I --> J[Identifier: console]
I --> K[Identifier: log]
H --> L[Arguments]
L --> M[Identifier: name]
如果觉得上面的图不直观,就看下面的结构图,更容易理解
Program
├─ VariableDeclaration (let)
│ ├─ VariableDeclarator
│ │ ├─ Identifier (name)
│ │ └─ Literal ('zs')
└─ ExpressionStatement
└─ CallExpression (console.log)
├─ MemberExpression
│ ├─ Identifier (console)
│ └─ Identifier (log)
└─ Identifier (name)
生成字节码
生成 AST 之后,V8 引擎的解释器(Ignition)就登场了,它会根据 AST 生成字节码。字节码是介于 AST 和机器码之间的一种代码。它与特定类型的机器码无关,需要通过解释器进一步转换为机器码才能被机器执行。
那为什么 V8 引擎不直接将 AST 转换为机器码,而是要多此一举生成字节码呢?这背后是有原因的。在 V8 的早期版本中,确实是直接将 AST 转换为机器码的。但是机器码有一个很大的问题,就是体积太大。一个很小的 JavaScript 文件,转换为机器码后,体积可能会膨胀几百甚至几千倍。这对于内存资源有限的设备来说,是一个巨大的负担,会导致严重的内存占用问题。
而字节码则要轻量得多。举个例子,如果把机器码比作一本厚厚的精装书,字节码就像是一本精简的平装书,虽然最终都能传达相同的信息,但平装书更轻便,占用空间更小。生成字节码虽然增加了一步转换操作,但它大大降低了内存的压力,并且在后续执行过程中,通过特定的优化策略,也能保证一定的执行效率。
执行代码
字节码生成之后,就进入到执行阶段了。解释器 Ignition 会逐条解释执行字节码。在这个过程中,V8 引擎会密切关注代码的执行情况。如果它发现某一部分代码被重复执行多次,就会将这部分代码标记为热点代码。
一旦发现热点代码,V8 引擎的编译器(TurboFan)就会闪亮登场。编译器会把这部分热点字节码编译为高效的机器码,并将其保存下来。这样,下次再执行到这部分代码时,就可以直接执行编译后的机器码,而不需要再次经过字节码到机器码的转换过程,从而大大提升了代码的执行效率。
这种字节码与编译器、解释器相结合的技术,就是我们常说的即时编译(JIT)。JavaScript 并不是传统意义上纯粹的解释型语言,因为它既有解释器对字节码的解释执行,又有编译器对热点代码的编译优化,是一种混合的执行模式。
V8 引擎执行 JavaScript 代码的过程,就像是一场精心编排的交响乐。从代码被解析成 AST,到生成字节码,再到字节码的解释执行以及热点代码的编译优化,每一个环节都紧密配合,共同为我们带来流畅的 Web 体验。希望通过这篇文章,大家对 V8 引擎执行 JavaScript 代码的过程有了更清晰的认识。