通过摇树优化减少 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" 模块导入所有内容(这可能会是大量代码)。在开发 build 中,这不会改变任何内容,因为无论如何导入整个模块。在正式版 build 中,可以将 webpack 配置为“shake”关闭未明确导入的 ES6 模块的 export,从而减小正式版 build。在本指南中,您将了解如何执行此操作!

寻找摇晃树的机会

为了便于说明,我们提供了一个单页应用示例来演示摇树优化的工作原理。如果愿意,您可以克隆该代码并跟随操作,但本指南中会一一介绍其中的每一步,因此没必要进行克隆(除非您选择实操学习)。

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

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

推动此应用的行为可拆分为供应商(即PreactEmotion)以及应用专属代码包(或“块”,Webpack 称之为“区块”):

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

上图所示的 JavaScript 软件包是正式版,意味着它们已通过污名化处理而得到优化。应用专用软件包为 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。

不可否认,此示例应用有点刻意,但它并没有改变一个事实,即这种合成场景与您在生产网络应用中可能遇到的实际优化机会相似。现在,您已经确定了摇树优化机会,那么它实际上是如何实现的?

阻止 Babel 将 ES6 模块转译为 CommonJS 模块

Babel 是一种不可或缺的工具,但它可能会使摇树优化的影响更加难以观察。如果您使用的是 @babel/preset-env,Babel 可以将 ES6 模块转换为兼容更广泛的 CommonJS 模块,也就是说,您 require 而不是 import 的模块。

因为对于 CommonJS 模块更难执行摇树优化,因此如果您决定使用 webpack,Webpack 将无法从 bundle 中删减什么。解决方案是配置 @babel/preset-env 以明确保留 ES6 模块。无论您在何处配置 Babel,无论是在 babel.config.js 还是 package.json 中,都需要添加一些额外的内容:

// 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 bundle 会缩减约 60%。这不仅减少了脚本完成下载所花的时间,还减少了处理时间。

去摇一摇树吧!

从摇树优化中您可以获得多大成果,都取决于您的应用及其依赖项和架构。试试看!如果您确实没有设置模块打包器来执行此优化,那么尝试了解它对您的应用有何益处也无妨。

您可能会通过摇树优化来提高性能,或者根本不会提升性能。但是,通过配置您的构建系统以在生产构建中利用此优化,并选择性地仅导入您的应用需要的内容,您可以主动将应用程序软件包保持尽可能小。

特别感谢 Kristofer Baxter、Jason MillerAddy OsmaniJeff Posnick、Sam Saccone 和 Philip Walton 提供的宝贵反馈,这些宝贵意见显著提高了本文的质量。