当今的 Web 应用可能非常庞大,尤其是其 JavaScript 部分。截至 2018 年年中,HTTP Archive 将移动设备上的 JavaScript 传输大小中位数定为约 350 KB。而这只是传输大小!JavaScript 在通过网络发送时通常会被压缩,这意味着在浏览器解压缩 JavaScript 后,其实际大小会大很多。请务必注意这一点,因为就资源处理而言,压缩无关紧要。900 KB 的解压缩 JavaScript 对解析器和编译器来说仍然是 900 KB,即使在压缩后可能只有大约 300 KB。
JavaScript 的处理开销很大。与图片不同,图片在下载后只会产生相对较短的解码时间,而 JavaScript 必须经过解析、编译,然后才能最终执行。因此,就字节而言,JavaScript 的开销高于其他类型的资源。
![一张图表,比较了 170 KB 的 JavaScript 与等同大小的 JPEG 图片的处理时间。与 JPEG 相比,JavaScript 资源在字节级别上更耗资源。](https://web.developers.google.cn/static/articles/reduce-javascript-payloads-with-tree-shaking/image/a-diagram-comparing-proc-5b49bd91e7285.png?hl=fi)
虽然我们会不断改进以提高 JavaScript 引擎的效率,但提升 JavaScript 性能始终是开发者的任务。
为此,我们提供了一些可提升 JavaScript 性能的技巧。代码分块就是其中一种技术,它通过将应用 JavaScript 分割成多个分块,并仅将这些分块提供给需要它们的应用路由来提升性能。
虽然此方法可行,但无法解决大量使用 JavaScript 的应用的常见问题,即包含从未使用的代码。树摇动会尝试解决此问题。
什么是摇树?
摇树优化是一种消除无用代码的方法。该术语由 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"
模块中的特定部分,而不是导入了 "array-utils"
模块中的所有内容(这可能包含大量代码)。在开发 build 中,这不会改变任何内容,因为无论如何都会导入整个模块。在生产 build 中,可以将 webpack 配置为“抖动”掉未明确导入的 ES6 模块中的导出,从而缩减这些生产 build 的大小。在本指南中,您将了解如何做到这一点!
寻找摇树的机会
为方便说明,我们提供了一个单页面应用示例,演示了树摇动的工作原理。您可以根据需要克隆该项目并按照相关步骤操作,但本指南将介绍整个过程的每个步骤,因此您无需克隆该项目(除非您喜欢通过实践来学习)。
示例应用是一个可搜索的吉他效果踏板数据库。您输入查询后,系统会显示效果踏板列表。
![用于搜索吉他效果踏板数据库的单页面应用示例的屏幕截图。](https://web.developers.google.cn/static/articles/reduce-javascript-payloads-with-tree-shaking/image/a-screenshot-a-sample-p-c7096b568d49b.png?hl=fi)
促使用户安装此应用的行为分为供应商(即Preact 和 Emotion)以及应用专用代码软件包(或 webpack 称为的“分块”):
![Chrome 开发者工具的“Network”(网络)面板中显示的两个应用代码软件包(或分块)的屏幕截图。](https://web.developers.google.cn/static/articles/reduce-javascript-payloads-with-tree-shaking/image/a-screenshot-two-applica-13160017c55f2.png?hl=fi)
上图中显示的 JavaScript 软件包是正式版 build,这意味着它们是通过 uglification 优化的。特定于应用的 bundle 的大小为 21.1 KB 还不错,但请注意,系统根本没有进行任何树摇动。我们来看看应用代码,看看可以采取哪些措施来解决此问题。
在任何应用中,查找树摇动机会都需要查找静态 import
语句。在主要组件文件顶部附近,您会看到如下代码行:
import * as utils from "../../utils/utils";
您可以通过多种方式导入 ES6 模块,但像这样的模块应该引起您的注意。这行代码的意思是“import
utils
模块中的所有内容,并将其放入名为 utils
的命名空间中”。这里的关键问题是“该模块中有多少内容?”
如果您查看 utils
模块源代码,会发现其中大约有 1,300 行代码。
您需要这些信息吗?我们来仔细检查一下,搜索导入 utils
模块的主要组件文件,看看该命名空间有多少个实例。
![在文本编辑器中搜索“utils.”,仅返回 3 条结果的屏幕截图。](https://web.developers.google.cn/static/articles/reduce-javascript-payloads-with-tree-shaking/image/a-screenshot-a-search-a-b9c933e924ee5.png?hl=fi)
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);
}
在包含大量导出的 1,300 行文件中,只有其中一个导出项会被使用。这会导致发送大量未使用的 JavaScript。
虽然这个示例应用有点牵强附会,但这并不妨碍我们得出这样的结论:这种合成场景类似于您在正式版 Web 应用中可能会遇到的实际优化机会。现在,您已经发现了树摇动有用之处,那么它实际上是如何实现的?
阻止 Babel 将 ES6 模块转译为 CommonJS 模块
Babel 是一款不可或缺的工具,但可能会使观察树木摇晃的效果变得稍微困难一些。如果您使用的是 @babel/preset-env
,Babel 可能会将 ES6 模块转换为更广泛兼容的 CommonJS 模块,也就是说,您使用 require
而不是 import
加载的模块。
由于对 CommonJS 模块执行树摇动更为困难,因此如果您决定使用 CommonJS 模块,webpack 将不知道从哪些文件包中修剪内容。解决方法是将 @babel/preset-env
配置为明确不处理 ES6 模块。无论您在 babel.config.js
还是 package.json
中配置 Babel,都需要额外添加一些内容:
// 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 中利用此优化,并仅选择性地导入应用所需的内容,您就可以主动尽可能缩减应用 bundle 的大小。
特别感谢 Kristofer Baxter、Jason Miller、Addy Osmani、Jeff Posnick、Sam Saccone 和 Philip Walton 提供宝贵的反馈意见,帮助显著提高本文的质量。