知海

Tree Shaking

webpackjsorg-main指南-教程

Tree shaking 是一个在 JavaScript 语境中常用来描述死代码消除的术语。它依赖于 ES2015 模块语法的静态结构,即 importexport。这个名称和概念由 ES2015 模块打包器 rollup 推广开来。

webpack 2 版本内置了对 ES2015 模块(别名 harmony modules)以及未使用的模块导出检测的支持。新的 webpack 4 版本在此能力之上进行了扩展,通过 package.json"sideEffects" 属性向编译器提供提示,用来标记项目中哪些文件是“纯粹”的,因此如果未被使用则可以安全地删除。

T> 本指南的其余部分将基于起步指南。如果你还没有阅读过,请现在阅读。

添加一个工具函数

让我们在项目中添加一个新的工具文件 src/math.js,它导出两个函数:

项目结构

diff 复制代码
 webpack-demo
  ├── package.json
  ├── package-lock.json
  ├── webpack.config.js
  ├── /dist
  │   ├── bundle.js
  │   └── index.html
  ├── /src
  │   ├── index.js
+ │   └── math.js
  └── /node_modules

src/math.js

js 复制代码
export function square(x) {
  return x * x;
}

export function cube(x) {
  return x * x * x;
}

mode 配置选项设置为 development,以确保 bundle 未被压缩:

webpack.config.js

diff 复制代码
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
+ mode: 'development',
+ optimization: {
+   usedExports: true,
+ },
};

配置就绪后,让我们更新入口脚本以使用这些新方法之一,并为了简化而移除 lodash

src/index.js

diff 复制代码
- import _ from 'lodash';
+ import { cube } from './math.js';

  function component() {
-   const element = document.createElement('div');
+   const element = document.createElement('pre');

-   // Lodash, now imported by this script
-   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+   element.innerHTML = [
+     'Hello webpack!',
+     '5 cubed is equal to ' + cube(5)
+   ].join('\n\n');

    return element;
  }

  document.body.appendChild(component());

请注意,我们没有从 src/math.js 模块中 import square 方法。该函数就是所谓的“死代码”,即一个应该被丢弃的未使用的 export。现在让我们运行 npm 脚本 npm run build,并检查输出的 bundle:

dist/bundle.js (大约 90 - 100 行)

{/* eslint-disable no-use-before-define */}

js 复制代码
/* 1 */
/***/ (function (module, __webpack_exports__, __webpack_require__) {
  "use strict";

  /* unused harmony export square */
  /* harmony export (immutable) */ __webpack_exports__.a = cube;
  function square(x) {
    return x * x;
  }

  function cube(x) {
    return x * x * x;
  }
});

注意上面的 unused harmony export square 注释。如果你看它下面的代码,会发现 square 没有被导入,但是它仍然被包含在 bundle 中。我们将在下一节中解决这个问题。

将文件标记为无副作用(side-effect-free)

在 100% ESM 模块的世界中,识别副作用是 straightforward 的。然而,我们还没有完全达到那个目标,因此,在此期间有必要向 webpack 的编译器提供关于你代码“纯净性”的提示。

实现这一目标的方式是 package.json 中的 "sideEffects" 属性。

json 复制代码
{
  "name": "your-project",
  "sideEffects": false
}

上面注记的所有代码都不包含副作用,因此我们可以将此属性标记为 false,以告知 webpack 它可以安全地删除未使用的导出。

T> “副作用”被定义为:在导入时执行了除暴露一个或多个导出之外的特定行为的代码。这方面的例子是 polyfills,它们会影响全局作用域,并且通常不提供导出。

如果你的代码确实有一些副作用,则可以提供一个数组:

json 复制代码
{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js"]
}

该数组接受相关文件的简单 glob 模式。它在底层使用 glob-to-regexp(支持:***{a,b}[a-z])。像 *.css 这样不包含 / 的模式将被视为 **/*.css

T> 注意,任何导入的文件都受 tree shaking 影响。这意味着如果你在项目中使用类似 css-loader 的工具并导入 CSS 文件,则需要将其添加到副作用列表中,以免在生产模式下被意外删除:

json 复制代码
{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}

最后,"sideEffects" 也可以从 module.rules 配置选项 中设置。

澄清 tree shaking 和 sideEffects

sideEffectsusedExports(更广为人知的 tree shaking)优化是两件不同的事情。

sideEffects 更有效,因为它允许跳过整个模块/文件以及完整的子树。

usedExports 依赖于 terser 来检测语句中的副作用。这在 JavaScript 中是一项艰巨的任务,并且不如直接的 sideEffects 标志有效。它也不能跳过子树/依赖项,因为规范规定副作用需要被评估。虽然导出函数工作正常,但 React 的高阶组件(HOC)在这方面是有问题的。

如果你使用动态 import(),你还可以使用 webpackExports 魔术注释来指定应该暴露的导出,从而允许 webpack 对其他的导出进行 tree shaking。请参阅 魔术注释

让我们看一个例子:

js 复制代码
import { Button } from "@shopify/polaris";

预打包的版本看起来像这样:

{/* eslint-disable @stylistic/spaced-comment, prefer-rest-params */}

js 复制代码
import hoistStatics from "hoist-non-react-statics";

function Button(_ref) {
  // ...
}

function merge() {
  const _final = {};

  for (
    let _len = arguments.length, objs = Array.from({ length: _len }), _key = 0;
    _key < _len;
    _key++
  ) {
    objs[_key] = arguments[_key];
  }

  for (let _i = 0, _objs = objs; _i < _objs.length; _i++) {
    const obj = _objs[_i];
    mergeRecursively(_final, obj);
  }

  return _final;
}

function withAppProvider() {
  return function addProvider(WrappedComponent) {
    const WithProvider =
      /*#__PURE__*/
      (function (_React$Component) {
        // ...
        return WithProvider;
      })(Component);

    WithProvider.contextTypes = WrappedComponent.contextTypes
      ? merge(WrappedComponent.contextTypes, polarisAppProviderContextTypes)
      : polarisAppProviderContextTypes;
    const FinalComponent = hoistStatics(WithProvider, WrappedComponent);
    return FinalComponent;
  };
}

const Button$1 = withAppProvider()(Button);

export {
  // ...,
  Button$1,
};

Button 未被使用时,你可以有效地移除 export { Button$1 }; 这一行,这留下了所有剩余的代码。所以问题是“这段代码有任何副作用,还是可以被安全地移除?”。很难说,特别是因为 withAppProvider()(Button) 这一行。withAppProvider 被调用,并且其返回值也被调用。调用 mergehoistStatics 是否有副作用?给 WithProvider.contextTypes 赋值(Setter?)或读取 WrappedComponent.contextTypes(Getter?)是否有副作用?

Terser 实际上试图弄清楚这一点,但在很多情况下它并不能确定。这并不意味着 terser 做得不好,因为它无法弄清楚。在像 JavaScript 这样的动态语言中,要可靠地确定这一点太难了。

但是我们可以通过使用 /*#__PURE__*/ 注释来帮助 terser。它将一条语句标记为无副作用。所以一个小小的改动就可以让代码被 tree-shaking:

js 复制代码
const Button$1 = /* #__PURE__ */ withAppProvider()(Button);

这将允许删除这段代码。但是关于导入仍然存在问题,这些导入需要被包含/评估,因为它们可能包含副作用。

为了解决这个问题,我们使用 package.json 中的 "sideEffects" 属性。

它类似于 /*#__PURE__*/,但是是在模块级别而不是语句级别。它("sideEffects" 属性)表示:“如果没有使用标记为无副作用的模块的直接导出,打包器可以跳过评估该模块的副作用。”。

在 Shopify 的 Polaris 示例中,原始模块看起来像这样:

index.js

js 复制代码
import "./configure";

export * from "./types";
export * from "./components";

components/index.js

js 复制代码
// ...
export { default as Breadcrumbs } from "./Breadcrumbs";
export { buttonFrom, buttonsFrom, default as Button } from "./Button";
export { default as ButtonGroup } from "./ButtonGroup";
// ...

package.json

json 复制代码
// ...
"sideEffects": [
  "**/*.css",
  "**/*.scss",
  "./esnext/index.js",
  "./esnext/configure.js"
],
// ...

对于 import { Button } from "@shopify/polaris";,这具有以下含义:

  • 包含它:包含该模块,评估它并继续分析依赖项
  • 跳过它:不包含它,不评估它,但继续分析依赖项
  • 排除它:不包含它,不评估它,也不分析依赖项

具体到每个匹配的资源:

  • index.js:没有使用直接导出,但被标记为有副作用 -> 包含它
  • configure.js:没有使用导出,但被标记为有副作用 -> 包含它
  • types/index.js:没有使用导出,未被标记为有副作用 -> 排除它
  • components/index.js:没有使用直接导出,未被标记为有副作用,但使用了重新导出的导出 -> 跳过它
  • components/Breadcrumbs.js:没有使用导出,未被标记为有副作用 -> 排除它。这也排除了所有依赖项,如 components/Breadcrumbs.css,即使它们被标记为有副作用。
  • components/Button.js:使用了直接导出,未被标记为有副作用 -> 包含它
  • components/Button.css:没有使用导出,但被标记为有副作用 -> 包含它

在这种情况下,只有 4 个模块被包含在 bundle 中:

  • index.js:几乎是空的
  • configure.js
  • components/Button.js
  • components/Button.css

在此优化之后,其他优化仍然可以应用。例如:来自 Button.jsbuttonFrombuttonsFrom 导出也未被使用。usedExports 优化会处理它,并且 terser 可能会从模块中删除一些语句。

模块拼接(Module Concatenation)也适用。因此,这 4 个模块加上入口模块(以及可能更多的依赖项)可以被拼接起来。index.js 最终没有生成任何代码

完整示例:理解带有 CSS 文件的副作用

为了更好地理解 sideEffects 标志的影响,让我们看一个带有 CSS 资产的 npm 包的完整示例,以及它们在 tree shaking 期间可能如何受到影响。我们将创建一个名为 “awesome-ui” 的虚构 UI 组件库。

包结构

我们的示例包看起来像这样:

bash 复制代码
awesome-ui/
├── package.json
└── dist/
    ├── index.js
    ├── components/
    │   ├── index.js
    │   ├── Button/
    │   │   ├── index.js
    │   │   └── Button.css
    │   ├── Card/
    │   │   ├── index.js
    │   │   └── Card.css
    │   └── Modal/
    │       ├── index.js
    │       └── Modal.css
    └── theme/
        ├── index.js
        └── defaultTheme.css

包文件内容

package.json

json 复制代码
{
  "name": "awesome-ui",
  "version": "1.0.0",
  "main": "dist/index.js",
  "sideEffects": false
}

dist/index.js

js 复制代码
export * from "./components";
export * from "./theme";

dist/components/index.js

js 复制代码
export { default as Button } from "./Button";
export { default as Card } from "./Card";
export { default as Modal } from "./Modal";

dist/components/Button/index.js

js 复制代码
import "./Button.css"; // This has a side effect - it applies styles when imported!

export default function Button(props) {
  // Button component implementation
  return {
    type: "button",
    ...props,
  };
}

dist/components/Button/Button.css

css 复制代码
.awesome-ui-button {
  background-color: #0078d7;
  color: white;
  padding: 8px 16px;
  border-radius: 4px;
  border: none;
  cursor: pointer;
}

dist/components/Card/index.jsdist/components/Modal/index.js 会有类似的结构。

dist/theme/index.js

js 复制代码
import "./defaultTheme.css"; // This has a side effect!

export const themeColors = {
  primary: "#0078d7",
  secondary: "#f3f2f1",
  danger: "#d13438",
};

消费此包时会发生什么?

现在,想象一个消费者应用,它只想使用 Button 组件:

js 复制代码
import { Button } from "awesome-ui";

// Use the Button component

在 package.json 中设置 sideEffects: false

当 webpack 在启用 tree shaking 的情况下处理此导入时:

  1. 它看到仅为 Button 的导入
  2. 它查看 package.json 并看到 sideEffects: false
  3. 它确定只需要包含 Button 组件代码
  4. 由于所有文件都被标记为无副作用,它将包含 Button 的 JavaScript 代码
  5. CSS 文件的导入被丢弃! 即使 Button.css 是在 Button/index.js 中导入的,webpack 也假定此导入没有副作用。

结果:Button 组件将渲染,但没有任何样式,因为 Button.css 在 tree shaking 期间被消除了。

此包的正确配置

为了解决这个问题,我们需要更新 package.json,以正确地标记 CSS 文件为具有副作用:

json 复制代码
{
  "name": "awesome-ui",
  "version": "1.0.0",
  "main": "dist/index.js",
  "sideEffects": ["**/*.css"]
}

使用此配置:

  1. Webpack 仍然识别出只需要 Button 组件
  2. 但现在它识别出 CSS 文件有副作用
  3. 因此,在处理 Button/index.js 时,它会包含 Button.css

副作用的决策树

以下是 webpack 在 tree shaking 期间评估模块的方式:

  1. 该模块的导出是否被直接或间接使用?

    • 如果是:包含该模块
    • 如果否:继续执行步骤 2
  2. 该模块是否被标记为具有副作用?

    • 如果是(sideEffects 包含此文件或为 true):包含该模块
    • 如果否(sideEffectsfalse 或不包含此文件):排除该模块及其依赖项

对于我们库中具有正确 sideEffects 配置的文件:

  • dist/index.js:未使用直接导出,无副作用 -> 跳过它
  • dist/components/index.js:未使用直接导出,无副作用 -> 跳过它
  • dist/components/Button/index.js:使用了直接导出 -> 包含
  • dist/components/Button/Button.css:无导出,有副作用 -> 包含
  • dist/components/Card/*:未使用导出,无副作用 -> 排除
  • dist/components/Modal/*:未使用导出,无副作用 -> 排除
  • dist/theme/*:未使用导出,无副作用 -> 排除

现实世界的影响

不正确的副作用配置的影响可能很大:

  1. CSS 未被包含:组件渲染时没有样式
  2. 全局 JavaScript 未运行:Polyfills 或全局配置不执行
  3. 初始化代码被跳过:注册组件或设置事件监听器的函数永远不会运行

这些问题可能特别难以调试,因为它们通常只在启用了 tree shaking 的生产构建中出现。

测试副作用配置

测试你的副作用配置是否正确的一个好方法:

  1. 创建一个只导入一个组件的最小应用程序
  2. 使用生产设置(启用 tree shaking)构建它
  3. 检查所有必要的样式和行为是否正常工作
  4. 查看生成的 bundle 以确认包含正确的文件

将函数调用标记为无副作用

可以使用 /*#__PURE__*/ 注释告诉 webpack 某个函数调用是无副作用的(纯粹的)。它可以放在函数调用前面,以将其标记为无副作用。传递给函数的参数不会被该注释标记,可能需要单独标记。当未使用变量声明中的初始值被认为是无副作用(纯粹)时,它会被标记为死代码,不执行,并被压缩器(minimizer)删除。此行为在 optimization.innerGraph 设置为 true 时启用。

file.js

js 复制代码
/* #__PURE__ */ double(55);

将函数声明标记为无副作用

Webpack 还支持 #__NO_SIDE_EFFECTS__ 注解来将函数声明标记为纯粹。以这种方式注解的函数的调用,当它们的返回值未被使用时,可以从 bundle 中消除,即使函数体不能被静态分析为纯粹。这对于工厂函数(factory)或构建器函数(builder)很有用,否则它们的调用点每次都需要 /*#__PURE__*/ 注解。

{/* eslint-disable */}

js 复制代码
// utils.js
/*#__NO_SIDE_EFFECTS__*/
export function createLogger(prefix) {
  return (msg) => console.log(`[${prefix}] ${msg}`);
}

{/* eslint-enable */}

js 复制代码
// app.js
import { createLogger } from "./utils";

// 被丢弃,因为 `createLogger` 被注解了并且其结果未被使用
const unused = createLogger("debug");

T> 自 webpack 5.108.0 起,该注解会跨模块边界传播,因此在导入模块中的未使用调用也会被 tree-shaken。(在 5.108.0 之前,它只在声明它的模块内生效。)

T> 如果你无法编辑源码来添加注解(例如,函数来自某个依赖项),你可以通过 module.parser.javascript.pureFunctions 从你的配置中标记这些名称为无副作用(5.108.0+)。

压缩输出

我们已经通过使用 importexport 语法将“死代码”提示给了编译器,但我们仍然需要将其从 bundle 中删除。为此,将 mode 配置选项设置为 production

webpack.config.js

diff 复制代码
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
- mode: 'development',
- optimization: {
-   usedExports: true,
- }
+ mode: 'production',
};

T> 注意,--optimize-minimize 标志也可用于启用 TerserPlugin

配置完成后,我们可以再次运行 npm run build,看看是否有什么变化。

注意到 dist/bundle.js 有什么不同吗?整个 bundle 现在被压缩和混淆了,但是,如果你仔细看,你不会看到 square 函数被包含在内,但会看到 cube 函数的一个混淆版本(function r(e){return e*e*e}n.a=r)。通过压缩和 tree shaking,我们的 bundle 现在小了几个字节!虽然在这个人为的示例中看起来不多,但在处理具有复杂依赖树的较大应用程序时,tree shaking 可以显著减小 bundle 大小。

T> 需要 ModuleConcatenationPlugin 才能使 tree shaking 工作。它是由 mode: 'production' 添加的。如果你没有使用它,记得手动添加 ModuleConcatenationPlugin

副作用的常见陷阱

在使用 tree shaking 和 sideEffects 标志时,有几个常见的陷阱需要避免:

1. 过于乐观的 sideEffects: false

在你的 package.json 中设置 sideEffects: false 对于最佳的 tree shaking 来说很诱人,但如果你的代码实际上有副作用,这可能会导致问题。隐藏副作用的例子:

  • CSS 导入(如上所述)
  • 修改全局对象的 Polyfills
  • 注册全局事件监听器的库
  • 修改原型链的代码

2. 带有副作用的重新导出

考虑这种模式:

js 复制代码
// 这个文件有副作用,可能会被跳过
import "./polyfill";

// 重新导出组件
export * from "./components";

如果消费者只导入特定的组件,如果 polyfill 导入没有被正确标记为有副作用,它可能会被完全跳过。

3. 忘记嵌套依赖

你的包可能正确标记了副作用,但如果它依赖于错误标记其副作用的第三方包,你仍然可能会遇到问题。

4. 只在开发模式下测试

Tree shaking 通常只在生产模式下完全激活。只在开发模式下测试可能会隐藏 tree shaking 问题,直到部署。

总结

我们学到的内容是,为了利用 tree shaking,你必须……

  • 使用 ES2015 模块语法(即 importexport)。
  • 确保没有编译器将你的 ES2015 模块语法转换为 CommonJS 模块(这是流行的 Babel 预设 @babel/preset-env 的默认行为 - 有关更多详细信息,请参阅文档)。
  • 在项目中的 package.json 文件中添加 "sideEffects" 属性。
  • 小心地正确标记具有副作用的文件,特别是 CSS 导入。
  • 使用 production mode 配置选项来启用各种优化,包括压缩和 tree shaking(副作用优化在开发模式下使用该标志值启用)。
  • 确保为 devtool 设置一个正确的值,因为其中一些在 production 模式下不能使用。

你可以将你的应用程序想象成一棵树。你实际使用的源代码和库代表了树上绿色、鲜活的叶子。死代码代表了秋天被消耗掉的棕色、枯死的叶子。为了摆脱枯叶,你必须摇动这棵树,让它们掉落。

如果你有兴趣了解更多优化输出(bundle)的方法,请跳转到下一指南,了解为生产环境构建的详细信息。

帮助我们改进文档

发现翻译问题或内容错误?请告诉我们。