脚本评估和长任务

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

在优化与下次绘制的交互 (INP)时,您遇到的大多数建议都是优化交互本身。例如,在优化长任务指南中,讨论了诸如使用 setTimeout 等技术来产生让步。这些技术是有益的,因为它们通过避免长任务来为主线程提供一些喘息空间,这可以为交互和其他活动提供更多机会更快地运行,而不是必须等待单个长任务。

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

什么是脚本评估?

如果您分析了一个发布大量 JavaScript 的应用程序,您可能已经看到长任务,其罪魁祸首被标记为评估脚本

Script evaluation work as visualized in the performance profiler of Chrome DevTools. The work causes a long task during startup, which blocks the main thread's ability to respond to user interactions.
脚本评估工作,如 Chrome DevTools 中的性能分析器所示。在这种情况下,这项工作足以导致一个长任务,阻止主线程承担其他工作,包括驱动用户交互的任务。

脚本评估是在浏览器中执行 JavaScript 的必要部分,因为 JavaScript 是在执行前进行即时编译。当评估脚本时,首先对其进行错误解析。如果解析器没有发现错误,则脚本将被编译为字节码,然后可以继续执行。

虽然脚本评估是必要的,但它可能会有问题,因为用户可能会在页面最初呈现后不久尝试与页面进行交互。但是,仅仅因为页面已呈现并不意味着页面已完成加载。在加载期间发生的交互可能会被延迟,因为页面正忙于评估脚本。虽然不能保证交互可以在此时发生(因为负责它的脚本可能尚未加载),但可能存在依赖于 JavaScript 的交互准备就绪,或者交互性根本不依赖于 JavaScript。

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

负责脚本评估的任务如何启动取决于您加载的脚本是使用典型的 <script> 元素加载,还是脚本是使用 type=module 加载的模块。由于浏览器倾向于以不同的方式处理事情,因此将触及主要浏览器引擎如何处理脚本评估,其中它们之间的脚本评估行为有所不同。

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

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

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

您可以通过避免加载大型 JavaScript 块来分解脚本评估工作,并使用额外的 <script> 元素加载更多单独的、较小的脚本。

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

Multiple tasks involving script evaluation as visualized in the performance profiler of Chrome DevTools. Because multiple smaller scripts are loaded instead of fewer larger scripts, tasks are less likely to become long tasks, allowing the main thread to respond to user input more quickly.
由于页面 HTML 中存在多个 <script> 元素而产生的多个用于评估脚本的任务。这比向用户发送一个大型脚本捆绑包更可取,后者更可能阻塞主线程。

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

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

现在可以使用 <script> 元素上的 type=module 属性在浏览器中原生加载 ES 模块。这种脚本加载方法带来了一些开发者体验优势,例如不必转换代码以供生产使用,尤其是在与 导入映射结合使用时。但是,以这种方式加载脚本会安排不同浏览器的任务。

基于 Chromium 的浏览器

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

Module compilation work in multiple tasks as visualized in Chrome DevTools.
基于 Chromium 的浏览器中的模块加载行为。每个模块脚本都将生成一个编译模块调用,以在评估之前编译其内容。

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

Just-in-time evaluation of a module as visualized in the performance panel of Chrome DevTools.
当模块中的代码运行时,将对该模块进行即时评估。

这里的效果是,在 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)将自动将动态导入的资源分解为自己的文件,从而避免更大的初始捆绑包大小。

缓存失效

缓存失效在页面重复访问时的加载速度方面起着重要作用。当您发布大型、单体脚本捆绑包时,您在浏览器缓存方面处于不利地位。这是因为当您更新第一方代码时(通过更新包或发布错误修复),整个捆绑包都会失效,并且必须再次下载。

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

嵌套模块和加载性能

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