保姆级教程:用 AST 自动提取 webpack / rspack 打包后的模块

保姆级教程:用 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 还原成可调试的模块图

难点很具体:

  1. webpack-bundle-analyzer 这类工具看的是体积占比 ,看不到模块函数体和 require 关系------而你要的恰恰是"谁 require 了谁"。
  2. 手写正则抓 数字: function(){...} 一旦换格式就废。真实项目里模块签名是混合 的:有的是标准三参数 function(module,exports,__webpack_require__),有的被 rspack 的模块合并压成了 function(B){B.exports=...}(1 个参数)或 function(B,N){...}(2 个参数)。正则根本分不清。
  3. 一个 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 产物看不清模块关系",现在有了真实可用的解法:

  1. 模块的本质是"数字 ID → 函数"的键值表,依赖通过"第 3 参数(数字)"表达;但真实项目里签名是混合的(三参数 + 合并模块),工具必须兼容。
  2. 用 acorn 做 AST 分析比正则稳:两遍扫描分别认出模块和依赖,源码直接切片保真。
  3. 用 BFS 从入口收集传递依赖 ,能精准裁剪------我的真实样本 1049 个模块只 reach 到 231 个,产物从 MB 级降到 121.7KB。
  4. 用运行时代码模板 + 空 IIFE 参数 ,把模块装回去就能得到一个真能跑的还原产物(exit 0 验证过)。

下一步你可以做的:把 graph 导出成 JSON,用 d3 / ECharts 画出模块依赖图;或按上面的"进阶方向"补全 rspack 合并模块的依赖提取,让分析 100% 覆盖。

如果对你有帮助,点赞收藏关注不迷路~ 有疑问或想看"依赖图可视化"的续篇,欢迎在评论区交流。

相关推荐
guwentian6 天前
Rolldown vs esbuild vs Webpack:Rust 打包器大战我们该怎么选
前端·webpack·rust·esbuild
飞翔的熊blabla9 天前
# Webpack 5 + Jenkins 持久化缓存实战:前端构建从 288 秒降到 30 秒>
前端·webpack·jenkins
梨想橙汁9 天前
Vite 优化、踩坑汇总 + Webpack 迁移 Vite 实战
前端·webpack·vite
天道kabuto11 天前
Webpack 迁 Vite 踩坑记:一个让人抓狂的 Sourcemap 报错怎么破?
webpack·vite
梨想橙汁11 天前
Webpack 快速上手:前端工程化、打包原理、从零搭建基础项目
前端·webpack·前端工程化
晴天1611 天前
Webpack3/4/5 核心差异、性能升级与迁移实战指南-Day38
webpack·node.js
晴天1612 天前
Vite vs Webpack 全方位对比
前端·webpack·node.js
PBitW15 天前
为什么vite中TS报错,可以继续运行?Webpack不行?
前端·webpack·typescript·vite
你别说话了15 天前
Webpack 如何迁移重构到 Vite
前端·webpack·重构