首页
/ Webpack 对 CommonJS 模块的 Tree Shaking:机制、配置与产物深度解析

Webpack 对 CommonJS 模块的 Tree Shaking:机制、配置与产物深度解析

2026-09-05 14:31:35作者:翟萌耘Ralph

本文基于 webpack 仓库中的官方示例 examples/cjs-tree-shaking/README.md,完整还原"webpack 如何对 CommonJS(require/exports)模块执行 tree shaking"这一主题:从入口源码、optimization 配置,到产物中未使用导出被重命名、被置入 __webpack_unused_export__ 占位变量的全过程,并结合仓库源码说明 usedExportsmangleExports 的默认行为及实现位置,帮助读者理解 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:导出 incrementincrementBy2decrement 三个函数,内部通过 const add = require("./math").add; 引用 math 模块;
  • examples/cjs-tree-shaking/math.js:导出 addmultiply 两个函数(注意:multiply 内部还有一处历史遗留的 sum 变量笔误,但本示例只关注导出使用情况,不影响分析)。

入口实际只使用了 increment.incrementmath.add,因此 math.multiplyincrement.incrementBy2increment.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);

usedExportsmangleExports 的默认值等于 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); };

/***/ })

这里发生了三件事:

  1. 导出状态标注:注释格式为 export <名字> [provided] [used/unused] [renamed to <短名>][usage prevents renaming] 表示该导出被入口(main chunk)实际使用,名字不能动;[unused] [renamed to l/K/B] 表示未使用且已被 mangle 成单字母名。
  2. 未使用导出被"架空"exports.multiply = ... 被改写为 __webpack_unused_export__ = ...。源码仍然保留(因为 CommonJS 的副作用无法在开发态断定),但它不再挂到 exports 对象上,且所有未使用导出共享同一个变量名 __webpack_unused_export__——这正是为压缩器做的铺垫:Terser 等 minifier 能识别出该变量从未被读取,从而把整个赋值连同函数体一并删除。
  3. 入口侧同样被静态化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 只剩 addmultiply 整个函数体消失了;
  • increment.js 只剩 incrementincrementBy2decrement 也消失了。

这就是第 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.multiplyt.incrementBy2t.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 语法被翻译成"导出依赖"

examples/cjs-tree-shaking/cases.txt 是这个示例配套的"模式清单",明确列出 webpack 期望识别的四类写法及反例:

  • BAD(无法识别,会破坏分析):module.exports = abc; module.exports.xxx = abc;exports = abc;、裸用 module.exports / exports / thisfunction f() { return this; } 等;
  • EXPORTS(可识别的导出):exports.xxx = abc;module.exports = { ... }; module.exports.xxx = abc; 等;
  • IMPORT(可识别的导入):require(x).xxxvar { 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

产物注释里 [export add [provided] [used in main]] 的双标记,正是这两个插件协作的结果。

3. 改写层:mangle 与"防重命名"

  • lib/optimize/MangleExportsPlugin.js:在 mangleExports 开启时,把未被任何 chunk 使用的导出重命名为 lKB 这类短名(对应产物注释中的 [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 对照,可以得出几条可直接使用的结论:

  1. CommonJS 模块也能被 tree shaking,但有前提:导出与导入都必须落在 cases.txt 所列的静态模式内(exports.xxx = ... + require(x).xxx / 解构导入);module.exports 整体动态重写等写法会使分析退化为 unknown。
  2. 生产模式默认开启usedExportsmangleExports 默认值跟随 production(见 lib/config/defaults.js),因此 mode: "production" 下无需额外配置即可生效;开发模式想要同样的分析注释,需要显式配置这两项并建议配合 output.pathinfo: true(如 webpack.config.js 所示)来观察导出状态。
  3. 删除是"两步走"的:webpack 负责标记 unused、重命名并脱钩到 __webpack_unused_export__;真正的代码删除由 minifier 完成。因此"开启 tree shaking 但产物没变小"时,应检查 minify 是否启用,以及模块是否含有动态写法。
  4. 验证手段:stats 中入口模块的 [no exports used] vs [used exports unknown]、产物 pathinfo 注释中的 [used in main] / [unused] [renamed to ...],是判断 tree shaking 是否对目标模块生效的最直接依据;本示例的对照组(447 bytes vs 615 bytes 的压缩产物)也给出了量级参考。
  5. 转译代码同样受益: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.jsdist/without.js 两份对照产物。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384