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

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

Jeremy Wagner
Jeremy Wagner

在优化 Interaction to Next Paint (INP) 时,您会遇到的大多数建议都是优化互动本身。例如,优化长任务指南中就讨论了使用 setTimeout 让出等其他技术。这些技术很有用,因为它们可以避免执行长时间的任务,从而让主线程有机会休息一下,这可以让互动和其他 activity 有更多机会更快地运行,而不是等待单个长时间的任务。

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

什么是脚本评估?

如果您对包含大量 JavaScript 的应用进行了性能分析,可能会发现一些耗时任务,其罪魁祸首标记为“评估脚本”。

Chrome 开发者工具的性能分析器中显示的脚本评估工作。该工作会导致启动期间出现长时间任务,从而阻止主线程响应用户互动。
脚本评估工作,如 Chrome DevTools 中的性能分析器所示。在这种情况下,工作足以导致长时间运行的任务阻止主线程执行其他工作,包括促成用户互动的任务。

脚本评估是浏览器中执行 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 修复程序)时,整个 bundle 都会失效,并且必须重新下载。

拆分脚本不仅可以将脚本评估工作拆分成更小的任务,还可以提高回访者从浏览器缓存(而非网络)提取更多脚本的可能性。这意味着网页加载速度整体会更快。

嵌套模块和加载性能

如果您要在生产环境中分发 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 指标方面获得更高的分数,从而提供更好的用户体验。