Skip to content

深入理解 webpackTree Shaking

原文链接

这篇文章主要关注于理解 webpack Tree Shaking 的概念,而不是深入探讨其底层代码实现。代码示例可以在这里找到,代码仓库

webpack 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 本身,而是关注于 webpack Tree Shaking 类别下的各种代码优化。

webpack Tree Shaking 主要涉及三种类型的优化:

  1. usedExports 优化:这涉及从模块中移除未使用的导出变量,从而进一步消除相关的无副作用语句。
  2. sideEffects 优化:这会从模块图中移除未使用导出变量的模块。
  3. DCE(无用代码消除)优化:这通常由压缩工具来进行实现,通过压缩工具来移除无用代码。功能也可以如 webpackConstPlugin 类似的工具实现,不过压缩工具会进行更深入的解析及其优化。

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

  • usedExports 关注于模块的导出变量。
  • sideEffects 关注于整个模块。
  • DCE 关注于模块中的 JavaScript 语句。

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

  • lib.js 中,变量 b 未被使用,那么 b 变量所关联的代码会通过 usedExports 优化而缺失在最终产物中。
  • util.js 中,没有导出变量被使用,那么 util 模块会通过 sideEffects 优化而缺失在最终输出中。
  • bootstrap.js 中,if 语句的决策逻辑永远为 false,那么相关的代码块 console.log('bad') 语句会通过 DCE 优化而缺失在最终产物中。
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');
}

上述的优化虽然是被独立实现的,但之间会相互影响。下面,我们详细介绍这些优化以及如何相互影响。

DCE 优化

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

  1. False Branch
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 {
}

如果 false_branch 未被移除,那么变量 a 将被视为已使用。当 false_branch 通过 DCE 移除后,那么变量 a 将被视为未使用。这就间接影响到 usedExportssideEffects 的分析,也就是说仅通过 usedExportssideEffects 移除得并不彻底,需要通过 DCE 来进一步移除。为了解决这个问题,webpack 提供了两次 DCE 的机会:

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

TerserDCE 更耗时且复杂,而 ConstPlugin 的优化更简单。例如,Terser 处理以下 false 分支可以成功移除,但 ConstPlugin 可能无法处理。

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

if (res != 2) {
  console.log(c);
}
  1. 未使用的顶层语句

在模块中,如果顶层语句未被导出,或被当前模块使用时内部不存在副作用,那么也可以被移除。因为它不会带来额外的副作用。例如,以下的 btest 可以安全删除(假设这是一个模块而不是脚本,因为脚本会污染全局作用域,无法安全移除)。

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

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

usedExports 优化

与其他打包工具的类似优化相比,webpackusedExports 优化相当聪明。它使用依赖的活动状态来确定模块内的变量是否被使用。之后,在生成代码阶段,如果导出变量未被使用,它不会生成对应的导出属性,从而使依赖于导出变量的代码段成为无用代码。这通过后续的压缩进一步辅助 DCE

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

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

在未启用 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 的引用并未被移除。当启用 optimization.usedExports 时,你会看到 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;

  /***/
};

那么在此之上再启用代码压缩,由于 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,因此我们发现 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。它允许对代码进行更深层次的静态分析。那么就可以确定 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
// index.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);
}
// lib.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 优化

上述的 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. 模块的导出变量被使用:

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

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

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

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

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

  1. 模块的导出变量未被使用

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

考虑以下示例,其中 c 在函数 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. 纯注释:通过在函数调用上标记纯注释,表明函数没有副作用:

    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
// package.json
{
  "sideEffects": false
}

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

考虑 button.js 导入 button.css 的场景,其中 button.jssideEffects: falsebutton.csssideEffects: 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 模块)可以被安全删除,这在组件库的上下文中特别有用。

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

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

桶模块

sideEffects 优化不仅可以优化叶节点模块,还可以优化中间节点。考虑一种常见场景,其中一个模块重新导出其他模块的内容。如果这样的模块本身(这里称为 mid)没有任何导出变量被使用,并且仅用于重新导出其他模块的内容,那么是否有必要保留重新导出模块呢?

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 将直接删除了重新导出的模块,并在 index.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);
})();

这种行为看起来就像是源代码的导入路径被修改为确切的路径:

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

next.jsumi.js 这样的框架也提供了类似的优化 Optimize Package Imports。它们会在 loader 阶段重写这些路径。需要注意的是,虽然 webpack 的桶优化专注于输出,但它仍然在构建阶段编译 components/index.js 及其子依赖项模块。然而,Next.js 等是采用直接修改源代码,这意味着 components/index.js 不参与编译。这可以显著优化例如桶模块需要重新导出数百或数千个子模块的库的场景。

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 字段。

调查 webpack Tree Shaking 问题

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

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

  1. sideEffect 优化失败

    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 optimization bailout show info

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

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

    webpack optimization bailout show info 2

  2. usedExports 优化失败

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

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

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

  3. DCE(无用代码消除)优化失败

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

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

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

Discuss

Released under the MIT License. (ee0e562)