当今的 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"
模块导入所有内容(可能会是大量代码)。在开发 build 中,这不会改变任何内容,因为无论如何都会导入整个模块。在生产 build 中,可以将 webpack 配置为“抖动”掉未明确导入的 ES6 模块中的导出,从而缩减这些生产 build 的大小。在本指南中,您将了解如何做到这一点!
寻找摇树的机会
为方便说明,我们提供了一个单页面应用示例,演示了树摇动的工作原理。您可以根据需要克隆该项目并按照相关步骤操作,但本指南将介绍整个过程的每个步骤,因此您无需克隆该项目(除非您喜欢通过实践来学习)。
示例应用是一个可搜索的吉他效果踏板数据库。您输入查询后,系统会显示效果踏板列表。
促使用户安装此应用的行为分为供应商(即Preact 和 Emotion)以及应用专属代码包(或“块”,Webpack 称之为“区块”):
上图所示的 JavaScript 软件包是正式版 build,这意味着它们已通过污名化处理而得到优化。特定于应用的 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 提供的宝贵反馈,这些宝贵意见显著提高了本文的质量。