保姆级教程:用 AST + BFS 精准提取 webpack 模块依赖闭包
摘要:当你手里有一个被 webpack 打包、还做了代码混淆 / 拆 chunk 的前端产物,只想单独运行或逆向分析其中某个入口模块时,面对动辄几千上万个模块该怎么办?本文给你一个基于AST 解析 + BFS 依赖闭包的小工具:输入入口模块 ID,自动算出它的全部传递依赖并注入可运行的 webpack 解释器模板。省去反复手动抠代码,也避免把整个几 MB 的 bundle 全搬下来。
环境 / 版本:Node.js 18+,解析依赖 acorn;成文于 2026-09。webpack 4 / 5 的「模块对象数组」格式通用,但不同项目的
require形参命名、chunk push 写法存在差异,适用边界见文末。
配套资源:完整的build_decode.js提取脚本 + 可直接运行的source.js解释器模板已打包上传,按需自取 👉 https://pan.baidu.com/s/58TwdNOLrh9io14LC6RVhtg(提取后把 bundle 放进source/、填好入口 ID 即可跑通)。
一、为什么需要这个工具:从"手动抠模块"说起
先看一个真实场景:你拿到一个线上前端产物,想本地跑一下其中某个功能对应的模块(比如逆向分析一个加密逻辑、或者复用某个业务组件)。但 webpack 早把源码揉成了一个巨大的 bundle,所有模块被编号塞进一个对象里。
常见的两种"土办法"都很痛苦:
| 做法 | 操作 | 痛点 |
|---|---|---|
| 反复跑代码抠 | 运行 bundle,打印出模块,再去源文件里按 ID 找下来,缺一个依赖再跑一次 | 依赖链一长就陷入"跑 → 缺 → 找 → 再跑"的死循环,效率极低 |
| 全量提取 | 把整个 bundle 里的所有模块一次性导出来 | 几 MB 的无关代码全搬进项目,体积爆炸,真正用到的可能只有几十个 |
| 本工具 | 给定入口模块 ID,自动算出传递依赖闭包,只导出用到的模块 | 一次跑通,体积可控,还顺手标出缺失模块 |
核心差异:别人是"我要什么就慢慢找什么"或者"全都要",我们做的是从入口出发,只把依赖链上真正用到的模块捞出来。
二、先看清 webpack bundle 的模块长什么样
动手前,得先认得 webpack 产出的模块长啥样。它有两类典型格式,我们的工具两种都兼容:
格式一:IIFE 参数式(主 bundle 最常见)
!function(modules){// 解释器:根据 ID 取模块、按需 require}({123:function(module,exports,__webpack_require__){varfoo=__webpack_require__(456);// 依赖模块 456}});格式二:chunk push 式(异步分包)
(window.webpackJsonp=window.webpackJsonp||[]).push([[0],{789:function(module,exports,__webpack_require__){// ...}}]);无论哪种格式,一个模块的本质都是数字ID: function(...)。注意函数签名:
webpack 标准模块签名是
function(module, exports, __webpack_require__),第三个参数就是require函数。模块内部通过requireParam(数字ID)的方式引用其他模块——这些被传进 require 的数字,就是这个模块的直接依赖。
只要我们能拿到每个模块"调用了哪些 ID",再做一次图遍历,就能得到任意一个入口的完整依赖闭包。
三、工具核心思路:AST 提取 + BFS 依赖闭包
整体流水线分五步,关键是"先建模块表,再算依赖图,最后注入模板":
IIFE 参数为空 {}] --> B[acorn 解析所有 -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'DIAMOND_START'
- 读模板:读取一个 webpack 解释器模板
source.js,它的 IIFE 参数目前是空的({})。 - 建模块表:用 acorn 把所有 bundle 解析成 AST,找出所有
数字key + 函数value的属性,存进Map。 - 算直接依赖:对每个模块,定位第三个参数(require 函数名),再扫描
requireParam(数字)调用,收集它直接依赖的 ID。 - BFS 闭包:从你指定的入口模块 ID 出发做广度优先遍历,把整条依赖链上用到的模块都标记出来,并记录缺失项。
- 注入输出:把需要的模块按 ID 排序后塞进
source.js的 IIFE 参数里,写出可直接运行的decode.js。
四、动手跑通:一次提取只需要改 3 个配置
工具配置集中在文件顶部,实际要改的只有三处:SOURCE_FILE(解释器模板)、SOURCE_DIR(放 bundle 的目录)、ENTRY_IDS(入口模块 ID)。
// build_decode.js 顶部配置区constSOURCE_FILE='./source.js';// webpack 解释器模板(IIFE 参数为空)constSOURCE_DIR='./source';// 把要分析的 .js 都丢进这个文件夹constENTRY_IDS=[73271,91318,4455];// 你要提取的入口模块 IDconstOUTPUT_FILE='./decode.js';// 输出文件目录结构建议:
project/ ├── build_decode.js# 本工具├── source.js# 解释器模板(参数留空)├── source/# 放待分析的 bundle│ ├── app.1.js │ └── app.2.js └── decode.js# 运行后生成的产物装好依赖就能跑:
npminstallacornnodebuild_decode.js预期输出(关键行已标注含义):
=== decode.js 构建工具 === [1] 读取模板: ./source.js IIFE 参数位置: 字符 12345 ~ 67890 [2] 解析 2 个 bundle 文件 app.1.js (120.5 KB) → 342 个模块 app.2.js (88.3 KB) → 210 个模块 ↳ 去重: 跳过 12 个重复模块 合计: 540 个唯一模块 [3] 收集传递依赖 (入口: 73271, 91318, 4455) 需要: 47 个模块 # ← 几千个模块里,真正用到的只有 47 个 [4] 注入模块到 source.js [5] 输出: /abs/path/decode.js 包含模块: 47 个 (其中 0 个缺失) === 下一步 === 所有模块已补全, 可以直接运行 decode.js node /abs/path/decode.js看到需要: 47 个模块就说明成功了——原本几百 KB 的模块池,最终只导出了真正服务于这三个入口的 47 个。
五、关键代码拆解:三个核心函数
下面把工具里最关键的三个函数拆开讲,方便你按需改。
1. extractModules:AST 提取模块 + 直接依赖
第一遍遍历找出所有「数字 ID + 函数」的属性;第二遍在每个模块函数体内,只匹配requireParam(数字)这种调用(排除.n / .e / .t等 webpack 运行时辅助方法),收集直接依赖:
functionextractModules(source,filePath){constast=acorn.parse(source,PARSE_OPTS);constmodules=newMap();// 第一遍:收集 数字key + 函数valuewalk(ast,(node)=>{if(node.type!=='Property')return;constkey=node.key;letmoduleId=null;if(key?.type==='Literal'){if(typeofkey.value==='number')moduleId=key.value;elseif(typeofkey.value==='string'&&/^\d+$/.test(key.value))moduleId=parseInt(key.value,10);}if(moduleId===null)return;constvalue=node.value;if(value.type!=='FunctionExpression'&&value.type!=='ArrowFunctionExpression')return;// 第三个参数即 require 函数(webpack 标准签名)letrequireParam=null;if(value.params.length>=3&&value.params[2].type==='Identifier')requireParam=value.params[2].name;if(!modules.has(moduleId))modules.set(moduleId,{id:moduleId,node:value,deps:[],file:filePath,requireParam});});// 第二遍:分析每个模块内部的 require(ID) 调用for(const[,mod]ofmodules){if(!mod.requireParam)continue;constdepSet=newSet();walk(mod.node,(node)=>{if(node.type!=='CallExpression')return;constcallee=node.callee;if(callee.type!=='Identifier'||callee.name!==mod.requireParam)return;constarg=node.arguments[0];if(arg?.type==='Literal'&&typeofarg.value==='number')depSet.add(arg.value);});mod.deps=[...depSet].sort((a,b)=>a-b);}returnmodules;}2. collectDeps:BFS 收集传递依赖闭包
入口入队,逐层展开。访问过的标记visited,bundle 里找不到的记进missing,方便你补文件:
functioncollectDeps(modules,entryIds){constvisited=newSet();constmissing=[];constqueue=[...entryIds];while(queue.length>0){constid=queue.shift();if(visited.has(id))continue;visited.add(id);constmod=modules.get(id);if(mod){for(constdepofmod.deps)if(!visited.has(dep))queue.push(dep);// 依赖入队,继续向外扩散}else{missing.push(id);// 当前 bundle 池里没有这个模块}}return{visited,missing};}3. locateIIFEArg:在模板里定位注入点
从文件末尾往前找}({这个 IIFE 调用特征,用括号配平算出参数对象的起止偏移,再把模块塞进去:
functionlocateIIFEArg(source){constcallStart=source.lastIndexOf('}({');if(callStart===-1)returnnull;constcontentStart=callStart+3;// 跳过 "}({"letdepth=1,i=contentStart;// 已进第一层 {while(i<source.length&&depth>0){if(source[i]==='{')depth++;elseif(source[i]==='}')depth--;i++;}if(depth!==0)returnnull;return{contentStart,contentEnd:i-1,callEnd:i};}注入时缺的模块不会报错,而是写成
// ID: *** 缺失 — 请提供包含此模块的 bundle 文件 ***注释,你补上对应 bundle 重跑即可。
六、常见问题与排错指南
| 现象 | 原因 | 解决 |
|---|---|---|
缺少 acorn, 请运行: npm install acorn | 没装解析依赖 | npm install acorn后重跑 |
source 文件夹为空 | bundle 没放进去 | 把.js丢进SOURCE_DIR |
输出包含模块: 47 个 (其中 3 个缺失) | 某些依赖在现有 bundle 里没有 | 把含这些 ID 的 chunk 文件补进source/,重跑 |
无法定位 IIFE 参数位置 | 模板不是标准的}({形式 | 检查source.js是否被二次压缩/改写,手动确认 IIFE 调用特征 |
| 提取出的模块依赖不全 | 混淆后 require 不是第三个参数,或被改名 | 在extractModules里放宽requireParam判定(如按调用特征而非固定形参位) |
风险提示:该工具用于本地逆向分析、调试、复用自有代码等合法场景。对来源不明的第三方产物做分析前,请确认不违反对方的服务条款与当地法律法规;不要用于绕过付费 / 授权校验等不当用途。
总结
- 思路闭环:先建「ID → 模块 → 直接依赖」的表,再 BFS 求传递闭包,最后注入解释器模板——从"给入口"到"拿到可运行子集"一步到位。
- 体积可控:从几千个模块里精准捞出几十个,比全量提取省下大量无关代码。
- 缺失可见:找不到的依赖会被注释标出,补文件即可,不会让你在运行时才踩坑。
- 立即可用:把
source.js模板、bundle 文件、入口 ID 配好,node build_decode.js就能产出decode.js。
适用边界与时效
- 适用于 webpack 4 / 5 的「模块对象数组」产物;对ESM 未被降级为函数模块、或模块 ID 非数字、或依赖通过动态
import()+ magic comment 走运行时加载的场景,需针对性调整提取规则。 - 依赖 acorn 做 AST 解析,对极度压缩 / 语法变形的 bundle 可能需要先美化或调
PARSE_OPTS(如ecmaVersion)。 - 原理层(模块签名、依赖图遍历)长期有效;具体特征字符串(
__webpack_require__、chunk push 写法)随 webpack 版本可能有微调,遇到问题对照上文"排错表"即可。
如果对你有帮助,点赞收藏关注不迷路~ 有问题欢迎在评论区交流,一起把这套提取流程打磨得更稳。