加载大型 JavaScript 资源会严重影响页面速度。将您的 JavaScript 拆分成更小的块,并且仅下载页面启动期间运行所必需的内容,可以极大地提高页面的加载响应速度,进而可以提高页面的Interaction to Next Paint (INP)。
当页面下载、解析和编译大型 JavaScript 文件时,可能会在一段时间内变得无响应。页面元素是可见的,因为它们是页面初始 HTML 的一部分,并且由 CSS 设置样式。但是,由于驱动这些交互式元素以及页面加载的其他脚本所需的 JavaScript 可能正在解析和执行 JavaScript 以使其正常运行。结果是用户可能会感觉交互被显着延迟,甚至完全中断。
这种情况通常发生是因为主线程被阻塞,因为 JavaScript 在主线程上解析和编译。如果此过程花费的时间太长,则交互式页面元素可能无法足够快地响应用户输入。一种补救方法是仅加载页面运行所需的 JavaScript,同时通过称为代码拆分的技术将其他 JavaScript 推迟到稍后加载。本模块重点介绍后两种技术。
通过代码拆分减少启动期间的 JavaScript 解析和执行
当 Lighthouse 发现 JavaScript 执行时间超过 2 秒时会发出警告,超过 3.5 秒时会失败。过多的 JavaScript 解析和执行在页面生命周期的任何时间点都可能成为问题,因为它有可能增加交互的 输入延迟,如果用户与页面交互的时间与负责处理和执行 JavaScript 的主线程任务运行的时刻重合。
不仅如此,过多的 JavaScript 执行和解析在初始页面加载期间尤其成问题,因为这是用户很可能与页面交互的页面生命周期中的时间点。事实上,Total Blocking Time (TBT)(加载响应速度指标)与 INP 高度相关,这表明用户在初始页面加载期间很可能尝试交互。
Lighthouse 审核会报告页面请求的每个 JavaScript 文件所花费的执行时间,这非常有用,因为它可以帮助您准确识别哪些脚本可能是 代码拆分 的候选对象。然后,您可以进一步使用 Chrome DevTools 中的 coverage tool 来准确识别页面加载期间页面 JavaScript 的哪些部分未使用。
代码拆分是一种有用的技术,可以减少页面的初始 JavaScript 有效负载。它可以让您将 JavaScript 包拆分为两个部分
- 页面加载时需要的 JavaScript,因此无法在任何其他时间加载。
- 其余 JavaScript 可以在稍后的时间点加载,最常见的情况是在用户与页面上的给定交互式元素交互时加载。
代码拆分可以通过使用 动态 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 打包器(如 webpack、Parcel、Rollup 和 esbuild)可以配置为在遇到源代码中的动态 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 模块时,您实际上会选择退出流式编译。如果您对 JavaScript 模块使用.mjs
扩展名,则 V8 可以确保模块化 JavaScript 代码的流式编译不会中断。
不要让这些考虑因素阻止您使用代码拆分。代码拆分是减少用户初始 JavaScript 有效负载的有效方法,但通过使用打包器并了解如何保留 V8 的流式编译行为,您可以确保您的生产 JavaScript 代码对用户来说尽可能快。
动态导入演示
webpack
webpack 附带一个名为 SplitChunksPlugin
的插件,可让您配置打包器如何拆分 JavaScript 文件。webpack 识别动态 import()
和静态 import
语句。SplitChunksPlugin
的行为可以通过在其配置中指定 chunks
选项来修改
chunks: async
是默认值,指的是动态import()
调用。chunks: initial
指的是静态import
调用。chunks: all
涵盖动态import()
和静态导入,允许您在async
和initial
导入之间共享块。
默认情况下,每当 webpack 遇到动态 import()
语句时,它都会为该模块创建一个单独的块
/* 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.node.org.cn/features/top-level-await
await import('/form-validation.js');
}
前面代码片段的默认 webpack 配置会产生两个单独的块
main.js
块(webpack 将其分类为initial
块),其中包含main.js
和./my-function.js
模块。async
块,其中仅包含form-validation.js
(如果已配置,则资源名称中包含 文件哈希)。仅当condition
为 truthy 时,才会下载此块。
此配置允许您延迟加载 form-validation.js
块,直到实际需要时才加载。这可以通过减少初始页面加载期间的 脚本评估 时间来提高加载响应速度。form-validation.js
块的脚本下载和评估发生在满足指定条件时,在这种情况下,将下载动态导入的模块。一个示例可能是仅针对特定浏览器下载 polyfill 的条件,或者(如前面的示例中)导入的模块对于用户交互是必需的。
另一方面,将 SplitChunksPlugin
配置更改为指定 chunks: initial
可确保仅在初始块上拆分代码。这些块是静态导入的块,或在 webpack 的 entry
属性 中列出的块。查看前面的示例,生成的块将是单个脚本文件中的 form-validation.js
和 main.js
的组合,从而可能导致更差的初始页面加载性能。
SplitChunksPlugin
的选项也可以配置为将较大的脚本分成多个较小的脚本,例如,通过使用 maxSize
选项来指示 webpack 将块拆分成单独的文件(如果它们超过 maxSize
指定的大小)。将大型脚本文件分成较小的文件可以提高加载响应速度,因为在某些情况下,CPU 密集型脚本评估工作被分成较小的任务,这些任务不太可能长时间阻塞主线程。
此外,生成较大的 JavaScript 文件也意味着脚本更可能遭受缓存失效。例如,如果您运送一个非常大的脚本,其中包含框架代码和第一方应用程序代码,则仅当框架更新时(但捆绑资源中的其他任何内容都没有更新时),整个捆绑包都可能失效。
另一方面,较小的脚本文件增加了回头客从缓存中检索资源的可能性,从而加快了重复访问时的页面加载速度。但是,与较大的文件相比,较小的文件从压缩中受益较少,并且可能会增加未预热浏览器缓存的页面加载时的网络往返时间。必须注意在缓存效率、压缩效果和脚本评估时间之间取得平衡。
webpack 演示
测试您的知识
执行代码拆分时使用哪种类型的 import
语句?
import()
。import
。哪种类型的 import
语句必须位于 JavaScript 模块的顶部,且不能位于其他任何位置?
import()
。import
。在 webpack 中使用 SplitChunksPlugin
时,async
块和 initial
块之间有什么区别?
async
块使用动态 import()
加载,initial
块使用静态 import
加载。async
块使用静态 import
加载,initial
块使用动态 import()
加载。下一步:延迟加载图片和 <iframe>
元素
虽然 JavaScript 往往是一种相当昂贵的资源类型,但它并不是您可以延迟加载的唯一资源类型。图片和 <iframe>
元素本身也是潜在的昂贵资源。与 JavaScript 类似,您可以通过延迟加载它们来延迟加载图片和 <iframe>
元素,这将在本课程的下一模块中进行解释。