设为首页 | 收藏本站欢迎来到卓越网络免费免备案CDN加速,DDoS和CC攻击防御,高防CDN管理平台!

已阅读

使用"BinaryAST"加快JavaScript脚本的解析速度?

作者:cdnfine      来源:cdnfine      发布时间:2019-05-27

JavaScirpt的冷启动

web应用的表现,越来越受制于启动时间。我们已经习惯于使用大量的JavaScript代码来开发丰富的web交互体验。从HTTPArchive上,我们可以看到,一个移动设备平均会加载350KB的JavaSript代码,10%的页面会加载超过1MB的JavaScipt代码。复杂的交互会使得这个数字越来越高。

尽管有缓存的帮助,但是常见的站点都会频繁的发布新代码,导致冷启动(首次加载)时间十分的重要。随着浏览器将缓存按照域来划分以防止跨站点泄露,冷启动的重要性正在增加,即使是从CDN加载的常用资源来说也是如此,因为它们不再能够安全地共享。

通常情况下,当我们谈论冷启动性能时,最常见的因素就是下载速度。然后,在现在的富交互页面上,另外一个影响冷启动的很重要因素是:JavaScipt的解析时间。咋看起来会有点让人意外,但是是合理的:在开始执行代码前,引擎不得不先解析下载的JavaScript,确保脚本没有语法错误,然后将其编译为基本的字节码。随着网络变得越来越快,JavaScipt的解析和编译可能会成为影响冷启动的最主要因素。

使用"BinaryAST"加快JavaScript脚本的解析速度?

设备能力(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编译为字节代码或本机代码以供执行。

使用"BinaryAST"加快JavaScript脚本的解析速度?

一段简单的将两个数相加的代码,用AST表示为:

使用"BinaryAST"加快JavaScript脚本的解析速度?

解析JavaScript不是一项简单的任务;无论使用哪种优化,它仍然需要逐字符读取整个文本文件,同时跟踪额外的上下文进行语法分析。

BinaryAST的目标是通过在解析器需要的时间和地点提供额外的信息和上下文,来降低复杂性和浏览器解析器必须完成的总体工作量。

要执行以BinaryAST方式传递的JavaScript,所需要的唯一步骤是:

使用"BinaryAST"加快JavaScript脚本的解析速度?

BinaryAST的另一个好处是它可以只解析启动所需的关键代码,完全跳过未使用的位。这可以显著提高初始加载时间。

使用"BinaryAST"加快JavaScript脚本的解析速度?

使用"BinaryAST"加快JavaScript脚本的解析速度?

这篇文章将更加详细地描述解析JavaScipt时遇到的挑战,解释我们是如何克服这些问题的,以及我们是如何在Worker中运行代码解释器的。

提升

JavaScript依赖于提升所有声明——变量、函数、类。提升是语言的一个属性,它允许你在语法上使用之后,再去声明变量,函数,类等。

让我们来看下面这个例子:

  •  
  •  
  •  
  •  
  •  
  •  
  •  
function f() {  return g();}
function g() {  return 42;}

在这里,当解析器查看F的主体时,它还不知道G指的是什么——它可能是一个已经存在的全局函数或者在同一个文件中进一步声明的某个函数——所以它无法最终解析原始函数并开始实际编译。

BinaryAST通过存储所有作用域信息并使其在实际表达式之前可用来解决这个问题。

使用"BinaryAST"加快JavaScript脚本的解析速度?

用JSON表示初始的AST和增强的AST之前的区别,如下图所示:

使用"BinaryAST"加快JavaScript脚本的解析速度?

延迟解析

现代引擎用来改进解析时间的一种常见技术是延迟解析。它利用了这样一个事实:许多网站包含的javascript比实际需要的要多,特别是对于新的网站。

例如,从文本中解析数字、布尔值甚至字符串等低级类型需要额外的分析和计算。这是没有必要的。您可以首先将它们存储和读取为本机二进制编码值,然后直接在另一端读取。

另一个问题是语法本身的歧义。这在ES5世界中已经是一个问题,但通常可以通过一些基于以前看到的标记的额外记录来解决。然而,在ES6+中,有些东西可能一直模糊不清,直到它们被完全解析为止。

例如,一个标记序列如下:

  •  
(a, {b: c, d}, [e = 1])...

上述标记序列可以是一个用嵌套的对象和数组文本以及赋值来启动带括号的逗号表达式:

  •  
(a, {b: c, d}, [e = 1]); // 这是一个表达式

也可以是一个带有嵌套对象和数组模式的箭头表达式函数的参数列表和默认值:

  •  
(a, {b: c, d}, [e = 1]) => … // 这是一个参数列表

 

这两种表示都是完全有效的,但语义完全不同,在看到最后一个标记之前,你无法知道要处理的是哪个。

为了解决这一问题,解析器通常要么回溯,这很容易以指数级的速度变慢,要么将内容解析为能够同时保存表达式和模式的中间节点类型,并进行后续的转换。后一种方法保留了线性性能,但使实现更加复杂,需要保留更多的状态。

在BinaryAST格式下,这个问题不再存在。因为解析器在开始解析内容前就可以看到每个节点的类型。

Cloudflare的实现

目前,这种格式仍在不断变化,但客户端实现的第一个版本几个月前在Firefox Nightly发布了。请记住,这只是一个初始的未经优化的原型,并且已经有几个实验改变了格式,以提高大小和解析性能。

在生产者方面,可以参考github.com/binast/binjs-ref的实现。我们的目标是采用这个实现,并考虑如何在CloudFlare规模上部署它。

如果深入研究代码库,您会发现它目前由两部分组成。

使用"BinaryAST"加快JavaScript脚本的解析速度?

一个是编码器本身,它负责获取解析的AST,用作用域和其他相关信息对其进行注释,并以当前支持的格式之一输出结果。这部分是用Rust开发的,并且是完全原生的。

另一部分是产生初始AST的部分——解析器。有趣的是,与编码器不同,它是在JavaScript中实现的。

不幸的是,目前还没有经过实战测试的、带有开放式API的本机JavaScript解析器,更不用说在Rust中实现了。有过一些尝试,但是考虑到JavaScript语法的复杂性,最好稍等一下,确保在将其合并到产品编码器中之前对其进行了良好的测试。

另一方面,在过去的几年里,JavaScript生态系统广泛依赖于在JavaScript中实现的开发人员工具。特别是,这推动了严格的解析器开发和测试。有几个Javascript解析器实现已经被证明可以在数千个实际项目上工作。

考虑到这一点,Binaryast实现选择使用它们中的一个(特别是Shift)并将其与Rust编码器集成,而不是尝试使用本地解析器。

结合Rust与JavaScript

结合是事情变得有趣的地方。

Rust是一种可以编译为可执行二进制文件的本地语言,但JavaScript需要单独的引擎来执行。为了连接它们,我们需要某种方法在两个系统之间传输数据,而不共享内存。

最初,最初,引用实现生成了一个动态嵌入输入的javascript代码,将其传递给node.js,然后在进程完成时读取输出。该代码包含一个Shift解析器的调用和一个内联输入字符串,并以JSON格式生成了AST。

在解析大量的javascript文件时,这并不能很好地扩展,所以我们首先要做的是将node.js端转换为一个长期存在的守护进程。现在,rust可以只生成一次所需的node.js进程,并不断地将输入传递给它,并将响应作为单个消息返回。

使用"BinaryAST"加快JavaScript脚本的解析速度?

在云端运行

虽然node.js解决方案在这些优化之后工作得相当好,但是将node.js实例和本机捆绑包运送到生产环境中需要一些工作。这也有潜在的风险,需要手动对两个进程进行沙盒处理,以确保我们不会意外地开始执行恶意代码。

另一方面,node.js只需要运行javascript解析器代码。我们已经有一个单独的javascript引擎在云计算中运行了— Cloudflare Workers!另外,通过将本机Rust编码器编译为WASM(使用本机工具链和WASM BindGen会非常容易),我们甚至可以在同一进程中运行两部分代码,使冷启动和通信比以前的模型快得多。

使用"BinaryAST"加快JavaScript脚本的解析速度?

优化数据传输

下一步是减少数据传输的开销。JSON在不同进程之间的通信方面工作得很好,但是通过一个进程,我们应该能够直接从基于JavaScript的AST中检索所需的位。为了做到这一点,首先,我们需要将JSON的直接使用转移到更通用的地方,这样我们就可以支持各种导入格式。Rust已经有一个惊人的系列化框架了 - Serde

除了允许我们在输入方面更加灵活之外,重写为serde也帮助了现有的本机用例。现在,不需要将JSON解析成一个中间表示,然后遍历它,而是可以流式方式从node.js进程的stdout管道中直接反序列化所有本机类型的AST结构。这显著提高了CPU使用率和内存压力。

但是还有一件事我们可以做:我们不需要从中间格式(更不用说,像JSON这样的文本格式)序列化和反序列化,我们应该能够[几乎]直接在javascript值上操作,节省内存和重复工作。

这怎么可能?wasm bindgen提供了一个名为jsValue的类型,它在javascript端存储一个任意值的句柄。此句柄在内部包含到预定义数组中的索引。

每次由于函数调用或属性访问而将一个javascript值传递到rust端时,它都存储在这个数组中,并向rust发送一个索引。下一次Rust想要对该值进行处理时,它会将索引传回来,javascript端从数组中检索原始值并执行所需的操作。

通过重用这个机制,我们可以实现一个serde反序列化程序,它只从JS端请求所需的值,并立即将其转换为其本机表示。现在,这个程序已经开源了:https://github.com/cloudflare/serde-wasm-bindgen

使用"BinaryAST"加快JavaScript脚本的解析速度?

一开始,我们的性能表现不太好,主要是因为:(1)在Wasm和JavaScript之间频繁互相调用。(2)JavaScript与C++之间频繁调用。对于第一点,SpiderMonkey已经有所提高,但是其他引擎仍然落后。对于第二点,大多数引擎都不能很好的优化。

JavaScript与C++之间的开销来自使用TextEncoder在JavaScript与WASM中的wasm-bindgen之间传递字符串,事实上,这是开销最多的。这并不奇怪——毕竟,字符串不仅可以出现在payloads中,也可以出现在属性名中,在使用通用的类似JSON的结构时,必须在JavaScript和WASM之间反复序列化和发送。

幸运的是,因为我们的反序列化程序不再需要与JSON兼容,所以我们可以使用我们对rust类型的了解,并将所有序列化的属性名缓存为javascript值处理一次,然后继续重用它们以进行进一步的属性访问。

这与我们上述对wasm bindgen的一些更改相结合,使得我们的反序列化程序在基准测试中比wasm bindgen中的原始Serde支持快3.5倍,同时节省了大约33%的代码大小。请注意,对于包含字符串多的数据结构,它可能仍然比当前基于JSON的集成慢,但当引用类型提案在WASM中落地时,情况有望随着时间的推移而改善。

在实现并集成了这个反序列化程序之后,我们使用webpack的wasm pack插件来构建一个同时包含rust和javascript部分的工作程序,并进行了测试。

展示实验数据

请记住,该提案处于非常早期的阶段,当前的基准和演示不能代表最终结果。

如前所述,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%。

使用"BinaryAST"加快JavaScript脚本的解析速度?

Level 3 (functions up to 3 levels deep are lazified)

但是,通过设置为跳过最多嵌套3层的函数函数,我们可以看到解析时间在90%到97%之间的显著改进。正如本文前面提到的,BinaryAST通过完全跳过标记的函数,使延迟解析基本上是无开销的。

使用"BinaryAST"加快JavaScript脚本的解析速度?

所有的数字都来自于在带有16GB内存的Linux x64 Intel i7上进行的手动测试。

虽然这些综合基准令人印象深刻,但它们并不能代表现实场景。通常,在启动期间至少要使用一些加载的javascript。为了检查这个场景,我们决定在桌面和移动Firefox上测试一些真实的页面和演示,发现页面加载速度也在加快。

通过下面的包含1.2MB JavaScript的示例程序(<https://github.com/cloudflare/binjs-demo>, <https://serve-binjs.that-test.site/>),我们得到了以下的初始脚本执行数据:

使用"BinaryAST"加快JavaScript脚本的解析速度?

以下是一段视频,它将让您了解移动FireFox用户所看到的改进(在本例中,显示整个页面启动时间):

使用"BinaryAST"加快JavaScript脚本的解析速度?

下一步是开始在现实网站上收集数据,同时改进底层格式。

如何在我的站点上测试BinaryAST?

我们已经开源了Worker的源代码,以便将其安装到任何CloudFlare区域:

https://github.com/binast/binjs-ref/tree/cf-wasm

目前需要注意的一件事是,即使结果存储在缓存中,初始编码仍然是一个昂贵的过程,并且可能很容易达到任何重要的javascript文件的CPU限制,并返回到未编码的变量。我们正在努力改善这种情况,在接下来的日子里,将BinaryAST编码器作为一个单独的功能发布,并有更宽松的限制。

同时,如果你想在更大的脚本上使用BinaryAST,另一种选择是使用https://github.com/binast/binjs-ref中的binjs_encode工具,提前对javascript文件进行预编码。然后,在浏览器支持和请求时,可以使用https://github.com/cloudflare/binast-cf-worker中的Worker,来处理生成的BinaryAST文件。

在客户端,您当前需要下载Firefox Nightly,转到about:config并通过以下选项启用无限制的binaryast支持。

使用"BinaryAST"加快JavaScript脚本的解析速度?

现在,当打开一个安装了Worker的网站时,Firefox会自动得到BinaryAST而不是javascript。

总结

现代应用程序中的javascript数量正在给所有消费者带来性能挑战。引擎供应商正在尝试各种不同的方法来改善这种情况——一些侧重于原始解码性能,一些侧重于并行操作以减少总体延迟,一些致力于研究用于数据表示的新的优化格式,还有一些正在发明和改进用于网络交付的协议。

不管是哪一个,我们都有一个共同的目标,那就是让网络变得更好、更快。

Keywords: 免费CDN加速 免备案CDN加速 高防CDN加速