通过摇树优化减少 JavaScript 载荷

如今的 Web 应用体量可能相当大,尤其是 JavaScript 部分。从 2018 年年中开始,HTTP Archive 将移动设备上的 JavaScript 传输大小的中位数约为 350 KB。而这只是传输大小!JavaScript 在通过网络发送时通常会进行压缩,这意味着浏览器将其解压缩后 JavaScript 的实际量会多得多。需要注意的是,就资源处理而言,压缩无关紧要。对于解析器和编译器来说,解压缩后的 900 KB JavaScript 仍然为 900 KB,尽管压缩后可能大约为 300 KB。

展示下载、解压缩、解析、编译和执行 JavaScript 的过程的示意图。
下载和运行 JavaScript 的过程。请注意,即使该脚本的传输大小压缩后为 300 KB,它仍然需要解析、编译和执行的 JavaScript 大小为 900 KB。

JavaScript 处理资源成本高昂。与下载后解码时间仅相对简单的图像不同,JavaScript 必须先进行解析、编译,最后执行。逐字节表示,这使得 JavaScript 比其他类型的资源更昂贵。

一张图,对比了 170 KB 的 JavaScript 与同等大小的 JPEG 图片的处理时间。与 JPEG 相比,JavaScript 资源的字节数要大得多。
解析/编译 170 KB 的 JavaScript 与同等大小的 JPEG 的解码时间的处理成本。(来源)。

尽管我们不断进行改进提高 JavaScript 引擎的效率,但提高 JavaScript 性能一如既往地是开发者的一项任务。

因此,可以采用一些技术来改善 JavaScript 性能。代码拆分就是这样一种技术,它通过将应用 JavaScript 划分为区块,并将这些区块传送给需要它们的应用的路由,从而提高性能。

尽管这种方法行之有效,但无法解决大量 JavaScript 应用的常见问题,即包含从不使用的代码。摇树优化试图解决这个问题。

什么是摇树优化?

Tree shaking 是一种消除死代码的方式。Rollup 是一个流行术语,但“死代码消除”这一概念已经存在了一段时间。这一概念还在 webpack 中发现了购买机制,本文通过示例应用演示了购买操作。

“摇树优化”一词来自于应用及其依赖项(树状结构)的思维模型。树中的每个节点都代表一个依赖项,可为应用提供独特的功能。在现代应用中,这些依赖项是通过静态 import 语句引入的,如下所示:

// Import all the array utilities!
import arrayUtils from "array-utils";

如果某个应用还很年轻(比如一棵树苗),它可能就会有很少的依赖项。它还会使用您添加的大多数(如果不是全部)依赖项。不过,随着您的应用日趋成熟,可以添加更多依赖项。更为复杂的是,较旧的依赖项将无法使用,但可能不会从您的代码库中删除。最终结果是应用最终会包含大量未使用的 JavaScript。摇树优化功能利用静态 import 语句从 ES6 模块中拉取特定部分来解决此问题:

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

import 示例与上一个示例之间的区别在于,此示例仅导入其中的特定部分,而不是从 "array-utils" 模块导入所有内容(这可能是大量代码)。在开发 build 中,这不会造成任何变化,因为无论如何,整个模块都会导入。在正式版 build 中,webpack 可配置为“摇晃”未明确导入的 ES6 模块中的导出内容,从而缩小这些正式版 build。在本指南中,您将了解如何执行该操作!

寻找摇树的机会

为便于说明,我们提供了一个单页示例应用来演示摇树优化的工作原理。如果您愿意,可以克隆该副本并跟着操作,但我们在本指南中会一一介绍完成过程中的每一步,因此不必克隆(除非您喜欢亲手学习)。

示例应用是一个可搜索的吉他效果踏板数据库。您只需输入查询内容,系统就会显示效果踏板列表。

用于搜索吉他效果踏板数据库的单页应用示例的屏幕截图。
示例应用的屏幕截图。

驱动此应用的行为分为供应商(即PreactEmotion)以及应用特定的代码库(或 webpack 所谓的“数据块”):

Chrome 开发者工具的网络面板中显示的两个应用代码包(或区块)的屏幕截图。
应用的两个 JavaScript 软件包。这些是未压缩的大小。

上图中显示的 JavaScript 软件包是正式版 build,这意味着它们已通过伪造功能进行了优化。对于特定于应用的软件包来说,21.1 KB 还不错,但请注意,不会发生任何摇树优化。我们来看一下应用代码,看看可以采取什么措施来解决这个问题。

在任何应用中,查找摇树优化机会都涉及查找静态 import 语句。在主组件文件的顶部附近,您会看到如下所示的一行代码:

import * as utils from "../../utils/utils";

您可以通过多种方式导入 ES6 模块,但此类方式应该能引起您的注意。此特定行显示“import 全部来自 utils 模块,并将其放入名为 utils 的命名空间中”。这里要问的一个重要问题是,“该模块中到底有多少内容?”

如果查看 utils 模块源代码,您会发现大约 1300 行代码。

你是否需要所有这些设备?让我们来仔细检查一下,搜索导入 utils 模块的主组件文件,看看出现了多少个该命名空间实例。

在文本编辑器中搜索“utils.”时的屏幕截图,仅返回 3 个结果。
我们从中导入了大量模块的 utils 命名空间仅在主组件文件中调用了三次。

事实证明,utils 命名空间仅出现在应用中的三个位置,但用于哪些函数呢?如果您再次查看主组件文件,您会发现它似乎只有一个函数,即 utils.simpleSort,用于在排序下拉菜单发生更改时按多种条件对搜索结果列表进行排序:

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

在包含众多导出内容的 1300 行文件中,仅使用了其中一项。这会导致提供大量未使用的 JavaScript。

虽然此示例应用确实有些人为设计,但并不能改变这样一个事实,即这种合成场景类似于您在正式版 Web 应用中可能会遇到的实际优化机会。现在,您已经确定了摇树优化非常有用的机会,那么它实际上是如何完成的呢?

使 Babel 从 ES6 模块转译为 CommonJS 模块

Babel 是一种不可或缺的工具,但可能会让摇树优化的效果更加难以观察。如果您使用的是 @babel/preset-env,Babel 可能会将 ES6 模块转换为兼容性更佳的 CommonJS 模块,即 require 而不是 import 的模块。

由于 CommonJS 模块较难执行摇树优化,因此,如果您决定使用 bundle 资源,webpack 将不知道如何从 bundle 中剪除。解决方案是配置 @babel/preset-env 以明确保持 ES6 模块不变。无论您在何处配置 Babel(无论是在 babel.config.js 还是 package.json 中),都需要添加一些额外的内容:

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

@babel/preset-env 配置中指定 modules: false 会使 Babel 按预期运行,从而允许 webpack 分析依赖项树并清理未使用的依赖项。

注意副作用

从应用抖动依赖项时,需要考虑的另一个因素是项目的模块是否会产生副作用。附带效应的一个例子是,函数修改了超出其自身范围的内容,这是其执行的附带效应:

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

在此示例中,addFruit 在修改 fruits 数组时会产生附带效应,但这超出了其作用域。

附带效应也适用于 ES6 模块,这在摇树优化环境中很重要。接受可预测输入并生成同样可预测的输出,而不修改其自身范围之外的任何内容的模块,属于依赖项,如果我们不使用,可以安全丢弃它们。它们是独立的模块化代码段。因此是“模块”。

对于 webpack 而言,可通过在项目的 package.json 文件中指定 "sideEffects": false 来指定软件包及其依赖项没有附带效应:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

或者,您可以告知 webpack 哪些特定文件没有附带效应:

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

在后例中,我们假定任何未指定的文件都不会产生副作用。如果您不想将其添加到 package.json 文件中,还可以通过 module.rules 在 webpack 配置中指定此标志

仅导入需要的内容

在指示 Babel 单独保留 ES6 模块后,需要对 import 语法稍作调整,以便仅引入 utils 模块所需的函数。在本指南的示例中,我们只需要使用 simpleSort 函数:

import { simpleSort } from "../../utils/utils";

由于只导入了 simpleSort,而不是整个 utils 模块,因此 utils.simpleSort 的每个实例都需要更改为 simpleSort

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

以上就是执行摇树优化在本示例中正常工作所需的全部操作。以下是摇晃依赖项树之前的 webpack 输出:

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

以下是成功执行摇树优化后的输出:

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

虽然两个软件包都缩减了,但真正受益最多的是 main 软件包。通过摇掉 utils 模块中未使用的部分,main 软件包会缩减约 60%。这不仅能缩短脚本完成下载所需的时间,还能缩短处理时间。

摇晃树吧!

您从摇树优化中获得的效果取决于您的应用及其依赖项和架构。试试看!如果您确实没有通过设置模块打包器来执行此优化,那么尝试了解它对您的应用有何益处也没坏处。

您可能会发现,摇树优化可以显著提升性能,或者可能不会有太大提升。但是,通过将您的构建系统配置为在正式版 build 中利用此优化,并选择性地仅导入您的应用需要的内容,您可以主动让应用软件包尽可能小。

特别感谢 Kristofer Baxter、Jason MillerAddy OsmaniJeff Posnick、Sam Saccone 和 Philip Walton 提供的宝贵反馈,这些反馈显著提高了本文的质量。