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

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

在优化 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 DevTools 的性能分析器中所示。由于加载的是多个较小的脚本,而不是较少的较大脚本,因此任务不太可能变成长任务,从而使主线程能够更快地响应用户输入。
由于网页的 HTML 中存在多个 <script> 元素,为评估脚本而生成多个任务。最好向用户发送一个大型脚本包,因为这更有可能阻塞主线程。

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

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

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

基于 Chromium 的浏览器

在 Chrome 等浏览器(或从中派生的浏览器)中,使用 type=module 属性加载 ES 模块会产生与不使用 type=module 时通常看到的任务不同的任务。例如,系统会运行每个模块脚本的任务,其中涉及标记为编译模块的 activity。

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

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

对模块进行实时评估,如 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 工作器是一种特殊的 JavaScript 用例。Web worker 会在主线程中注册,然后 worker 中的代码会在自己的线程中运行。这非常有益,因为虽然用于注册 Web Worker 的代码在主线程上运行,但 Web Worker 中的代码不会在主线程上运行。这可以减少主线程拥塞,并有助于主线程对用户互动做出更快速的响应。

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

权衡和考虑因素

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

压缩效率

在拆分脚本时,压缩是一个因素。脚本越小,压缩效率就越低。脚本越大,压缩带来的好处就越大。虽然提高压缩效率有助于尽可能缩短脚本的加载时间,但这有点平衡,可以确保将脚本分解成足够小的块,以提高启动期间的交互性。

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

  • 对于 webpack,其 SplitChunksPlugin 插件可以派上用场。如需了解可用于帮助管理资源大小的选项,请参阅 SplitChunksPlugin 文档
  • 对于其他捆绑工具(例如 Rollupesbuild),您可以在代码中使用动态 import() 调用来管理脚本文件大小。这些捆绑器以及 webpack 会自动将动态导入的资源拆分到自己的文件中,从而避免初始捆绑包大小变大。

缓存失效操作

缓存失效对网页在重复访问时的加载速度有很大影响。如果您发布的是大型单体式脚本软件包,那么在浏览器缓存方面会处于劣势。这是因为,当您更新第一方代码(无论是通过更新软件包还是发布 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() 调用缩减初始软件包的大小。这在捆绑工具中也适用,因为捆绑工具会将每个动态导入的模块视为“分屏点”,从而为每个动态导入的模块生成单独的脚本。
  • 请务必权衡压缩效率和缓存失效等权衡因素。脚本越大,压缩率就越好,但也更有可能在更少的任务中涉及费用更高的脚本评估工作,从而导致浏览器缓存失效,从而导致整体缓存效率降低。
  • 如果以原生方式使用 ES 模块(而非捆绑),请使用 modulepreload 资源提示来优化启动期间的模块加载。
  • 与往常一样,尽可能少地提供 JavaScript。

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