摇树:参考指南
已发表: 2022-03-10在开始学习什么是 tree-shaking 以及如何为成功做好准备之前,我们需要了解 JavaScript 生态系统中的模块。
从早期开始,JavaScript 程序的复杂性和它们执行的任务数量都在增长。 将这些任务划分为封闭的执行范围的需求变得很明显。 这些任务或值的隔间就是我们所说的模块。 它们的主要目的是防止重复并利用可重用性。 因此,架构被设计为允许这种特殊类型的范围,公开它们的价值和任务,并使用外部价值和任务。
为了更深入地了解模块是什么以及它们是如何工作的,我推荐“ES Modules: A Cartoon Deep-Dive”。 但是要理解 tree-shaking 和模块消耗的细微差别,上面的定义就足够了。
摇树实际上是什么意思?
简单地说,tree-shaking 意味着从包中删除无法访问的代码(也称为死代码)。 正如 Webpack 版本 3 的文档所述:
“你可以把你的应用想象成一棵树。 您实际使用的源代码和库代表了这棵树的绿色、活生生的叶子。 死代码代表秋天消耗掉的棕色枯叶。 为了摆脱枯叶,你必须摇动树,让它们倒下。”
该术语最早由 Rollup 团队在前端社区推广。 但是所有动态语言的作者早在很久以前就一直在努力解决这个问题。 摇树算法的想法至少可以追溯到 1990 年代初期。
在 JavaScript 领域,自 ES2015 中的 ECMAScript 模块 (ESM) 规范(以前称为 ES6)以来,tree-shaking 已经成为可能。 从那时起,大多数捆绑器默认启用了tree-shaking,因为它们在不改变程序行为的情况下减少了输出大小。
主要原因是 ESM 本质上是静态的。 让我们剖析一下这意味着什么。
ES 模块与 CommonJS
CommonJS 比 ESM 规范早了几年。 它旨在解决 JavaScript 生态系统中对可重用模块缺乏支持的问题。 CommonJS 有一个require()
函数,它根据提供的路径获取外部模块,并在运行时将其添加到作用域中。
与程序中的任何其他函数一样, require
是一个function
,这使得在编译时评估其调用结果变得足够困难。 最重要的是,可以在代码的任何地方添加require
调用——包装在另一个函数调用中,在 if/else 语句中,在 switch 语句中,等等。
随着 CommonJS 架构的广泛采用所带来的学习和奋斗,ESM 规范已经确定了这种新架构,其中模块通过相应的关键字import
和export
导入和导出。 因此,不再有函数调用。 ESM 也只允许作为顶级声明——不可能将它们嵌套在任何其他结构中,因为它们是静态的:ESM 不依赖于运行时执行。
范围和副作用
然而,为了避免膨胀,摇树必须克服另一个障碍:副作用。 当一个函数改变或依赖于执行范围之外的因素时,它被认为具有副作用。 具有副作用的函数被认为是不纯的。 纯函数将始终产生相同的结果,无论上下文或运行它的环境如何。
const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c
打包器通过尽可能多地评估提供的代码来确定模块是否是纯的,从而达到其目的。 但是编译时或捆绑时的代码评估只能到此为止。 因此,假设即使在完全无法访问的情况下,也无法正确消除具有副作用的包。
正因为如此,捆绑器现在接受模块的package.json
文件中的密钥,允许开发人员声明模块是否没有副作用。 这样,开发人员可以选择退出代码评估并提示捆绑程序; 如果没有可访问的导入或链接到它的require
语句,则可以消除特定包中的代码。 这不仅使包更精简,而且还可以加快编译时间。
{ "name": "my-package", "sideEffects": false }
因此,如果您是包开发人员,请在发布之前认真使用sideEffects
,当然,每次发布时都要对其进行修改,以避免任何意外的重大更改。
除了根sideEffects
键之外,还可以通过在方法调用中注释内联注释/*@__PURE__*/
来逐个文件确定纯度。
const x = */@__PURE__*/eliminated_if_not_called()
我认为这个内联注释是消费者开发人员的一个逃生口,如果包没有声明sideEffects: false
或者库确实对特定方法产生副作用,则需要这样做。
优化 Webpack
从版本 4 开始,Webpack 需要越来越少的配置来获得最佳实践。 几个插件的功能已合并到核心中。 而且由于开发团队非常重视包的大小,他们使 tree-shaking 变得容易。
如果您不是一个修补匠,或者如果您的应用程序没有特殊情况,那么摇树依赖项只需一行。
webpack.config.js
文件有一个名为mode
的根属性。 只要此属性的值为production
,它将摇树并充分优化您的模块。 除了使用TerserPlugin
消除死代码之外, mode: 'production'
将为模块和块启用确定性的错位名称,并将激活以下插件:
- 标记依赖项使用,
- 标志包括块,
- 模块连接,
- 没有发出错误。
触发值是production
并非偶然。 您不希望在开发环境中完全优化您的依赖项,因为这会使问题更难调试。 所以我建议用两种方法之一来解决它。
一方面,您可以将mode
标志传递给 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 版本 3 及以下
值得一提的是,本节中的场景和示例可能不适用于最新版本的 Webpack 和其他打包程序。 本节考虑使用 UglifyJS 版本 2,而不是 Terser。 UglifyJS 是 Terser 派生出来的包,因此它们之间的代码评估可能不同。
因为 Webpack 版本 3 及以下版本不支持package.json
中的sideEffects
属性,所以在消除代码之前必须对所有包进行完全评估。 仅这一点就使该方法不太有效,但也必须考虑几个警告。
如上所述,编译器无法自行发现包何时篡改全局范围。 但这不是它跳过摇树的唯一情况。 还有更模糊的场景。
从 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
是否会引发副作用。 因此,不会消除任何代码。
以下是具有类似结果的其他情况:
- 从编译器无法检查的第三方模块调用函数,
- 重新导出从第三方模块导入的函数。
babel-plugin-transform-imports 可以帮助编译器进行 tree-shaking 工作。 它将所有成员和命名导出拆分为默认导出,允许单独评估模块。
// 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
语句。 因此,如今,捆绑器将每个模块提升到顶层:
// moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()
这种方法与 ESM 完全兼容; 另外,它允许代码评估轻松地发现没有被调用的模块并删除它们。 这种方法的警告是,在编译期间,它需要更多的时间,因为它会在过程中触及每条语句并将包存储在内存中。 这就是为什么捆绑性能已成为每个人都更加关注的一个重要原因,也是为什么编译语言被用于 Web 开发工具的一个重要原因。 例如,esbuild 是一个用 Go 编写的打包器,SWC 是一个用 Rust 编写的 TypeScript 编译器,它与 Spark 集成,Spark 也是一个用 Rust 编写的打包器。
为了更好地理解范围提升,我强烈推荐 Parcel 版本 2 的文档。
避免过早的转译
不幸的是,有一个特定的问题相当普遍,并且可能对摇树造成破坏性影响。 简而言之,当您使用特殊的加载器,将不同的编译器集成到您的捆绑器时,就会发生这种情况。 常见的组合是 TypeScript、Babel 和 Webpack——在所有可能的排列中。
Babel 和 TypeScript 都有自己的编译器,它们各自的加载器允许开发人员使用它们,以便于集成。 隐藏的威胁就在其中。
这些编译器会在代码优化之前到达您的代码。 而且无论是默认还是错误配置,这些编译器通常会输出 CommonJS 模块,而不是 ESM。 如前一节所述,CommonJS 模块是动态的,因此无法正确评估死代码消除。
随着“同构”应用程序(即在服务器端和客户端运行相同代码的应用程序)的增长,这种情况如今变得更加普遍。 因为 Node.js 还没有对 ESM 的标准支持,所以当编译器针对node
环境时,它们会输出 CommonJS。
因此,请务必检查您的优化算法正在接收的代码。
摇树检查清单
现在您已经了解了捆绑和 tree-shaking 如何工作的细节,让我们自己绘制一个清单,当您重新访问当前的实现和代码库时,您可以将其打印在方便的地方。 希望这可以节省您的时间,让您不仅可以优化代码的感知性能,还可以优化管道的构建时间!
- 使用 ESM,不仅在您自己的代码库中,而且还支持将 ESM 输出为消耗品的软件包。
- 确保您确切知道哪些(如果有)依赖项没有声明
sideEffects
或将它们设置为true
。 - 在使用带有副作用的包时,使用内联注释来声明纯粹的方法调用。
- 如果您要输出 CommonJS 模块,请确保在转换导入和导出语句之前优化您的包。
包创作
希望到此为止,我们都同意 ESM 是 JavaScript 生态系统的前进方向。 但是,与软件开发中的往常一样,转换可能很棘手。 幸运的是,包作者可以采取非破坏性措施来促进其用户的快速无缝迁移。
通过对package.json
的一些小添加,您的包将能够告诉打包者该包支持的环境以及如何最好地支持它们。 这是 Skypack 的清单:
- 包括 ESM 导出。
- 添加
"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
添加到您的包中,以便在每次发布之前轻松执行检查。
包起来
我希望这篇文章对你有用。 如果是这样,请考虑与您的网络共享它。 我期待在评论或 Twitter 上与您互动。
有用的资源
文章和文档
- “ES Modules: A Cartoon Deep-Dive”,Lin Clark,Mozilla Hacks
- “摇树”,Webpack
- “配置”,Webpack
- “优化”,Webpack
- “Scope Hoisting”,Parcel 版本 2 的文档
项目和工具
- 泰瑟
- babel-plugin-transform-imports
- 天空背包
- 网页包
- 包裹
- 卷起
- esbuild
- SWC
- 包裹检查