Jason Pan

解析与抽象语法树 (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:

img

为了让读者能更容易地理解执行源代码之前发生的第一件事,上图只是解析器生成 AST 的简化版本,而实际的 AST 要复杂得多。利用在线工具 AST Explorer ,贴入 JavaScript 代码,可以观察到实际的 AST 的结构。

解析耗时

您可能会问,为什么我需要知道 JavaScript 解析器是如何工作的。毕竟,保证 JavaScript 正常工作是浏览器的责任——这有点对。下图显示了 JavaScript 执行过程中总时间在不同步骤上的分配。

img

仔细观察可以发现,平均而言,浏览器需要大约总执行时间的 15% 到 20% 来解析 JavaScript。这些是来自现实世界的应用程序和网站的统计信息,它们以这样或那样的方式使用着 JavaScript。典型的单页应用 (Single-page application, SPA) 会加载约 0.4MB 的 JavaScript,浏览器大约需要 370ms 进行解析。15% 似乎并不多,但是请相信我,这已经很多了,这只是将 JavaScript 代码解析为 AST 所需要的时间,还不包括执行或页面加载期间发生的任何其他过程(如 CSS 和 HTML 的渲染)。所有这些统计都是在台式机上的数据,一旦进入移动领域,事情就会变得更加复杂。在手机上解析花费的时间通常是在桌面上的时间的 2 到 5 倍。

img

上图显示了在不同移动和台式设备上,1MB JavaScript 包的解析时间。

而且,为了引入更加原生的用户体验,越来越多的业务将逻辑放在客户端,因此 Web 应用程序变得越来越复杂。这给 Web 应用程序/网站带了明显的影响。开发者需要关注并衡量衡量在页面完全加载之前,解析、编译以及浏览器中发生的所有其他事情所花费的时间,这可以通过浏览器提供的开发工具来完成。

img

虽然移动浏览器上没有开发工具,但这并不意味着我们没法解决。DeviceTiming 之类的工具,可以帮助测量受控环境中脚本的解析和执行时间。DeviceTiming 将本地脚本与检测代码包装在一起来工作,通过这样的方式,每次从不同设备访问您的页面时,开发者都可以在本地测量解析和执行时间。

引擎针对解析做的优化

JavaScript 引擎会做很多事情来避免冗余的工作,并获得更多的优化。主流的浏览器引擎做了这些事情:

这些优化不会直接影响 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,具体的过程是:

  1. 声明函数 foo ,它接受一个参数 x。它有一个 return 语句。该函数在返回 x + 10 的结果。
  2. 声明函数 baz ,它接受两个参数 xy。它有一个 return 语句。该函数在返回 x + y 的结果。
  3. 使用两个参数 100200baz 进行函数调用。
  4. 将上一个函数调用的结果作为参数,调用 console.log 函数。

img

那到底发生了什么?解析器看到了 foo 函数的声明、bar 函数的声明、bar 函数的调用以及 console.log 函数的调用。请注意,解析器还做了一些无关的工作,那就是解析 foo 函数。为什么说是无关的?因为函数 foo 不会被调用(至少在此段代码中没有被调用过)。这是一个简单的示例,可能看起来有些不寻常,但在许多实际应用中,会有很多已声明/定义了但不会被调用的函数。

在这里,我们注意到解析器声明了 foo,但是没有解析,也就没有指定其具体功能。实际的解析是在必要时才进行的(比如执行该函数之前)。lazy 解析仍然需要找到整个函数体,并声明该函数,但仅此而已。它不需要抽象语法树,因为它不会被处理。另外,它不会从堆中分配内存,而堆通常会占用大量系统资源。简而言之,跳过这些步骤将大大提高性能。

因此,在前面的示例中,解析器实际上将执行以下操作。

img

请注意,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 解析操作起来很困难,原因有几点:

因为这些原因,开发者不想去手动指定代码的解析模式,这一点上,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 个加速解析的技巧

您可以遵循的一些技巧,以尽可能快地向用户提供应用程序

参考资料