前言
Webpack
从 V2 版本支持 Tree Shaking,其目的是在打包过程中,消除代码中未使用的死代码,以减小最终构建产物的大小。
类似于 DCE
1,Tree Shaking 同样是一种死代码消除,区别是相对于传统的 DCE
基于代码流分析,Tree Shaking
是作用于模块级别的,基于 ESM
的静态分析能力。
在介绍 Webpack
Tree Shaking 之前,我们先来简单回顾下 ESM
与 CJS
的使用以及它们之间的区别。
区别
CommonJS
CommonJS
是一个专门为服务端设计的模块化规范,其主要实现是 NodeJS
。它规定每一个文件都是一个独立的模块,拥有着自己独立的作用域。模块通过 require
导入,它支持路径运行时计算,其依赖关系无法在编译时确定。
const env = process.env.NODE_ENV || 'prod'
const path = env === 'prod' ? './prod.js' : './dev.js'
const config = require(path)
使用 exports
/ module.exports
导出内部成员。
// 动态导出示例
if (condition) {
module.exports = { funcA }
} else {
module.exports = { funcB }
}
Webpack
无法在构建阶段准确追踪动态导出和导入的引用关系,因此 无法对 CJS
模块实施 Tree Shaking。
ES Module
ESM
又称 ES Module
,是 ECMAScript
标准引入的模块化规范,相对于 CommonJS
是动态导入,ESM
的模块是静态的,可以在编译时就确定其依赖关系。它采用 import
/ export
来导入/导出成员,它们必须在顶层声明(不能在条件语句里)。
// 静态导入导出(可分析)
import { utils } from './lib.js' // ✅ 路径为字面量
export const PI = 3.14 // ✅ 顶层声明
ESM 同样支持动态导入,具体参考 import() 2,不过由于其动态性可能会影响 Tree Shaking,你可以使用相应的魔法注释3来减小动态导入对 Tree Shaking 的影响。
// math.js
export const add = (a, b) => a + b
export const multiply = (a, b) => a * b
// 动态导入(仅使用 add)
import('./math.js').then(({ add }) => add(2, 3))
静态结构允许构建工具在打包时精确建立模块依赖图谱,识别未被引用的导出项,为代码消除提供必要条件。
通过对比可见,ESM
的静态模块特性是 Tree Shaking 能够实现的技术基石。接下来我们将深入解析 Webpack
实现 Tree Shaking 的具体工作机制。
Webpack Tree Shaking 流程解析
Webpack
的 Tree Shaking 实现可分为四个关键阶段,详细可以参考4:
1.模块解析阶段
- 构建模块依赖图谱
- 识别所有导出项(
harmony exports
) - 记录导入引用关系
2.标记阶段
/***/ (__unused_webpack_module, __unused_webpack___webpack_exports__, __webpack_require__) => {
eval(
'/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils */ "./src/utils.js");\n/* harmony import */ var _side_effect__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./side-effect */ "./src/side-effect.js");\n\n\n\nconsole.log((0,_utils__WEBPACK_IMPORTED_MODULE_1__.add)(2, 3));\n\n\n//# sourceURL=webpack://webpack/./src/index.js?'
)
/***/
},
- 遍历所有导出项
- 标记被引用的导出为
harmony export
- 未引用项标记为
unused harmony export
3.副作用分析
- 默认认为所有模块都有副作用
- 通过配置声明无副作用模块
- 对无副作用且无引用的模块进行删除
{
"sideEffects": ["./src/side-effects.js"]
}
Webpack
会根据package.json
/module.rules
的sideEffects
的配置来判断哪些文件具有副作用,可以通过设置sideEffects
来告知Webpack
安心的删除未使用到的代码。
4.代码消除阶段
- 删除未被标记的导出声明
- 移除无副作用未引用模块
- 保留有副作用的未引用模块
代码演示
// utils.js
export const add = (a, b) => a + b
export const sub = (a, b) => a - b // 该函数不会被使用
// index.js
import { add } from './utils'
import './side-effect'
console.log(add(2, 3))
// side-effect.js
export const say = (name) => {
console.log(name)
return name
}
window.x = 'sideEffect'
module.exports = {
mode: 'development',
optimization: {
usedExports: true,
},
}
可以看到,当我们设置 mode
为 development
时,此时的构建产物出现了 harmony
/ unused harmony
的标记,
你可以尝试切换 mode
为 production
去看看构建产物的变化。
/******/ var __webpack_modules__ = {
/***/ './src/index.js':
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
/***/ (__unused_webpack_module, __unused_webpack___webpack_exports__, __webpack_require__) => {
eval(
'/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils */ "./src/utils.js");\n/* harmony import */ var _side_effect__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./side-effect */ "./src/side-effect.js");\n\n\n\nconsole.log((0,_utils__WEBPACK_IMPORTED_MODULE_1__.add)(2, 3));\n\n\n//# sourceURL=webpack://webpack/./src/index.js?'
)
/***/
},
/***/ './src/side-effect.js':
/*!****************************!*\
!*** ./src/side-effect.js ***!
\****************************/
/***/ (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval(
'/* unused harmony export say */\nconst say = (name) => {\n console.log(name);\n return name;\n};\nwindow.x = "sideEffect";\n\n\n//# sourceURL=webpack://webpack/./src/side-effect.js?'
)
/***/
},
/***/ './src/utils.js':
/*!**********************!*\
!*** ./src/utils.js ***!
\**********************/
/***/ (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval(
'/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ add: () => (/* binding */ add)\n/* harmony export */ });\n/* unused harmony exports sub, calculator */\n// 显式导出独立函数\nconst add = (a, b) => a + b;\nconst sub = (a, b) => a - b; // 该函数不会被使用\n\n// 导出对象(需要特殊处理)\nconst calculator = {\n multiply(a, b) {\n return a * b;\n },\n divide(a, b) {\n return a / b;\n },\n};\n\n\n//# sourceURL=webpack://webpack/./src/utils.js?'
)
/***/
},
/******/
}
补充
- usedExports 的作用
usedExports
会为每个模块标记 harmony
/ unused harmony
,你可以通过 usedExports
设置成 true 来开启。
"usedExports: false 会禁用标记系统,但生产模式下默认启用的
minimize
仍会触发Terser
的静态分析删除,这是两种不同的死代码消除机制。
与 sideEffects
不同的是,usedExports
仅提供标记功能,标记本身不会删除代码,而是为类似 Terser 等压缩工具提供元数据。最终删除由压缩工具代码生成阶段完成。具体可以参考下方流程图:
- concatenateModules
concatenateModules
是一种 Scope Hoisting
跨模块优化技术将多个模块合并到一个共享的闭包中,用于进一步优化代码体积,它默认在 production
模式下开启。
import { add } from './utils'
import './side-effect'
console.log(add(2, 3))
/**
* 开启 concatenateModules 后的构建产物
* */
;(() => {
'use strict'
console.log(5)
})()
优化 Tree shaking 的建议
- 确保使用
ESM
: 只有ESM
模块是静态的,Tree shaking 才能正常工作。尽量使用import
和export
,避免使用CommonJS
模块,需要注意的是使用babel
确保它不会将ESM
模块转换成其他模块。 - 标记副作用: 在
package.json
中使用sideEffects
字段,告知打包工具哪些模块是无副作用的,这样可以更好地执行 Tree shaking。 - 避免动态代码: 动态导入和动态依赖(如
require()
)会阻碍 Tree shaking 的工作,尽量避免这些模式。 - 通过魔法注释
/*#__PURE__*/
将函数标记为无副作用5。