关于 webpack 的 tree shaking

最近重新翻看了下 ant design 的文档,发现这样一段话

antd 默认支持基于 ES modules 的 tree shaking,对于 js 部分,直接引入 import { Button } from 'antd' 就会有按需加载的效果。

而之前的版本要做到按需加载是通过 babel-plugin-import ,通过修改引用路径来实现的。

虽然也知道 tree shaking,但没有深入去了解具体的配置。以下简单记录一下探索的过程,webpack 版本为 5.37.1。

tree shaking 简单来说就是可以将代码中没有使用到的代码移除,以减少包的体积。

根据 webpack 文档可知,影响 webpack tree shaking 有以下几点:

  1. 使用 esm 语法(import,export)
  2. 未将代码编译成 CommonJS,常见的如 @babel/preset-env 这个预设就会将代码编译成 CommonJS
  3. package.json 添加 sideEffects 字段
  4. mode 必须是 production

文档里的说明有点绕,因此我简单写了几个 case 测试实际效果如何

1. 符合所有条件的情况

// a.js
export const a1 = 'a1'
export const a2 = 'a2'

// index.js
import { a1, a2 } from './a'
console.log(a1)

// webpack.config.js
mode: 'production'

// package.json
"sideEffects": false

// 输出
(()=>{"use strict";console.log("a1")})();

可以看到虽然 a.js 文件中包含了 a2,但是实际打包却并未将其打包进去

2. 非 production 模式

将上述 mode 改成 development

// 输出
...
/***/ "./src/a.js":
/*!******************!*\
  !*** ./src/a.js ***!
  \******************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"a1\": () => (/* binding */ a1),\n/* harmony export */   \"a2\": () => (/* binding */ a2)\n/* harmony export */ });\nconst a1 = 'a1'\nconst a2 = 'a2'\n\n//# sourceURL=webpack://test-webpack/./src/a.js?");

/***/ }),
...

可以看到这次将 a2 打包进来了,即 development 模式下是不会进行 tree shaking 的

而如果,我们开启了optimization.usedExports

// webpack.config.js
optimization: {
  usedExports: true,
},

可以看到

/***/ './src/a.js':
      /*!******************!*\
  !*** ./src/a.js ***!
  \******************/
      /***/ (
        __unused_webpack_module,
        __webpack_exports__,
        __webpack_require__
      ) => {
        eval(
          '/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   "a1": () => (/* binding */ a1)\n/* harmony export */ });\n/* unused harmony export a2 */\nconst a1 = "a1"\n\nconst a2 = \'a2\'\n\n\n\n//# sourceURL=webpack://test-webpack/./src/a.js?'
        )

        /***/
      },

这里看到 unused harmony export a2 即 webpack 标识 a2 没有被实际使用到。

这里 optimization.usedExports 在 webpack 文档里提到,可以用于别的压缩工具压缩时移除掉。

根据文档说明,配置了concatenateModulesTerserPlugin ,但是没能将 a2 tree shaking 掉

// webpack.config.js
{
  ...
  mode: 'development',
  optimization: {
    usedExports: true,
    minimize: true,
    minimizer: [new TerserPlugin({})],
    concatenateModules: true,
  }
}

//输出
;(() => {
  'use strict'
  var __webpack_modules__ = {
      './src/index.js': () => {
        eval(
          '\n;// CONCATENATED MODULE: ./src/a.js\nconst a1 = "a1"\n\nconst a2 = \'a2\'\n\n\n;// CONCATENATED MODULE: ./src/index.js\n\n\nconsole.log(a1)\n\n\n//# sourceURL=webpack://test-webpack/./src/index.js_+_1_modules?'
        )
      },
    },
    __webpack_exports__ = {}
  __webpack_modules__['./src/index.js']()
})()

注意到上述代码是 eval 包裹的,应该由于 development 模式下 source map 默认为 eval 导致的,那么我这里将 source map 关掉

// webpack.config.js
{
	// ...
	mode: 'development',
  devtool: false,
  optimization: {
    usedExports: true,
    minimize: true,
    minimizer: [new TerserPlugin({})],
    concatenateModules: true,
  },
}

// 输出
(()=>{"use strict";console.log("a1")})();

同样,将 mode 改成 ‘none’ 之后也是能正确 tree shaking 的

// webpack.config.js
{
  ...
  mode: 'none',
  optimization: {
    usedExports: true,
    minimize: true,
    minimizer: [new TerserPlugin({})],
    concatenateModules: true,
    providedExports: true,
  },
}
// 输出
(()=>{"use strict";console.log("a1")})();

如果将相关配置移除掉,那即使是在 none 模式下也是不能正常 tree shaking 的

// webpack.config.js
{
	...
	mode: 'none',
  optimization: {
    usedExports: true,
    // minimize: true,
    // minimizer: [new TerserPlugin({})],
    // concatenateModules: true,
    // providedExports: true,
  },
}
  
// 输出
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "a1": () => (/* binding */ a1)
/* harmony export */ });
/* unused harmony export a2 */
const a1 = "a1"

const a2 = 'a2'

/***/ })

可见配置好了 usedExports 与 TerserPlugin 以及 concatenateModules 等相关配置,即使在别的 mode 下也是能将无用的代码 tree shaking 掉的,而这也是为什么文档里提到需要在 production 模式下才能 tree shaking 的原因了

3. 改成 commonjs

// index.js
const { a1 } = require('./a')
console.log(a1)

// 输出(格式化后)
...
var e = {
      85: (e, o, r) => {
        'use strict'
        r.r(o), r.d(o, { a1: () => t, a2: () => n })
        const t = 'a1',
          n = 'a2'
      },
    }
...

可以看到 a2 还是被打包进来了,因此 commonjs 是不支持 tree shaking 的。奇怪的是在 https://webpack.docschina.org/blog/2020-10-10-webpack-5-release/#commonjs-tree-shaking 里提到 webpack5 是支持对部分 commonjs 导出做 tree shaking 的,但是我实验的结果却是没有效果,不知道是否哪里有出入

4. sideEffects

// package.json 移除掉 sideEffects
// "sideEffects": false

// 输出
(()=>{"use strict";console.log("a1")})();

看起来即使未配置 sideEffects 能 tree shaking

对此我产生了一个疑惑,那么 sideEffects 的作用是什么?为什么文档里又说 sideEffects 是必须要配置的?比如 ant design 为什么要这么配置 sideEffects 呢?

因此我又做了以下几个实验

  1. IIFE

    // a.js
    export const a1 = 'a1'
    export const a2 = 'a2'
    
    export const a3 = (function () {
      console.log('a3')
      return 'a3'
    })()
    
    // index.js
    import { a1, a2 } from './a'
    console.log(a1)
    
    // 输出
    (()=>{"use strict";console.log("a3"),console.log("a1")})();

    无论 package.json 中 sideEffects 是否配置,即使我们没有使用到 a3, IIFE 中的代码会被打包进去执行,但是 a3 是不会被打包进去的。

  2. 样式文件

    // webpack.config.js
    module: {
      rules: [
        {
          test: /\.css$/i,
          use: ['style-loader', 'css-loader'],
        },
      ],
    },
      
    // style.css
    body {
      background-color: red;
    }
      
    // index.js
    import './style.css'

    这里就有差别了,如果我们此时配置 sideEffects: false 那么输出将会是空白内容,如果将 sideEffects: false 删掉,则输出

    ...
    o.push([e.id, 'body {\n  background-color: red;\n}\n', ''])
    ...

    也就是说引入样式文件会被认为一种副作用,如果 sideEffects:false,则认为我们的代码中没有包含副作用的模块,可以放心将样式文件移除。如果我们配置 sideEffects:["src/style.css"],即配置 style.css 为有副作用的模块,那么此时也是会将 style.css 打包进去的

  3. polyfill

    如果在代码中修改了全局变量,或原型会发生什么

    // b.js
    window.b1 = 'b1'
    
    Array.prototype.test = function () {
      console.log('test')
    }
    
    // index.js
    import './b'
    
    // package.json
    // sideEffects: false
    
    // 输出
    ...
    ;(window.b1 = 'b1'),
      (Array.prototype.test = function () {
      console.log('test')
    })
    ...

    与样式的结果是一样的,即如果配置了 sideEffect: false,则认为所有文件模块都是没有副作用的,就不会将 b.js 中的内容打包

  4. 作为 npm 包

    上面的实验都是引用本地文件,那 webpack 是如何对依赖进行 tree shaking 的呢?这里我新开一个项目 test-npm,并且将当前项目 link 过去

    // npm-test src/index.js
    export { a3 } from './a'
    export const a1 = 'a1'
    export const a2 = 'a2'
    
    // npm-test src/index.js
    export const a3 = "a3"
    window.a4 = "a4"
    
    // npm-test package.json
    "module": "src/index.js",

    在 test-npm 中,指定 esm 入口文件为 src/index.js,在这里重新的导出 a.js 中的 a3,然而 a.js 中存在一个副作用 window.a4 = "a4"

    题外话:这里通过 module 字段制定 esm 入口,实际上 module 并不是 package.json 的标准字段,而是打包工具约定的字段,package.json 中标准的入口实际上是 main。如果我们的库需要同时支持 esm 与 commonjs,可以参考 antd,构建出 lib/ 和 es/ 两个文件夹,并配置package.jsonmainmodule字段。

    当我们在项目中使用时,无论是否用到 a3,都会将 a4 这个副作用引进来

       // index.js
       import { a1 } from 'test-npm'
       console.log(a1)
       
       // 输出
    (()=>{"use strict";window.a4="a4",console.log("a1")})();

    而当在 test-npm 中我们配置了 sideEffects: false 之后,只有使用到 a3 才会将 a4 这个副作用引进来。

       // index.js
       import { a1 } from 'test-npm'
       console.log(a1)
       
       // 输出
    (()=>{"use strict";console.log("a1")})();
       // index.js
       import { a1, a3 } from 'test-npm'
       console.log(a1, a3)
       
       // 输出
    (()=>{"use strict";window.a4="a4",console.log("a1","a3")})();

    实际上我们还可以通过 /*#__PURE__*/ 这个注释来表示语句是无副作用的。如果你有去注意到 babel 如何编译 jsx,你就会对这个注释比较熟悉

    // react 
    let div = <div className="header">hello</div>;
    
    // babel 编译后
    let div = /*#__PURE__*/React.createElement("div", {
      className: "header"
    }, "hello");

    这里就不展开探讨

总结

上面的用例并没有做到完全的控制变量,仅供参考。然后这里我结合自己的理解来试图说明 webpack 中 tree shaking :

webpack 有两个方式做 tree shaking,一方面是通过 optimization.usedExports 标记未使用的导出,然后通过压缩工具来移除。但是这种方式做不到移除包含副作用的代码,如果我们需要移除可能包含副作用的代码,就需要用到第二个方式:配置 sideEffects 告知 webpack 当前项目中哪些文件可能包含副作用或者当前项目完全没有副作用。