摇树优化

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

webpack 2 版本内置支持 ES2015 模块(别名 harmony 模块)以及未使用的模块导出检测。新的 webpack 4 版本在此功能的基础上扩展,提供了一种通过 "sideEffects" package.json 属性向编译器提供提示的方法,以指示项目中的哪些文件是“纯净”的,因此如果未使用,可以安全地修剪。

添加实用程序

让我们向项目中添加一个新的实用程序文件 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());

请注意,我们**没有从src/math.js模块中import 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 的编译器提供关于代码“纯度”的提示。

实现这一点的方法是"sideEffects" package.json 属性。

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

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

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

经过此优化后,其他优化仍然可以应用。例如:Button.js 中的 buttonFrombuttonsFrom 导出也未被使用。usedExports 优化将拾取它,并且 terser 可能能够从模块中删除一些语句。

模块串联也适用。因此,这 4 个模块加上入口模块(可能还有更多依赖项)可以被串联。index.js 最终没有生成代码

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

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

file.js

/*#__PURE__*/ double(55);

压缩输出

因此,我们已经使用 importexport 语法将我们的“死代码”排队,以便将其删除,但我们仍然需要将其从 bundle 中删除。为此,将 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 有什么不同吗?整个 bundle 现在被压缩和混淆了,但是,如果你仔细观察,你不会看到 square 函数被包含,但会看到 cube 函数的混淆版本(function r(e){return e*e*e}n.a=r)。通过压缩和树摇,我们的 bundle 现在小了几字节!虽然在这个人为的例子中可能看起来不多,但树摇在处理具有复杂依赖关系树的更大应用程序时可以显着减少 bundle 大小。

结论

我们已经了解到,为了利用tree shaking,您必须...

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

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

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

16 位贡献者

simon04zacangeralexjovermavant1dmitriidprobablyupgishlumo10byzykpnevaresEugeneHlushkoAnayaDesigntorifatrahul3vsnitin315