Performance Patterns
翻译自 https://www.patterns.dev/vanilla/bundle-splitting 图片可参考原文
因此我们可以将非初始页面渲染所需的代码与初始页面所需的代码分离,而不是一开始就请求对当前导航优先级不高的部分代码。通过将大型捆绑包拆分为两个较小的捆绑包 main.bundle.js 和 emoji-picker.bundle.js,我们通过获取更少量的数据来减少初始加载时间。在这个项目中,我们将介绍一些方法,允许我们将应用程序拆分为多个较小的捆绑包,并以最高效和最出色的方式加载资源。
性能模式之资源压缩
Compressing JavaScript
JavaScript 是网页大小贡献排名第二的网络资源,仅次于图片,也是互联网上请求量排名第二的资源。我们使用减少 JavaScript 传输、加载和执行时间的模式来提高网站性能。压缩有助于减少通过网络传输脚本所需的时间。
你可以将压缩与其他技术(如代码最小化、代码拆分、捆绑、缓存和懒加载)相结合,以减少大量 JavaScript 对性能的影响。然而,这些技术的目标有时会相互冲突。本节探讨了 JavaScript 压缩技术,并讨论了决定采用代码拆分和压缩策略时需要考虑的细节。
Gzip 和 Brotli 是压缩 JavaScript 的最常见方式,现代浏览器广泛支持这两种方式。在相似的压缩级别下,Brotli 提供了更高的压缩率。Next.js 默认提供 Gzip 压缩,但建议在 Nginx 等 HTTP 代理上启用它。如果你使用 Webpack 来捆绑你的代码,你可以使用 CompressionPlugin 进行 Gzip 压缩或使用 BrotliWebpackPlugin 进行 Brotli 压缩。Oyo 和 Wix 在改用 Brotli 压缩后,文件大小分别减少了 15-20%和 21-25%。
压缩满足以下不等式:compress(a + b)≤compress(a)+compress(b)。单个大型捆绑包会比多个较小的捆绑包提供更好的压缩效果。这导致了粒度权衡问题,其中去重和缓存与浏览器性能和压缩相冲突。粒度分块可以帮助解决这种权衡问题。
代码拆分(Bundle Splitting)
在构建现代 web 应用时,像 Webpack 或 Rollup 这样的打包工具会将应用程序的源代码打包成一个或多个捆绑包。当用户访问网站时,会请求并加载捆绑包,以便将数据展示给用户屏幕。
诸如 V8 之类的 JavaScript 引擎能够在数据加载时解析和编译用户请求的数据。尽管现代浏览器已经尽可能地快速和高效地解析和编译代码,但开发者仍然需要负责优化过程中的两个步骤:
请求数据的加载时间 和 执行时间。
我们希望确保尽可能地缩短执行时间,以防止阻塞主线程。
即使现代浏览器能够在收到捆绑包时进行流式传输,但在第一个像素绘制在用户设备上之前,仍然需要相当长的时间。捆绑包越大,引擎到达首次渲染调用的行数所需要的时间就越长。在那段时间里,用户必须长时间盯着空白屏幕,这可能会非常令人沮丧!
针对这种情况,Bundle Splitting 技术通过将代码拆分成更小的块来优化加载时间和执行时间。这样,浏览器可以按需加载必要的代码块,而不是一次性加载整个应用程序的捆绑包。通过这种方式,可以提高应用程序的初始加载速度和性能,减少用户的等待时间并提高用户体验。
通过将应用程序进行拆分打包,我们可以减少加载、处理和执行捆绑包所需的时间!通过减少加载和执行时间,我们可以减少从第一次内容绘制在用户屏幕上开始到页面显示前的耗时(First Contentful Paint
),以及到屏幕渲染最大组件所需的时间(Largest Contentful Paint
)。
虽然能在屏幕上看到数据非常好,但我们不仅仅希望看到内容。为了拥有完整功能的应用程序,我们希望用户能够与之交互!只有在捆绑包加载并执行后,用户界面才能变得交互。从所有内容都绘制在屏幕上并且变得交互所需的时间被称为交互时间(Time To Interactive
)。
在用户能够在屏幕上看到任何内容之前,引擎仍然需要加载、解析和编译代码,即使这些代码在初始渲染中没有使用到。虽然解析和编译成本可以因为浏览器高效地处理这两个步骤而几乎可以忽略不计,但是获取不必要的较大捆绑包可能会损害应用程序的性能。对于低端设备或较慢网络的用户来说,在获取捆绑包之前加载时间会显著增长。即使引擎只需要文件中的最后一部分内容,第一部分的加载和处理仍然必要。
HTTP 压缩
压缩可以减少文档和文件的大小,使其占用的磁盘空间比原始文件小。较小的文档占用较少的带宽,可以更快地通过网络传输。HTTP 压缩使用这一简单概念来压缩网站内容,减少页面权重,降低带宽要求,提高性能。
HTTP 数据压缩可以按不同的方式进行分类。其中之一是有损压缩与无损压缩。
有损压缩意味着在压缩-解压缩循环中会稍微改变文档,同时保留其可用性。对于最终用户来说,这种改变通常是察觉不到的。有损压缩最常见的例子是 JPEG 图像压缩。
无损压缩是指经过压缩和随后的解压缩后恢复的数据与原始数据完全匹配。PNG 图像是无损压缩的一个例子。无损压缩与文本传输有关,应应用于基于文本的格式,如 HTML、CSS 和 JavaScript。
由于您希望在浏览器中运行所有有效的 JS 代码,因此您应该使用无损压缩算法对 JavaScript 代码进行压缩。在压缩 JS 之前,先进行简化处理有助于消除不必要的语法,将其缩减到仅包含执行所需的代码。
代码压缩
为了减少负载大小,可以在压缩之前对 JavaScript 进行压缩。压缩通过去除空格和不必要的代码,创建一个更小但完全有效的代码文件,从而 complement 压缩。当编写代码时,我们使用换行、缩进、空格、友好的变量名和注释来提高代码的可读性和可维护性。然而,这些元素会增加 JavaScript 的总体大小,并不是浏览器执行所必需的。压缩将 JavaScript 代码减少到成功执行所需的最小值。
压缩是 JS 和 CSS 优化的标准做法。JavaScript 库开发者通常会提供用于生产部署的压缩版本文件,通常以.min.js 名称扩展名表示(例如,jquery.js 和 jquery.min.js)。
有多种工具可用于压缩 HTML、CSS 和 JS 资源。Terser 是一个流行的 JavaScript 压缩工具,适用于 ES6+,且 Webpack v4 默认包含此库的插件以创建压缩构建文件。您还可以在旧版本的 Webpack 中使用 TerserWebpackPlugin,或者作为一个命令行工具使用 Terser,而不需要模块打包器。
静态压缩与动态压缩
压缩技术可以大大减小文件大小,而 JavaScript 的压缩能够提供更为显著的效果。您可以采用两种方式来执行服务器端压缩。
静态压缩:您可以使用静态压缩来预先压缩资源,并在构建过程中提前保存它们。在这种情况下,您可以使用更高的压缩级别以提高代码下载速度。高额的构建时间不会影响到网站性能。对于不太经常变化的文件,您最好使用静态压缩。
动态压缩:在此流程中,当浏览器请求资源时,压缩将即时进行。动态压缩更容易实现,但您只能使用较低的压缩级别。使用更高的压缩级别需要花费更多时间,而您将失去从较小内容大小中获得的优势。对于经常变化或应用程序生成的内容,您最好使用动态压缩。
您可根据应用程序内容的类型选择使用静态或动态压缩技术。您可以使用流行的压缩算法来同时启用静态和动态压缩技术,但在每种情况下建议的压缩级别有所不同。让我们来了解一下压缩算法以更好地理解这一点。
压缩算法
Gzip 和 Brotli 是目前用于压缩 HTTP 数据的两种最常见的算法。省略 ...
JavaScript 压缩与加载粒度
要全面理解 JavaScript 压缩的效果,还需考虑其他优化方面,如基于路由的拆分、代码拆分和打包。
现代大型 JavaScript 应用程序通常使用不同的代码拆分和打包技术以高效加载代码。应用程序使用逻辑边界来拆分代码,例如对单页应用程序(SPA)的路由级别拆分,或基于用户交互或视口可见性逐步提供 JavaScript。您可以配置打包器以识别这些边界。
以下是与我们讨论相关的一些关键术语:
模块:模块是功能的独立块,旨在提供良好的抽象和封装。详细信息请参见模块模式。
打包:一组独立模块的集合,包含源文件的最终版本,并经过打包器的加载和编译过程。
打包拆分:打包器用于将应用程序拆分成多个打包的过程,使每个打包可以独立发布、下载或缓存。
块:来自 Webpack 术语,块是打包和代码拆分过程的最终输出。Webpack 可以根据入口配置、SplitChunksPlugin 或动态导入来拆分打包为块。
如果模块包含在源文件中,经过代码或打包拆分后的构建过程的最终输出被称为块。注意,源文件和块之间可能互相依赖。
JavaScript 的输出大小指的是通过 JavaScript 捆绑器或编译器优化后得到的块大小或原始大小。大型的 JavaScript 应用程序可以被解构为可独立加载的 JavaScript 文件块。加载粒度指的是输出块的数量——块的数量越多,每个块的大小就越小,粒度就越高。
有些块比其他块更重要,因为它们被加载得更频繁,或者是更重要的代码路径的一部分(例如,加载“结账”小部件)。虽然需要了解哪些块最重要需要应用程序知识,但可以假定“基本”块始终必不可少。
页面所需的每个块的每个字节都需要由用户设备下载并解析/执行。这是直接影响应用程序性能的代码。由于块是最终要下载的代码,因此压缩块可以提高下载速度。
有了这个背景知识,我们来讨论加载粒度和压缩之间的相互作用。
颗粒度权衡
在理想的世界中,颗粒度和分块策略的目标应是达到以下几个互相矛盾的目标:
提高下载速度:正如在之前的部分中看到的那样,使用压缩可以提高下载速度。
但是,压缩一个大的块会比压缩多个具有相同代码的小块产生更好的结果或更小的文件大小。也就是说,压缩两个块(a 和 b)的合并(即 a+b)所得到的结果不一定优于分别压缩这两个块(即 compress(a) 和 compress(b))再合并的结果。
具体来说:compress(a + b) <= compress(a) + compress(b)。这也可以理解为,将大块压缩比将多个小块分别压缩再合并起来更为高效。
提高缓存命中率和缓存效率:更小尺寸的块能提高缓存效率,尤其是对于增量加载 JS 的应用。
更改隔离在更少的块与更小的块中。 如果发生代码更改,只需重新下载受影响的块,并且这些代码的大小可能很小。其余的块可以在缓存中找到,从而提高缓存命中率。
使用较大的块时,很可能会影响大量的代码,并在代码更改后需要重新下载。
因此,使用缓存机制时更希望使用较小的块。
快速执行:为了使代码快速执行,它应该满足以下条件。
- 所有必需的依赖项都随时可用——它们已一起下载或在缓存中可用。这意味着您应该将所有相关代码捆绑在一起作为较大的块。
- 只有页面/路由所需的代码应该执行。这要求不要下载或执行额外的代码。一个通用块可能包含大多数但并非所有页面所需的常见依赖项。重复代码的消除需要更小、独立的块。
- 主线程上的长时间任务可能会阻塞很长时间。因此,这些任务需要分解成较小的块。
尝试优化上述其中一个目标可能无法让你兼顾其他目标。这是粒度权衡取舍的问题。删除重复项并缓存可能与浏览器性能和压缩相冲突。因此,大多数生产应用当前使用的最大块数约为 10 块左右。该限制需要增加以支持大型 JavaScript 应用程序实现更好的缓存和去重。
SplitChunksPlugin 和粒度拆分
解决粒度权衡的潜在方案必须满足以下要求:
- 增加块数量:允许创建 40 到 100 个较小的块,以改善缓存和去重,而不影响性能。
- 性能开销缓解:解决因管理多个脚本标签而产生的进程间通信(IPC)、输入/输出(I/O)和处理成本的开销。
- 最小化压缩损失:避免由于多个较小块而导致的显著压缩损失。
尽管理想方案仍在开发中,Webpack v4 的 SplitChunksPlugin 及其粒度拆分策略在一定程度上增强了加载粒度。
- 历史背景
在早期版本中,Webpack 使用 CommonsChunkPlugin 来将公共依赖项打包为单个块,这可能会不必要地增加未使用这些公共模块页面的下载和执行时间。为优化特定页面,Webpack 在 v4 中引入了 SplitChunksPlugin,根据配置创建多个拆分块,避免在不同路由之间获取重复代码。
- Next.js 的实现
Next.js 采用了 SplitChunksPlugin,并实施了一种粒度拆分策略,以有效应对粒度权衡:
- 大模块隔离:任何大于 160 KB 的第三方模块都拆分为独立块。
- 框架依赖:为框架依赖(如 React 和 ReactDOM)生成单独的块。
- 共享块:可根据需要创建多个共享块,最多可达 25 个。
- 最小块大小:块生成的最小大小设置为 20 KB。
策略的好处
通过生成多个共享块,而不是单一块,该策略减少了在不同页面下载不必要或重复代码的量。为大型第三方库生成独立块提高了缓存效率,因为这些库不太可能频繁更改。20 KB 的最小块大小有助于保持可接受的压缩损失。
这一粒度拆分策略帮助多个 Next.js 应用有效减少网站的总 JavaScript 使用量。
结论: 仅压缩无法完全解决 JavaScript 性能问题,但了解浏览器和捆绑器后台如何运作,有助于创建更好的捆绑策略,从而更好地支持压缩。生态系统中的不同平台都需要解决加载粒度问题。颗粒化分片可能是一个方向的一步,但我们还有很长的路要走。在 Gatsby 中也实施了颗粒化分片策略,观察到其产生了相似的好处。