原文地址:How JavaScript works: Optimizing for parsing efficiency
原文作者:Alvin Wan
译者:Chor
编写高效率的 JavaScript ,其中一个关键就是要理解它的工作原理。编写高效代码的方法数不胜数,例如,你可以编写对编译器友好的 JavaScript 代码,从而避免将一行简单代码的运行速度拖慢 7 倍。
本文我们会专注讲解可以最小化 Javascript 代码解析时间的优化方法。我们进一步缩小范围,只讨论 V8 这一驱动 Electron, Node.js 和 Google Chrome 的 JS 引擎。为了理解这些对解析友好的优化方法,我们还得先讨论 JavaScript 的解析过程,在深入理解代码解析过程的基础上,再对三个编写更高速 JavaScript 的技巧进行一一概述。
先简单回顾一下 JavaScript 执行的三个阶段。
从源代码到语法树 —— 解析器从源码中生成一棵 抽象语法树。
从语法树到字节码 —— V8 的解释器 Ignition 从语法树中生成字节码(在 2017 年之前 并没有该步骤,具体可以看 这篇文章)。
从字节码到机器码 —— V8 的编译器 TurboFan 从字节码中生成图,用高度优化的机器码替代部分字节码。
上述的第二和第三阶段 涉及到了 JavaScript 的编译。在这篇文章中,我们将重点介绍第一阶段并解释该阶段对编写高效 JavaScript 的影响。我们会按照从左到右、从上到下的顺序介绍解析管道,该管道接受源代码并生成一棵语法树。
抽象语法树(AST)。它是在解析器(图中蓝色部分)中创建的。
源代码首先被分解成 chunk,每个 chunk 都可能采用不同的编码,稍后会有一个字符流将所有 chunk 的编码统一为 UTF-16。
在解析之前,扫描器会将 UTF-16 字符流分解成 token。token 是一段脚本中具有语义的最小单元。有不同类型的 token,包括空白符(用于 自动插入分号)、标识符、关键字以及代理对(仅当代理对无法被识别为其它东西时才会结合成标识符)。这些 token 之后被送往预解析器中,接着再送往解析器。
解析器的工作量是最少的,只要足够跳过传入的源代码并进行懒解析(而不是全解析)即可。预解析器确保输入的源代码包含有效语法,并生成足够的信息来正确地编译外部函数。这个准备好的函数稍后将按需编译。
解析器接收到扫描器生成的 token 后,现在需要生成一个供编译器使用的中间表示。
首先我们来讨论解析树。解析树,或者说 具体语法树(CST)将源语法表示为一棵树。每个叶子节点都是一个 token,而每个中间节点则表示一个语法规则。在英语里,语法规指的是名词、主语等,而在编程里,语法规则指的是一个表达式。不过,解析树的大小随着程序大小会增长得很快。
相反,抽象语法树 要更加简洁。每个中间节点表示一个结构,比如一个减法运算(-),并且这棵树并没有展示源代码的所有细节。例如,由括号定义的分组是蕴含在树的结构中的。另外,标点符号、分隔符以及空白符都被省略了。你可以在 这里 了解更多 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 是单字符串 token,而对象字面量可能包含大量的嵌套对象和 token;
语法对上下文是敏感的。解析器逐字检查源代码,并不知道某个代码块是一个对象字面量。而左大括号不仅可以表明它是一个对象字面量,还可以表明它是一个解构对象或者箭头函数。
不过,值得注意的是,JSON.parse 同样会阻塞主线程。对于超过 1MB 的文件,可以使用 FlatBuffers 提高解析效率。
最后,你可以通过完全规避解析来提高解析效率。对于服务端编译来说, WebAssembly (WASM) 是个不错的选择。然而,它没办法替代 JavaScript。对于 JS,更合适的方法是最大化代码缓存。
值得注意的是,缓存并不是任何时候都生效的。在执行结束之前编译的任何代码都会被缓存 —— 这意味着处理器、监听器等不会被缓存。为了最大化代码缓存,你必须最大化执行结束之前编译的代码数量。其中一个方法就是使用立即执行函数(IIFE)启发式:解析器会通过启发式的方法标识出这些 IIFE 函数,它们会在稍后立即被编译。因此,使用启发式的方法可以确保一个函数在脚本执行结束之前被编译。
此外,缓存是基于单个脚本执行的。这意味着更新脚本将会使缓存失效。V8 团队建议可以分割脚本或者合并脚本,从而实现代码缓存。但是,这两个建议是互相矛盾的。你可以阅读“JavaScript 开发中的代码缓存”来了解更多代码缓存相关的信息。
解析时间的优化涉及到工作线程的延迟解析以及通过最大化缓存来避免完全解析。理解了 V8 的解析机制后,我们也能推断出上面没有提到的其它优化方法。
下面给出了更多了解解析机制的资源,这个机制通常来说同时适用于 V8 和 JavaScript 的解析。
jQuery简洁通用的方法集把编码者从繁重的工作中解脱出来,也拉低了进入javascript的门槛,初学者对浏览器兼容性一无所知的情况下,几行代码就可以写出超炫的特效。
编程容易产生挫折,即使作为一种业余爱好也可能是这样。建立一个网页,手机APP或桌面应用都是个很大的工程,好的记笔记技能是让这个工程井然有序的关键,也是克服压力、绝望和倦怠的好方法。
不知大家有没类似这样的经历:一天忙到晚,一会被PM叫去确认需求,一会被设计拉去确认UI是否能实现,一会又被测试叫去确认bug,然后貌似做了很多事,但好像工作进度也没什么进展。然后,只能晚上加班,在夜深人静时还得敲代码,苦逼
属性选择器非常神奇。它们可以使你摆脱棘手的问题,帮助你避免添加类,并指出代码中的一些问题。但是不要担心,虽然属性选择器非常复杂和强大,但是它们很容易学习和使用。在本文中,我们将讨论它们是如何运行的,并给出一些如何使用它们的想法。
近年来,远程工作愈来愈流行。远程工作能够帮助雇主能够降低运营成本,同时员工有机会实现工作与生活之间的平衡并避免通勤。下面我们就为大家推荐13个有用的工具
MySQL count(1) 真的比 count(*) 快么? 反正同事们都是这么说的,我也姑且觉得对吧,那么没有自己研究一下究竟?如果我告诉你他们一样,你信么?
vue 提供了组件功能,组件又可以分为全局组件和非全局组件。区别是全局组件你可以直接在 .vue 文件中直接使用自定义的 html 即可。非全局组件必须在 Vue 的对象中定义 components 引入这个组件
传说程序员打字速度要快,很多人仍然会以这样一个标准来片面判断技术水平.拜托,你是一个程序员,不是一个打字员,打字快可以代表一些,但也并不代表什么.互联网行业还在纠结打字速度的,不是外行,就是一个没有独立思考的人.
编程容易产生挫折,即使作为一种业余爱好也可能是这样。建立一个网页,手机APP或桌面应用都是个很大的工程,好的记笔记技能是让这个工程井然有序的关键,也是克服压力、绝望和倦怠的好方法
因为代码对于性能的优化还是有实际性的价值的,并不是冰山哪一张,代码的编写实际对性能就起到了很大占比,就比如:一个常用common类,如果每一次使用都实例化那么这个类在内存中就会用很多个实例
内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!