Tree-Shaking: Javascript 如何在打包时优化代码
自早期以来,JavaScript 程序的复杂性和执行的任务数量都在增长。将这些任务划分为封闭的执行范围的必要性变得显而易见。“Tree-shaking” 是 JavaScript bulding 时必要性能优化。在本文中,我们将更深入地探讨它的工作原理,以及规范和实践如何相互交织,使捆绑包更精简、更高效。此外,你会得到一份 tree-shaking 清单,用于你的项目。
自早期以来,JavaScript 程序的复杂性和执行的任务数量都在增长。将这些任务划分为封闭的执行范围的必要性变得显而易见。这些任务或价值观的隔间就是我们所说的模块(module)。它们的主要目的是防止重复并利用可重用性。因此,设计架构是为了允许这种特殊类型的范围,暴露它们的值和任务,并消费部值和任务。
为了更深入地了解模块是什么以及它们是如何工作的,推荐阅读 “ES Modules: A Cartoon Deep-Dive”。但要理解树抖动(tree-shaking)和模块消费的细微差别,上述定义就足够了。
Tree-Shaking 是什么呢?
简单来说, tree-shaking 指的是从 bundle 包中删除没有使用到的代码(也称为 dead code)。正如 Webpack 3 的文档所述:
“你可以把你的应用\想象成一棵树。你实际使用的源代码和库代表了树上的绿色活叶。死代码代表了秋天消耗的棕色枯叶。为了摆脱枯叶,你必须摇动树,让它们落下。”
该术语最初由 Rollup 团队在前端社区推广。但所有动态语言的作者早就在努力解决这个问题。摇树算法的想法至少可以追溯到 20 世纪 90 年代初。
在 JavaScript 领域,自 ES2015 中的 ECMAScript 模块(ESM)规范(此前称为 ES6)以来, tree-shaking 已经成为可能。从那时起,大多数打包器(bundler)默认启用了树抖动(tree-shaking),因为它们在不改变程序行为的情况下减小了输出大小。
其主要原因是 ESM 上是静态的。让我们剖析一下这意味着什么。
ES Modules Vs. CommonJS
CommonJS 比 ESM 规范早几年出现。它旨在解决 JavaScript 生态系统中缺乏对可重用模块的支持的问题。CommonJS 有一个 require()
函数,它根据提供的路径获取外部模块,并在运行时将其添加到作用域中。
require
是一个与程序中其他函数一样的函数,这使得在编译时评估其调用结果变得很困难。最重要的是,可以在代码中的任何地方添加require
调用——比如,封装在另一个函数调用中、if/else
语句中、switch
语句中等。
随着 CommonJS 架构的广泛采用所带来的学习和斗争,ESM 规范已经确定了这种新的架构,其中模块由相应的关键字 import
和 export
导入和导出。因此,不再有函数调用。ESM 也只允许作为顶级声明使用——由于它们是静态的,因此不可能将它们嵌套在任何其他结构中:ESM 不依赖于运行时执行。
作用域及副作用
然而,为了避免膨胀, tree-shaking 必须克服另一个障碍:副作用。当一个函数改变或依赖于执行作用于之外的因素时,它被认为具有副作用。具有副作用的函数被认为是不纯的。无论上下文或运行环境如何,纯函数总是会产生相同的结果。
const pure = (a:number, b:number) => a + b
const impure = (c:number) => window.foo.number + c
捆绑器(Bundler)通过尽可能多地评估提供的代码来确定模块是否是纯的,从而达到目的。但编译或捆绑期间的代码评估只能到此为止。因此,假设即使完全无法访问,也无法正确消除具有副作用的包。
因此,捆绑器(bundler)现在接受模块内 package.json
文件的 key,它允许开发者声明模块没有副作用。这样,开发人员可以选择退出代码评估并提示捆绑器(Bundler);如果没有可访问的导入或 require
语句链接到特定包中,则可以删除该包中的代码。这不仅可以使包更精简,还可以加快编译时间。
{
"name": "my-package",
"sideEffects": false
}
因此,如果你是软件包开发人员,请在发布之前认真使用 sideEffects
,当然,在每次发布时都要对其进行修改,以避免任何意外的破坏性更改。
除了根 sideEffects
键外,还可以通过在方法调用中注解内联注释 /*@__PURE__*/
来逐个文件地确定纯度。
const x = */@__PURE__*/eliminated_if_not_called()
我将这个内联注释当作是消费开发人员的一个逃逸窗口,在包没有声明 sideEffects: false
的情况下,或者在库确实对特定方法产生副作用的情况下进行。
优化 Webpack
从版本4开始,Webpack 需要越来越少的配置就能使最佳实践发挥作用。几个插件的功能已被整合到核心中。由于开发团队非常重视捆绑包的大小,他们让 tree-shaking 变得容易。
如果你不是一个修补匠,或者你的应用没有特殊情况,那么 tree-shaking 你的依赖关系只需要一行代码。
webpack.config.js
文件有一个名为 mode
的根属性。每当这个属性的值为 production
时,它都会 tree-shake 并完全优化你的模块。除了使用 TerserPlugin
消除无用代码外,mode: 'production'
还将为模块和块(chunk)启用确定性的损坏名称,并将激活以下插件:
- flag dependency usage,
- flag included chunks,
- module concatenation,
- no emit on errors.
触发值为 production
,并非偶然。你不希望在开发环境中完全优化依赖关系,因为这会使问题更难调试。因此,我建议采取以下两种方法之一。
一是,可以将 mode
flag 传递给 Webpack 命令行界面:
# This will override the setting in your webpack.config.js
webpack --mode=production
此外,你可以在 webpack.config.js
中使用 process.env.NODE_ENV
变量:
mode: process.env.NODE_ENV === 'production' ? 'production' : development
情形下,你必须记得在部署管道中这种传入 --NODE_ENV=production
。
这两种方法都 是Webpack 3 及以下版本中众所周知的 definePlugin
之上的抽象。你选择任何一个方式完全没有区别。
Webpack V3 及其以下版本
值得一提的是,本节中的场景和示例可能不适用于 Webpack 和其他捆绑器的最新版本。本节考虑使用 UglifyJS 版本 2,而不是 Terser。UglifyJS 是 Terser 分叉的包,因此它们之间的代码评估可能不同。
由于 Webpack 3 及以下版本不支持 package.json
中的 sideEffects
属性,因此在删除代码之前,必须完全评估所有包。仅此一点就降低了这种方法的有效性,但也必须考虑几个注意事项。
如上所述,编译器无法自行发现包何时篡改了全局作用域。但这并不是它跳过摇树(tree-shaking)的唯一情况。还有更模糊的场景。
以 Webpack 文档中的此软件包为例:
// transform.js
import * as mylib from 'mylib';
export const someVar = mylib.transform({
// ...
});
export const someOtherVar = mylib.transform({
// ...
});
以下是消费者捆绑包的入口点:
// index.js
import { someVar } from './transforms.js';
// Use `someVar`...
我们无法确定 mylib.transform
是否会引发副作用。因此,不会删除任何代码。
以下是其他具有类似结果的情况:
- 从编译器无法检查的第三方模块调用函数,
- 重新导出从第三方模块导入的函数。
// before transformation
import { Row, Grid as MyGrid } from 'react-bootstrap';
import { merge } from 'lodash';
// after transformation
import Row from 'react-bootstrap/lib/Row';
import MyGrid from 'react-bootstrap/lib/Grid';
import merge from 'lodash/merge';
它还有一个配置属性,警告开发人员避免有问题的导入语句。如果你使用的是 Webpack 3 或更高版本,并且你已经对基本配置进行了尽职调查,并添加了推荐的插件,但你的捆绑包看起来仍然臃肿,那么我建议你试试这个软件包。
作用域提升及编译时间
在 CommonJS 时代,大多数打包器只是将每个模块包装在另一个函数声明中,并将它们映射到一个对象中。这与任何映射对象都没有什么不同:
(function (modulesMap, entry) {
// provided CommonJS runtime
})({
"index.js": function (require, module, exports) {
let { foo } = require('./foo.js')
foo.doStuff()
},
"foo.js": function(require, module, exports) {
module.exports.foo = {
doStuff: () => { console.log('I am foo') }
}
}
}, "index.js")
除了难以静态分析外,这与 ESM 根本不兼容,因为我们已经看到我们无法包装 import
和 export
语句。因此,如今,打包器(bundler)将每个模块提升到顶层:
// moduleA.js
let $moduleA$export$doStuff = () => ({
doStuff: () => {}
})
// index.js
$moduleA$export$doStuff()
这种方法与 ESM 完全兼容;此外,它允许代码评估轻松发现未被调用的模块并删除它们。这种方法的需要注意的是,在编译过程中,它需要花费更多的时间,因为它会在过程中接触到每条语句并将包存储在内存中。这就是为什么打包性能成为每个人更关心的问题,以及为什么编译语言被用于 web 开发工具的一个重要原因。例如,esbuild 是用 Go 编写的 bundler,SWC 是用 Rust 编写的 TypeScript 编译器,与 Spark 集成,Spark 也是用 Rust 写的 bundler。
为了更好地理解作用域提升,我强烈推荐 Parcel 版本 2 的文档。
避免过早转运
不幸的是,有一个具体的问题相当普遍,可能会对 tree-shaking 造成毁灭性的影响。简言之,当使用特殊的 loader,将不同的编译器集成到打包器时,就会发生这种情况。常见的组合是 TypeScript、Babel 和 Webpack ——在所有可能的排列中。
Babel 和 TypeScript 都有自己的编译器,它们各自的加载器允许开发人员使用它们,以便于集成。这就是隐藏的威胁。
这些编译器在代码优化之前到达您的代码。无论是默认还是配置错误,这些编译器通常输出 CommonJS 模块,而不是 ESM。正如前一节所述, CommonJS 模块是动态的,因此无法正确评估以消除无用代码。
随着“同构”应用程序(即在服务器端和客户端运行相同代码的应用)的增长,这种情况在当今变得更加普遍。因为 Node.js 还没有对 ESM 的标准支持,所以当编译器针对节点环境时,它们会输出 CommonJS。
因此,请务必检查您的优化算法正在接收的代码。
Tree-Shaking 清单
现在已经了解了捆绑和树摇动的工作原理,让我们为自己画一个清单,你可以在方便的地方打印出来,以便在重新审视当前的实现和代码库时使用。希望这将节省你的时间,并使你不仅可以优化代码的感知性能,甚至可以优化管道的构建时间!
- 使用 ESM,不仅仅在自己的代码库中,同时将你使用的包输出 ESM 作为消耗品。
- 确保你确切地知道哪些依赖项(如果有的话)没有声明
sideEffects
或将其设置为true
。 - 在使用具有副作用的包时,使用内联注释将方法调用声明为
pure
。 - 如果要输出 CommonJS 模块,请确保在转换导入和导出语句之前优化你的包。
软件包编写
希望到目前为止,我们都同意 ESM 是 JavaScript 生态系统的前进方向。然而,在软件开发中,转换可能很棘手。幸运的是,包作者可以采取非破坏性措施,为用户提供快速无缝的迁移。
通过对 package.json
进行一些小的添加,你的包将能够告诉打包者该包支持的环境以及如何最好地支持它们。以下是 Skypack
的清单:
- 引入 ESM export
- 添加
"type": "module"
. - 通过
"module": "./path/entry.js"
指明入口处(社区共识).
以下是一个遵循所有最佳实践并且同时支持 web 和 Node.js 环境的例子:
{
// ...
"main": "./index-cjs.js",
"module": "./index-esm.js",
"exports": {
"require": "./index-cjs.js",
"import": "./index-esm.js"
}
// ...
}
除此之外,Skypack 团队还引入了包质量分数作为基准,以确定给定的包是否为长期支持和最佳实践而设置。该工具在 GitHub 上开源,可以作为 devDependency
添加到你的包中,以便在每次发布之前轻松执行检查。
小结
本文介绍了 tree-shaking 及其工作原理,以及实践中需要注意的一些点,希望对你有所帮助。