编程

Tree-Shaking: Javascript 如何在打包时优化代码

20 2025-06-21 10:25:00

自早期以来,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 规范已经确定了这种新的架构,其中模块由相应的关键字 importexport 导入和导出。因此,不再有函数调用。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 根本不兼容,因为我们已经看到我们无法包装 importexport 语句。因此,如今,打包器(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 清单

现在已经了解了捆绑和树摇动的工作原理,让我们为自己画一个清单,你可以在方便的地方打印出来,以便在重新审视当前的实现和代码库时使用。希望这将节省你的时间,并使你不仅可以优化代码的感知性能,甚至可以优化管道的构建时间!

  1. 使用 ESM,不仅仅在自己的代码库中,同时将你使用的包输出 ESM 作为消耗品。
  2. 确保你确切地知道哪些依赖项(如果有的话)没有声明 sideEffects 或将其设置为 true
  3. 在使用具有副作用的包时,使用内联注释将方法调用声明为 pure
  4. 如果要输出 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 及其工作原理,以及实践中需要注意的一些点,希望对你有所帮助。