关于 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 有以下几点:
- 使用 esm 语法(import,export)
- 未将代码编译成 CommonJS,常见的如 @babel/preset-env 这个预设就会将代码编译成 CommonJS
- package.json 添加 sideEffects 字段
- 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 文档里提到,可以用于别的压缩工具压缩时移除掉。
根据文档说明,配置了concatenateModules 与 TerserPlugin ,但是没能将 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 呢?
因此我又做了以下几个实验
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 是不会被打包进去的。
样式文件
// 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 打包进去的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 中的内容打包
作为 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.json的main、module字段。当我们在项目中使用时,无论是否用到 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 当前项目中哪些文件可能包含副作用或者当前项目完全没有副作用。