如今的 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 中得到应用,本文将通过一个示例应用对此进行演示。
“摇树(优化)”一词源自将应用及其依赖项视为树状结构的心理模型。树中的每个节点都表示一个依赖项,可为您的应用提供不同的功能。在现代应用中,这些依赖项通过 static 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,这意味着它们已通过精简进行优化。对于特定于应用的软件包,21.1 KB 的大小不算大,但需要注意的是,根本没有进行任何摇树(优化)。我们来看看应用代码,了解可以采取哪些措施来解决此问题。
在任何应用中,查找可进行树状结构抖动的机会都涉及查找静态 import 语句。在主组件文件顶部附近,您会看到类似如下的一行代码:
import * as utils from "../../utils/utils";
您可以通过多种方式导入 ES6 模块,但以下方式应引起您的注意。这行特定代码表示“从 utils 模块导入所有内容,并将其放入名为 utils 的命名空间中。”这里要问的关键问题是,“该模块中到底有多少内容?”import
如果您查看 utils 模块源代码,会发现其中大约有 1,300 行代码。
您需要所有这些东西吗?我们来搜索导入 utils 模块的主组件文件,看看该命名空间出现了多少次,以便进行仔细检查。
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 模块更难进行摇树优化,因此如果您决定使用它们,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 中利用此优化,并仅选择性地导入应用所需的内容,您将能够主动尽可能缩小应用软件包。
特别感谢 Kristofer Baxter、Jason Miller、Addy Osmani、Jeff Posnick、Sam Saccone 和 Philip Walton 提供的宝贵反馈,这些反馈显著提升了本文的质量。