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

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

关于优化 Interaction to Next Paint (INP),您遇到的大部分建议是优化交互本身。例如,在优化长时间运行的任务指南中,我们讨论了使用 setTimeoutisInputPending 等生成等技术。这些技术是有益的,因为它们可以避免耗时较长的任务,为主线程留出一些余地,让互动和其他 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 模块。这种脚本加载方法对开发者来说会带来一些好处,例如无需转换代码以用于生产用途,尤其是在与导入映射结合使用时。不过,如果以这种方式加载脚本,系统会为各个浏览器安排不同的任务。

基于 Chromium 的浏览器

在 Chrome 等浏览器(或派生自该模块的浏览器)中,使用 type=module 属性加载 ES 模块生成的任务类型与您通常不使用 type=module 时通常会看到的任务不同。例如,每个模块脚本将运行一个涉及标记为 Compile 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 Worker 本身还可以通过 importScripts 或支持模块 worker 的浏览器中的静态 import 语句,加载要在 worker 上下文中使用的外部脚本。其结果是,Web Worker 请求的任何脚本均在主线程以外进行求值。

权衡和考虑因素

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

压缩效率

拆分脚本时需要压缩。如果脚本较小,压缩效率会降低。较大的脚本从压缩中受益更多。虽然提高压缩效率有助于尽可能缩短脚本的加载时间,但有一些平衡点,可确保将脚本拆分为足够小的块,以便在启动期间实现更好的互动性。

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

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

缓存失效

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

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

嵌套模块和加载性能

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

主打图片来自 Unsplash 用户,由 Markus Spiske 制作。