Tree Shaking

摇树优化 (Tree shaking) 是 JavaScript 上下文中常用的术语,用于指代死代码消除。它依赖于 ES2015 模块语法的静态结构,即 importexport。这个名称和概念由 ES2015 模块打包器 rollup 推广开来。

webpack 2 版本内置支持 ES2015 模块(别名 harmony modules)以及未使用的模块导出检测。新的 webpack 4 版本通过 package.json"sideEffects" 属性向编译器提供提示,以表示项目中哪些文件是“纯粹的”,因此在未使用时可以安全地移除,从而扩展了这一功能。

添加一个工具函数

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

项目

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

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

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

mode 配置选项设置为 development,以确保打包后的文件未被压缩。

webpack.config.js

const path = require('path');

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

有了这些,让我们更新入口脚本,以利用其中一个新方法,并为简化起见删除 lodash

src/index.js

- 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());

请注意,我们**没有 import src/math.js 模块中的 square 方法**。该函数被称为“死代码”,即未使用的 export,应该被删除。现在,让我们运行 npm 脚本 npm run build,并检查输出的打包文件:

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

/* 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 并没有被导入,但它仍然包含在打包文件中。我们将在下一节中修复此问题。

将文件标记为无副作用

在一个 100% ESM 模块的世界中,识别副作用是直截了当的。然而,我们还没到那一步,所以在此期间,需要向 webpack 编译器提供关于代码“纯粹性”的提示。

实现此目的的方法是使用 package.json"sideEffects" 属性。

{
  "name": "your-project",
  "sideEffects": false
}

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

如果您的代码确实存在一些副作用,则可以改为提供一个数组:

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

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

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

最后,"sideEffects" 也可以通过 module.rules 配置选项进行设置。

澄清摇树优化和 sideEffects

sideEffectsusedExports(更广为人知的是摇树优化)优化是两个不同的概念。

sideEffects 效率更高,因为它允许跳过整个模块/文件和完整的子树。

usedExports 依赖 terser 来检测语句中的副作用。在 JavaScript 中这是一项艰巨的任务,并且不如直接使用 sideEffects 标志有效。它也无法跳过子树/依赖项,因为规范规定必须评估副作用。虽然导出函数运行良好,但 React 的高阶组件 (HOC) 在这方面存在问题。

让我们举个例子:

import { Button } from '@shopify/polaris';

预打包版本如下:

import hoistStatics from 'hoist-non-react-statics';

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

function merge() {
  var _final = {};

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

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

  return _final;
}

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

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

var 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。它将一个语句标记为无副作用。因此,一个小小的更改就可以实现对代码的摇树优化:

var Button$1 = /*#__PURE__*/ withAppProvider()(Button);

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

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

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

在 Shopify 的 Polaris 示例中,原始模块如下所示:

index.js

import './configure';
export * from './types';
export * from './components';

components/index.js

// ...
export { default as Breadcrumbs } from './Breadcrumbs';
export { default as Button, buttonFrom, buttonsFrom } from './Button';
export { default as ButtonGroup } from './ButtonGroup';
// ...

package.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 个模块被包含到打包文件中:

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

在此优化之后,其他优化仍可应用。例如:Button.js 中的 buttonFrombuttonsFrom 导出也未使用。usedExports 优化会识别它们,terser 可能会从模块中删除一些语句。

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

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

为了更好地理解 sideEffects 标志的影响,让我们看一个包含 CSS 资源的 npm 包的完整示例,以及它们在摇树优化过程中可能受到的影响。我们将创建一个名为“awesome-ui”的虚构 UI 组件库。

包结构

我们的示例包结构如下:

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

{
  "name": "awesome-ui",
  "version": "1.0.0",
  "main": "dist/index.js",
  "sideEffects": false
}

dist/index.js

export * from './components';
export * from './theme';

dist/components/index.js

export { default as Button } from './Button';
export { default as Card } from './Card';
export { default as Modal } from './Modal';

dist/components/Button/index.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

.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

import './defaultTheme.css'; // This has a side effect!

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

消费此包时会发生什么?

现在,假设一个消费者应用程序只想使用 Button 组件:

import { Button } from 'awesome-ui';

// Use the Button component

在 package.json 中设置 sideEffects: false

当 webpack 在启用摇树优化的情况下处理此导入时:

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

结果:Button 组件将渲染,但没有任何样式,因为 Button.css 在摇树优化期间被移除了。

此包的正确配置

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

{
  "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 在摇树优化期间评估模块的方式:

  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 未运行:Polyfill 或全局配置不执行。
  3. 初始化代码被跳过:注册组件或设置事件监听器的函数永远不会运行。

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

测试副作用配置

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

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

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

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

file.js

/*#__PURE__*/ double(55);

压缩输出

我们已经通过使用 importexport 语法,将“死代码”标记为待删除,但我们仍然需要将其从打包文件中删除。要做到这一点,将 mode 配置选项设置为 production

webpack.config.js

const path = require('path');

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

处理完这些后,我们可以再次运行 npm run build,看看是否有任何变化。

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

副作用的常见陷阱

在使用摇树优化和 sideEffects 标志时,有几个常见陷阱需要避免:

1. 过于乐观的 sideEffects: false

在您的 package.json 中设置 sideEffects: false 对于实现最佳摇树优化很有诱惑力,但这可能会在您的代码实际存在副作用时导致问题。隐藏副作用的例子包括:

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

2. 带有副作用的重导出

考虑这种模式:

// This file has side effects that might be skipped
import './polyfill';

// Re-export components
export * from './components';

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

3. 忘记嵌套依赖

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

4. 仅在开发模式下测试

摇树优化通常只在生产模式下完全激活。仅在开发模式下测试可能会隐藏摇树优化问题,直到部署。

总结

我们学到的是,为了利用摇树优化,您必须...

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

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

如果您对其他优化输出的方法感兴趣,请跳转到下一指南,了解有关为生产环境构建的详细信息。

17 贡献者

simon04zacangeralexjovermavant1dmitriidprobablyupgishlumo10byzykpnevaresEugeneHlushkoAnayaDesigntorifatrahul3vsnitin315vansh5632