今天小编给大家分享一下JavaScript的运行原理分析的相关知识点,内容详细,逻辑清晰,相信大部分人都还太了解这方面的知识,所以分享这篇文章给大家参考一下,希望大家阅读完这篇文章后有所收获,下面我们一起来了解一下吧。
扫描器
源代码首先被分解成 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 接着要执行的两个阶段。
三个技巧优化你的 JavaScript
下面罗列的技巧清单中,我会省略那些已经广泛使用的技巧,例如缩减代码来最大化信息密度,从而使扫描器更具有时效性。另外,我也会跳过那些适用范围很小的建议,例如避免使用非 ASCII 字符。
提高解析性能的方法数不胜数,让我们着眼于其中适用范围最广泛的方法吧。
1.尽可能遵从工作线程
主线程被阻塞会导致用户交互的延迟,所以应该尽可能减少主线程上的工作。关键就是要识别并避免会导致主线程中某些任务长时间运行的解析行为。
这种启发式超出了解析器的优化范围。例如,用户控制的 JavaScript 代码段可以使用 web workers 达到相同的效果。你可以阅读 实时处理应用 和 在 angular 中使用 web workers 来了解更多信息。
避免使用大量的内联脚本
内联脚本是在主线程中处理的,根据之前的说法,应该尽量避免这样做。事实上,除了异步和延迟加载之外,任何 JavaScript 的加载都会阻塞主线程。
避免嵌套外层函数
懒编译也是发生在主线程上的。不过,如果处理得当的话,懒解析可以加快启动速度。想要强制进行全解析的话,可以使用诸如 optimize.js(已经不维护)这样的工具来决定进行全解析或者懒解析。
分解超过 100kB 的文件
将大文件分解成小文件以最大化并行脚本的加载速度。“2019 年 JavaScript 的性能开销”一文比较了 Facebook 网站和 Reddit 网站的文件大小。前者通过在 300 多个请求中拆分大约 6MB 的 JavaScript ,成功将解析和编译工作在主线程上的占比控制到 30%;相反,Reddit 的主线程上进行解析和编译工作的达到了将近 80%。
2. 使用 JSON 而不是对象字面量 —— 偶尔
在 JavaScript 中,解析 JSON 比解析对象字面量来得更加高效。 parsing benchmark 已经证实了这一点。在不同的主流 JavaScript 执行引擎中分别解析一个 8MB 大小的文件,前者的解析速度最高可以提升 2 倍。
2019 年谷歌开发者大会 也讨论过 JSON 解析如此高效的两个原因:
JSON 是单字符串 token,而对象字面量可能包含大量的嵌套对象和 token;
语法对上下文是敏感的。解析器逐字检查源代码,并不知道某个代码块是一个对象字面量。而左大括号不仅可以表明它是一个对象字面量,还可以表明它是一个解构对象或者箭头函数。
不过,值得注意的是,JSON.parse 同样会阻塞主线程。对于超过 1MB 的文件,可以使用 FlatBuffers 提高解析效率。
3. 最大化代码缓存
最后,你可以通过完全规避解析来提高解析效率。对于服务端编译来说, WebAssembly (WASM) 是个不错的选择。然而,它没办法替代 JavaScript。对于 JS,更合适的方法是最大化代码缓存。
值得注意的是,缓存并不是任何时候都生效的。在执行结束之前编译的任何代码都会被缓存 —— 这意味着处理器、监听器等不会被缓存。为了最大化代码缓存,你必须最大化执行结束之前编译的代码数量。其中一个方法就是使用立即执行函数(IIFE)启发式:解析器会通过启发式的方法标识出这些 IIFE 函数,它们会在稍后立即被编译。因此,使用启发式的方法可以确保一个函数在脚本执行结束之前被编译。
此外,缓存是基于单个脚本执行的。这意味着更新脚本将会使缓存失效。V8 团队建议可以分割脚本或者合并脚本,从而实现代码缓存。但是,这两个建议是互相矛盾的。你可以阅读“JavaScript 开发中的代码缓存”来了解更多代码缓存相关的信息。
以上就是“JavaScript的运行原理分析”这篇文章的所有内容,感谢各位的阅读!相信大家阅读完这篇文章都有很大的收获,小编每天都会为大家更新不同的知识,如果还想学习更多的知识,请关注编程之家行业资讯频道。