作者|Jeremy Wagner译者|薛命灯
现代 Web 应用程序可能会变得非常巨大,特别是它们的 JavaScript 部分。HTTP Archive 网站的数据显示,截至 2018 年中,传输到移动设备上的 JavaScript 文件中值大约为 350 KB。而这只是传输大小,JavaScript 在通过网络传输时通常会被压缩,也就是说,在浏览器端解压后,JavaScript 的实际数量会更多。
从资源处理方面来看,压缩并不会给资源处理带来任何好处,比如 900 KB 的 JavaScript 被压缩后可能只有 300 KB,但在解压后解析器和编译器仍然要处理 900 KB 的 JavaScript。
上图是下载和运行 JavaScript 的过程。请注意,即使压缩后的脚本为 300 KB,但在后面仍然要解析、编译和执行 900 KB 的 JavaScript。
处理 JavaScript 非常耗费资源。与图像不一样,图像在下载完之后只需要对其进行解码,而 JavaScript 必须被解析、编译和执行,因此处理 JavaScript 比处理其他类型的资源更昂贵。
上图显示了解析和编译 170 KB JavaScript 与解码等效大小 JPEG 图像的处理成本。
引擎开发者在不断努力提升 JavaScript 引擎的执行效率,但说到底,提升 JavaScript 代码的性能更多的是开发人员的责任。
有一些技术可以用于提升 JavaScript 的性能。代码拆分就是这样的一种技术,它将应用程序 JavaScript 划分为较小的块,并只向应用程序路由提供它们必需的块,以此来提升性能。这种方式是有效的,但它并没有解决 JavaScript 应用程序的其他常见问题,比如那些被包含但从未使用的代码。为了解决这个问题,我们需要使用摇树(tree shaking)优化技术。
什么是摇树?
摇树是一种消除死代码的方法。这个词最初是由 Rollup 发起的,并逐渐流行开来,但消除死代码的概念却早已存在。webpack 中也涉及了这个概念,本文将通过示例进行演示。
这项技术之所以被称为“摇树”,主要是因为应用程序的依赖项是树状结构。树中的每个节点都代表了一个依赖项,这些依赖项为应用程序提供了不同的功能。在现代应用程序中,这些依赖项通过静态导入语句进行引入,如下所示:
// Import all the array utilities!
import arrayUtils from "array-utils";
在你的应用程序还很年轻的时候(如果你愿意,可以把它叫作“树苗”),应用程序的依赖项相对较少,而且你使用了大多数(如果不是全部)添加的依赖项。但是,随着应用程序的老化,更多的依赖项被添加进来,更糟糕的是,较旧的依赖项不再被使用,但可能无法从代码库中删除。最终的结果就是应用程序会传输大量未使用的 JavaScript 到客户端。摇树利用了静态导入语句来导入 ES6 模块的特定部分,从而解决了这个问题:
// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";
这个示例和之前示例之间的区别在于,它没有从“array-utils”模块中导入所有内容,而是导入它的特定部分。在开发阶段的构建中,这样做并不会有真正的效果,因为不管怎样它都会导入整个模块。但是,在生产阶段的构建中,我们可以通过配置 webpack 让它“摇掉”未明确指定的 ES6 模块,从而减小最终的构建体积。在本文中,你将学会如何做到这一点!
寻找摇树的机会
为了方便说明,我创建了一个单页应用程序示例(https://github.com/malchata/webpack-tree-shaking-example),这个应用程序使用 webpack 来演示摇树的工作原理。如果你愿意,可以拉取这个示例应用程序。不过我们将在本文中一步步介绍这个方法,所以不一定要拉取代码,除非你喜欢边学边动手。
示例应用程序是一个超级简单的吉他踏板数据库搜索程序,输入关键字,可以搜索到吉他踏板的清单。
应用程序的行为被分为 vendor(即 Preact 和 Emotion)和特定于应用程序的代码包(或者在 webpack 中叫作“chunk”):
上图中显示的 JavaScript 包是生产未压缩版本,也就是说它们通过 uglification(http://lisperator.net/uglifyjs/)进行了优化。特定于应用程序的捆绑包的大小为 21.1 KB 算是不错的了。但请注意,这里并没有经过摇树优化。现在让我们来看看应用程序的代码,看看可以做些什么来解决这个问题。
在任何一个应用程序中,在寻找摇树机会时,都会先查找静态导入语句。在主组件文件的顶部附近,你将看到如下所示的行:
import * as utils from "../../utils/utils";
也许你之前也见过这样的东西。导入 ES6 模块的方式有很多,但你要特别注意这个。这行语句好像在说:“导入 utils 模块的所有内容,并把它们放在名称空间 utils 中”。问题是,“这个模块中究竟有多少东西?”
如果你去看一下 utils 模块的源代码,你会发现它包含的东西非常多,可能有 1,300 行代码。
或许所有这些东西都会被用到?事实是这样的吗?让我们搜索一下主组件文件,看看出现了多少 utils 命名空间里的东西。
我们从 utils 导入了大量的模块,但在主组件文件中只调用了三次。
这样不太好。我们只在应用程序代码的三个地方使用了 utils 命名空间里的东西。那么它们是用来实现什么功能的呢?如果再看一下主组件文件,我们会发现,似乎只调用了一个函数,即 utils.simpleSort,用于在下拉列表发生变化时按照一定的条件对搜索结果进行排序:
if (this.state.sortBy === "model") {
// Simple sort 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 行的文件,却只使用了其中一个函数。
需要注意的是,这个示例特意做得这么简单,所以可以很容易地找出膨胀代码的来源。但在包含大量模块的大型项目中,很难找出哪些导入造成了捆绑包的数量激增,不过我们可以借助 Webpack Bundle Analyzer 和 source-map-explorer 这些工具。
这个示例是专门为这篇文章定制的,似乎有点牵强,但在实际的项目当中,遇到这样的情况是不可避免的。也就是说,你已经发现了可以进行摇树的机会了,那么接下来应该怎么做呢?
不要让 Babel 将 ES6 模块转换为 CommonJS 模块
Babel 是大多数应用程序不可或缺的工具。可惜的是,它会给摇树优化带来一些麻烦。如果使用了 babel-preset-env,它会自动将 ES6 模块转换为 CommonJS 模块(即你 require 的模块,而不是 import 的模块)。这本来是件好事,但在进行摇树优化时问题就来了。
针对 CommonJS 模块进行摇树优化会比较困难,而且 webpack 不知道需要从捆绑中去掉哪些东西。解决方案很简单:在配置 babel-preset-env 时,让它不要处理 ES6 模块。无论你在哪里配置 Babel(无论是.babelrc 还是 package.json),只要增加一些额外的东西:
{
"presets": [
["env", {
"modules": false
}]
]
}
在 babel-preset-env 配置中指定“modules”: false,webpack 就可以分析依赖关系树,并去掉那些未使用的依赖项。此外,它不会导致兼容性问题,因为 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 数组时会产生副作用,因为它超出了 addFruit 函数的作用域。
副作用也适用于 ES6 模块,所以会影响到摇树优化。有些模块接收可预测的输入,返回可预测的结果,并且不会修改自身作用域之外的任何东西,如果我们没有使用到这些模块,那么就可以安全地将它们“摇”掉。它们是模块化的独立代码片段。
我们可以在 package.json 文件中指定“sideEffects”: false,告诉 webpack 哪个模块及其依赖项是无副作用的:
{
"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 文件中添加这些内容,可以在 webpack 配置的 module.rules 中指定(https://github.com/webpack/webpack/issues/6065#issuecomment-351060570)。
只导入需要的东西
之前我们让 Babel 不要处理 ES6 模块,现在需要对导入语法稍作调整,只从 utils 模块中导入我们需要的函数。在本示例中,我们只需要 simpleSort:
import { simpleSort } from "../../utils/utils";
我们像是在说:“只要把 utils 模块中的 simpleSort 给我就行了”。因为我们只将 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 输出:
下面是进行摇树优化后的输出:
两个捆绑包都缩小了,不过 main 捆绑包的缩小幅度更大。通过去掉 utils 模块的未使用部分,我们已经设法从这个捆绑中砍掉了大约 60%的代码。这不仅可以缩短脚本下载所需的时间,还可以缩短处理脚本的时间。
更复杂的场景
在大多数情况下,只要在近期版本的 webpack 中稍作调整就可以进行摇树优化,但总有一些例外情况会让你感到头疼。例如,本文所描述的方法对 lodash 就不起作用。由于 lodash 自身的架构问题,你需要安装 lodash-es(https://www.npmjs.com/package/lodash-es)来代替常规的 lodash,并且使用不同的语法(被叫作“cherry-picking”)来去掉依赖项:
// This still pulls in all of lodash even if everything is configured right.
import { sortBy } from "lodash";
// This will only pull in the sortBy routine.
import sortBy from "lodash-es/sortBy";
如果你希望保持一致的导入语法,那么可以在使用标准 lodash 包的同时安装 babel-plugin-lodash 插件(http://babel-plugin-lodash/)。在将这个插件添加到 Babel 中后,你可以使用典型的导入语法去掉未使用的依赖项。
如果遇到一个很顽固的库,先看看它是否使用 ES6 语法进行导出。如果它用 CommonJS 格式进行导出(例如 module.exports),那么这些代码将不能通过 webpack 进行摇树优化。有一些插件为 CommonJS 模块提供了摇树功能,例如 webpack-common-shake,但仍然有一些模式的 CommonJS 是无法进行摇树优化的。如果你想要进行可靠的依赖项消除,最好只针对 ES6 模块。
英文原文:
https://developers.google.com/web/fundamentals/performance/optimizing-javascript/tree-shaking/