脚本评估和耗时较长的任务

加载脚本时,浏览器需要先评估脚本,然后才能执行,这可能会导致长时间运行的任务。了解脚本评估的工作原理,以及如何防止脚本评估在网页加载期间导致长时间运行的任务。

在优化 Interaction to Next Paint (INP) 方面,您会遇到的大部分建议都是优化互动本身。例如,在优化长时间运行的任务指南中,讨论了使用 setTimeout 等技术。这些技术很有用,因为它们可以通过避免长任务来为主线程提供一些喘息空间,从而让互动和其他活动有更多机会更快运行,而不是必须等待单个长任务。

但是,加载脚本本身导致长时间运行的任务又该怎么办呢?这些任务可能会干扰用户互动,并影响网页在加载期间的 INP。本指南将探讨浏览器如何处理由脚本评估启动的任务,并研究您可以采取哪些措施来分解脚本评估工作,以便您的主线程在网页加载时能够更快速地响应用户输入。

什么是脚本评估?

如果您分析过大量 JavaScript 的应用,可能会看到长时间运行的任务,其中罪魁祸首被标记为 Evaluate Script

Chrome 开发者工具的性能分析器中直观显示的脚本评估工作。该工作会在启动期间导致长任务,从而阻塞主线程响应用户互动的能力。
Chrome 开发者工具的性能分析器中显示的脚本评估工作。在这种情况下,这项工作足以导致长时间运行的任务,从而阻止主线程承担其他工作,包括驱动用户互动的任务。

脚本评估是在浏览器中执行 JavaScript 的必要环节,因为 JavaScript 会在执行前进行即时编译。评估脚本时,系统会先解析脚本以查找错误。如果解析器未发现错误,则脚本会被编译为字节码,然后可以继续执行。

虽然脚本评估是必要的,但它可能会带来问题,因为用户可能会在网页首次呈现后不久尝试与网页互动。但是,网页呈现并不意味着网页已加载完毕。在加载期间发生的互动可能会延迟,因为网页正忙于评估脚本。虽然无法保证互动会在此时发生(因为负责互动的脚本可能尚未加载),但可能会有依赖于 JavaScript 的互动已准备就绪,或者互动根本不依赖于 JavaScript。

脚本与评估脚本的任务之间的关系

负责脚本评估的任务的启动方式取决于您加载的脚本是使用典型的 <script> 元素加载的,还是使用 type=module 加载的模块。由于浏览器倾向于以不同的方式处理事务,因此,如果主要浏览器引擎处理脚本评估的方式各不相同,则会介绍它们如何处理脚本评估。

使用 <script> 元素加载的脚本

调度用于评估脚本的任务数通常与网页上的 <script> 元素数直接相关。每个 <script> 元素都会启动一个任务来评估所请求的脚本,以便对其进行解析、编译和执行。基于 Chromium 的浏览器、Safari、 Firefox 都是这种情况。

这一点为何重要?假设您使用捆绑器来管理生产脚本,并且已将其配置为将网页运行所需的所有内容捆绑到单个脚本中。如果您的网站是这种情况,您可以预期系统会调度单个任务来评估该脚本。这是坏事吗?不一定,除非该脚本非常庞大。

您可以避免加载大量 JavaScript 来分解脚本评估工作,并使用其他 <script> 元素加载更多单独的较小脚本。

虽然您应始终努力在网页加载期间加载尽可能少的 JavaScript,但拆分脚本可确保您拥有更多不会阻塞主线程的较小任务,而不是一个可能会阻塞主线程的大型任务,或者至少比您最初的任务少。

多个涉及脚本评估的任务,如 Chrome 开发者工具的性能分析器中所示。由于加载的是多个较小的脚本,而不是较少的较大脚本,因此任务不太可能变成长任务,从而使主线程能够更快地响应用户输入。
由于网页的 HTML 中存在多个 <script> 元素,因此生成了多个任务来评估脚本。与向用户发送一个大型脚本软件包相比,这种做法更可取,因为后者更有可能阻塞主线程。

您可以将脚本评估任务的分解视为与在互动期间运行的事件回调期间的退让有些类似。不过,对于脚本评估,退让机制会将您加载的 JavaScript 分解为多个较小的脚本,而不是分解为数量较少但更有可能阻塞主线程的较大脚本。

使用 <script> 元素和 type=module 属性加载的脚本

现在,您可以使用 type=module 属性 在浏览器中原生加载 ES 模块。<script>这种脚本加载方法具有一些开发者体验优势,例如无需转换代码以供生产使用,尤其是在与导入映射结合使用时。不过,以这种方式加载脚本会安排因浏览器而异的任务。

基于 Chromium 的浏览器

在 Chrome 等浏览器(或衍生自 Chrome 的浏览器)中,使用 type=module 属性加载 ES 模块会生成与您通常在不使用 type=module 时看到的任务不同的任务。例如,每个模块脚本的任务都将运行,其中涉及标记为 Compile module 的活动。

Chrome 开发者工具中直观显示的多项任务中的模块编译工作。
基于 Chromium 的浏览器中的模块加载行为。每个模块脚本都会生成一个 Compile module 调用,以便在评估之前编译其内容。

模块编译完成后,随后在其中运行的任何代码都会启动标记为 Evaluate module 的活动。

模块的即时评估,如 Chrome 开发者工具的“性能”面板中所示。
当模块中的代码运行时,系统会即时评估该模块。

至少在 Chrome 和相关浏览器中,使用 ES 模块时,编译步骤会被分解。就管理长时间运行的任务而言,这是一个明显的优势;不过,由此产生的模块评估工作仍然意味着您需要承担一些不可避免的费用。虽然您应努力尽可能少地提供 JavaScript,但无论使用哪种浏览器,使用 ES 模块都具有以下优势:

  • 所有模块代码都会自动以严格模式运行,这允许 JavaScript 引擎进行潜在的优化,否则在非严格上下文中无法进行这些优化。
  • 使用 type=module 加载的脚本会被视为默认延迟。您可以在使用 type=module 加载的脚本上使用 async 属性来更改此行为。

Safari 和 Firefox

在 Safari 和 Firefox 中加载模块时,每个模块都会在单独的任务中进行评估。这意味着,从理论上讲,您可以加载一个仅包含对其他模块的 静态 import 语句的顶级模块,并且加载的每个模块都会产生单独的网络请求和任务来评估它。

使用动态 import() 加载的脚本

动态 import() 是加载脚本的另一种方法。与必须位于 ES 模块顶部的静态 import 语句不同,动态 import() 调用可以出现在脚本中的任何位置,以便按需加载 JavaScript 代码块。这种技术称为代码拆分

在提高 INP 方面,动态 import() 有两个优势:

  1. 延迟加载的模块通过减少当时加载的 JavaScript 量,减少了启动期间的主线程争用。这释放了主线程,使其能够更快速地响应用户互动。
  2. 进行动态 import() 调用时,每个调用都会有效地将每个模块的编译和评估分离到自己的任务中。当然,加载非常大的模块的动态 import() 会启动一个相当大的脚本评估任务,如果互动与动态 import() 调用同时发生,则可能会干扰主线程响应用户输入的能力。因此,尽可能少地加载 JavaScript 仍然非常重要。

动态 import() 调用在所有主要浏览器引擎中的行为类似:生成的脚本评估任务将与动态导入的模块数量相同。

在 Web Worker 中加载的脚本

Web Worker 是一种特殊的 JavaScript 用例。Web Worker 在主线程上注册,然后 Worker 中的代码会在自己的线程上运行。从某种意义上说,这是非常有益的,因为注册 Web Worker 的代码在主线程上运行,而 Web Worker 中的代码则不会。这减少了主线程拥塞,有助于使主线程更快速地响应用户互动。

除了减少主线程工作之外,Web Worker 本身还可以加载外部脚本以在 Worker 上下文中使用,方法是通过 importScripts 或支持 模块 Worker 的浏览器中的静态 import 语句。结果是,Web Worker 请求的任何脚本都会在主线程之外进行评估。

权衡和考虑因素

虽然将脚本拆分为单独的较小文件有助于限制长时间运行的任务,而不是加载数量较少但体积较大的文件,但在决定如何拆分脚本时,请务必考虑一些事项。

压缩效率

压缩在拆分脚本时是一个需要考虑的因素。脚本越小,压缩效率就越低。较大的脚本将从压缩中受益更多。虽然提高压缩效率有助于尽可能缩短脚本的加载时间,但为了确保将脚本拆分为足够多的较小代码块,以便在启动期间实现更好的互动性,需要进行一些权衡。

捆绑器是管理网站所依赖脚本的输出大小的理想工具:

  • 就 webpack 而言,其 SplitChunksPlugin 插件可以提供帮助。请参阅 SplitChunksPlugin 文档,了解您可以设置哪些选项来帮助管理资源大小。
  • 对于 Rollupesbuild 等其他捆绑器,您可以使用代码中的动态 import() 调用来管理脚本文件大小。这些捆绑器(以及 webpack)会自动将动态导入的资源拆分为自己的文件,从而避免较大的初始软件包大小。

缓存失效操作

缓存失效操作在网页重复访问时的加载速度方面发挥着重要作用。当您提供大型的单体脚本软件包时,在浏览器缓存方面处于不利地位。这是因为,当您更新第一方代码(无论是通过更新软件包还是提供 bug 修复)时,整个软件包都会失效,并且必须重新下载。

通过拆分脚本,您不仅可以将脚本评估工作分解为较小的任务,还可以提高回访者从浏览器缓存而不是从网络获取更多脚本的可能性。这会转化为整体更快的网页加载速度。

嵌套模块和加载性能

如果您在生产环境中提供 ES 模块并使用 type=module 属性加载它们,则需要了解模块嵌套如何影响启动时间。模块嵌套是指 ES 模块静态导入另一个 ES 模块,而该 ES 模块又静态导入另一个 ES 模块:

// a.js
import {b} from './b.js';

// b.js
import {c} from './c.js';

如果您的 ES 模块未捆绑在一起,则上述代码会导致网络请求链:当从 <script> 元素请求 a.js 时,系统会为 b.js 调度另一个网络请求,然后涉及对 c.js另一个 请求。避免这种情况的一种方法是使用捆绑器,但请确保您将捆绑器配置为拆分脚本以分散脚本评估工作。

如果您不想使用捆绑器,那么解决嵌套模块调用的另一种方法是使用 modulepreload 资源提示,该提示会提前预加载 ES 模块,以避免网络请求链。

总结

毫无疑问,优化浏览器中的脚本评估是一项棘手的任务。具体方法取决于网站的要求和限制。不过,通过拆分脚本,您可以将脚本评估工作分散到许多较小的任务中,从而使主线程能够更高效地处理用户互动,而不是阻塞主线程。

总而言之,您可以采取以下措施来分解大型脚本评估任务:

  • 使用 <script> 元素加载脚本而不使用 type=module 属性时,请避免加载非常大的脚本,因为这些脚本会启动资源密集型脚本评估任务,从而阻塞主线程。将脚本分散到更多 <script> 元素中,以分解这项工作。
  • 使用 type=module 属性在浏览器中原生加载 ES 模块将为每个单独的模块脚本启动单独的评估任务。
  • 使用动态 import() 调用来缩减初始软件包的大小。这在捆绑器中也适用,因为捆绑器会将每个动态导入的模块视为“拆分点”,从而为每个动态导入的模块生成单独的脚本。
  • 请务必权衡压缩效率和缓存失效操作等权衡因素。较大的脚本压缩效果更好,但更有可能在较少的任务中涉及更昂贵的脚本评估工作,并导致浏览器缓存失效,从而导致整体缓存效率降低。
  • 如果原生使用 ES 模块而不进行捆绑,请使用 modulepreload 资源提示来优化启动期间的加载。
  • 与往常一样,尽可能少地提供 JavaScript。

这当然需要权衡,但通过拆分脚本并使用动态 import() 缩减初始载荷,您可以实现更好的启动性能,并在关键的启动期间更好地响应用户互动。这应该有助于您在 INP 指标上获得更高的分数,从而提供更好的用户体验。