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

加载脚本时,浏览器在执行之前需要一些时间来对其进行评估,这可能会导致任务耗时较长。了解脚本评估的工作原理,以及如何防止脚本在网页加载期间导致耗时较长的任务。

在优化 Interaction to Next Paint (INP) 时,您会遇到的大部分建议都是优化互动本身。例如,优化耗时较长的任务指南中讨论了使用 setTimeout 等技术让出结果等。这些技术非常有益,因为它们通过避免耗时较长的任务为主线程留出一定的空间,以便有更多机会让互动和其他 activity 更快地运行,而无需等待单个长任务。

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

什么是脚本评估?

如果您对交付大量 JavaScript 的应用进行了性能分析,可能会遇到耗时较长的任务,其罪魁祸首是评估脚本

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 属性加载的脚本

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

基于 Chromium 的浏览器

在 Chrome 等浏览器(或派生自它的浏览器)中,使用 type=module 属性加载 ES 模块产生的任务会与您在不使用 type=module 时通常看到的任务不同。例如,每个模块脚本都会运行一项涉及标记为“编译模块”的活动。

模块编译可在多个任务中完成,如 Chrome 开发者工具所示。
基于 Chromium 的浏览器中的模块加载行为。每个模块脚本都将生成一个 Compile 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 工作器本身还可以通过 importScripts 或支持模块工作器的浏览器中的静态 import 语句来加载要在工作器上下文中使用的外部脚本。结果是 Web Worker 请求的任何脚本都会在主线程之外进行求值。

权衡和考虑因素

虽然将脚本拆分为多个较小的文件有助于限制耗时较长的任务,而不是加载较少且较大的文件,但在决定如何拆分脚本时,请务必考虑一些因素。

压缩效率

在拆分脚本时,压缩是一项因素。当脚本较小时,压缩效率会有所降低。较大的脚本将从压缩中受益很多。虽然提高压缩效率有助于尽可能缩短脚本的加载时间,但这有点平衡,可以确保将脚本分解成足够小的块,以提高启动期间的交互性。

Bundler 是用于管理网站所依赖脚本的输出大小的理想工具:

  • 对于 webpack,其 SplitChunksPlugin 插件可以提供帮助。请参阅 SplitChunksPlugin 文档,了解您可以设置有助于管理素材资源尺寸的选项。
  • 对于其他捆绑器(例如 Rollupesbuild),您可以在代码中使用动态 import() 调用来管理脚本文件大小。这些捆绑器以及 webpack 会自动将动态导入的资源拆分到其自己的文件中,从而避免出现较大的初始 bundle。

缓存失效

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

通过分解脚本,您不仅可以将脚本评估工作拆分到多个较小的任务中,还可以增加回访者从浏览器缓存(而非网络)抓取更多脚本的可能性。这可以从总体上加快网页加载速度。

嵌套模块和加载性能

如果您要在生产环境中提供 ES 模块并使用 type=module 属性加载这些模块,您需要了解模块嵌套对启动时间的影响。模块嵌套是指某个 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 模块,以避免网络请求链。

总结

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

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

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

这无疑是一种平衡的做法,但通过使用动态 import() 拆分脚本和减少初始载荷,您可以实现更好的启动性能,并更好地适应关键启动期间的用户互动。这将有助于您在 INP 指标上获得更高的得分,从而提供更好的用户体验。

Unshot 中的主打图片,由 Markus Spiske 制作。