JavaScript 的工作原理:优化解析效率

2025-06-04

JavaScript 的工作原理:优化解析效率

作者:Alvin Wan✏️

理解 JavaScript 的工作原理是编写高效 JavaScript 的关键。编写更高效的代码有很多方法。例如,你可以编写编译器友好的 JavaScript,以避免简单的单行代码导致速度降低 7 倍

在本文中,我们将重点介绍能够最大程度缩短解析时间的 JavaScript 优化方法。我们将重点讨论 V8,它是ElectronNode.jsGoogle Chrome 的JS 引擎。为了理解解析友好的优化方法,我们必须首先讨论 JavaScript 解析的工作原理。本教程概述了三个编写更快 JavaScript 的技巧,每个技巧都基于对解析的深入了解。

作为复习,让我们回顾一下 JavaScript 执行的三个阶段。

  1. 源到语法树——解析器从源生成抽象语法树(AST)
  2. 语法树到字节码——V8 的解释器Ignition从语法树生成字节码(此字节码步骤在 2017 年之前不存在;2017 年之前的 V8 在此处描述
  3. 字节码到机器码——V8 的编译器TurboFan从字节码生成图表,用高度优化的机器码替换字节码的部分内容

LogRocket 免费试用横幅

第二阶段和第三阶段涉及JavaScript 编译。在本教程中,我们将详细讨论第一阶段,并阐明其对编写高效 JavaScript 的影响。我们将按顺序(从左到右、从上到下)讨论解析管道。该管道接收源代码并输出语法树。

JavaScript 解析的抽象语法树 (AST)
抽象语法树(AST)。树本身在解析器中构建,以蓝色突出显示。

扫描器

源代码首先被分解成多个块;每个块可能与不同的编码相关联。然后,流将所有块统一为 UTF-16 编码。

在解析之前,扫描器会将 UTF-16 流分解成标记。标记是脚本中具有语义的最小单位。标记分为几类,包括空格(用于自动插入分号)、标识符、关键字和代理对(仅当代理对无法被识别为其他任何内容时,才会组合起来构成标识符)。这些标记首先被送入预解析器,然后再送入解析器。

预解析器

预解析器只做少量工作,刚好够跳过传入的源代码,从而实现惰性解析(而非即时解析)。预解析器确保输入源代码包含有效的语法,并生成足够的信息来正确编译外部函数。预解析后的函数稍后会按需编译。

解析器

给定扫描仪生成的标记,解析器现在需要生成一个中间表示以供编译器使用。

我们首先需要讨论一下解析树。解析树,又称具体语法树 (CST),将源语法表示为一棵树。每个叶节点代表一个标记,每个中间节点代表一条语法规则。对于英语来说,语法规则可以是名词、主语等。对于代码来说,语法规则是一个表达式。然而,解析树的大小会随着程序规模的扩大而迅速增长。

另一方面,抽象语法树 (AST)更加紧凑。每个中间体代表一个结构,例如减法运算 ( ),并且源代码中并非所有细节都会在树中呈现。例如,括号定义的分组由树结构隐含。此外,标点符号、分隔符和空格都被省略了。您可以在此处-找到 AST 和 CST 之间差异的具体示例

让我们特别关注一下 AST。以下面的 JavaScript 斐波那契程序为例。

function fib(n) {
  if (n <= 1) return n;
  return fib(n-1) + fib(n-2);
}
Enter fullscreen mode Exit fullscreen mode

相应的抽象语法如下,表示为 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"
}

(Source: GitHub)
Enter fullscreen mode Exit fullscreen mode

上面的要点是,每个节点都是一个运算符,叶子节点是操作数。然后,该 AST 将作为 JavaScript 执行的下两个阶段的输入。

优化 JavaScript 的 3 个技巧

在下面的列表中,我们将省略一些被广泛采用的技巧,例如压缩代码以最大化信息密度,从而提高扫描器的时间效率。此外,我们也会跳过一些不太适用的建议,例如避免使用非 ASCII 字符。

提升解析性能的方法有很多。让我们重点介绍一些最常用的方法。

1. 尽可能推迟到工作线程

阻塞主线程会延迟用户交互,因此应尽可能将工作从主线程中卸载。关键在于识别并避免可能导致主线程中任务长时间运行的解析器行为。

这种启发式方法的扩展范围超越了针对解析器的优化。例如,用户控制的 JavaScript 代码片段可以利用Web Worker达到同样的效果。更多信息,请参阅实时处理应用程序教程和使用 Web Worker 的 Angular教程。

避免使用大型内联脚本

内联脚本在主线程上处理,根据上述启发式方法,应避免使用。事实上,任何 JavaScript 加载都会阻塞主线程,异步加载和延迟加载除外。

避免包装外部函数

惰性编译也会在主线程上进行。但是,如果操作正确,惰性解析可以加快启动时间。要强制使用即时解析,您可以使用诸如Optimize.js(已停止维护)之类的工具来决定是使用即时解析还是惰性解析。

拆分 100kB 以上的文件

将大文件拆分成较小的文件,以最大限度地提高脚本的并行加载效率。“ 2019 年Java脚本成本”报告比较了 Facebook 和 Reddit 的文件大小。前者通过将约 6MB 的 JavaScript 拆分到近 300 个请求中,仅在主线程上执行了 30% 的解析和编译。相比之下,Reddit JavaScript 的 80% 解析和编译都在主线程上执行。

2. 有时使用 JSON 代替对象字面量

在 JavaScript 中,解析 JSON 比解析对象字面量效率高得多。在所有主流 JavaScript 执行引擎中,对于 8MB 的文件,解析效率最高可提高 2 倍,正如此解析基准测试所证明的那样

正如2019 年 Chrome 开发者峰会上所讨论的那样,JSON 解析效率高有两个原因

  1. JSON 是一个字符串标记,而对象文字可能包含各种嵌套对象和标记
  2. 语法是上下文敏感的。解析器逐个字符地检查源代码,并不知道这段代码块是一个对象字面量。左括号不仅可以表示对象字面量,还可以表示对象解构或箭头函数。

不过值得注意的是,它JSON.parse也会阻塞主线程。对于大于 1MB 的文件,FlatBuffers可以提高解析效率

3. 最大化代码缓存

最后,你可以通过完全绕过解析来提高解析效率。服务器端编译的一个选项是WebAssembly (WASM)。然而,这并不能取代 JavaScript。对于所有 JS,另一种可能性是最大化代码缓存。

值得注意的是缓存何时生效。任何在执行结束前编译的代码都会被缓存——这意味着处理程序、监听器等不会被缓存。为了最大化代码缓存,必须最大化在执行结束前编译的代码量。一种方法是利用立即调用函数表达式 (IIFE) 启发式算法:解析器使用启发式算法识别这些 IIFE 函数,然后立即对其进行编译。因此,利用这些启发式算法可以确保函数在脚本执行结束前被编译。

此外,缓存是按脚本执行的。这意味着更新脚本将使其缓存失效。然而,V8 开发者对于拆分合并脚本以利用代码缓存给出了相互矛盾的理由。有关代码缓存的更多信息,请参阅“面向 JavaScript 开发者的代码缓存”。

结论

优化解析时间包括将解析延迟到工作线程,以及通过最大化缓存来完全避免解析。通过了解 V8 解析框架,我们可以推断出上面未列出的其他优化方法。

以下是更多用于了解解析框架的资源,包括适用于 V8 和 JavaScript 解析的资源。

额外提示:了解 JavaScript 错误和性能如何影响您的用户。

追踪生产环境中 JavaScript 异常或错误的原因既耗时又费力。如果您有兴趣监控 JavaScript 错误和应用程序性能,以了解问题如何影响用户,不妨尝试 LogRocket。https : //logrocket.com/signup/LogRocket 仪表板免费试用横幅

LogRocket就像 Web 应用的 DVR,可以记录您网站上发生的所有事件。LogRocket 可让您汇总并报告错误,以了解错误发生的频率以及受影响的用户群数量。您可以轻松回放发生错误的特定用户会话,以了解用户的操作导致了错误。

LogRocket 会记录您的应用的请求/响应(包含标头和正文),并结合用户的上下文信息,从而全面了解问题。它还能记录页面上的 HTML 和 CSS,即使是最复杂的单页应用,也能重现像素级完美的视频。

增强您的 JavaScript 错误监控能力 –开始免费监控

JavaScript 的工作原理:优化解析效率一文首先出现在LogRocket 博客上。

文章来源:https://dev.to/bnevilleoneill/how-javascript-works-optimizing-for-parsing-efficiency-892
PREV
如何使用 CSS 滚动捕捉
NEXT
面向前端开发人员的 Docker