用卡通的方式理解 JavaScript 引擎
*本文最初由 Raji Ayinla 发表于 codeburst.io,他现在为 howtocodejs.com 撰写内容。想以有趣的方式学习 JavaScript 吗?那就来 howtocodejs.com 看看吧。
概述
JavaScript 是编译型的。没错,你没看错。不过,与其他语言编译器不同,JavaScript 编译器并非像其他语言编译器那样拥有允许早期优化的构建阶段,而是在最后一刻才进行编译——真的是最后一刻。这种用于编译 JavaScript 的技术被恰如其分地命名为即时编译(JIT)。这种“即时编译”技术出现在现代 JavaScript 引擎中,旨在提升浏览器的运行速度。
当开发者称 JavaScript 为解释型语言时,可能会让人有些困惑。这是因为直到最近,JavaScript 引擎一直都与解释器联系在一起。而现在,有了像 Google V8 引擎这样的引擎,开发者就可以两全其美了——一个引擎可以同时拥有解释器和编译器。
我们将向您展示如何使用新型的 JIT 编译器处理 JavaScript 代码。但我们不会向您展示这些新型 JavaScript 引擎优化代码的复杂机制。这些机制包括内联(移除空格)、利用隐藏类以及消除冗余等技术。本文将简要介绍编译理论的基本概念,帮助您了解现代 JavaScript 引擎的内部工作原理。
免责声明:你可能会成为一名代码素食主义者。
语言和代码
为了理解编译器如何读取代码,不妨想想你正在阅读本文的语言:英语。我们都曾在开发控制台中遇到过醒目的红色语法错误,但当我们挠头苦思寻找缺失的分号时,可能从未停下来思考过诺姆·乔姆斯基。乔姆斯基对语法的定义是:
“研究特定语言中句子构成原则和过程的学科。”
我们将根据诺姆·乔姆斯基的定义来调用我们的“内置”simplify();函数。
simplify(quote, "grossly");
//Result: Languages order their words differently.
当然,乔姆斯基指的是德语和斯瓦希里语之类的语言,而不是 JavaScript 和 Ruby。尽管如此,高级编程语言的设计灵感仍然来源于我们日常使用的语言。本质上,JavaScript 编译器是由经验丰富的工程师“训练”来“阅读”JavaScript 代码的,就像我们的父母和老师训练我们的大脑阅读句子一样。
我们可以观察到与编译器相关的三个语言学研究领域:词汇单位、句法和语义。换句话说,分别是研究词语的意义及其关系、研究词语的排列方式以及研究句子的意义(为了便于讨论,我们对语义的定义进行了限定)。
请看这句话:我们吃了牛肉。
词汇单位
注意句子中的每个词都可以如何分解成词汇意义的单元:我们/吃/牛肉
句法
这个基本句子在语法上遵循主谓宾一致原则。我们假设所有英语句子都必须这样构成。为什么呢?因为编译器必须严格按照这些规则运行才能检测语法错误。所以,“Beef we ate”虽然可以理解,但在我们这种过于简化的英语中却是错误的。
语义
从语义上讲,这句话本身没有问题。我们知道过去有很多人吃过牛肉。我们可以通过把句子改写成“我们+牛肉吃过”来剥夺它原本的含义。
现在,让我们把原来的英文句子翻译成 JavaScript 表达式。
let sentence = "We ate beef";
词汇单位
表达式可以分解为词素:let/sentence/=/“我们吃了牛肉”/;
句法
我们的表达式,就像句子一样,必须符合语法规则。JavaScript 和大多数其他编程语言一样,遵循类型/变量/赋值/值的顺序。类型的定义取决于上下文。如果您和我们一样对类型声明的宽松性感到困扰,您可以简单地在程序的全局作用域中添加 `use strict`。`use strict` 就像一个严格的语法管理员,强制执行 JavaScript 的语法规则。使用它的好处远大于它带来的不便。相信我们。
语义
从语义上讲,我们的代码具有意义,最终机器会通过编译器理解这些意义。为了使代码具有语义,编译器必须能够读取代码。我们将在下一节深入探讨这一点。
注意:上下文与作用域不同。进一步解释将超出本文的“范围”。
左侧/右侧
我们阅读英文文章时是从左到右,而编译器读取代码时则是双向的。这是怎么做到的呢?通过左侧查找(LHS)和右侧查找(RHS)。让我们来详细了解一下。
左侧查找(LHS 查找)关注的是赋值语句的“左侧”。这实际上意味着它负责赋值语句的目标。我们应该用“目标”而不是“位置”来概念化,因为左侧查找语句的目标位置可能不同。此外,赋值语句并不明确指代赋值运算符。
请查看以下示例以获得更清晰的说明:
function square(a){
return a*a;
}
square(5);
函数调用会触发对 a 的左侧查找。为什么?因为传递参数 5 会隐式地为 a 赋值。注意,目标值无法通过位置直接确定,必须进行推断。
相反,右侧查找(RHS 查找)关注的是值本身。所以,如果我们回到之前的例子,右侧查找会找到表达式 a*a 中 a 的值;
需要注意的是,这些查找发生在编译的最后阶段,即代码生成阶段。我们将在进入该阶段后详细讨论。现在,让我们先来了解一下编译器。
编译器
把编译器想象成一个肉类加工厂,它有多种机制将代码研磨成计算机认为可以食用或执行的程序包。在这个例子中,我们将处理表达式。
分词器
首先,分词器将代码分解成称为词元的单元。
这些词法单元随后会被分词器识别。当分词器发现语言中不存在的“字母表”时,就会发生词法错误。请记住,这与语法错误不同。例如,如果我们使用了 @ 符号而不是赋值运算符,分词器就会看到 @ 符号并发出警告:“嗯……这个词素不在 JavaScript 的词库中……立即停止所有操作。红色警报!”
注意:如果同一个系统能够将一个标记与另一个标记关联起来,然后像解析器一样将它们分组在一起,那么它将被视为词法分析器。
解析器
解析器会查找语法错误。如果没有错误,它会将标记打包成一个名为解析树的数据结构。在编译过程的这一阶段,JavaScript 代码被认为已经解析完毕,接下来会进行语义分析。如果再次符合 JavaScript 的规则,则会生成一个新的数据结构,称为抽象语法树 (AST)。
在此过程中,源代码会经过一个中间步骤,由解释器逐条语句地转换成中间代码(通常是字节码)。然后,字节码会在虚拟机中执行。
之后,代码会进行优化,包括去除空格、无用代码和冗余代码等诸多优化步骤。
代码生成器
代码优化完成后,代码生成器的任务是将中间代码转换成机器能够轻松理解的底层汇编语言。此时,生成器负责:
(1)确保底层代码保留与源代码相同的指令;
(2)将字节码映射到目标机器;
(3)决定值应该存储在寄存器还是内存中,以及应该从哪里检索值。
代码生成器在此处执行左侧查找和右侧查找。简而言之,左侧查找将目标值写入内存,而右侧查找从内存中读取值。
如果一个值同时存储在缓存和寄存器中,生成器必须优先选择从寄存器中获取该值以进行优化。从内存中获取值应该是最不推荐的方法。
最后……
(4)决定指令的执行顺序。
最后想说的话
理解 JavaScript 引擎的另一种方法是观察你的大脑。当你阅读这段文字时,你的大脑正在从视网膜获取数据。这些数据由视神经传输,是本网页的镜像版本。你的大脑通过翻转图像来编译它,使其可解释。
除了翻转图像和着色之外,你的大脑还能根据其识别模式的能力来填充空白区域,就像编译器从缓存中读取值一样。
所以,如果我们写上“请访问我们的网站______”,你应该能够轻松执行该代码。
对了,还要介绍一下 Lex,我们内置的交互式 JavaScript 编辑器。
资源
《编译器剖析》(作者:James Alan Farrel)
你不知道的JS 第一章:
JavaScript的工作原理
编译器设计





