代码拆分 JavaScript

加载大型 JavaScript 资源会严重影响网页速度。将 JavaScript 拆分为更小的块,并在启动期间仅下载网页正常运行所需的内容,可以大幅提高网页的加载响应速度,进而提高网页的“与下次绘制的互动时间”(INP)

当网页下载、解析和编译大型 JavaScript 文件时,可能会在一段时间内变得无响应。网页元素是可见的,因为它们是网页初始 HTML 的一部分,并且由 CSS 设置样式。不过,由于为这些互动元素提供支持所需的 JavaScript(以及网页加载的其他脚本)可能会解析和执行 JavaScript,因此这些元素才能正常运行。这样一来,用户可能会感觉互动严重延迟,甚至完全中断。

这种情况通常是因为主线程被阻塞,因为 JavaScript 是在主线程上解析和编译的。如果此过程耗时过长,互动式网页元素可能无法及时响应用户输入。一种补救措施是仅加载网页正常运行所需的 JavaScript,同时通过一种称为代码拆分的技术,延迟加载其他 JavaScript。本模块重点介绍后一种技术。

通过代码拆分减少启动期间的 JavaScript 解析和执行

JavaScript 执行时间超过 2 秒时,Lighthouse 会抛出警告;当执行时间超过 3.5 秒时,Lighthouse 会失败。过多的 JavaScript 解析和执行在网页生命周期的任何时间点都可能成为问题,因为如果用户与网页互动的时间与负责处理和执行 JavaScript 的主线程任务的运行时间重合,则可能会增加互动的输入延迟时间

不仅如此,过多的 JavaScript 执行和解析在网页初始加载期间尤其成问题,因为这是用户很可能会与网页互动的网页生命周期阶段。事实上,总阻塞时间 (TBT)(一种加载响应速度指标)与 INP 高度相关,这表明用户在网页初始加载期间很可能会尝试互动。

Lighthouse 审核会报告网页请求的每个 JavaScript 文件的执行时间,这有助于您准确识别哪些脚本可能适合进行代码拆分。然后,您可以使用 Chrome 开发者工具中的覆盖率工具来准确识别网页加载期间未使用的 JavaScript 部分,从而进一步优化。

代码拆分是一种实用技术,可减少网页的初始 JavaScript 载荷。它可让您将 JavaScript 软件包拆分为两部分:

  • 在网页加载时需要,因此无法在任何其他时间加载。
  • 可在稍后时间点加载的剩余 JavaScript,通常是在用户与网页上的指定互动元素互动时加载。

您可以使用 dynamic import() 语法来拆分代码。与在启动期间请求给定 JavaScript 资源的 <script> 元素不同,此语法会在网页生命周期的后期请求 JavaScript 资源。

document.querySelectorAll('#myForm input').addEventListener('blur', async () => {
  // Get the form validation named export from the module through destructuring:
  const { validateForm } = await import('/validate-form.mjs');

  // Validate the form:
  validateForm();
}, { once: true });

在上面的 JavaScript 代码段中,只有当用户模糊处理表单的任何 <input> 字段时,系统才会下载、解析和执行 validate-form.mjs 模块。在这种情况下,负责驱动表单验证逻辑的 JavaScript 资源仅在最有可能实际使用时才与网页相关联。

JavaScript 捆绑器(例如 webpackParcelRollupesbuild)可以配置为在源代码中遇到动态 import() 调用时,将 JavaScript 捆绑包拆分为更小的块。大多数工具都会自动执行此操作,但 esbuild 需要您选择启用此优化。

有关代码拆分的实用说明

虽然代码拆分是一种在初始网页加载期间减少主线程争用的有效方法,但如果您决定审核 JavaScript 源代码以寻找代码拆分机会,请务必注意以下几点。

尽可能使用捆绑器

开发者通常会在开发过程中使用 JavaScript 模块。这是一项出色的开发者体验改进,可提高代码的可读性和可维护性。不过,将 JavaScript 模块发布到生产环境时,可能会出现一些次优的性能特征。

最重要的是,您应使用打包程序来处理和优化源代码,包括您打算进行代码拆分的模块。打包器不仅能有效地对 JavaScript 源代码应用优化,还能在压缩率与软件包大小等性能考虑因素之间取得平衡。压缩效果会随着软件包大小的增加而提高,但打包器也会尽量确保软件包不会过大,以免因脚本评估而产生长时间的任务。

捆绑器还可以避免通过网络传送大量未捆绑的模块。使用 JavaScript 模块的架构往往具有庞大而复杂的模块树。当模块树未捆绑时,每个模块都代表一个单独的 HTTP 请求,如果您不捆绑模块,Web 应用中的互动可能会延迟。虽然可以使用 <link rel="modulepreload"> 资源提示 尽可能早地加载大型模块树,但从加载性能的角度来看,JavaScript 软件包仍然是首选。

不会意外停用流式编译

Chromium 的 V8 JavaScript 引擎开箱即用,可提供多项优化,确保您的生产 JavaScript 代码尽可能高效地加载。其中一种优化称为流式编译,它与流式传输到浏览器的 HTML 的增量解析类似,会编译从网络到达的 JavaScript 流式传输块。

您可以通过多种方式确保在 Chromium 中对 Web 应用进行流式编译:

  • 转换生产代码,以避免使用 JavaScript 模块。打包器可以根据编译目标转换 JavaScript 源代码,而目标通常特定于给定环境。V8 会对未使用模块的任何 JavaScript 代码应用流式编译,您可以配置打包程序,将 JavaScript 模块代码转换为不使用 JavaScript 模块及其功能的语法。
  • 如果您想将 JavaScript 模块发布到正式版,请使用 .mjs 扩展程序。无论您的生产 JavaScript 是否使用模块,使用模块的 JavaScript 与不使用模块的 JavaScript 都没有特殊的内容类型。就 V8 而言,如果您在生产环境中使用 .js 扩展程序交付 JavaScript 模块,则实际上是选择不使用流式编译。如果您将 .mjs 扩展程序用于 JavaScript 模块,V8 可以确保基于模块的 JavaScript 代码的流式编译不会中断。

不过,请不要因为这些考虑因素而放弃使用代码拆分。代码拆分是减少用户初始 JavaScript 载荷的有效方法,但通过使用打包程序并了解如何保留 V8 的流式编译行为,您可以确保生产 JavaScript 代码尽可能快地运行。

动态导入演示

webpack

webpack 附带一个名为 SplitChunksPlugin 的插件,可让您配置捆绑器拆分 JavaScript 文件的方式。webpack 可识别动态 import() 和静态 import 语句。您可以在 SplitChunksPlugin 的配置中指定 chunks 选项,以修改其行为:

  • chunks: async 是默认值,是指动态 import() 调用。
  • chunks: initial 是指静态 import 调用。
  • chunks: all 同时涵盖动态 import() 和静态导入,让您可以在 asyncinitial 导入之间共享块。

默认情况下,每当 webpack 遇到动态 import() 语句时,它都会为相应模块创建一个单独的 chunk:

/* main.js */

// An application-specific chunk required during the initial page load:
import myFunction from './my-function.js';

myFunction('Hello world!');

// If a specific condition is met, a separate chunk is downloaded on demand,
// rather than being bundled with the initial chunk:
if (condition) {
  // Assumes top-level await is available. More info:
  // https://v8.dev/features/top-level-await
  await import('/form-validation.js');
}

上述代码段的默认 webpack 配置会生成两个单独的 chunk:

  • 包含 main.js./my-function.js 模块的 main.js chunk(webpack 将其归类为 initial chunk)。
  • async 代码块,仅包含 form-validation.js(如果已配置,则包含资源名称中的文件哈希)。仅当 conditiontruthy 时,才会下载此块。

此配置可让您延迟加载 form-validation.js 代码块,直到实际需要时才加载。这样可以减少初始网页加载期间的脚本评估时间,从而提高加载响应速度。当满足指定条件时,系统会下载并评估 form-validation.js 代码块的脚本,在这种情况下,系统会下载动态导入的模块。例如,仅针对特定浏览器下载 Polyfill 的情况,或者(如前面的示例所示)导入的模块是用户互动所必需的。

另一方面,将 SplitChunksPlugin 配置更改为指定 chunks: initial 可确保仅在初始块上拆分代码。这些是静态导入的块,或在 webpack 的 entry 属性中列出的块。在上述示例中,生成的块将是单个脚本文件中的 form-validation.js main.js 的组合,这可能会导致初始网页加载性能变差。

您还可以配置 SplitChunksPlugin 的选项,将较大的脚本拆分为多个较小的脚本,例如使用 maxSize 选项指示 webpack 将块拆分为单独的文件(如果它们超过 maxSize 指定的大小)。将大型脚本文件拆分为较小的文件可以提高加载响应速度,因为在某些情况下,CPU 密集型脚本评估工作会拆分为较小的任务,这些任务不太可能长时间阻塞主线程。

此外,生成较大的 JavaScript 文件还意味着脚本更容易受到缓存失效的影响。例如,如果您随附一个包含框架代码和第一方应用代码的大型脚本,那么即使仅更新框架,捆绑资源中的其他内容保持不变,整个软件包也可能会失效。

另一方面,较小的脚本文件会增加回访者从缓存中检索资源的可能性,从而加快重复访问时的网页加载速度。不过,与较大的文件相比,较小的文件从压缩中获得的益处较少,并且在浏览器缓存未预先填充的情况下,可能会增加网页加载时的网络往返时间。必须注意在缓存效率、压缩效果和脚本评估时间之间取得平衡。

webpack 演示

webpack SplitChunksPlugin 演示

知识测验

执行代码拆分时使用哪种类型的 import 语句?

动态 import()
静态 import

哪种类型的 import 语句必须位于 JavaScript 模块的顶部,而不能位于其他位置?

动态 import()
静态 import

在 webpack 中使用 SplitChunksPlugin 时,async chunk 和 initial chunk 有何区别?

async chunk 使用静态 import 加载,initial chunk 使用动态 import() 加载。
async 代码块使用动态 import() 加载,initial 代码块使用静态 import 加载。

接下来:延迟加载图片和 <iframe> 元素

虽然 JavaScript 往往是一种相当昂贵的资源类型,但您不仅可以延迟加载 JavaScript,还可以延迟加载其他资源类型。图片和 <iframe> 元素本身就是潜在的昂贵资源。与 JavaScript 类似,您可以延迟加载图片和 <iframe> 元素,这将在本课程的下一单元中进行说明。