Skip to content

Deep Dive Into Webpack Tree Shaking

A Note to Our Readers

While maintaining fidelity to the source material, this translation incorporates explanatory content and localized expressions, informed by the translator's domain expertise. These thoughtful additions aim to enhance readers' comprehension of the original text's core messages. For any inquiries about the content, we welcome you to engage in the discussion section or consult the source text for reference.

这篇文章主要关注于理解 webpacktree shaking 概念,并非深入探讨其底层代码是如何实现。代码示例可以在这里找到,代码仓库

webpacktree shaking 实现是具有挑战性的,其中会涉及到多种与 tree shaking 相关的优化方式之间的协同工作。webpacktree shaking 这个术语的使用有些不一致,通常广泛地指代用于消除无用代码的优化。tree shaking 被定义为:

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. The name and concept have been popularized by the ES2015 module bundler rollup.


tree shaking 是一个在 javascript 上下文中常用的术语,用于消除无用代码。它依赖于 ES2015 模块语法的静态结构(例如, importexport),这个名称和概念由 ES2015 模块打包工具 rollup 推广。

在某些上下文中,像 usedExports 优化被统称为 tree shakingsideEffects

The sideEffects and usedExports (more known as tree shaking) optimizations are two different things.


sideEffectsusedExports(更广为人知的称为 tree shaking)是两种不同的优化方式。

为了避免对 tree shaking 的理解产生歧义,这篇讨论将不专注于 tree shaking 本身,而是关注于 webpacktree shaking 特性的各种优化方式。

webpack 中的 tree shaking 特性主要涉及如下三种优化方式:

  1. usedExports 优化:这涉及从模块中移除未使用的导出变量,从而进一步消除相关的无副作用语句。

    js
    import { a } from './a';
    console.log(a);
    js
    // further eliminate relevant side-effect-free statements.
    function unused() {
      return 1;
    }
    // unused export
    export const a = unused;
    // used export
    export const b = 2;
  2. sideEffects 优化:这会从模块图中移除未使用导出变量的模块。

    js
    // sideEffects: true
    import { a } from './a';
    console.log(a);
    js
    // sideEffects: true
    export const a = 1;
    js
    /**
     * sideEffects: true
     * ⚠️ Even if the module is set to sideEffects:true,
     * it is unreachable in the application, so the side effects of
     * the module cannot be triggered.
     */
    export const b = 3;
    console.log(b);
  3. DCE(无用代码消除)优化:这通常由压缩工具来进行实现,通过压缩工具来移除无用代码。功能也可以如 webpackConstPlugin 类似的工具实现,不过压缩工具会进行更深入的解析及其优化。

上述的优化涉及在 不同维度 上操作:

  • usedExports 涉及的维度是 模块的导出变量tree shaking 优化。
  • sideEffects 涉及的维度是 整个模块tree shaking 优化。
  • DCE 涉及的维度是 模块中的 javascript 语句tree shaking 优化。

考虑以下示例,其中 main.js 作为 入口模块

js
import { a } from './lib';
import { c } from './util';
import './bootstrap';

console.log(a);
js
export const a = 1;
export const b = 2;
js
export const c = 3;
export const d = 4;
js
console.log('bootstrap');
if (false) {
  console.log('bad');
} else {
  console.log('good');
}
  • lib.js 模块中,变量 b 未被使用,那么 b 变量所关联的代码会通过 usedExports 优化而在最终产物中被移除。
  • util.js 模块中,没有导出变量被使用,那么 util 模块会通过 sideEffects 优化而在最终产物中被移除。
  • bootstrap.js 模块中,if 语句的决策逻辑永远为 false 值,那么相关的代码块 console.log('bad') 语句会通过 DCE 优化而在最终产物中被移除。

上述的优化特性虽然是被独立实现的,但在 tree shaking 期间,相互之间会相互影响。接下来,详细介绍这些优化以及如何相互影响。

DCE Optimization

webpack 中,DCE 相对简单,有两个重要的场景:

False Branch Optimization

js
if (false) {
  false_branch;
} else {
  true_branch;
}

在这里,因为 false_branch 永远不会执行,所以可以直接移除。移除 false_branch 可能会产生两个影响:

  1. 减少最终产物的大小。
  2. 影响引用关系。

考虑以下示例:

js
import { a } from './a';
if (false) {
  console.log(a);
} else {
}

在未实施 DCE 的前提下,由于 false_branch 未被移除,那么变量 a 将被视为已使用。

DCE 起作用,false_branch 被移除后,那么变量 a 将被视为未使用。这就间接影响到 usedExportssideEffects 的分析,也就是说仅通过 usedExportssideEffects 两种优化特性所优化后的代码并不彻底,需要再通过 DCE 来进一步移除。

为了解决这个问题,webpack 提供了两次 DCE 的机会:

  • 通过 parse 阶段的 ConstPlugin,执行基本的 DCE 来尽可能多地判断模块的导入和导出引用被其他模块使用的情况,从而增强后续的 sideEffectusedExport 优化。
  • 通过 processAssets 阶段的 Terserminify 进行更复杂的 DCE,主要旨在减少代码大小。

Terser 插件的 DCE 优化更耗时且复杂,而 ConstPlugin 插件相比则较为简单。例如,Terser 插件处理以下 false_branch 可以成功移除,但 ConstPlugin 插件可能无法处理。

js
function get_one() {
  return 1;
}
const res = get_one() + get_one();

if (res != 2) {
  console.log(c);
}

Unused Top Level Statement

在模块中,若顶层语句未被导出或导出变量未被消费,同时模块中不存在顶层副作用语句,那么这个模块被视为无副作可以被安全移除。

ts
function add(a: number, b: number) {
  return a + b;
}

/**
 * module is not exported or the exported variable is not consumed.
 *
 * not top level side-effect statements
 * e.g. console.log('hello')
 */

例如,以下的 btest 可以安全删除(假设这是一个模块而不是脚本,因为脚本会污染全局作用域,无法安全移除)。

js
// index.js
export const a = 10;
const b = 20;
function test() {}

webpackusedExports 优化利用了这一特性来简化其实现。

usedExports Optimization

webpackusedExports 优化使用依赖的活动状态来确定模块内的变量是否被使用。之后,在生成代码阶段,若导出变量未被使用,它不会生成对应的导出属性,使得依赖于这些导出变量的代码段成为无用代码段。通过后续的压缩进一步辅助 DCE 优化。

webpack 通过 optimization.usedExports 配置项来启用 usedExports 优化。考虑以下示例:

js
import { a } from './lib';
console.log({ a });
js
export const a = 1;
export const b = 2;

webpack 未启用 tree shaking 的情况下,输出产物中会包含关于 b 的信息:

js
var __webpack_modules__ = [
  (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, {
      a: () => a,
      b: () => b
    });
    const a = 1;
    const b = 2;
  }
];

可见 b 的引用信息并未被 webpack 移除。

webpack 启用了 optimization.usedExports 优化时,b 的导出将会被移除。

但需要注意的是与 b 相关的代码段 const b = 2 仍然存在。由于 b 未被使用,const b = 2 将被视为 无用代码段

js
/***/ (
  __unused_webpack_module,
  __webpack_exports__,
  __webpack_require__
) => {
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
    /* harmony export */ a: () => /* binding */ a
    /* harmony export */
  });
  /* unused harmony export b */
  const a = 1;
  const b = 2;

  /***/
};

那么后续可以通过启用 代码压缩 来移除上述阶段产生的 无用代码段

js
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  __webpack_require__.d(__webpack_exports__, {
    a: () => a
  });
  const a = 1;
},
  (__webpack_module_cache__ = {});

还需要注意的是,分析 b 是否被使用并不总是那么简单。考虑以下情况:

js
import { a, b } from './lib';
console.log({ a });
function test() {
  console.log(b);
}
function test1() {
  test();
}
js
export const a = 1;
export const b = 2;

上述例子中,函数 test 使用了变量 b

webpack 构建后的产物中可以发现 b 并没有直接被移除,这是因为 webpack默认策略 并不执行 深度静态分析。换句话说,即使 test 没有被使用,b 没有被使用,webpack 也未能推断出是否可以安全删除这些 无用代码段

js
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  __webpack_require__.d(__webpack_exports__, {
    a: () => a,
    b: () => b
  });
  const a = 1,
    b = 2;
},
  (__webpack_module_cache__ = {});

幸运的是,webpack 提供了另一个配置项 optimization.innerGraph。它允许对代码进行更深层次的静态分析。那么 webpack 就可以判断 b 变量并未被使用,可以安全将 b 变量移除:

js
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
    /* harmony export */ a: () => /* binding */ a
    /* harmony export */
  });
  /* unused harmony export b */
  const a = 1;
  const b = 2;

  /***/
};

DCE 也影响 usedExports 优化。

考虑以下情况:

js
import { a, b, c } from './lib';
console.log({ a });
if (false) {
  console.log(b);
}
function get_one() {
  return 1;
}
const res = get_one() + get_one();

if (res != 2) {
  console.log(c);
}
js
export const a = 1;
export const b = 2;
export const c = 3;

通过 webpack 内部的 ConstPlugin 插件的 DCE 优化,b 变量被成功移除掉。但由于 ConstPlugin 插件的能力有限,c 变量并未被移除。

js
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
    /* harmony export */ a: () => /* binding */ a,
    /* harmony export */ c: () => /* binding */ c
    /* harmony export */
  });
  /* unused harmony export b */
  const a = 1;
  const b = 2;
  const c = 3;

  /***/
};

sideEffects Optimization

上述的 usedExports 优化专注于模块中导出的变量,sideEffects 优化更为彻底和高效,它专注于移除整个没有副作用的模块。

要安全地移除一个模块,必须满足以下两个条件:

  1. 模块的导出变量未被入口模块的所有可达模块集合所包含的所有副作用语句所关联。
  2. 模块顶层执行语句中不包括副作用的语句。

webpack 通过 optimization.sideEffects 配置来启用 sideEffects 优化。

让我们看一个简单的例子:

js
import { a } from './lib';
import { c } from './util';
console.log({ a });
js
export const a = 1;
export const b = 2;
js
export const c = 123;
export const d = 456;

在未启用 optimization.sideEffects 的情况下,产物中保留了 util 模块:

js
const modules = {
  /***/ './src/lib.js': /***/ (
    __unused_webpack_module,
    __webpack_exports__,
    __webpack_require__
  ) => {
    __webpack_require__.r(__webpack_exports__);
    /* harmony export */ __webpack_require__.d(__webpack_exports__, {
      /* harmony export */ a: () => /* binding */ a,
      /* harmony export */ b: () => /* binding */ b
      /* harmony export */
    });
    const a = 1;
    const b = 2;

    /***/
  },

  /***/ './src/util.js': /***/ (
    __unused_webpack_module,
    __webpack_exports__,
    __webpack_require__
  ) => {
    __webpack_require__.r(__webpack_exports__);
    /* harmony export */ __webpack_require__.d(__webpack_exports__, {
      /* harmony export */ c: () => /* binding */ c,
      /* harmony export */ d: () => /* binding */ d
      /* harmony export */
    });
    const c = 123;
    const d = 456;

    /***/
  }
};

当启用 optimization.sideEffects 时,util.js 模块将从产物中移除。这是因为 util 模块满足 sideEffects 移除所需的两个条件。

如果违反 sideEffects 的任意一个条件会发生什么:

  1. util.js 中引入顶层副作用语句:

    js
    export const c = 123;
    export const d = 456;
    console.log('hello');

    那么将导致 util.js 模块重新出现在产物中。

  2. util.js 模块的导出变量被入口模块的所有可达模块集合所包含的所有副作用语句所关联:

    现在,撤销上述更改并修改 main.js 以使用 util.js 中的变量 c

    js
    import { a } from './lib';
    import { c } from './util';
    console.log({ a }, c);

    此修改也导致 util.js 模块重新出现在产物中。

这些实验表明,必须同时满足 sideEffects 的两个条件才能安全地移除一个模块。确保上述两个条件得到满足在实际应用中有效利用 sideEffect 优化启着至关重要的作用。

让我们重新审视 sideEffects 优化安全移除模块所需的两个前置条件:

  1. 模块的导出变量未被入口模块的所有可达模块集合所包含的所有副作用语句所关联

这个条件虽然看似简单,但遇到的挑战与 usedExports 优化中类似,可能需要大量的语义分析来确定模块中的变量的实际使用情况。

考虑以下示例,其中 cmain.js 模块的 test 函数中被使用,阻止了 util.js 的成功移除:

js
import { a } from './lib';
import { c } from './util';
console.log({ a });
function test() {
  console.log(c);
}
js
export const a = 1;
export const b = 2;
js
export const c = 123;
export const d = 456;

sideEffects Property

与确认模块导出变量是否被使用相比,确定模块内部是否存在副作用是一个更复杂的过程。考虑以下对 util.js 的修改:

js
// util.js
export const c = 123;
export const d = test();
function test() {
  return 456;
}

在这种情况下,尽管 test() 语句是一个无副作用的函数调用,但 webpack 仍然无法确定这一点,仍然认为模块可能存在副作用。因此,util.js 被包含在最终输出中:

为了告知 webpacktest 标记为无副作用,有两种方法可用:

  1. 纯注释:通过在函数调用上标记纯注释,显示告知 webpack 该函数中并不存在副作用,可以安全的移除:

    js
    // util.js
    export const c = 123;
    export const d = /*#__PURE__*/ test();
    function test() {
      return 456;
    }
  2. sideEffects 属性:当一个模块包含大量顶级语句时,为每个语句标记纯注释可能繁琐且容易出错。因此,webpack 引入了 sideEffects 属性来将整个模块标记为无副作用。在模块的 package.json 中添加 "sideEffects": false 可以安全地移除 util.js

json
{
  "sideEffects": false
}

然而,当一个标记为 sideEffect: false 的模块依赖于另一个标记为 sideEffect: true 的模块时,会出现一个挑战。

考虑 button.js 模块导入 button.css 模块的场景,其中 button.js 模块配置为 sideEffects: falsebutton.css 模块配置为 sideEffects: true

js
import { Button } from 'antd';
js
import { Button } from './button';
js
import './button.css';
import './side-effect';
export const Button = () => {
  return `<div class="button">btn</div>`;
};
css
.button {
  background-color: red;
}
js
console.log('side-effect');
json
{
  "sideEffects": ["**/*.css", "**/side-effect.js"]
}

如果 sideEffects 仅标记当前模块具有副作用。由于 button.cssside-effect.js 模块具有了副作用,根据 ESM 标准,它们应该被打包。然而,webpack 的产物中并不包括 button.cssside-effect.js 模块。

因此,sideEffects 字段的真正含义是:

sideEffects is much more effective since it allows to skip whole modules/files and the complete subtree.


sideEffects 是更为强大和有效的,因为它引导打包工具跳过整个模块/文件及其完整的子树。

如果一个模块被标记为 sideEffect: false,若这个模块的导出变量均未被入口模块的所有可达模块集合所包含的所有副作用语句所关联。这意味着这个模块及其整个依赖子树模块均可以被安全的移除。

这一解释澄清了为什么在上述的示例中,button.js 及其所有的依赖子树模块(包括 button.cssside-effect.js 模块)可以被安全移除,这在组件库的上下文中特别有用。

json
{
  "name": "antd",
  "version": "5.23.3",
  "description": "An enterprise-class UI design language and React components implementation",
  "sideEffects": ["*.css"],
  "main": "lib/index.js",
  "module": "es/index.js",
  "unpkg": "dist/antd.min.js",
  "typings": "es/index.d.ts"
}
json
{
  "name": "vuetify",
  "description": "Vue Material Component Framework",
  "version": "3.7.9",
  "main": "lib/framework.mjs",
  "module": "lib/framework.mjs",
  "jsdelivr": "dist/vuetify.js",
  "unpkg": "dist/vuetify.js",
  "types": "lib/index.d.mts",
  "sass": "lib/styles/main.sass",
  "styles": "lib/styles/main.css",
  "sideEffects": ["*.sass", "*.scss", "*.css", "*.vue"]
}

不幸的是,这种行为在不同的打包工具中表现不一致。测试显示:

  • webpack:安全删除子树中的带副作用的 css 模块和 js 模块。
  • rollup:安全删除子树中的带副作用的 css 模块和 js 模块。
  • esbuild:删除子树中的带副作用的 js 模块,但不删除 css 模块。

Affects Barrel Files

barrel files 的具体介绍和优化方案可以参考 Barrel Files。其中会涉及到与 sideEffects 优化相关的优化方案。

通常情况下,barrel files 模块本身是 side effect free 的,本身仅用作相关功能集合的统一接口,与 npm 包提供的入口模块(i.e. index.jsmain.js)有着相似意义。

当启用 barrel files 优化时,sideEffects 优化不仅可以优化叶节点模块,还可以优化中间节点(barrel files)。

考虑以下常见场景:

js
import { Button } from './components';
console.log('button:', Button);
js
export * from './button';
export * from './tab';
export const mid = 'middle';
js
export const Button = () => 'button';

测试表明,webpack 将直接删除了重新导出的模块(barrel files),并在 main.js 中直接导入 button.js 模块的内容。

js
(() => {
  __webpack_require__.r(__webpack_exports__);
  var _components__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
    './src/components/button.js'
  );
  console.log('button:', _components__WEBPACK_IMPORTED_MODULE_0__.Button);
})();

上述的行为看起来就像是 webpack 将目标模块的导入路径(barrel files)修改为目标模块的具体路径(button.js):

js
import { Button } from './components'; 
import { Button } from './components/button'; 

next.jsumi.js 这样的框架也提供了类似的优化 Optimize Package Imports

它们会在 loader 阶段重写这些路径。需要注意的是,虽然 webpack 的桶优化专注于输出,但它仍然在构建阶段编译 components/index.js 模块及其所有的子依赖模块。

next.js 在解析 barrel files 时会借助 esbuild 的能力来快速扫描导入的模块是否为 barrel files 模块,针对 barrel files 模块,会在内部实现目标模块与具体路径的映射表,用于后续的路径重写工作。

具体实现可参考 Optimize Feature - Barrel Files,这显著优化了桶模块重新导出数百或数千个依赖模块的库的耗时场景。

esbuildrollup 在这方面的行为保持一致:

  • esbuild:删除桶模块中的副作用。见示例
  • rollup:删除桶模块中的副作用。见示例

rollup sideEffects 注意事项

rollup 自身并不尊重 package.json 中的 sideEffects 字段,参考:rollup/rollup#2593。如果需要让 sideEffects 生效,有几种解决方案:

  1. rollup.config.js 中设置 treeshake.moduleSideEffects,作为全局模块的副作用配置。
  2. 在用户插件的 resolveIdloadtransform 钩子中返回 moduleSideEffects 字段来为特定模块指定副作用。
  3. 官方的 @rollup/plugin-node-resolve 插件会尊重 package.json 中的 sideEffects 字段。

onCall 期间经常会遇到的问题是,"为什么我的 tree shaking 实效呢?"。

排查这些问题可能相当具有挑战性。当面临这种问题时,首先想到的通常是哪种 tree shaking 优化失败了?这通常分为三类:

sideEffect Optimization Failed

sideEffect 优化的失败通常表现为一个模块,其导出变量未被使用,且模块中并没有包含副作用,但模块却被包含在最终产物中。webpack 的一个鲜为人知的功能是能够通过 stats.optimizationBailout 来调试各种优化失败,包括 sideEffect 失败的原因。考虑以下示例:

js
import { a } from './lib';
import { abc } from './util';
console.log({ a });
js
export const a = 1;
export const b = 2;
js
export function abc() {
  console.log('abc');
}
export function def() {
  console.log('def');
}
console.log('xxx');

配置项中添加 optimization.sideEffects=truestats.optimizationBailout:true 后,webpack 提示的信息如下:

可见 webpack 会在日志中清楚地显示,util.js 文件第 7 行的 console.log('xxx') 存在副作用,sideEffect 优化失败,导致该模块的副作用被包含在产物包中。

如果我们在 package.json 中进一步配置 sideEffects: false,则上述警告将会消失,因为设置了 sideEffect 属性后,webpack 停止副作用分析,并直接基于 sideEffects 字段进行副作用优化。

usedExports Optimization Failed

usedExports 优化失败将出现模块中未使用的导出变量仍会包含在最终的产物中。在这种情况下,排查问题时就有必要先确定模块的导出引用被使用的位置。

然而,确定变量为何被使用以及被哪位 importer 模块使用可能并不容易,因为 webpack 没有提供关于导出变量以及被哪些模块消费的详细记录。

webpack 的一个可能改进是跟踪并报告特定导出变量在模块树中的使用情况。这将极大地促进对 usedExports 优化问题进行分析和故障排除。

DCE Optimization Failed

除了 sideEffectusedExports 优化存在失败的问题外,大多数其他 tree shaking 失败的原因可以归因于 DCE 优化失败。

DCE 失败的常见原因包括使用动态代码,例如 evalnew Function,这将可能导致在压缩期间异常。排查这些问题通常与所用的 压缩工具 有关,通常需要对输出代码进行 二分查找 来检索问题。不幸的是,当前的压缩工具很少会提供详细的失败原因,这是一个未来可以改进的领域。

总之,在 webpack 中实现有效的 tree shaking 需要对涉及的各种优化(sideEffectusedExportsDCE)及其执行优化过程中如何相互影响需要有深入的理解。通过正确配置和应用这些优化,开发人员可以显著减少包的大小,提高性能和效率。随着 webpack 和其他打包工具的发展,持续的学习和调整将是保持应用程序性能优化的必要条件。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (41765f2)