当今的 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 的应用的常见问题,即包含从未使用的代码。树摇动会尝试解决此问题。
什么是摇树?
摇树优化是一种消除无用代码的方法。该术语由 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 的大小。在本指南中,您将了解如何做到这一点!
寻找摇树的机会
为方便说明,我们提供了一个单页面应用示例,演示了树摇动的工作原理。您可以根据需要克隆该项目并按照相关步骤操作,但本指南将介绍整个过程的每个步骤,因此您无需克隆该项目(除非您喜欢通过实践来学习)。
示例应用是一个可搜索的吉他效果踏板数据库。您输入查询后,系统会显示效果踏板列表。
促使用户安装此应用的行为分为供应商(即Preact 和 Emotion)以及应用专用代码软件包(或 webpack 称为的“分块”):
上图中显示的 JavaScript 软件包是正式版 build,这意味着它们是通过 uglification 优化的。特定于应用的 bundle 的大小为 21.1 KB 还不错,但请注意,系统根本没有进行任何树摇动。我们来看看应用代码,看看可以采取哪些措施来解决此问题。
在任何应用中,查找树摇动机会都需要查找静态 import
语句。在主要组件文件顶部附近,您会看到如下代码行:
import * as utils from "../../utils/utils";
您可以通过多种方式导入 ES6 模块,但像这样的模块应该引起您的注意。这行代码的意思是“import
utils
模块中的所有内容,并将其放入名为 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);
}
在包含大量导出的 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 提供宝贵的反馈意见,帮助显著提高本文的质量。