通过摇树优化减少 JavaScript 载荷

当今的 Web 应用可能非常庞大,尤其是其 JavaScript 部分。截至 2018 年年中,HTTP Archive 将移动设备上的 JavaScript 传输大小中位数定为约 350 KB。而这只是传输大小!JavaScript 在通过网络发送时通常会被压缩,这意味着在浏览器解压缩 JavaScript 后,其实际大小会大很多。这一点非常重要,因为就资源处理而言,压缩无关紧要。900 KB 的解压缩 JavaScript 对解析器和编译器来说仍然是 900 KB,即使在压缩后可能只有大约 300 KB。

一张示意图,展示了下载、解压缩、解析、编译和执行 JavaScript 的过程。
下载和运行 JavaScript 的过程。请注意,即使脚本的传输大小压缩后为 300 KB,但仍有 900 KB 的 JavaScript 代码需要解析、编译和执行。

JavaScript 的处理开销很大。与图片不同,图片在下载后只会产生相对较短的解码时间,而 JavaScript 必须经过解析、编译,然后才能最终执行。因此,就字节而言,JavaScript 的开销高于其他类型的资源。

一张图表,比较了 170 KB 的 JavaScript 与等同大小的 JPEG 图片的处理时间。与 JPEG 相比,JavaScript 资源的字节占用资源量要高得多。
解析/编译 170 KB 的 JavaScript 的处理开销与解码大小相当的 JPEG 所需时间。(来源)。

虽然我们会不断改进提高 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 的大小。在本指南中,您将了解如何做到这一点!

寻找摇树的机会

为方便说明,我们提供了一个单页面应用示例,演示了树摇动的工作原理。您可以根据需要克隆该项目并按照相关步骤操作,但本指南将介绍整个过程的每个步骤,因此您无需克隆该项目(除非您喜欢通过实践来学习)。

示例应用是一个可搜索的吉他效果踏板数据库。您输入查询后,系统会显示效果踏板列表。

用于搜索吉他效果踏板数据库的单页面应用示例的屏幕截图。
示例应用的屏幕截图。

驱动此应用的行为分为供应商(即PreactEmotion)以及应用专用代码软件包(或 webpack 称为的“分块”):

Chrome 开发者工具的“Network”(网络)面板中显示的两个应用代码软件包(或分块)的屏幕截图。
应用的两个 JavaScript 软件包。这些是未压缩的大小。

上图中显示的 JavaScript 软件包是正式版 build,这意味着它们已通过 uglification 进行优化。特定于应用的 bundle 为 21.1 KB 并不算多,但请注意,系统根本没有进行任何树摇动。我们来看看应用代码,看看可以采取哪些措施来解决此问题。

在任何应用中,查找树摇动机会都需要查找静态 import 语句。在主要组件文件顶部附近,您会看到如下代码行:

import * as utils from "../../utils/utils";

您可以通过多种方式导入 ES6 模块,但像这样的模块应该引起您的注意。这行代码的意思是“import utils 模块中的所有内容,并将其放入名为 utils 的命名空间中”。这里的关键问题是“该模块中有多少内容?”

如果您查看 utils 模块源代码,会发现其中大约有 1,300 行代码。

需要所有这些信息吗?我们来仔细检查一下,搜索导入 utils 模块的主要组件文件,看看该命名空间有多少个实例。

在文本编辑器中搜索“utils.”,仅返回 3 条结果的屏幕截图。
我们从中导入了大量模块的 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 MillerAddy OsmaniJeff Posnick、Sam Saccone 和 Philip Walton 提供宝贵的反馈,帮助显著提高本文的质量。