保姆级教程:用 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% 覆盖。

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

相关推荐
liangshanbo12154 天前
面试题:Webpack 的 publicPath 有什么作用?
前端·webpack·node.js
liangshanbo12156 天前
Webpack vs Vite 底层原理高频追问的问题
前端·webpack·node.js
liangshanbo12156 天前
面试题:AI 对话上下文超限怎么办?
面试·webpack·devops
liangshanbo12157 天前
Webpack 文件指纹面试题整理
前端·webpack·node.js
liangshanbo12157 天前
Webpack 常见 Plugin 面试题
前端·webpack·node.js
liangshanbo12157 天前
Webpack Tree Shaking 面试题整理
前端·webpack·node.js
陪我一起学编程7 天前
vue3笔记
前端·webpack·vue3·vite·脚手架·vue3笔记·组件式
liangshanbo12157 天前
Webpack的分包策略面试题
前端·webpack·node.js
liangshanbo12159 天前
面试题:如何优化 Webpack 的打包速度?
前端·webpack·node.js
晚风醉蝶10 天前
webpack 模块提取
前端·webpack·node.js·ast·逆向分析