摇树:参考指南

已发表: 2022-03-10
快速总结↬ “Tree-shaking”是捆绑 JavaScript 时必须具备的性能优化。 在本文中,我们将深入探讨它的工作原理以及规范和实践如何相互交织以使捆绑包更精简、更高效。 另外,您将获得一份用于您的项目的摇树检查清单。

在开始学习什么是 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 规范已经确定了这种新架构,其中模块通过相应的关键字importexport导入和导出。 因此,不再有函数调用。 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 不兼容,因为我们已经看到我们无法包装importexport语句。 因此,如今,捆绑器将每个模块提升到顶层:

 // 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 如何工作的细节,让我们自己绘制一个清单,当您重新访问当前的实现和代码库时,您可以将其打印在方便的地方。 希望这可以节省您的时间,让您不仅可以优化代码的感知性能,还可以优化管道的构建时间!

  1. 使用 ESM,不仅在您自己的代码库中,而且还支持将 ESM 输出为消耗品的软件包。
  2. 确保您确切知道哪些(如果有)依赖项没有声明sideEffects或将它们设置为true
  3. 在使用带有副作用的包时,使用内联注释来声明纯粹的方法调用。
  4. 如果您要输出 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
  • 包裹检查