使用 tree shaking 减少 JavaScript 负载转载
今天的 Web 应用程序可以变得相当大,尤其是其中的 JavaScript 部分。 截至 2018 年年中,HTTP Archive 将 JavaScript 在移动设备上的传输大小中值设置为大约 350 KB。 这只是传输大小! JavaScript 在通过网络发送时通常会被压缩,这意味着在浏览器解压缩后 JavaScript 的实际数量要多得多。 指出这一点很重要,因为就资源处理而言,压缩是无关紧要的。 900 KB 的解压 JavaScript 对于解析器和编译器来说仍然是 900 KB,尽管压缩后可能大约是 300 KB。
JavaScript 是一种昂贵的处理资源。 与下载后只会产生相对微不足道的解码时间的图像不同,JavaScript 必须被解析、编译,然后最终执行。 逐字节,这使得 JavaScript 比其他类型的资源更昂贵。
尽管不断改进以提高 JavaScript 引擎的效率,但提高 JavaScript 性能始终是开发人员的任务。
为此,有一些技术可以提高 JavaScript 性能。 代码拆分正是其中一种,它通过将 JavaScript 划分为块,并将这些块仅提供给需要它们的应用程序的路由来提高性能。
虽然这种技术有效,但它并没有解决 JavaScript 繁重的应用程序的一个常见问题,即包含从未使用过的代码。 Tree shaking 试图解决这个问题。
什么是 tree shaking?
Tree shaking 是死代码消除的一种形式。 该术语由 Rollup 推广,但消除死代码的概念已经存在了一段时间。 这个概念也可以在 webpack 中找到,本文通过示例应用程序对此进行了演示。
术语“tree shaking”来自应用程序的心智模型及其依赖项作为树状结构。 树中的每个节点都代表一个为您的应用程序提供不同功能的依赖项。 在现代应用程序中,这些依赖项是通过静态导入语句引入的,如下所示:
// Import all the array utilities!
import arrayUtils from "array-utils";
当一个应用程序很年轻时——如果你愿意的话,它可能是一棵树苗——它可能几乎没有依赖关系。 它还使用了大多数(如果不是全部)您添加的依赖项。 但是,随着您的应用程序成熟,可以添加更多依赖项。 更复杂的是,旧的依赖项不再使用,但可能不会从您的代码库中删除。 最终结果是应用程序最终会附带大量未使用的 JavaScript。 Tree Shaking 通过利用静态导入语句如何拉入 ES6 模块的特定部分来解决这个问题:
// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";
这个import
示例与前一个示例的不同之处在于,该示例不是从"array-utils"
模块中导入所有内容(这可能是很多代码),而是仅导入其中的特定部分。 在开发版本中,这不会改变任何东西,因为无论如何都会导入整个模块。 在生产构建中,可以将 webpack 配置为“摆脱”未明确导入的 ES6 模块的导出,从而使这些生产构建更小。 在本指南中,您将学习如何做到这一点!
寻找 Tree shake 的机会
出于说明目的,提供了一个单页应用程序示例来演示 tree shake 的工作原理。 如果您愿意,您可以克隆它并继续进行操作,但我们将在本指南中一起介绍每一步,因此不需要克隆(除非您需要动手学习)。
示例应用程序是一个可搜索的吉他效果器踏板数据库。 您输入一个查询,就会出现一个效果器踏板列表。
此应用程序分为三方代码包(即 Preact 和 Emotion)和特定于应用程序自身的代码包(或“块”,webpack 称它们为“块”):
上图中显示的 JavaScript 包是生产构建的包,这意味着它们通过"丑化"进行了优化。 21.1 KB 的应用程序自身的包还不错,但应该注意的是,这没有进行过任何tree shake。 让我们看看应用程序代码,看看可以做些什么来解决这个问题。
在任何应用程序中,寻找 tree shaking 机会都将涉及寻找静态 import
语句。 在主组件文件的顶部附近,您会看到如下一行:
import * as utils from "../../utils/utils";
您可以通过多种方式导入 ES6 模块,但是像这样的方式应该引起您的注意。 这一条表明了 “从 utils
模块 import
所有内容,并将其放在名为 utils
的命名空间中”。 这里要问的一个重要的问题是,“那个模块中有多少东西?”
如果您查看 utils 模块源代码,您会发现大约有 1,300 行代码。
你需要所有这些东西吗? 让我们通过搜索导入 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);
}
在包含大量 exports 的 1,300 行文件中,仅使用其中一个。 这导致传输了大量未使用的 JavaScript。
虽然这个示例应用程序确实有点"做作",但它并没有改变这样一个事实,即这种合成场景类似于您在生产 Web 应用程序中可能遇到的实际优化机会。 既然您已经确定了树抖动的有用机会,那么它实际上是如何完成的?
阻止 Babel 将 ES6 模块转换为 CommonJS 模块
Babel 是一个不可或缺的工具,但它可能会使摇树的效果更难以观察。 如果你使用@babel/preset-env,Babel 可能会将 ES6 模块转换为更广泛兼容的 CommonJS 模块——也就是说,你导入的模块已经不仅仅是你需要的模块。
因为 CommonJS 模块更难进行 tree shaking,所以如果你决定使用它们,webpack 将不知道从包中修剪什么。 解决方案是配置 @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 模块,这在 tree shaking 的上下文中很重要。 采用可预测输入并产生同样可预测输出而不修改其自身范围之外的任何内容的模块是可以安全删除的依赖项,如果我们不使用它们。 它们是独立的、模块化的代码片段。 因此,“模块”。
在涉及 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 模块之后,需要对我们的导入语法稍作调整,以仅从 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);
}
在本例中,这应该是 tree shaking 工作所需的全部内容。 这是摇动依赖树之前的 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
这是 tree shaking 成功后的输出:
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
虽然这两个包都缩小了,但它确实是受益最大的主要包。 通过去掉 utils 模块中未使用的部分,主包缩小了大约 60%。 这不仅减少了脚本下载所需的时间,还减少了处理时间。