解析与抽象语法树 (AST) + 5个加速解析的技巧
潘忠显 / 2021-04-14
“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Lachezar Nickolov,翻译 潘忠显。
[TOC]
这是该系列的第14个帖子,专门探讨 JavaScript 及其构建组件。在之前的文章中,我们讨论了诸如 JS 引擎、运行时和调用堆栈之类的主题,以及 Google Chrome 和 NodeJS 都在使用的 V8 引擎。它们在整个 JavaScript 执行过程中都起着至关重要的作用。我们今天介绍 JavaScript 的解析及抽象语法树 (AST),以及如何将这些知识转化成开发者的优势。
编程语言工作原理
JavaScript 代码不仅需要通过网络传输,还需要被解析、编译成字节码、最终执行。如果一大堆的 JavaScript 搅合在一起,会将事情搞得一团糟。
我们首先来看看编程语言是如何工作的。无论您使用哪种编程语言,都始终需要一些软件,这些软件可以获取源代码并使计算机实际执行某些操作,可能是解释器 (interpreter),也可能是编译器 (compiler)。
无论您使用的是解释型语言 (JavaScript、Python、Ruby),还是编译型语言 (C#、Java、Rust),都会有一个共同的过程:将源代码作为纯文本解析成为一个被称作**抽象语法树(AST)**的数据结构,AST 以结构化的方式表示源代码。在语义分析过程中,AST 也起着至关重要的作用,编译器将验证程序和语言元素的使用是否正确。最后,AST 用于生成实际的字节码或机器码。
AST 的应用
在计算机世界中,AST 具有许多应用。
除了刚才提到的语言解释器和编译器,静态代码分析是 AST 最常见用途之一。静态分析器需要了解代码的结构,但不会执行作为输入的代码。比如,您可能想实现一个查找通用代码结构的工具,以便您可以对其进行重构精简。通过使用字符串的比较也可以做到这一点,但其实现功能会很基础、很有限。如果您有兴趣实现这种工具,不需编写自己的解析器,有许多与 ECMAScript 规范完全兼容的开源实现(比如 Esprima 和 Acorn),还有许多工具可以协助解析器生成输出 AST。
AST 也被广泛用于实现代码转译器 (transpiler),比如,您可能想要实现一个将 Python 代码转换为 JavaScript 的转译器。基本思路是:利用 Python 编译器生成 AST,然后将 AST 反向生成 JavaScript 代码。事实是,AST 实际上只是某种语言的另一种表示方式:在解析之前,它会以文本形式表示,并遵循构成某种语言的规则;解析后,它表示为树结构,其中包含与输入文本完全相同的信息。因此,我们总是可以执行相反的步骤,然后返回到文本形式的表示。
JavaScript 解析
接下来,让我们以一个简单的 JavaScript 函数为例,看看是如何构建 AST 的:
function foo(x) {
if (x > 10) {
var a = 2;
return a * x;
}
return x + 10;
}
解析器将产生以下 AST:
为了让读者能更容易地理解执行源代码之前发生的第一件事,上图只是解析器生成 AST 的简化版本,而实际的 AST 要复杂得多。利用在线工具 AST Explorer ,贴入 JavaScript 代码,可以观察到实际的 AST 的结构。
解析耗时
您可能会问,为什么我需要知道 JavaScript 解析器是如何工作的。毕竟,保证 JavaScript 正常工作是浏览器的责任——这有点对。下图显示了 JavaScript 执行过程中总时间在不同步骤上的分配。
仔细观察可以发现,平均而言,浏览器需要大约总执行时间的 15% 到 20% 来解析 JavaScript。这些是来自现实世界的应用程序和网站的统计信息,它们以这样或那样的方式使用着 JavaScript。典型的单页应用 (Single-page application, SPA) 会加载约 0.4MB 的 JavaScript,浏览器大约需要 370ms 进行解析。15% 似乎并不多,但是请相信我,这已经很多了,这只是将 JavaScript 代码解析为 AST 所需要的时间,还不包括执行或页面加载期间发生的任何其他过程(如 CSS 和 HTML 的渲染)。所有这些统计都是在台式机上的数据,一旦进入移动领域,事情就会变得更加复杂。在手机上解析花费的时间通常是在桌面上的时间的 2 到 5 倍。
上图显示了在不同移动和台式设备上,1MB JavaScript 包的解析时间。
而且,为了引入更加原生的用户体验,越来越多的业务将逻辑放在客户端,因此 Web 应用程序变得越来越复杂。这给 Web 应用程序/网站带了明显的影响。开发者需要关注并衡量衡量在页面完全加载之前,解析、编译以及浏览器中发生的所有其他事情所花费的时间,这可以通过浏览器提供的开发工具来完成。
虽然移动浏览器上没有开发工具,但这并不意味着我们没法解决。DeviceTiming 之类的工具,可以帮助测量受控环境中脚本的解析和执行时间。DeviceTiming 将本地脚本与检测代码包装在一起来工作,通过这样的方式,每次从不同设备访问您的页面时,开发者都可以在本地测量解析和执行时间。
引擎针对解析做的优化
JavaScript 引擎会做很多事情来避免冗余的工作,并获得更多的优化。主流的浏览器引擎做了这些事情:
- V8 执行脚本流和代码缓存。脚本流式传输意味着下载开始后,异步脚本和延迟脚本就会在单独的线程上进行解析。这意味着,在完成下载脚本的同时,几乎立即完成了解析。这一动作能提升10%左右的加载速度。
- 通常在每次访问页面时,JavaScript 代码都会被编译为字节码。但是,一旦用户导航到另一个页面,该字节码便会被丢弃,理由是编译后的代码在很大程度上取决于机器在编译时的状态和上下文。Chrome 42 开始引入的字节码缓存,是一种本地存储编译后代码的技术,当用户返回同一页面时,可以跳过下载、解析和编译一系列步骤。这样一来,Chrome 可以节省大约 40% 的解析和编译时间。此外,这还可以节省移动设备的电池寿命。
- 在 Opera 中,Carakan 引擎可以重用近期经过编译器编译的输出,对代码的来源页面设置域都没有要求。实际上,这种缓存技术非常有效,可以完全跳过编译步骤。它依赖于典型用户行为和浏览场景:每当用户在应用程序/网站中遵循特定的用户旅程 (user journey) 时,就会加载相同的 JavaScript 代码。但是,Carakan 引擎早已被 Google 的 V8 取代。
- Firefox 使用的 SpiderMonkey 引擎不会缓存所有内容。它可以过渡到监视阶段,在此阶段它可以计算给定脚本的执行次数。基于此计数,确定哪些代码是热点代码并且需要优化。
- 有些浏览器不会针对这点做任何事情。 Safari 的主要开发人员 Maciej Stachowiak 指出,Safari 不会对已编译的字节码进行任何缓存。他们有考虑到这点,但却没有去实现的原因是代码生成的耗时低于总执行时间的 2%。
这些优化不会直接影响 JavaScript 源代码的解析,但是它们肯定会尽最大努力完全跳过它。没有什么比完全不做更好的优化。
针对解析的其他优化
我们可以采取许多措施来缩短应用程序的初始加载时间。开发者可以最大程度地减少所交付的 JavaScript 数量:更少的脚本,更少的解析,更少的执行。为达到这一目的,我们只传递特定路线上所需的代码,而不是加载所有内容。例如, PRPL 模式就是这种类型的代码交付模式。
另外,我们可以检查我们的依赖关系,看看是否有多余的代码。有些内容,除了让代码库更臃肿之外,没什么其他用途。不过,重构代码是个大话题,可以单独来讨论。
本文的目的是讨论 Web 开发人员可以做什么来帮助 JavaScript 解析器更快地完成其工作。
另外,现代的 JavaScript 解析器使用启发式 (heuristics) 方法来确定是要立即执行某段代码,还是将来将其执行推迟一段时间。
Eager 解析与 Lazy 解析
上文提到,JavaScript 解析器会基于启发式方法进行判断,对代码执行 eager parsing (饥饿解析) 或 lazy parsing (延迟解析)。
eager 解析贯穿需要立即编译的函数,它做三件事:构建AST,构建范围层次结构,查找所有语法错误。
lazy 解析仅用于尚不需要编译的函数。它不会生成 AST,也不会发现所有语法错误,它只构建范围层次结构,比 eager 解析节省了大约一半的时间。这不是一个新概念,甚至像 IE 9 这样的浏览器都支持这种类型的优化,与当前解析器的工作方式相比,它只是一种基本的方式。
我们通过一个例子,看看解析器是如何工作的。假设我们的 JavaScript 包含以下代码段:
function foo() {
function bar(x) {
return x + 10;
}
function baz(x, y) {
return x + y;
}
console.log(baz(100, 200));
}
就像在前面的示例中一样,代码被输入到解析器,该解析器进行语法分析并输出 AST,具体的过程是:
- 声明函数
foo
,它接受一个参数x
。它有一个return
语句。该函数在返回x + 10
的结果。 - 声明函数
baz
,它接受两个参数x
和y
。它有一个return
语句。该函数在返回x + y
的结果。 - 使用两个参数
100
和200
对baz
进行函数调用。 - 将上一个函数调用的结果作为参数,调用
console.log
函数。
那到底发生了什么?解析器看到了 foo
函数的声明、bar
函数的声明、bar
函数的调用以及 console.log
函数的调用。请注意,解析器还做了一些无关的工作,那就是解析 foo
函数。为什么说是无关的?因为函数 foo
不会被调用(至少在此段代码中没有被调用过)。这是一个简单的示例,可能看起来有些不寻常,但在许多实际应用中,会有很多已声明/定义了但不会被调用的函数。
在这里,我们注意到解析器声明了 foo
,但是没有解析,也就没有指定其具体功能。实际的解析是在必要时才进行的(比如执行该函数之前)。lazy 解析仍然需要找到整个函数体,并声明该函数,但仅此而已。它不需要抽象语法树,因为它不会被处理。另外,它不会从堆中分配内存,而堆通常会占用大量系统资源。简而言之,跳过这些步骤将大大提高性能。
因此,在前面的示例中,解析器实际上将执行以下操作。
请注意,foo
函数声明只是被确认,没有做任何其他进入该函数体的事情。在这个示例中,函数主体只是单个 return
语句。但大多数实际应用程序中,函数体可能更大,会包含多个 return
语句、条件、循环、变量声明,甚至是嵌套函数声明。由于永远不会调用该函数,因此解析这样的函数完全是浪费时间和系统资源。
概念非常简单,但实现绝非易事。在这里,我们只是展示了一个例子,而这整个解析过程基本上适用于函数、循环、条件、对象等需要解析的所有内容。
再例如,以下是在 JavaScript 中一种非常常见的实现模式:
var myModule = (function() {
// The whole logic of my module
// Return the module object
})();
大多数现代 JavaScript 解析器都可以识别这种模式,这意味着内部的代码需要直接进行 eager 解析。
那么为什么解析器不总是 lazy 解析呢?如果某些需要立即执行的代码,被延迟解析了,会让解析变得更慢。首先会进行一次 lazy 解析,然后立马又进行一次 eager 解析,这与直接 eager 解析相比,速度会降低 50 %。
显式的 eager 解析
现在,我们已经对后台发生的事情有了基本的了解,是时候考虑我们可以做些什么来帮助解析器了。开发者可以用以下方式来编码,以便解析函数的时间能更合理。有一种大多数解析器都能识别的模式:将函数包装在圆括号中,我们可以通过显式声明一个将立即执行的函数。对于解析器而言,这几乎总是一个肯定的信号,表明该函数将立即执行。如果解析器看到一个圆括号,并且在该函数声明之后立即出现,它会进行 eager 解析该函数。
假设我们有一个名为 foo
的函数:
function foo(x) {
return x * 10;
}
由于没有明显的迹象表明该函数将立即执行,因此浏览器将进行延迟解析。但是,我们确定这是不正确的,因此我们可以做两件事。
第一件事,我们将函数存储在变量中:
var foo = function foo(x) {
return x * 10;
};
请注意,我们在 function
关键字和左括号之间保留了函数名称。这不是必需的,但建议这样做,因为在引发异常的情况下,堆栈跟踪将包含函数的实际名称,而不仅仅是提示 <anonymous>
。
解析器仍将进行 lazy 解析。通过将函数包装在括号中这一个小动作,可以防止 lazy 解析,而是进行 eager 解析:
var foo = (function foo(x) {
return x * 10;
});
此时,当解析器在 function
关键字之前看到左括号时,它将立即进行一次 eager 解析。
使用工具优化代码的解析方式
手动的去指定哪些代码需要 eager 解析操作起来很困难,原因有几点:
- 需要知道在哪种情况下解析器会采用 lazy 解析或是 eager 解析
- 需要花时间考虑是否会立即调用某个函数
- 代码将变得可读性很差,令人难以阅读和理解
因为这些原因,开发者不想去手动指定代码的解析模式,这一点上,Optimize.js 之类的工具可以提供帮助。这些工具唯一目的就是优化 JavaScript 源代码的初始加载时间,会对您的代码进行静态分析,并对其进行修改,以使首先需要执行的功能被括在括号中,以便浏览器可以对其进行 eager 解析并为执行做好准备。
因此,我们像往常一样进行编码,其中有一段代码看起来像这样:
(function() {
console.log('Hello, World!');
})();
一切似乎都很好,可以按预期工作,而且速度很快,因为在函数声明之前有一个括号,很好!当然,在放到生产环境之前,我们需要精简代码以节省字节。以下代码是 Minifier 的输出:
!function(){console.log('Hello, World!')}();
该代码可以与以前一样工作,不过有些不足:压缩程序删除了包裹函数的括号,而是在函数之前放置了一个感叹号,这意味着解析器将跳过此操作并将进行延迟解析;最重要的是,要执行该函数,需要在 lazy 解析之后,立即进行一次 eager 解析。上述这一系列动作,使我们的代码运行速度变慢。
幸运的是,Optimize.js 之类的工具可以为我们完成艰苦的工作。通过 Optimize.js 传递缩小的代码将产生以下输出:
!(function(){console.log('Hello, World!')})();
这样,我们得到了两全其美的结果:既压缩了代码,又能让解析器正确的识别那些函数需要 eager 解析,哪些需要 lazy 解析。
预编译
但是为什么我们不能在服务器端完成所有工作?毕竟,如果服务器端去做,只需要一次就可以完成,然后将结果提供给客户端,而不需要每个客户端每次都去解析。为保证时间不浪费在客户端浏览器上,人们正在讨论引擎是否应该提供一种执行预编译脚本的能力。本质上,这个想法是要有一个可以生成字节码服务器端工具,而我们只需要在网上传输并在客户端执行这些字节码即可,这样主要的差异将集中在执行时间上。
预编译听起来很诱人,但并事实可能非如此,甚至可能会产生相反的效果。因为预编译后的字节码文件可能更大,并且出于安全原因,很可能需要对代码进行签名和处理。例如,在 V8 团队内部正进行避免重新解析的工作,从另个角度说明,预编译实际上可能没有那么大的益处。
A few tips that you can follow to serve your app to users as fast as possible
5 个加速解析的技巧
您可以遵循的一些技巧,以尽可能快地向用户提供应用程序
- 检查依存关系,移除所有没有用到的代码
- 将您的代码拆分成较小的块,而不是加载一个大的 Blob
- 尽可能推迟 JavaScript 的加载,只基于当前路由加载所需的代码段
- 使用开发工具和 DeviceTiming 找出性能瓶颈
- 使用 Optimize.js 之类的工具优化,以帮助解析器确定哪些代码需要 eager 解析或是 lazy 解析
参考资料
- https://en.wikipedia.org/wiki/Abstract_syntax_tree
- https://medium.com/@jotadeveloper/abstract-syntax-trees-on-javascript-534e33361fc7
- https://medium.com/reloading/javascript-start-up-performance-69200f43b201
- https://timkadlec.com/2014/09/js-parse-and-execution-time/
- https://www.youtube.com/watch?v=Fg7niTmNNLg