有兴趣阅读我们的下一本书吗?了解更多关于使用 React 构建大型 JavaScript Web 应用程序

性能模式

压缩 JavaScript

压缩你的 JavaScript 并注意你的块大小以获得最佳性能。过高的 JavaScript 捆绑粒度可以帮助去重和缓存,但会导致压缩效果变差,并在 50-100 个块范围内影响加载(由于浏览器进程、缓存检查等)。最终,选择最适合你的压缩策略。

JavaScript 是网页大小的第二大贡献者,也是继图片之后互联网上第二大请求的网页资源。我们使用了一些模式来减少 JavaScript 的传输、加载和执行时间,以提高网站性能。压缩可以帮助减少通过网络传输脚本所需的时间。

你可以将压缩与其他技术结合使用,例如压缩、代码拆分、捆绑、缓存和延迟加载,以减少大量 JavaScript 对性能的影响。然而,这些技术的目标有时会相互矛盾。本节将探讨 JavaScript 压缩技术,并讨论在决定代码拆分和压缩策略时应考虑的细微差别。

  • GzipBrotli 是压缩 JavaScript 的两种最常见方法,并且受到现代浏览器的广泛支持。
  • Brotli 在类似的压缩级别下提供更好的压缩率
  • Next.js 默认提供Gzip 压缩,但建议在 Nginx 等 HTTP 代理上启用它。
  • 如果你使用Webpack 来捆绑你的代码,你可以使用CompressionPlugin 来进行 Gzip 压缩,或者使用BrotliWebpackPlugin 来进行 Brotli 压缩。
  • Oyo 在切换到Brotli 压缩而不是 Gzip 后,文件大小减少了15-20%,Wix 减少了21-25%
  • compress(a + b) <= compress(a) + compress(b) - 一个大的捆绑包比多个小的捆绑包压缩率更高。这会导致粒度权衡,即 **去重和缓存与浏览器性能和压缩相矛盾。粒度化分块** 可以帮助解决这种权衡。

HTTP 压缩

压缩减少了文档和文件的大小,因此它们占用的磁盘空间比原始文件更少。较小的文档占用较低的带宽,可以快速通过网络传输。HTTP 压缩利用了这个简单的概念来压缩网站内容,减少页面权重,降低带宽需求,并提高性能。

HTTP 数据压缩可以以不同的方式进行分类。其中之一是有损压缩与无损压缩。

有损压缩意味着压缩-解压缩循环会导致文档略有改变,但仍保持可用性。这种改变对于最终用户来说大多是无法察觉的。有损压缩最常见的例子是用于图像的 JPEG 压缩。

使用无损压缩,压缩和随后解压缩后恢复的数据将与原始数据完全匹配。PNG 图像是无损压缩的一个例子。无损压缩与文本传输有关,应该应用于基于文本的格式,例如 HTML、CSS 和 JavaScript。

由于你希望所有有效的 JS 代码都在浏览器上,因此你应该对 JavaScript 代码使用无损压缩算法。在我们压缩 JS 之前,压缩有助于消除不必要的语法并将其简化为执行所需代码。


压缩

为了减少负载大小,你可以在压缩之前压缩 JavaScript。 压缩通过删除空格和任何不必要的代码来补充压缩,以创建一个更小但完全有效的代码文件。在编写代码时,我们使用换行符、缩进、空格、命名良好的变量和注释来提高代码可读性和可维护性。但是,这些元素会增加 JavaScript 的整体大小,并且在浏览器上执行时并非必需。压缩将 JavaScript 代码减少到成功执行所需的最小程度。

压缩是 JS 和 CSS 优化的标准做法。JavaScript 库开发者通常会为生产部署提供他们文件的压缩版本,通常用 min.js 扩展名表示。(例如,jquery.jsjquery.min.js

有多种工具可用于压缩 HTML、CSS 和 JS 资源。 Terser 是一个流行的 ES6+ JavaScript 压缩工具,Webpack v4 默认包含该库的插件,用于创建压缩的构建文件。你也可以在旧版本的 Webpack 中使用 TerserWebpackPlugin,或者在没有模块捆绑器的情况下将 Terser 用作 CLI 工具。


静态压缩与动态压缩

压缩有助于显着减少文件大小,但压缩 JS 可以带来更大的收益。你可以通过两种方式实现服务器端压缩。

静态压缩:你可以使用静态压缩来预先压缩资源,并在构建过程中提前保存它们。在这种情况下,你可以使用更高的压缩级别来提高代码下载时间。较长的构建时间不会影响网站性能。如果你要处理很少更改的文件,最好使用静态压缩。

动态压缩:使用此过程,压缩是在浏览器请求资源时动态进行的。动态压缩更容易实现,但你只能使用较低的压缩级别。较高的压缩级别需要更多时间,并且会失去从较小内容大小中获得的优势。如果你要处理经常更改或由应用程序生成的内容,最好使用动态压缩。

你可以根据应用程序内容的类型使用静态压缩或动态压缩。你可以使用流行的压缩算法启用静态和动态压缩,但每个情况下的推荐压缩级别是不同的。让我们看看压缩算法以更好地理解这一点。


压缩算法

GzipBrotli 是如今用于压缩 HTTP 数据的两种最常见的算法

Gzip

Gzip 压缩格式已经存在了近 30 年,是一种基于Deflate 算法的无损算法。Deflate 算法本身结合了LZ77 算法霍夫曼编码来处理输入数据流中的数据块。

LZ77 算法识别重复字符串,并用反向引用来替换它们,反向引用是指向其先前出现位置的指针,后跟字符串的长度。随后,霍夫曼编码识别常用引用,并用更短的比特序列引用来替换它们。较长的比特序列用于表示不常用的引用。

图片来自:https://www.youtube.com/watch?v=whGwm0Lky2s&t=851s

所有主要浏览器都支持 Gzip。 Zopfli 压缩算法是 Deflate/Gzip 的较慢但改进的版本,可以生成更小的与 GZip 兼容的文件。它最适合静态压缩,在这种情况下,它可以提供更大的收益。

Brotli

2015 年,谷歌推出了Brotli 算法Brotli 压缩数据格式。与 GZip 一样,Brotli 也是一种基于 LZ77 算法和霍夫曼编码的无损算法。此外,它使用二阶上下文建模来实现类似速度下的更密集压缩。上下文建模是一种功能,允许在同一个块中为同一个字母使用多个霍夫曼树。Brotli 还支持用于反向引用的更大的窗口大小,并且具有静态字典。这些功能有助于提高它作为压缩算法的效率。

Brotli 被支持如今所有主要的服务器和浏览器,并且越来越流行。它也受到支持,并且可以轻松地通过托管提供商和中间件启用,包括NetlifyAWSVercel

拥有大量用户群的网站,例如OYOWix,在将 Gzip 替换为 Brotli 之后,已经显着提高了性能。

比较 Gzip 和 Brotli

下表显示了 Brotli 和 Gzip 压缩率和速度在不同压缩级别下的基准比较。

此外,以下是 Chrome 对使用 Gzip 和 Brotli 压缩 JS 的研究的一些见解

  • Gzip 9 具有最佳的压缩率和良好的压缩速度,你应该考虑在使用其他级别的 Gzip 之前使用它。
  • 对于 Brotli,请考虑使用 6-11 级。否则,我们可以使用 Gzip 以更快的速度实现类似的压缩率。
  • 在所有尺寸范围内,Brotli 9-11 的性能远优于 Gzip,但速度相当慢。
  • 包体越大,压缩率和速度越好。
  • 所有包体大小的算法之间的关系都类似(例如,对于所有包体大小,Brotli 7 都优于 Gzip 9,而 Gzip 9 的速度都比 Brotli 5 快)。

现在让我们看看服务器和浏览器之间关于所选压缩格式的通信。


启用压缩

您可以在构建过程中启用静态压缩。如果您使用 Webpack 来捆绑代码,则可以使用 CompressionPlugin 进行 Gzip 压缩,或使用 BrotliWebpackPlugin 进行 Brotli 压缩。插件可以包含在 Webpack 配置文件中,如下所示。

module.exports = {
  //...
  plugins: [
    //...
    new CompressionPlugin(),
  ],
};

Next.js 默认情况下提供 Gzip 压缩,但建议在 Nginx 等 HTTP 代理上启用它。Gzip 和 Brotli 都在 Vercel 平台 的代理级别受到支持。

您可以在支持不同压缩算法的服务器(包括 Node.js)上启用动态无损压缩。浏览器通过请求中的 Accept-Encoding HTTP 标头传达其支持的压缩算法。例如,Accept-Encoding: gzip, br

这表明浏览器支持 Gzip 和 Brotli。您可以按照特定服务器类型的说明在服务器上启用不同类型的压缩。例如,您可以在 此处 找到关于在 Apache 服务器上启用 Brotli 的说明。 Express 是 Node 的一个流行 Web 框架,它提供了一个 compression 中间件库。使用它来压缩任何请求的资产。

与其他压缩算法相比,建议使用 Brotli,因为它会生成更小的文件大小。您可以启用 Gzip 作为不支持 Brotli 的浏览器的备用方案。如果配置成功,服务器将返回 Content-Encoding HTTP 响应标头以指示响应中使用的压缩算法。例如,Content-Encoding: br


审计压缩

您可以在 Chrome 中检查服务器是否压缩了下载的脚本或文本 -> 开发者工具 -> 网络 -> 标头。开发者工具显示了响应中使用的 content-encoding,如下所示。

Lighthouse 报告包含针对“启用文本压缩”的性能审核,该审核会检查未将 content-encoding 标头设置为“br”、“gzip”或“deflate”的基于文本的资源类型。Lighthouse 使用 Gzip 来计算资源的潜在节省。

图片来自:https://web.dev/uses-text-compression/#how-to-enable-text-compression-on-your-server


JavaScript 压缩和加载粒度

要充分了解 JavaScript 压缩的影响,您还必须考虑 JavaScript 优化的其他方面,例如 基于路由的拆分代码拆分捆绑

具有大量 JavaScript 代码的现代 Web 应用程序通常使用不同的代码拆分和捆绑技术来高效加载代码。应用程序使用逻辑边界来拆分代码,例如单页面应用程序的路由级拆分,或在交互或视窗可见性时增量提供 JavaScript。您可以配置捆绑器来识别这些边界。

在继续讨论这如何影响压缩之前,让我们先介绍一些与代码拆分和捆绑相关的基本定义。

捆绑术语

以下是与我们讨论相关的几个关键术语。

  1. 模块:模块是旨在提供可靠的抽象和封装的独立功能块。有关更多详细信息,请参见 模块模式
  2. :包含源文件最终版本的一组不同的模块,这些模块已经在捆绑器中经历了加载和编译过程。
  3. 包拆分:捆绑器用于将应用程序拆分为多个包的过程,以便每个包都可以独立地隔离、发布、下载或缓存。
  4. :从 Webpack 术语中采用,块是捆绑和代码拆分过程的最终输出。Webpack 可以根据 entry 配置、SplitChunksPlugin动态导入 将包拆分为块。

如果模块包含在源文件中,则代码或包拆分后构建过程的最终输出称为。请注意,源文件和块可能相互依赖。

图片来自:​​https://www.youtube.com/watch?v=ImjzA7EMI6I&list=PLyspMSh4XhLP-mqulUMcaqTbLo-ZJxSX5&index=29

JavaScript 的输出大小是指块的大小,或 JavaScript 捆绑器或编译器优化后的原始大小。大型 JS 应用程序可以分解为独立可加载的 JavaScript 文件块。加载粒度是指输出块的数量——块的数量越多,每个块的大小越小,粒度越高。

一些块比其他块更重要,因为它们加载频率更高,或者属于更重要的代码路径(例如,加载“结账”小部件)。确定哪些块最重要需要了解应用程序,但可以安全地假设“基础”块始终是必不可少的。

页面所需的每个块字节都需要由用户设备下载和解析/执行。这是 直接影响 应用程序性能的代码。由于块是最终将下载的代码,因此压缩块可以提高下载速度。

有了这些背景,让我们讨论一下加载粒度和压缩之间的相互作用。

粒度权衡

在理想情况下,粒度和块划分策略应该旨在实现以下目标,但这些目标相互矛盾。

  1. 提高下载速度:如前几节所述,可以使用压缩来提高下载速度。但是,压缩一个大型块比压缩具有相同代码的多个小型块会产生更好的结果或更小的文件大小。

compress(a + b) <= compress(a) + compress(b)

有限的本地数据表明,对于较小的块,损失率为 5% 到 10%。对于未捆绑块的极端情况,尺寸会增加 20%。在大型块的情况下,每个共享的块都会附加额外的 IPC、I/O 和处理成本。v8 引擎有一个 30K 流/解析阈值。这意味着所有小于 30K 的块都将在关键加载路径上解析,即使它是非关键的。

出于上述原因,对于相同代码,较大的块可能比较小的块更有效地优化下载和浏览器性能。

  1. 提高缓存命中率和缓存效率:较小的块会带来更好的缓存效率,特别是对于增量加载 JS 的应用程序。
  • 更改隔离在较少的块中,而较小的块中包含更少的更改。如果代码有更改,则只需要重新下载受影响的块,并且与这些块对应的代码大小可能很小。剩余的块可以在缓存中找到,从而增加缓存命中率。

  • 对于较大的块,代码更改后,可能需要重新下载大量代码。

    因此,较小的块有利于利用缓存机制。

  1. **快速执行** - 为了使代码快速执行,它应该满足以下要求。
  • 所有必需的依赖项都已准备好——它们已一起下载或在缓存中可用。这意味着您应该将所有相关代码捆绑在一起作为较大的块。
  • 只有页面/路由所需的代码应该执行。这要求不要下载或执行额外的代码。包含常见依赖项的“commons”块可能具有大多数页面(但并非所有页面)所需的依赖项。代码去重需要更小的独立块。
  • 主线程上的长时间任务可能会长时间阻塞它。因此,需要将其分解为更小的块。

图片来自:https://www.youtube.com/watch?v=ImjzA7EMI6I&list=PLyspMSh4XhLP-mqulUMcaqTbLo-ZJxSX5&index=29

如上图三角形所示,试图优化上述目标之一的加载粒度可能会使您偏离其他目标。这就是粒度权衡问题。

去重和缓存与浏览器性能和压缩相冲突。

由于这种权衡,如今大多数生产应用程序使用的最大块数量约为 10 个。需要增加此限制以支持具有大量 JavaScript 的应用程序的更好的缓存和去重。


SplitChunksPlugin 和粒度块划分

粒度权衡的一个潜在解决方案将解决以下要求。

  1. 允许更多数量的块(40 到 100 个),并且块大小更小,以实现更好的缓存和去重,而不会影响性能。
  2. 解决由于多个较小的块而导致的性能开销,因为多个脚本标记会产生 IPC、I/O 和处理成本。
  3. 解决多个较小的块在压缩时造成的损失。

一个可以解决这些问题的潜在解决方案仍在开发中。但是,Webpack v4 的 SplitChunksPlugin 和粒度块划分策略可以帮助在一定程度上提高加载粒度。

早期版本的 Webpack 使用 CommonsChunkPlugin 将常见依赖项或共享模块捆绑到单个块中。这会导致未使用这些常见模块的页面的下载和执行时间不必要地增加。为了允许对这些页面进行更好的优化,Webpack 在 v4 中引入了 SplitChunksPlugin。基于默认值或配置创建多个拆分块,以防止在各种路由之间获取重复代码。

Next.js 采用了 SplitChunksPlugin 并实现了以下 粒度块划分 策略,以生成解决粒度权衡问题的 Webpack 块。

  • 任何足够大的第三方模块(大于 160 KB)都将拆分为单个块。
  • 为框架依赖项创建单独的框架块。(react、react-dom 等)
  • 根据需要创建尽可能多的共享块。(最多 25 个)
  • 生成块的最小尺寸更改为 20 KB。

发出多个共享块而不是单个块可以最大程度地减少在不同页面上下载或执行的不必要(或重复)代码量。为大型第三方库生成独立的块可以提高缓存,因为它们不太可能经常更改。20 kB 的最小块大小确保压缩损失合理低。

粒度块划分策略帮助多个 Next JS 应用程序减少了网站使用的总 JavaScript 量。

粒度块划分策略也在 Gatsby 中实现,并观察到类似的优势。


结论

仅仅依靠压缩无法解决所有 JavaScript 性能问题,但了解浏览器和打包工具背后的工作机制可以帮助制定更好的打包策略,从而支持更有效的压缩。加载粒度问题需要在生态系统中不同的平台上得到解决。细粒度分块可能是一个方向,但我们还有很长的路要走。