- 原文地址:How JavaScript works: Optimizing for parsing efficiency
- 原文做者:Alvin Wan
- 译者:Chor
编写高效率的 JavaScript ,其中一个关键就是要理解它的工做原理。编写高效代码的方法数不胜数,例如,你能够编写对编译器友好的 JavaScript 代码,从而避免将一行简单代码的运行速度拖慢 7 倍。javascript
本文咱们会专一讲解能够最小化 Javascript 代码解析时间的优化方法。咱们进一步缩小范围,只讨论 V8 这一驱动 Electron, Node.js 和 Google Chrome 的 JS 引擎。为了理解这些对解析友好的优化方法,咱们还得先讨论 JavaScript 的解析过程,在深刻理解代码解析过程的基础上,再对三个编写更高速 JavaScript 的技巧进行一一律述。html
先简单回顾一下 JavaScript 执行的三个阶段。java
上述的第二和第三阶段 涉及到了 JavaScript 的编译。在这篇文章中,咱们将重点介绍第一阶段并解释该阶段对编写高效 JavaScript 的影响。咱们会按照从左到右、从上到下的顺序介绍解析管道,该管道接受源代码并生成一棵语法树。node
抽象语法树(AST)。它是在解析器(图中蓝色部分)中建立的。android
源代码首先被分解成 chunk,每一个 chunk 均可能采用不一样的编码,稍后会有一个字符流将全部 chunk 的编码统一为 UTF-16。git
在解析以前,扫描器会将 UTF-16 字符流分解成 token。token 是一段脚本中具备语义的最小单元。有不一样类型的 token,包括空白符(用于 自动插入分号)、标识符、关键字以及代理对(仅当代理对没法被识别为其它东西时才会结合成标识符)。这些 token 以后被送往预解析器中,接着再送往解析器。github
解析器的工做量是最少的,只要足够跳过传入的源代码并进行懒解析(而不是全解析)便可。预解析器确保输入的源代码包含有效语法,并生成足够的信息来正确地编译外部函数。这个准备好的函数稍后将按需编译。web
解析器接收到扫描器生成的 token 后,如今须要生成一个供编译器使用的中间表示。chrome
首先咱们来讨论解析树。解析树,或者说 具体语法树(CST)将源语法表示为一棵树。每一个叶子节点都是一个 token,而每一个中间节点则表示一个语法规则。在英语里,语法规指的是名词、主语等,而在编程里,语法规则指的是一个表达式。不过,解析树的大小随着程序大小会增加得很快。typescript
相反,抽象语法树 要更加简洁。每一个中间节点表示一个结构,好比一个减法运算(-
),而且这棵树并无展现源代码的全部细节。例如,由括号定义的分组是蕴含在树的结构中的。另外,标点符号、分隔符以及空白符都被省略了。你能够在 这里 了解更多 AST 和 CST 的区别。
接下来咱们将重点放在 AST 上。如下面用 JavaScript 编写的斐波那契程序为例:
function fib(n) { if (n <= 1) return n; return fib(n-1) + fib(n-2); }
下面的 JSON 文件就是对应的抽象语法了。这是用 AST Explorer 生成的。(若是你不熟悉这个,能够点击这里来详细了解 如何阅读 JSON 格式的 AST)。
{ "type": "Program", "start": 0, "end": 73, "body": [ { "type": "FunctionDeclaration", "start": 0, "end": 73, "id": { "type": "Identifier", "start": 9, "end": 12, "name": "fib" }, "expression": false, "generator": false, "async": false, "params": [ { "type": "Identifier", "start": 13, "end": 14, "name": "n" } ], "body": { "type": "BlockStatement", "start": 16, "end": 73, "body": [ { "type": "IfStatement", "start": 20, "end": 41, "test": { "type": "BinaryExpression", "start": 24, "end": 30, "left": { "type": "Identifier", "start": 24, "end": 25, "name": "n" }, "operator": "<=", "right": { "type": "Literal", "start": 29, "end": 30, "value": 1, "raw": "1" } }, "consequent": { "type": "ReturnStatement", "start": 32, "end": 41, "argument": { "type": "Identifier", "start": 39, "end": 40, "name": "n" } }, "alternate": null }, { "type": "ReturnStatement", "start": 44, "end": 71, "argument": { "type": "BinaryExpression", "start": 51, "end": 70, "left": { "type": "CallExpression", "start": 51, "end": 59, "callee": { "type": "Identifier", "start": 51, "end": 54, "name": "fib" }, "arguments": [ { "type": "BinaryExpression", "start": 55, "end": 58, "left": { "type": "Identifier", "start": 55, "end": 56, "name": "n" }, "operator": "-", "right": { "type": "Literal", "start": 57, "end": 58, "value": 1, "raw": "1" } } ] }, "operator": "+", "right": { "type": "CallExpression", "start": 62, "end": 70, "callee": { "type": "Identifier", "start": 62, "end": 65, "name": "fib" }, "arguments": [ { "type": "BinaryExpression", "start": 66, "end": 69, "left": { "type": "Identifier", "start": 66, "end": 67, "name": "n" }, "operator": "-", "right": { "type": "Literal", "start": 68, "end": 69, "value": 2, "raw": "2" } } ] } } } ] } } ], "sourceType": "module" } (来源:GitHub)
上面代码的要点是,每一个非叶子节点都是一个运算符,而每一个叶子节点都是操做数。这棵语法树稍后将做为输入传给 JavaScript 接着要执行的两个阶段。
下面罗列的技巧清单中,我会省略那些已经普遍使用的技巧,例如缩减代码来最大化信息密度,从而使扫描器更具备时效性。另外,我也会跳过那些适用范围很小的建议,例如避免使用非 ASCII 字符。
提升解析性能的方法数不胜数,让咱们着眼于其中适用范围最普遍的方法吧。
主线程被阻塞会致使用户交互的延迟,因此应该尽量减小主线程上的工做。关键就是要识别并避免会致使主线程中某些任务长时间运行的解析行为。
这种启发式超出了解析器的优化范围。例如,用户控制的 JavaScript 代码段可使用 web workers 达到相同的效果。你能够阅读 实时处理应用 和 在 angular 中使用 web workers 来了解更多信息。
内联脚本是在主线程中处理的,根据以前的说法,应该尽可能避免这样作。事实上,除了异步和延迟加载以外,任何 JavaScript 的加载都会阻塞主线程。
懒编译也是发生在主线程上的。不过,若是处理得当的话,懒解析能够加快启动速度。想要强制进行全解析的话,可使用诸如 optimize.js(已经不维护)这样的工具来决定进行全解析或者懒解析。
将大文件分解成小文件以最大化并行脚本的加载速度。“2019 年 JavaScript 的性能开销”一文比较了 Facebook 网站和 Reddit 网站的文件大小。前者经过在 300 多个请求中拆分大约 6MB 的 JavaScript ,成功将解析和编译工做在主线程上的占比控制到 30%;相反,Reddit 的主线程上进行解析和编译工做的达到了将近 80%。
在 JavaScript 中,解析 JSON 比解析对象字面量来得更加高效。 parsing benchmark 已经证明了这一点。在不一样的主流 JavaScript 执行引擎中分别解析一个 8MB 大小的文件,前者的解析速度最高能够提高 2 倍。
2019 年谷歌开发者大会 也讨论过 JSON 解析如此高效的两个缘由:
不过,值得注意的是,JSON.parse
一样会阻塞主线程。对于超过 1MB 的文件,可使用 FlatBuffers 提升解析效率。
最后,你能够经过彻底规避解析来提升解析效率。对于服务端编译来讲, WebAssembly (WASM) 是个不错的选择。然而,它没办法替代 JavaScript。对于 JS,更合适的方法是最大化代码缓存。
值得注意的是,缓存并非任什么时候候都生效的。在执行结束以前编译的任何代码都会被缓存 —— 这意味着处理器、监听器等不会被缓存。为了最大化代码缓存,你必须最大化执行结束以前编译的代码数量。其中一个方法就是使用当即执行函数(IIFE)启发式:解析器会经过启发式的方法标识出这些 IIFE 函数,它们会在稍后当即被编译。所以,使用启发式的方法能够确保一个函数在脚本执行结束以前被编译。
此外,缓存是基于单个脚本执行的。这意味着更新脚本将会使缓存失效。V8 团队建议能够分割脚本或者合并脚本,从而实现代码缓存。可是,这两个建议是互相矛盾的。你能够阅读“JavaScript 开发中的代码缓存”来了解更多代码缓存相关的信息。
解析时间的优化涉及到工做线程的延迟解析以及经过最大化缓存来避免彻底解析。理解了 V8 的解析机制后,咱们也能推断出上面没有提到的其它优化方法。
下面给出了更多了解解析机制的资源,这个机制一般来讲同时适用于 V8 和 JavaScript 的解析。
跟踪生产过程当中 JavaScript 的异常或者错误是很耗时的,并且也很使人伤脑筋。若是你有兴趣监控 JavaScript 的错误和应用性能是如何对用户形成影响的,能够尝试使用 LogRocket。
LogRocket 就像是为 web 应用量身订造的 DVR(录像机),它能够确切地记录你的网站上发生的全部事情。LogRocket 能够帮助你统计并报告错误,以查看错误发生的频率以及它们对你的用户群的影响程度。你能够轻松地重现错误发生时特定的用户会话,以查看是用户的哪些操做致使了 bug。
LogRocket 能够记录你的 app 上的请求和响应(包含 header 和 body)以及用户相关的上下文信息,从而窥探问题全貌。它也能够记录页面的 HTML 和 CSS,即便是面对最复杂的单页面应用,也能够重构出像素完美级别的视频。
若是你想提升你的 JavaScript 错误监控能力,LogRocket 是个不错的选择。