原文: Faster script loading with BinaryAST?本文首发于公众号:符合预期的CoyPan
JavaScirpt的冷启动
web应用的表现,越来越受制于启动时间。我们已经习惯于使用大量的JavaScript代码来开发丰富的web交互体验。从HTTPArchive上,我们可以看到,一个移动设备平均会加载350KB的JavaSript代码,10%的页面会加载超过1MB的JavaScipt代码。复杂的交互会使得这个数字越来越高。
尽管有缓存的帮助,但是常见的站点都会频繁的发布新代码,导致冷启动(首次加载)时间十分的重要。随着浏览器将缓存按照域来划分以防止跨站点泄露,冷启动的重要性正在增加,即使是从CDN加载的常用资源来说也是如此,因为它们不再能够安全地共享。
通常情况下,当我们谈论冷启动性能时,最常见的因素就是下载速度。然后,在现在的富交互页面上,另外一个影响冷启动的很重要因素是:JavaScipt的解析时间。咋看起来会有点让人意外,但是是合理的:在开始执行代码前,引擎不得不先解析下载的JavaScript,确保脚本没有语法错误,然后将其编译为基本的字节码。随着网络变得越来越快,JavaScipt的解析和编译可能会成为影响冷启动的最主要因素。
设备能力(CPU或内存性能)是影响JavaScript解析时间和相应应用程序启动时间变化的最重要因素。在现代桌面或高端移动设备上,一个1MB的javascript文件需要100毫秒的解析时间,但在普通手机上,解析时间可以超过一秒钟。
关于在不同设备上javascript解析、编译和执行的总体成本,这篇文章给出了详细的介绍。以news.google.com为例,在Pixel 2上,解析、编译、执行JS的总耗时为4s,而在一些低端的设备上,需要28s。
虽然引擎不断提高原始解析性能,尤其是在过去的一年里,V8引擎的性能翻了一番,并且使更多的东西脱离了主线程,但解析器仍然需要做大量可能不必要的工作,这些工作会消耗内存、电池,并可能延迟有用资源的处理。
BinaryAST提案
"BinaryAST"应运而生。BinaryAST是Mozilla提出并积极开发的一种新的在线javascript格式,旨在加快解析速度,同时保持原始javascript的语义不变。它的实现方式是:使用有效的二进制来表示代码和数据结构,并且存储和提供额外的信息来提前指导解析器工作。
之所以使用BinaryAST这个名字,是因为这种格式以AST的方式存储JavaScript源码,然后编码到一个二进制文件中。该规范位于tc39.github.io/proposal-binary-ast,目前正由Mozilla、Facebook、Bloomberg和CloudFlare的工程师开发。
解析JavaScript
对于要在浏览器中执行的常规JavaScript代码,源代码被解析为一个称为AST的中间表示,它描述了代码的语法结构。然后,可以将此AST编译为字节代码或本机代码以供执行。
一段简单的将两个数相加的代码,用AST表示为:
解析JavaScript不是一项简单的任务;无论使用哪种优化,它仍然需要逐字符读取整个文本文件,同时跟踪额外的上下文进行语法分析。
BinaryAST的目标是通过在解析器需要的时间和地点提供额外的信息和上下文,来降低复杂性和浏览器解析器必须完成的总体工作量。
要执行以BinaryAST方式传递的JavaScript,所需要的唯一步骤是:
BinaryAST的另一个好处是它可以只解析启动所需的关键代码,完全跳过未使用的位。这可以显著提高初始加载时间。
这篇文章将更加详细地描述解析JavaScipt时遇到的挑战,解释我们是如何克服这些问题的,以及我们是如何在Worker中运行代码解释器的。
提升
JavaScript依赖于提升所有声明——变量、函数、类。提升是语言的一个属性,它允许你在语法上使用之后,再去声明变量,函数,类等。
让我们来看下面这个例子:
function f() {
return g();
}
function g() {
return 42;
}
在这里,当解析器查看F的主体时,它还不知道G指的是什么——它可能是一个已经存在的全局函数或者在同一个文件中进一步声明的某个函数——所以它无法最终解析原始函数并开始实际编译。
BinaryAST通过存储所有作用域信息并使其在实际表达式之前可用来解决这个问题。
用JSON表示初始的AST和增强的AST之前的区别,如下图所示:
延迟解析
现代引擎用来改进解析时间的一种常见技术是延迟解析。它利用了这样一个事实:许多网站包含的javascript比实际需要的要多,特别是对于新的网站。
例如,从文本中解析数字、布尔值甚至字符串等低级类型需要额外的分析和计算。这是没有必要的。您可以首先将它们存储和读取为本机二进制编码值,然后直接在另一端读取。
另一个问题是语法本身的歧义。这在ES5世界中已经是一个问题,但通常可以通过一些基于以前看到的标记的额外记录来解决。然而,在ES6+中,有些东西可能一直模糊不清,直到它们被完全解析为止。
例如,一个标记序列如下:
(a, {b: c, d}, [e = 1])...
上述标记序列可以是一个用嵌套的对象和数组文本以及赋值来启动带括号的逗号表达式:
(a, {b: c, d}, [e = 1]); // 这是一个表达式
也可以是一个带有嵌套对象和数组模式的箭头表达式函数的参数列表和默认值:
(a, {b: c, d}, [e = 1]) => … // 这是一个参数列表
这两种表示都是完全有效的,但语义完全不同,在看到最后一个标记之前,你无法知道要处理的是哪个。
为了解决这一问题,解析器通常要么回溯,这很容易以指数级的速度变慢,要么将内容解析为能够同时保存表达式和模式的中间节点类型,并进行后续的转换。后一种方法保留了线性性能,但使实现更加复杂,需要保留更多的状态。
在BinaryAST格式下,这个问题不再存在。因为解析器在开始解析内容前就可以看到每个节点的类型。
展示实验数据
请记住,该提案处于非常早期的阶段,当前的基准和演示不能代表最终结果。
如前所述,BinaryAST可以标记应该提前进行惰性分析的函数。通过在编码器https://github.com/binast/binjs-ref/blob/b72aff7dac7c692a604e91f166028af957cdcda5/crates/binjs_es6/src/lazy.rs#L43中使用不同级别的惰性化,对一些流行的javascript库运行测试时,我们发现了以下速度的提升。
Level 0 (no functions are lazified)
在两个解析器中都禁用了惰性解析之后,原始解析速度提高了3%到10%。
Level 3 (functions up to 3 levels deep are lazified)
但是,通过设置为跳过最多嵌套3层的函数函数,我们可以看到解析时间在90%到97%之间的显著改进。正如本文前面提到的,BinaryAST通过完全跳过标记的函数,使延迟解析基本上是无开销的。
通过下面的包含1.2MB JavaScript的示例程序https://github.com/cloudflare...
https://serve-binjs.that-test...
我们得到了以下的初始脚本执行数据:
以下是一段视频,它将让您了解移动FireFox用户所看到的改进(在本例中,显示整个页面启动时间):
下一步是开始在现实网站上收集数据,同时改进底层格式。
如何在我的站点上测试BinaryAST?
我们已经开源了Worker的源代码,以便将其安装到任何CloudFlare区域:
https://github.com/binast/bin...
目前需要注意的一件事是,即使结果存储在缓存中,初始编码仍然是一个昂贵的过程,并且可能很容易达到任何重要的javascript文件的CPU限制,并返回到未编码的变量。我们正在努力改善这种情况,在接下来的日子里,将BinaryAST编码器作为一个单独的功能发布,并有更宽松的限制。
同时,如果你想在更大的脚本上使用BinaryAST,另一种选择是使用https://github.com/binast/bin...,提前对javascript文件进行预编码。然后,在浏览器支持和请求时,可以使用https://github.com/cloudflare...,来处理生成的BinaryAST文件。
在客户端,您当前需要下载Firefox Nightly,转到about:config并通过以下选项启用无限制的binaryast支持。
现在,当打开一个安装了Worker的网站时,Firefox会自动得到BinaryAST而不是javascript。
总结
现代应用程序中的javascript数量正在给所有消费者带来性能挑战。引擎供应商正在尝试各种不同的方法来改善这种情况——一些侧重于原始解码性能,一些侧重于并行操作以减少总体延迟,一些致力于研究用于数据表示的新的优化格式,还有一些正在发明和改进用于网络交付的协议。
不管是哪一个,我们都有一个共同的目标,那就是让网络变得更好、更快。