Webpack 对 CommonJS 模块的 Tree Shaking:机制、配置与产物深度解析
本文基于 webpack 仓库中的官方示例 examples/cjs-tree-shaking/README.md,完整还原"webpack 如何对 CommonJS(require/exports)模块执行 tree shaking"这一主题:从入口源码、optimization 配置,到产物中未使用导出被重命名、被置入 __webpack_unused_export__ 占位变量的全过程,并结合仓库源码说明 usedExports 与 mangleExports 的默认行为及实现位置,帮助读者理解 CommonJS 模块在打包后的"死代码消除"边界。
一、示例能解决什么问题
通常大家认为 tree shaking 只对 ES Module 生效,因为 CommonJS 的 require 是运行时行为、导出对象也可能被动态修改。webpack 对此做了静态分析:只要 CommonJS 模块的"导出方式"和"导入方式"都落在它能静态识别的模式集合内,它就能像处理 ESM 一样标记哪些导出被使用(used exports)、哪些未被使用,并在 mangleExports 开启时把未使用的导出重命名成短名字,方便压缩器后续真正删除它们。
本示例用最简单的三个文件(入口 + 两个 CommonJS 依赖)演示了这一点,并给出"开启 tree shaking"与"关闭 tree shaking"两份产物做对照。
二、示例源码:三种"可识别"的导入写法
入口文件 examples/cjs-tree-shaking/example.js 覆盖了 webpack 能静态识别的三种 CommonJS 导入模式:
// Property access pattern(属性访问模式)
const inc = require("./increment").increment;
var a = 1;
inc(a); // 2
// Destructuring assignment pattern(解构赋值模式)
const { add } = require("./math");
add(a, 2); // 3
// Aliased destructuring(带别名的解构)
const { increment: inc2 } = require("./increment");
inc2(a); // 2
两个依赖模块均为纯 exports.xxx = function 风格的 CommonJS 模块:
- examples/cjs-tree-shaking/increment.js:导出
increment、incrementBy2、decrement三个函数,内部通过const add = require("./math").add;引用 math 模块; - examples/cjs-tree-shaking/math.js:导出
add与multiply两个函数(注意:multiply内部还有一处历史遗留的sum变量笔误,但本示例只关注导出使用情况,不影响分析)。
入口实际只使用了 increment.increment 和 math.add,因此 math.multiply、increment.incrementBy2、increment.decrement 三个导出应当被判为 unused。
三、webpack 配置:usedExports 与 mangleExports 的对照实验
examples/cjs-tree-shaking/webpack.config.js 导出两个编译配置,形成对照:
"use strict";
/** @type {import("webpack").Configuration[]} */
const config = [
{
entry: "./example.js",
output: {
pathinfo: true, // 在产物中输出模块/导出信息注释
filename: "output.js"
},
optimization: {
moduleIds: "size",
usedExports: true, // 分析哪些导出被使用
mangleExports: true // 把未使用的导出重命名为短名
}
},
{
entry: "./example.js",
output: {
pathinfo: true,
filename: "without.js" // 关闭 tree shaking 的对照产物
},
optimization: {
moduleIds: "size",
usedExports: false,
mangleExports: false
}
}
];
module.exports = config;
要点:
pathinfo: true会在产物中生成/*! export add [provided] [used in main] ... */这类注释,是理解分析结果的关键;- 第一个配置开启
usedExports+mangleExports,产物为dist/output.js; - 第二个配置显式关闭两者,产物为
dist/without.js,用于展示"不做导出分析"时的产物形态。
关于默认值:从源码 lib/config/defaults.js 可以看到
D(optimization, "usedExports", production);
D(optimization, "mangleExports", production);
即 usedExports 与 mangleExports 的默认值等于 mode === "production"。因此生产模式下这两项默认开启(这也是 README 中"Production mode"小节产物更小、且被压缩的原因);开发模式下默认关闭,除非手动配置,这也是为什么示例要显式写出这两个选项。
四、开启 tree shaking 的产物分析(dist/output.js)
README 展示的开发态产物(未压缩,保留 pathinfo 注释)中,两个依赖模块的导出状态一目了然:
/* 0 */
/*!*****************!*\
!*** ./math.js ***!
\*****************/
/*! default exports */
/*! export add [provided] [used in main] [usage prevents renaming] */
/*! export multiply [provided] [unused] [renamed to l] */
/*! runtime requirements: __webpack_exports__ */
/***/ ((__unused_webpack_module, exports) => {
var __webpack_unused_export__;
exports.add = function add() { /* ...原样保留... */ };
__webpack_unused_export__ = function multiply() { /* ...原样保留... */ };
/***/ }),
/* 1 */
/*! default exports */
/*! export decrement [provided] [unused] [renamed to K] */
/*! export increment [provided] [used in main] [usage prevents renaming] */
/*! export incrementBy2 [provided] [unused] [renamed to B] */
/***/ ((__unused_webpack_module, exports, __webpack_require__) => {
var __webpack_unused_export__;
const add = (__webpack_require__(/*! ./math */ 0).add);
exports.increment = function increment(val) { return add(val, 1); };
__webpack_unused_export__ = function incrementBy2(val) { return add(val, 2); };
__webpack_unused_export__ = function decrement(val) { return add(val, 1); };
/***/ })
这里发生了三件事:
- 导出状态标注:注释格式为
export <名字> [provided] [used/unused] [renamed to <短名>]。[usage prevents renaming]表示该导出被入口(main chunk)实际使用,名字不能动;[unused] [renamed to l/K/B]表示未使用且已被 mangle 成单字母名。 - 未使用导出被"架空":
exports.multiply = ...被改写为__webpack_unused_export__ = ...。源码仍然保留(因为 CommonJS 的副作用无法在开发态断定),但它不再挂到exports对象上,且所有未使用导出共享同一个变量名__webpack_unused_export__——这正是为压缩器做的铺垫:Terser 等 minifier 能识别出该变量从未被读取,从而把整个赋值连同函数体一并删除。 - 入口侧同样被静态化:
dist/output.js中的入口代码保留了原始注释与结构(const inc = (__webpack_require__(1).increment)等),三种导入模式全部被解析为"具体导出的依赖",而不是对整个模块的黑盒依赖。
五、生产模式产物:真正"删掉"了未使用的代码
README 的 "dist/output.js (production)" 小节给出压缩后的产物(447 字节):
/*! For license information please see output.js.LICENSE.txt */
(()=>{var n=[(n,t)=>{t.add=function(){for(var n=0,t=0,r=arguments,e=r.length;t<e;)n+=r[t++];return n}},(n,t,r)=>{const e=r(0).add;t.increment=function(n){return e(n,1)}}];const t={};function r(e){...}(0,r(1).increment)(1);const{add:e}=r(0);e(1,2);const{increment:o}=r(1);o(1)})();
对比源码可以发现:
math.js只剩add,multiply整个函数体消失了;increment.js只剩increment,incrementBy2、decrement也消失了。
这就是第 4 节中 __webpack_unused_export__ 占位变量存在的意义:开发态只负责"改名 + 脱钩",生产态的 minifier 负责"删除",两步配合完成了 CommonJS 模块的死代码消除。
六、对照组:关闭 tree shaking 的产物(dist/without.js)
"dist/without.js (same without tree shaking)" 小节给出关闭 usedExports/mangleExports 后的压缩产物(615 字节):
(()=>{var n=[(n,t)=>{t.add=function(){...},t.multiply=function(){...}},(n,t,r)=>{const e=r(0).add;t.increment=function(n){return e(n,1)},t.incrementBy2=function(n){return e(n,2)},t.decrement=function(n){return e(n,1)}}];...
差异一目了然:
t.multiply、t.incrementBy2、t.decrement全部保留;- 体积 615 bytes vs 开启后的 447 bytes。
README 的 "Info" 小节同时给出了两种模式下的 stats 输出,其中最能说明问题的行是入口模块下的导出状态标注:
# Unoptimized 模式
asset output.js 3.18 KiB [emitted] (name: main) # 开启 usedExports
asset without.js 3.32 KiB [emitted] (name: main) # 关闭时略大
./example.js 277 bytes [built] [code generated]
[no exports used] # output.js:入口无对外导出,明确"无导出被使用"
[used exports unknown] # without.js:无法判断哪些导出被使用
[no exports used] 与 [used exports unknown] 的对照,正是 usedExports 分析生效与否在 stats 层面的直接体现:未开启时 webpack 对导出使用情况一无所知(unknown),自然不可能做重命名,更谈不上压缩删除。
七、从源码看这套机制在 webpack 内部的实现
以上产物并非"魔法",对应到仓库中的实现可以分为三层:
1. 静态解析层:CommonJS 语法被翻译成"导出依赖"
- lib/dependencies/CommonJsExportsParserPlugin.js:解析
exports.xxx = ...、module.exports.xxx = ...、Object.defineProperty(exports, ...)等导出语句,记录"本模块提供了哪些导出"; - lib/dependencies/CommonJsImportsParserPlugin.js:解析
require(x).xxx、const { xxx } = require(x)等导入语句,记录"本模块使用了哪些导出"; - lib/dependencies/CommonJsPlugin.js:将上述解析器注册到 webpack 的解析流程中,并提供相应的依赖与模板处理。
examples/cjs-tree-shaking/cases.txt 是这个示例配套的"模式清单",明确列出 webpack 期望识别的四类写法及反例:
- BAD(无法识别,会破坏分析):
module.exports = abc; module.exports.xxx = abc;、exports = abc;、裸用module.exports/exports/this、function f() { return this; }等; - EXPORTS(可识别的导出):
exports.xxx = abc;、module.exports = { ... }; module.exports.xxx = abc;等; - IMPORT(可识别的导入):
require(x).xxx、var { xxx } = require(x);、var x = require(x); x.xxx;; - REEXPORT(可识别的再导出):
module.exports.xxx = require(x).xxx;、var xxx = require(x); module.exports = { xxx: xxx.xxx };等; - TRANSPILED:TypeScript 的
__export(m)与 Babel 的_interopRequireDefault辅助函数生成的代码也在支持范围内——这说明经 TS/Babel 转译出的 CommonJS 同样有机会享受 tree shaking。
这份清单同时划出了边界:一旦模块中出现"整对象重新赋值后再接着挂属性"这类动态模式,webpack 就无法静态确定导出集合,分析会退化为 unknown(对应 stats 里的 [used exports unknown]),tree shaking 也就无从谈起。
2. 标记层:判定 provided / used / unused
- lib/optimize/FlagDependencyExportsPlugin.js:在
usedExports开启时执行,为每个模块的导出打上[provided](由该模块提供)标记; - lib/optimize/FlagDependencyUsagePlugin.js:根据各依赖(import)实际读取了哪些导出,反向标记消费方模块中的导出为
[used in main]或[unused]。
产物注释里 [export add [provided] [used in main]] 的双标记,正是这两个插件协作的结果。
3. 改写层:mangle 与"防重命名"
- lib/optimize/MangleExportsPlugin.js:在
mangleExports开启时,把未被任何 chunk 使用的导出重命名为l、K、B这类短名(对应产物注释中的[renamed to l]),保证多个未使用导出可以折叠成同一个变量; - lib/ConstPlugin.js:负责把
exports.multiply = fn这类赋值改写成__webpack_unused_export__ = fn的 ConstDependency 替换,完成"脱钩"。
MangleExportsPlugin 中还有一个值得注意的限制(lib/optimize/MangleExportsPlugin.js):
"optimization.mangleExports can't be used with cacheUnaffected as export mangling is a global effect"
即 mangleExports 是跨 chunk 的全局效果,与某些增量缓存场景不兼容——在需要"仅对受影响模块重编译"的高级缓存配置中,这是一个真实的取舍点。
此外,lib/config/defaults.js 中还有 D(splitChunks, "usedExports", optimization.usedExports === true),说明 optimization.usedExports 会进一步传递给 splitChunks,用于按导出使用情况拆分公共 chunk——tree shaking 的标记是整个优化管线共享的数据,而非孤立功能。
八、实践结论
结合本示例的产物与 stats 对照,可以得出几条可直接使用的结论:
- CommonJS 模块也能被 tree shaking,但有前提:导出与导入都必须落在 cases.txt 所列的静态模式内(
exports.xxx = ...+require(x).xxx/ 解构导入);module.exports整体动态重写等写法会使分析退化为 unknown。 - 生产模式默认开启:
usedExports与mangleExports默认值跟随production(见 lib/config/defaults.js),因此mode: "production"下无需额外配置即可生效;开发模式想要同样的分析注释,需要显式配置这两项并建议配合output.pathinfo: true(如 webpack.config.js 所示)来观察导出状态。 - 删除是"两步走"的:webpack 负责标记 unused、重命名并脱钩到
__webpack_unused_export__;真正的代码删除由 minifier 完成。因此"开启 tree shaking 但产物没变小"时,应检查 minify 是否启用,以及模块是否含有动态写法。 - 验证手段:stats 中入口模块的
[no exports used]vs[used exports unknown]、产物 pathinfo 注释中的[used in main]/[unused] [renamed to ...],是判断 tree shaking 是否对目标模块生效的最直接依据;本示例的对照组(447 bytes vs 615 bytes 的压缩产物)也给出了量级参考。 - 转译代码同样受益:TypeScript/Babel 输出的 CommonJS 辅助函数模式(
__export、_interopRequireDefault)在支持清单内,现代工程链路中的 JS/TS 混用不必放弃 CommonJS 侧的 tree shaking。
如需复现,可按 examples/cjs-tree-shaking/README.md 的流程执行该示例目录下的构建脚本(node examples/buildAll.js cjs-tree-shaking 或按 examples/README.md 说明构建单个示例),即可得到上述 dist/output.js 与 dist/without.js 两份对照产物。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00