保姆级教程:用 AST 自动提取 webpack / rspack 打包后的模块
摘要 :我手上有一个真实的前端项目(某 PC Web,构建信息里写着
bundler=rspack@1.2.5),打包后是一堆看不出结构的压缩文件。本文就用这个项目真实跑一遍 一个基于acorn的提取脚本,把"1049 个模块里入口到底 reach 到哪 231 个"这件事讲清楚,并附真实运行日志。读完你能照着写出一个自己的 webpack / rspack 模块提取器,也能避开我在真实文件上踩到的坑。
环境 / 版本 :Node.js 18+,acorn@8.18;真实样本来自 rspack@1.2.5(与 webpack 4/5 模块表格式兼容);成文于 2026-09。下文方法依赖"数字 ID → 函数"的标准模块表,版本敏感点已标注。
一、背景:为什么正则救不了你
先说清楚这件事值不值得做。拿到打包后的产物,常见的诉求是:
- 想把某个第三方库到底把哪些模块打进了主包列出来;
- 接手只剩
dist/的老项目,要先"逆向"出模块关系才能维护; - 做依赖审计 / 安全分析,需要把混淆不重的 bundle 还原成可调试的模块图。
难点很具体:
webpack-bundle-analyzer这类工具看的是体积占比 ,看不到模块函数体和require关系------而你要的恰恰是"谁 require 了谁"。- 手写正则抓
数字: function(){...}一旦换格式就废。真实项目里模块签名是混合 的:有的是标准三参数function(module,exports,__webpack_require__),有的被 rspack 的模块合并压成了function(B){B.exports=...}(1 个参数)或function(B,N){...}(2 个参数)。正则根本分不清。 - 一个 vendor 包动辄上千模块,你只关心入口可达的那一小撮,全量提取既慢又吵。
结论:正则匹配的是"文本长得像",AST 分析的是"语法结构对"。只要抓住 webpack / rspack 模块的语法约定,提取工具就稳了,而且能优雅地处理上面的混合签名。
二、先搞懂:webpack 系的模块到底长什么样
所有"标准 webpack 系产物"(webpack 4/5、rspack 均兼容)都遵循同一个约定:模块被编译成"数字 ID → 函数"的键值表,再喂给运行时代码(bootstrap)。
2.1 两种常见形态
| 形态 | 出现位置 | 代码片段特征 | 本文工具能否识别 |
|---|---|---|---|
| 主 bundle(IIFE) | 入口 app.[hash].js |
!function(modules){...}({ 123: function(){...} }) |
✅ 支持 |
| 异步 chunk(push) | 懒加载 *.chunk.js |
`(_web |
关键:无论哪种形态,模块本身长相一致 ------
数字 ID : function(...) {...},所以提取逻辑可以共用,不用为每个文件写特殊分支。
2.2 模块的"标准签名"与它的例外
webpack 在编译时把每个源文件包成:
javascript
// 标准三参数签名(webpack / rspack 默认)
123: function(module, exports, __webpack_require__) {
var util = __webpack_require__(91318); // 依赖 91318 号模块
module.exports = util.doSomething();
}
约定是:第 3 个参数是模块内部的 require 函数,依赖全部通过"第 3 参数(数字)"表达。
但真实项目里这一条会被打破(我在 vendor.ce9f19ea.js 里统计过,357 个模块里三参数的只有 287 个,其余是 1/2 参数合并模块)。这正是后文要讲清的适用边界------先记住:工具要同时兼容这两种情况。
渲染错误: Mermaid 渲染失败: Parse error on line 6: ...ion} E -->|第3参数名(数字)| F记录依赖 depId ----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'
三、核心一:用 acorn 解析 AST,提取模块与依赖
工具第一步:把 bundle 用 acorn 解析成 AST,再从 AST 里"捞"模块。
3.1 解析配置
javascript
const acorn = require('acorn');
const PARSE_OPTS = {
ecmaVersion: 'latest', // 支持最新语法
sourceType: 'script', // webpack 系产物通常是 script
allowReturnOutsideFunction: true,
allowHashBang: true,
allowAwaitOutsideFunction: true, // 兼容顶层 await 等变体
};
3.2 一个零依赖的 AST 遍历器
不用引 @babel/traverse,手写递归 walk 更轻:
javascript
function walk(node, visitor, parent) {
if (!node || typeof node.type !== 'string') return;
visitor(node, parent);
for (const key of Object.keys(node)) {
// 跳过位置信息字段,只遍历语法节点
if (['type', 'start', 'end', 'loc', 'range'].includes(key)) continue;
const child = node[key];
if (Array.isArray(child)) {
for (const item of child) {
if (item && typeof item.type === 'string') walk(item, visitor, node);
}
} else if (child && typeof child.type === 'string') {
walk(child, visitor, node);
}
}
}
3.3 两遍扫描:extractModules
先认模块,再认依赖------所以叫"两遍扫描":
javascript
function extractModules(source, filePath) {
let ast;
try {
ast = acorn.parse(source, PARSE_OPTS);
} catch (e) {
console.error(` ⚠ 解析失败: ${filePath} (${e.message})`);
return null; // 单文件解析失败不影响其他文件
}
const modules = new Map();
// 第一遍:找 "数字 key + 函数 value" 的属性节点
walk(ast, (node) => {
if (node.type !== 'Property') return;
const key = node.key;
let moduleId = null;
if (key && key.type === 'Literal') {
if (typeof key.value === 'number') moduleId = key.value;
else if (typeof key.value === 'string' && /^\d+$/.test(key.value)) {
moduleId = parseInt(key.value, 10); // 兼容 "123" 字符串 key
}
}
if (moduleId === null) return;
const value = node.value;
if (value.type !== 'FunctionExpression' &&
value.type !== 'ArrowFunctionExpression') return;
// 第三个参数是 require 函数(webpack 标准签名)
let requireParam = null;
if (value.params.length >= 3) {
const p = value.params[2];
if (p.type === 'Identifier') requireParam = p.name;
}
if (!modules.has(moduleId)) {
modules.set(moduleId, {
id: moduleId,
node: value,
source: source.slice(value.start, value.end), // 直接切原始文本,保真
requireParam,
deps: [],
file: path.basename(filePath),
});
}
});
// 第二遍:分析每个模块的依赖
for (const [, mod] of modules) {
if (!mod.requireParam) continue; // 没有第3参数 → 跳过依赖分析(见边界)
const depSet = new Set();
walk(mod.node, (node) => {
if (node.type !== 'CallExpression') return;
const callee = node.callee;
if (callee.type !== 'Identifier' || callee.name !== mod.requireParam) return;
const arg = node.arguments[0];
if (arg && arg.type === 'Literal' && typeof arg.value === 'number') {
depSet.add(arg.value);
}
});
mod.deps = [...depSet].sort((a, b) => a - b);
}
return modules;
}
三个值得说清的设计点:
- 依赖用
Set去重再排序 ,避免模块体内多次require同一 ID 导致重复。 - 只匹配"标识符直接调用" (
callee.name === requireParam),特意排除__webpack_require__.n/.e/.t这类内部 helper------它们不是真正的模块依赖。 - 模块源码直接
source.slice(start, end)切原文 ,而不是用escodegen重新生成,能 100% 保真,连压缩后的变量名都不走样。
真实样本验证 :在
vendor.ce9f19ea.js上单跑这一步,提取到357个模块,其中287个三参数模块成功解析出依赖数组,其余 1/2 参数合并模块requireParam=null、依赖暂为空(这是后文边界讨论的源头)。
四、核心二:BFS 收集"入口可达"的传递依赖
有了全量模块表,下一步是从几个入口模块 出发,只收"可达"的那部分。这里用 BFS 而不是一次性全拿。
javascript
function collectDeps(modules, entryIds) {
const visited = new Set();
const missing = [];
const graph = {};
const queue = [...entryIds];
while (queue.length > 0) {
const id = queue.shift();
if (visited.has(id)) continue;
visited.add(id);
const mod = modules.get(id);
if (mod) {
graph[id] = mod.deps;
for (const dep of mod.deps) {
if (!visited.has(dep)) queue.push(dep); // 入队未访问的依赖
}
} else {
missing.push(id); // 声明了依赖,但 bundle 里没有 → 缺失
}
}
return { visited, missing, graph };
}
为什么用 BFS 而不是全量 / DFS?
- 精准裁剪 :入口
91318依赖51个模块,这些模块又各自依赖别的......BFS 自然把链铺开,没被引用的自动排除。我的真实样本里 1049 个模块只 reach 到 231 个------裁剪掉 78%,产物从 3MB 级降到 121.7KB。 - 顺带产出依赖图 :返回值的
graph是{ 模块ID: [依赖ID...] },可直接画依赖图。 - 顺手发现缺失 :
missing列表告诉你"入口需要某模块,但手里的 bundle 里没有"------往往是"还有别的 chunk 没放进来"。
五、核心三:定位 IIFE 参数,把模块注入模板
提取完,最后把模块"装回"运行时代码模板(本文叫 source.js,即 rspack 运行时代码),让它能独立跑。关键是精准定位模板里那个空 IIFE 参数 {}。
javascript
function locateIIFEArg(source) {
// 从文件末尾向前找 "}({" ------ IIFE 调用参数的起点
const callStart = source.lastIndexOf('}({');
if (callStart === -1) return null;
const contentStart = callStart + 3; // 跳过 "}({"
let depth = 1; // 已进入了第一个 {
let i = contentStart;
while (i < source.length && depth > 0) {
if (source[i] === '{') depth++;
else if (source[i] === '}') depth--;
i++;
}
if (depth !== 0) return null; // 括号没闭合,定位失败
return { callStart, contentStart, contentEnd: i - 1, callEnd: i };
}
真实坑 :
lastIndexOf('}({')依赖模板里 IIFE 的闭合顺序必须是}在前、)在后。我第一次自己手写的模板写成了})({()在前),直接定位失败。所以这一步要用真实模板文件,别凭记忆拼。
定位到 { 与闭合 } 的偏移后,把中间内容替换成生成的模块表。缺失模块用注释占坑,保证产物结构完整、一眼看出缺哪:
javascript
function generateModulesObject(modules, neededIds) {
const sortedIds = [...neededIds].sort((a, b) => a - b);
const parts = [];
for (const id of sortedIds) {
const mod = modules.get(id);
if (mod) {
const depComment = mod.deps.length > 0 ? ` // deps: ${mod.deps.join(', ')}` : '';
parts.push(` ${id}: ${mod.source},${depComment}`);
} else {
// 缺失模块:留注释,提示去哪补
parts.push(` // ${id}: *** 缺失 --- 请提供包含此模块的 bundle 文件 ***`);
}
}
return parts.join('\n');
}
主流程就是"读模板 → 解析所有 bundle 合并去重 → BFS 收集 → 注入 → 输出"四步(main() 里的 [1]~[5] 日志)。
六、真实项目实测(基于 rspack@1.2.5 产物)
下面所有数字都来自我手上的真实文件,不是举例凑的。
6.1 准备
目录结构(模板 + 三个真实 bundle 丢进 source/):
text
.
├── source.js # rspack 运行时代码模板(IIFE 参数为空 {})
├── build_decode.js # 本文脚本(唯一依赖 acorn)
└── source/
├── library-polyfill.d9182cd6.js # 206.7 KB
├── vendor.ce9f19ea.js # 1609.3 KB
└── vendor-dynamic.08c1ff80.js # 1330.3 KB
入口取模板里真实调用的两个:var c = r(73271) 和 var u = r(91318),所以配置:
javascript
const ENTRY_IDS = [73271, 91318]; // 必须和 source.js 里 r() 调用的模块一致
注意:脚本原示例里写的
4455在我这个真实模板里根本不是入口 ------直接拿来跑会把它当成缺失模块报出来。入口 ID 一定要从你自己的source.js里查,别照抄别人的。
6.2 运行与真实输出(节选)
bash
npm install acorn
node build_decode.js
text
[1] 读取模板: ./source.js
IIFE 参数位置: 字符 36810 ~ 36814
[2] 解析 3 个 bundle 文件
library-polyfill.d9182cd6.js (206.7 KB) → 569 个模块
vendor.ce9f19ea.js (1609.3 KB) → 357 个模块
vendor-dynamic.08c1ff80.js (1330.3 KB) → 123 个模块
合计: 1049 个唯一模块
[3] 收集传递依赖 (入口: 73271, 91318)
需要: 231 个模块
依赖关系图(节选):
73271 → [2605] (vendor-dynamic...)
91318 → [907, 3398, 4137, ... 共51个] (vendor...)
1706 → [2940, 4279, 6965, ... 共17个] (library-polyfill...)
3236 → [1706, 4279, ... 共33个] (library-polyfill...)
[5] 输出: .../decodeceshi.js
文件大小: 121.7 KB
包含模块: 231 个 (其中 0 个缺失)
6.3 运行验证(不是"理论上可运行")
生成的 decodeceshi.js 在 Node 下真实执行:
bash
$ node decodeceshi.js
73271:
2605:
46490:
...(共 231 行,每个可达模块一行)
# 退出码 0,stderr 为空,无异常抛出
这一步是我特意在真实产物上验证过的:1049 个模块 → BFS 裁剪到 231 个 → 注入 → 生成 121.7KB 文件 →
node直接跑通。结论闭环,不靠脑补。
【图:把 graph 导出成 JSON,用 ECharts 画出的 231 个模块依赖关系图,建议放一张,视觉冲击力强】
七、适用边界与排错(真实踩坑)
工具好用,但边界要讲清楚------这部分来自真实文件,不是泛泛而谈。
| 场景 | 是否适用 | 真实依据 |
|---|---|---|
| webpack 4/5 标准模块表 | ✅ | 数字 ID + 函数 value,通用 |
| rspack(webpack 兼容) | ✅ | 本文样本即 rspack@1.2.5,跑通 |
| 多 chunk 分片产物 | ✅ | 自动扫描 source/ 并去重合并 |
| 入口可达性分析 | ✅ | 1049 → 231 的真实裁剪 |
| rspack 合并模块(1/2 参数) | ⚠️ 依赖不全 | 样本中 287/357 是三参数,其余 requireParam=null,依赖暂不提取 |
| 被重度混淆(key 非数字、require 改名、动态 require) | ❌ | 识别规则建立在"标准签名"上 |
| 模块加密 / 计算属性 key | ❌ | 静态 AST 拿不到运行时才确定的 key |
针对 1/2 参数合并模块(rspack scope-hoisting)的进阶方向 :这类模块的 require 不在第 3 参数上,而是被提升/改写为模块级 helper 调用(如 B(数字) 或 j(数字))。要补全依赖,扩展点很明确------在 extractModules 第二遍里,除了匹配"第 3 参数名(数字)",再额外扫描模块体内直接出现的 标识符(纯数字字面量) 调用,并把"出现在模块定义作用域、且只接收数字参数"的调用也记为候选依赖。这是从"能用"到"全量精准"的关键一步,留给你按需实现。
合规提醒 :本文方法仅适用于你拥有合法权利分析的文件(自己的项目、已授权的审计、开源代码)。请勿用于未经授权的逆向、破解或侵犯他人知识产权,遵守所在地区法律法规与平台条款。
总结
回到开头那个问题------"拿到压缩成一团的 rspack 产物看不清模块关系",现在有了真实可用的解法:
- 模块的本质是"数字 ID → 函数"的键值表,依赖通过"第 3 参数(数字)"表达;但真实项目里签名是混合的(三参数 + 合并模块),工具必须兼容。
- 用 acorn 做 AST 分析比正则稳:两遍扫描分别认出模块和依赖,源码直接切片保真。
- 用 BFS 从入口收集传递依赖 ,能精准裁剪------我的真实样本 1049 个模块只 reach 到 231 个,产物从 MB 级降到 121.7KB。
- 用运行时代码模板 + 空 IIFE 参数 ,把模块装回去就能得到一个真能跑的还原产物(exit 0 验证过)。
下一步你可以做的:把
graph导出成 JSON,用d3/ECharts画出模块依赖图;或按上面的"进阶方向"补全 rspack 合并模块的依赖提取,让分析 100% 覆盖。
如果对你有帮助,点赞收藏关注不迷路~ 有疑问或想看"依赖图可视化"的续篇,欢迎在评论区交流。