JavaScript 工作原理之十四-解析,语法抽象树及最小化解析时间的 5 条小技巧

1,013 阅读15分钟

原文请查阅这里,本文采用知识共享署名 4.0 国际许可协议共享,BY Troland

本系列持续更新中,Github 地址请查阅这里

这是 JavaScript 工作原理的第十四章。

概述

我们都知道运行一大段 JavaScript 代码性能会变得很糟糕。代码不仅仅需要在网络中传输而且还需要解析,编译为字节码,最后运行。之前的文章讨论了诸如 JS 引擎,运行时及调用栈,还有为 Google Chrome 和 NodeJS 广泛使用的 V8 引擎的话题。它们都在整个 JavaScript 的运行过程中扮演着重要的角色。

今天所讲的主题也非常重要:了解到大多数的 JavaScript 引擎是如何把文本解析为机器能够理解的代码,转换之后发生的事情以及开发者如何利用这一知识。

编程语言原理

那么,首先让我们回顾一下编程语言原理。无论使用何种编程语言,你经常需要一些软件来处理源码以便让计算机能够理解。该软件可以是解释器或编译器。不管是使用解释型语言(JavaScript, Python, Ruby) 或者编译型语言(C#, Java, Rust),它们都有一个共同点:把源码作为纯文本解析为语法抽象树(AST)的数据结构。AST 不仅要以结构化地方式展示源码,而且在语义分析中扮演了重要的角色,编译器检查验证程序和语言元素的语法使用是否正确。之后, 使用 AST 来生成实际的字节码或者机器码。

AST 程序

AST 不止应用于语言解释器和编译器,在计算机世界中,还有其它用途。最为常见的用途之一即静态代码分析。静态代码分析并不会运行输入的代码。但是,它们仍然需要理解代码的结构。比如,实现一个工具来找出常见的代码结构以便用来代码重构减少重复代码。或许你可以使用字符串比较来实现,但是工具会相当简单且有局限性。当然了,如果你有兴趣实现这样的工具,你不必自己动手去编写解析器,有许多完美兼容于 Ecmascript 规范的开源项目。Esprima 和 Acorn 即是黄金搭档。还有其它工具可以用来帮助解析器输出代码,即 ASTs.ASTs 被广泛应用于代码转换。举个栗子,你可能想实现一个转换器用来转换 Python 代码为 JavaScript.大致的思路即使用 Python 代码转换器来生成 AST,然后使用该 AST 来生成 JavaScript 代码。你可能会觉得难以置信。事实是 ASTs 只是部分语言的不同表示法。在解析之前,它表现为文本,该文本遵守着构成语言的一些语法规则。解析之后,它表现为一种树状结构,该结构所包含的信息和输入文本几乎一样。因此,也可以进行反向解析然后回到文本。

JavaScript 解析

让我们看一下 AST 的构造。以如下一个简单 JavaScript 函数为例子:

function foo(x) {
    if (x > 10) {
        var a = 2;
        return a * x;
    }

    return x + 10;
}

解析器会产生如下的 AST。

请注意,这里为了展示用只是解析器输出的简化版本。实际的 AST 要更加复杂。然而,这里的意思即了解一下运行源码之前的第一个步骤。可以访问 AST Explorer 来查看实际的 AST 树。这是一个在线工具,你可以在上面写 JavaScript 代码,然后网站会输出目标代码的 AST。

也许你会问为什么我得学习 JavaScript 解析器的工作原理。反正,浏览器会负责运行 JavaScript 代码。你有那么一丁点是正确的。以下图表展示了 JavaScript 运行过程中不同阶段的耗时。瞪大眼睛瞅瞅,也许你可以发现点有趣的东西。

发现没?通常情况下,浏览器大概消耗了 15% 到 20% 的总运行时间来解析 JavaScript.我没有具体统计过这些数值。这些统计数据来自于现实世界中程序和网站的各种 JavaScript 使用姿势。 现在也许 15% 看起来不是很多,但相信我,很多的。一个典型的单页程序会加载大约 0.4M 的 JavaScript 代码,然后消耗掉浏览器大概 370ms 的时间来进行解析。也许你会又说,这也不是很多嘛。本身花费的时间并不多。但记住了,这只是把 JavaScript 代码转化为 ASTs 所消耗的时间。其中不包含运行本身的时间或者页面加载期间其它诸如 CSS 和 HTML 渲染的过程的耗时。这仅仅只是桌面浏览器所面临的问题。移动浏览器的情况会更加复杂。一般情况下,手机移动浏览器解析代码的时间是桌面浏览器的 2-5 倍。

以上图表展示了不同移动和桌面浏览器解析 1MB JavaScript 代码所消耗的时间。

另外,为了获得更多类原生的用户体验而把越来越多的业务逻辑堆积在前端,网页程序变得越来越复杂。网页程序越来越胖,都快走不动了。你可以轻易地想到网络应用受到的性能影响。只需打开浏览器开发者工具,然后使用该工具来检测解析,编译及其它发生于浏览器中直到页面完全加载所消耗的时间。

不幸的是,移动浏览器没有开发者工具来进行性能检测。不用担心。因为有 DeviceTiming 工具。它可以用来帮助检测受控环境中脚本的解析和运行时间。它通过插入代码来封装本地代码,这样每当从不同设备访问的时候,可以本地测量解析和运行时间。

好事即 JavaScript 引擎做了大量的工作来避免冗余工作及更加高效。以下为主流浏览器使用的技术。

例如,V8 实现了 script 流和代码缓存技术。Script 流即当脚本开始下载的时候,async 和 deferred 的脚本在单独的线程中进行解析。这意味着解析会在脚本下载完成时立即完成。这会提升 10% 的页面加载速度。

每当访问页面的时候,JavaScript 代码通常会被编译为字节码。但是,当用户访问另一个页面的时候,该字节码会作废。这是因为编译的代码严重依赖于编译阶段机器的状态和上下文。从 Chrome 42 开始带来了字节码缓存。该技术会本地缓存编译过的代码,这样当用户返回到同一页面的时候,诸如下载,解析和编译等所有步骤都会被跳过。这样就会为 Chrome 节约大概 40% 的代码解析和编译时间。另外,这同样会节省手机电量。

Opera 中,Carakan 引擎可以复用另一个程序最近编译过的输出。不要求代码在同一页面或是相同域名下。该缓存技术非常高效且可以完全跳过编译步骤。它依赖于典型的用户行为和浏览场景:每当用户在程序/网站上遵循特定的用户浏览习惯,则会加载相同的 JavaScript 代码。然而,Carakan 早就被谷歌 V8 引擎所取代。

Firefox 使用的 SpiderMonkey 引擎没有使用任何的缓存技术。它可以过渡到监视阶段,在那里记录脚本运行次数。基于此计算,它推导出频繁使用而可以被优化的代码部分。

很明显地,一些人选择不做任何处理。Safari 首席开发者 Maciej Stachowiak 指出 Safari 不缓存编译的字节码。他们可能已经想到了缓存技术但并没付诸实施,因为生成代码的耗时小于总运行时间的 2%。

这些优化措施没有直接影响 JavaScript 源码的解析时间,但是会尽可能完全避免。毕竟聊胜于无。

有许多方法可以用来减少程序的初始化加载时间。最小化加载的 JavaScript 数量:代码越少,解析耗时越少,运行时间越少。为了达到此目的,可以用特殊的方法传输必需的代码而不是一股劳地加载一大坨代码。比如,PRPL 模式即表示该种代码传输类型。或者,可以检查依赖然后查看是否有无用、冗余的依赖导致代码库的膨胀。然而,这些东西需要很大的篇幅来进行讨论。

本文的目标即开发者如何帮助加快 JavaScript 解析器的解析速度。现代 JavaScript 解析器使用 heuristics(启发法) 来决定是否立即运行指定的代码片段或者推迟在未来的某个时候运行。基于这些 heuristics,解析器会进行立即或者懒解析。立即解析会运行需要立即编译的函数。其主要做三件事:构建 AST,构建作用域层级,然后检查所有的语法错误。而懒解析只运行未编译的函数,它不构建 AST和检查任何语法错误。只构建作用域层级,这样相对于立即解析会节省大约一半的时间。

显然,这并不是一个新概念。甚至像 IE9 这样老掉牙的浏览器也支持该优化技术,虽然和现代解析器的工作方式相比是以一种简陋的方式实现的。

举个栗子吧。假设有如下代码片段:

function foo() {
    function bar(x) {
        return x + 10;
    }

    function baz(x, y) {
        return x + y;
    }

    console.log(baz(100, 200));
}

和之前代码类似,把代码输入解析器进行语法分析然后输出 AST。这样表述如下:

声明 bar 函数接收 x 参数。有一个返回语句。函数返回 x 和 10 相加的结果。

声明 baz 函数接收两个参数(x 和 y)。有一个返回语句。函数函数 x 和 y 相加结果。

调用 baz 函数传入 100 和 2。

调用 console.log 参数为之前函数调用的返回值。

那么期间发生了什么呢?解析器发现了 bar 函数声明, baz 函数声明,调用 bar 函数及调用 console.log 函数。然而,解析器做了完全不相关的额外无用功即解析 bar 函数。为何不相关?因为函数 bar 从未被调用(或者至少不是在对应时间点上)。这只是一个简单示例及可能有些不同寻常,但是在现实生活的许多程序中,许多函数声明从未被调用过。

这里不解析 bar 函数,该函数声明了却没有指出其用途。只在需要的时候在函数运行前进行真正的解析。懒解析仍然只需要找出整个函数体然后为其声明。它不需要语法树因其将不会被处理。另外,它不从内存堆中分配内存,而这会消耗相当一部分系统资源。简而言之,跳过这些步骤可以有巨大的性能提升。

所以之前的例子,解析器实际上会像如下这样解析:

注意到这里仅仅只是确认函数 bar 声明。没有进入 bar 函数体。当前情况下,函数体只有一句简单的返回语句。然而,正如现代世界中的大多数程序那样,函数体可能会更加庞大,包含多个返回语句,条件语句,循环,变量声明甚至嵌套函数声明。由于函数从未被调用,这完全是在浪费时间和系统资源。

实际上这是一个相当简单的概念,然而其实现是非常难的。不局限于以上示例。整个方法还可以应用于函数,循环,条件语句,对象等等。一般情况下,所有代码都需要解析。

例如,以下是一个实现 JavaScript 模块的相当常见的模式。

var myModule = (function() {
  // 整个模块的逻辑
  // 返回模块对象
})();

该模式可以被大多数现代 JavaScript 解析器识别且标识里面的代码需要立即解析。

那么为何解析器不都使用懒解析呢?如果懒解析一些代码,而该代码必须立即运行,这样就会降低代码运行速度。需要运行一次懒解析之后进行另一个立即解析。和立即解析相比,运行速度会降低 50%。

现在,对解析器底层原理有了大致的理解,是时候考虑如何帮助提高解析器的解析速度了。可以以这样的方式编写代码,这样就可以在正确的时间解析函数。这里有一个为大多数解析器所识别的模式:使用括号封装函数。这样会告诉解析器需要立即函数。如果解析器看到一个左括号且之后为函数声明,它会立即解析该函数。可以通过显式声明立即运行函数来帮助解析器加快解析速度。

假设有一个 foo 函数

function foo(x) {
    return x * 10;
}

因为没有明显地标识表明需要立即运行该函数所以浏览器会进行懒解析。然而,我们确定这是不对的,那么可以运行两个步骤。

首先,把函数存储为一变量。

var foo = function foo(x) {
    return x * 10;
};

注意,在 function 关键字和函数参数的左括号之间的函数名。这并不是必要的,但推荐这样做,因为当抛出异常错误的时候,堆栈追踪会包含实际的函数名而不是 。

解析器仍然会做懒解析。可以做一个微小的改动来解决这一问题:用括号封装函数。

var foo = (function foo(x) {
    return x * 10;
});

现在,解析器看见 function 关键字前的左括号便会立即进行解析。

因需要知道解析器在何种情况下懒解析或者立即解析代码,所以可操作性会很差。同样地,开发者需要花时间考虑指定的函数是否需要立即解析。肯定没人想费力地这么做。最后,这肯定会让代码难以阅读和理解。可以使用 Optimize.js 来处理此类情况。该工具只是用来优化 JavaScript 源代码的初始加载时间。他们对代码运行静态分析,然后通过使用括号封装需要立即运行的函数以便浏览器立即解析并准备运行它们。

那么,可以如平常杂编码然后一小段代码如下:

(function() {
    console.log('Hello, World!');
})();

一切看起来很美好,因为在函数声明前添加了左括号。当然,在进入生产环境之前需要进行代码压缩。以下为压缩工具的输出:

!function(){console.log('Hello, World!')}();

看起来一切正常。代码如期运行。然而好像少了什么。压缩工具移除了封装函数的括号代之以一个感叹号。这意味着解析器会跳过该代码且将会运行懒解析。总之,为了运行该函数解析器会在懒解析之后进行立即解析。这会导致代码运行变慢。幸运的是,可以利用 Optimize.js 来解决此类问题。传给 Optimize.js 压缩过的代码会输出如下代码:

!(function(){console.log('Hello, World!')})();

现在,充分利用了各自的优势:压缩代码且解析器正确地识别懒解析和立即解析的函数。

预编译

但是为何不在服务端进行这些工作呢?总之,比强制各个客户端重复做该项事情更好的做法是只运行一次并在客户端输出结果。那么,有一个正在进行的讨论即引擎是否需要提供一个运行预编译代码的功能以节省浏览器的运行时间。本质上,该思路即使用服务端工具来生成字节码,这样就只需要传输字节码并在客户端运行。之后,将会看到启动时间上的一些主要差异。这听起来很有诱惑性但实现起来会很难。可能会有反效果,因为它将会很庞大且由于安全原因很有可能需要进行签名和处理。例如,V8 团队已经在内部解决重复解析问题,这样预编译有可能实际上没啥鸟用。

一些提升网络应用速度的建议

  • 检查依赖。减少不必要的依赖。
  • 分割代码为更小的块而不是一整块。如 webpack 的 code-spliting 功能。
  • 尽可能延迟加载 JavaScript 代码。可以只加载当前路由所要求的代码片段。比如只在点击某个元素的时候引入 某段代码模块。
  • 使用开发者工具和 DeviceTiming 来检测性能瓶颈。
  • 使用像 Optimize.js 的工具来帮助解析器选择立即解析或者懒解析以加快解析速度。

拓展

有时候,特别是手机端浏览器,比如当你点击前进/后退按钮的时候,浏览器会进行缓存。但是在有些场景下,你可能不需要浏览器的这种功能。有如下解决办法:

window.addEventListener('pageshow', (event) => {
  // 检查前进/后退缓存,是否从缓存加载页面
  if (event.persisted || window.performance && 
    window.performance.navigation.type === 2) {
    // 进行相应的逻辑处理
  }
};

招贤纳士

今日头条招人啦!发送简历到 likun.liyuk@bytedance.com ,即可走快速内推通道,长期有效!国际化PGC部门的JD如下:c.xiumi.us/board/v5/2H…,也可内推其他部门!

本系列持续更新中,Github 地址请查阅这里