简介面向需要研究微信小程序内部实现的开发者与安全审计人员核心为 wxappUnpacker 反编译工具可将小程序包还原为可读的 WXML、WXSS、JS 与 JSON 文件并分别处理主包和分包便于分析业务逻辑、组件结构、加载策略与潜在安全风险。压缩包共 780 个文件约 1.97MB以 374 个 js 脚本、83 个 json 配置、71 个 md 说明和 70 个 ts 文件为主另含 map、license、ps1、cmd 以及 beautify/parse 相关脚本反映其包含运行依赖、命令行入口与使用文档。已有 269 人学习适合具备一定前端基础、希望从工程结构层面理解小程序或开展合规安全检测的读者。借助其中的脚本入口、依赖模块和说明文档可较快搭建反编译环境结合 WXML 界面结构、WXSS 样式组织、JS 逻辑与 JSON 配置之间的关系还能形成对小程序工程的整体认识并借鉴其解析思路用于代码审查、性能排查与合规安全审计。1. 拿到一个 wxapkg先别急着改后缀第一次接手别人留下的小程序很多人会先做同一个动作把__APP__.wxapkg改成.zip双击打不开换个编辑器硬看满屏都是define(pages/index/index.js, function(require, module, exports, ...){...})单文件上万行标签和样式全糊在一起。这正是小程序反编译文件最典型的形态——所有逻辑、视图、样式被打包器压成几个大文件源码结构被彻底摊平。wxappUnpacker 解决的就是这一步把 wxapkg 重新摊回pages/、components/目录拆出app.js、app.json、app.wxss再把压缩过的 JS、WXSS 美化到能读。它适合三类人接手历史项目但拿不到源码的、想研究某个组件到底怎么写的、以及在上线前给自己产物做一次自查的。纯 Node 命令行工具不依赖图形界面。2. 项目定位与技术栈它到底在还原什么2.1 wxapkg 里装的是四类产物不是源码理解这个工具的前提是先搞清楚小程序打包后到底剩下了什么。一个编译完成的包逻辑层被合并成app-service.js里面是无数个define(路径, 函数)调用每个调用对应一个原始 JS 文件视图层被合并进app-wxss.js里面既有setCssToHead([...])形式的样式集合也有__wxAppCode__[pages/index/index.wxml]这类视图入口配置被抽成app-config.json页面路由、窗口样式、分包信息都在里面剩下还有page-frame.html之类由运行时注入的壳文件。这四类产物对应四种还原难度。配置最好办本质是 JSON 重排JS 次之define的边界清晰截取函数体就能还原单文件WXSS 麻烦在它被拆成数组片段、还带 rpx 换算WXML 最难模板已经被编译成$gwx函数体内的_n(view)、_v()这类调用序列没有原始标签文本可截取只能按调用顺序反推。我一般把 wxappUnpacker 看成四个还原器的组合而不是一个一键反编译的黑盒——知道哪一层出了问题排查才有方向。还有一层常被忽略分包。主包里只有app-config.json和首屏页面其余业务代码在subpackages目录下的独立 wxapkg 里。只解主包你会得到一个页面存在但代码为空的残缺工程然后误以为工具坏了。2.2 技术栈Node 命令行 AST 解析 代码美化工具本身不复杂主干是 Node.js 写的命令行脚本围绕读二进制 → 切文件 → 解析 → 美化 → 落盘这条流水线展开。真正干活的是几个解析类依赖JS 侧需要一个能产出 AST 的解析器用来精确定位define调用和setCssToHead的边界样式侧需要 CSS 解析与美化能力把压缩成一行的规则重新缩进输出阶段还要处理编码避免中文注释或字符串变成乱码。这套选型的好处是无状态、可脚本化——你可以把它塞进 CI也可以在自己写的 Node 脚本里直接 require 它的模块做批处理。代价是对 Node 版本有要求老版本跑不起来npm install阶段还容易因为原生依赖或镜像源问题卡住这一点在第 5 章会细说。需要提前明确能力边界避免预期错位。这个工具能做的是结构还原 可读性还原目录树、文件边界、配置项、样式规则、模板标签结构基本都能回来。它做不到的是语义还原被打包器改名的局部变量、被删掉的注释、编译期条件分支、被内联的常量都不会自己长回来。至于云函数、后端接口的真实逻辑压根不在包里别指望从这里挖出来。注意只对你自己开发、或已获得授权的包做还原。拿别人的线上包拆代码再复用属于侵权跟工具本身没关系是使用者的问题。2.3 和在线反编译付费工具的差别市面上有网页版的反编译服务上传即出结果。方便但你的包也就上传到了别人的服务器上商业项目基本不能这么干。wxappUnpacker 是本地跑的包不出机器这一点在正经项目里比什么功能都重要。另一类差别在可控性。图形化工具给你一个结果错了你不知道错在哪命令行工具每一步都有中间产物——解包目录、切分后的单文件、美化前的原始片段。我排查问题时习惯保留中间产物先看app-config.json有没有正常还原再看app-service.js的define数量对不对最后才看单个文件的美化效果。分层验证比对着一个报错猜要快得多。3. 目录结构与运行从 clone 到产出可读工程3.1 仓库里每个文件负责什么把仓库拉下来第一眼看到的就是一堆wu开头的脚本。别被名字唬住按职责分就四组文件职责什么时候会用到wuWxapkg.js主入口解析包结构、分发文件你唯一需要直接执行的脚本wuJs.js逻辑层还原切define、美化 JS页面 JS 还原不全时看它wuWxml.js视图层还原翻译$gwx调用模板结构错乱时看它wuWxss.js样式还原拆样式数组、处理 rpx样式丢失或数值异常时看它wuConfig.js配置还原重建app.json页面路由缺失时看它wuLib.js公共工具读写文件、编码、美化封装想二次开发时先读它这个划分方式很值得学主入口只做编排具体还原逻辑各自独立公共能力抽到wuLib。你想加一种新的产物处理只要在入口处挂一个分发分支不用动已有代码。二次开发基本都从这个结构下手。3.2 装依赖并跑通第一条命令准备工作就两步Node 环境和依赖。Node 建议用当前 LTS太老的版本对 ES 语法支持不够解析阶段会直接抛错。# 进入仓库目录先确认 Node 版本建议 16 及以上 node -v # 安装解析与美化相关依赖 npm install # 解主包第一个参数是 wxapkg 路径产物默认落在同级目录 node wuWxapkg.js ../pkg/__APP__.wxapkg三条命令里第三条才是关键。参数含义wuWxapkg.js是入口脚本后面跟的是待解包的 wxapkg 绝对或相对路径。产物默认输出到包文件同级的同名目录里不用额外指定。如果执行完没有任何报错、也没看到输出目录八成是路径写错了或者文件本身是空壳。跑完先别急着看代码先确认三件事输出目录里有没有app.jsonpages/下每个页面是不是js/json/wxml/wxss四件套齐全app-service.js对应的原始文件是不是被拆成了多个。这三项都对说明主干链路通了剩下的都是细节问题。3.3 分包与常用参数分包是新手最容易翻车的地方。主包解出来只有首屏几个页面业务页面全在分包里很多人到这一步就以为工具只还原了一部分。正确做法是先把分包目录指出来再逐个解# -s 指定分包所在目录让工具能把分包路径映射回主包结构 node wuWxapkg.js -s../pkg ../pkg/__APP__.wxapkg # 分包逐个体解产物会按 subpackages 的路径归位 node wuWxapkg.js ../pkg/subpackages__shop.wxapkg-s的作用是告诉工具分包文件在哪个目录下避免路径解析时找不到对应关系。各版本参数名可能略有出入跑之前先执行一次node wuWxapkg.js -h看帮助输出比照着手册猜靠谱得多。另外常见的还有只解包不还原这类开关用途是先把原始文件切出来、再手动控制还原步骤——排查问题阶段我通常会用这个模式保留最原始的中间态。解完之后建议自己列一遍产物清单确认分包是否真的归位。判断方法很直接分包页面的.js文件应该出现在subpackages/包名/pages/...下而不是散落在根目录。3.4 产物怎么读才不迷路还原出来的工程不是开箱即用的可编译项目它更像是骨架 血肉缺的是构建配置和依赖声明。读的顺序建议反过来先看app.json的pages数组这是整个工程的地图再按地图逐个页面看四件套最后才去翻公共组件和工具函数。看 JS 时优先找两类文件app.js里的全局逻辑和utils/下的工具函数。这两处往往是理解业务的关键。看 WXML 时注意事件绑定的函数名它和 JS 里的方法名一一对应是打通视图与逻辑的最短路径。看 WXSS 时别纠结具体数值先看选择器结构能还原出布局骨架就够了。4. 核心模块拆解一个包从二进制到工程目录4.1 第一步读懂 wxapkg 的文件头与索引表包格式本身不复杂头部固定字段 一张文件索引表 连续的数据区。自己写十几行代码就能验证包是不是完整这在排查解包为空时特别有用。const fs require(fs); // 读取 wxapkg 头部确认包结构完整 function readHeader(filePath) { const buf fs.readFileSync(filePath); // 首字节必须是 0xBE不是就说明文件被截断或根本不是 wxapkg if (buf[0] ! 0xbe) throw new Error(不是有效的 wxapkg 文件); const info1 buf.readUInt32BE(1); // 版本/保留字段通常为 0 const indexLen buf.readUInt32BE(5); // 索引区字节数 const bodyLen buf.readUInt32BE(9); // 数据区字节数 const tailMark buf[13]; // 索引区前的结束标记应为 0xED const fileCount buf.readUInt32BE(14); // 包内文件数量 return { info1, indexLen, bodyLen, tailMark, fileCount, total: buf.length }; } console.log(readHeader(process.argv[2]));这段代码的价值不在能跑在能证伪。fileCount是最直观的指标主包一般几十到几百如果打印出来是 0 或者一个离谱的大数说明读偏移错了或者文件损坏。total和indexLen bodyLen对不上说明包被截断——常见于传输中断或从缓存目录里抠出来的半截文件。参数说明readUInt32BE用大端读四字节偏移量 1/5/9/14 是格式规定的固定位置不能随意挪动。确认头部没问题再按索引表逐条读文件名、偏移和长度就能把每个文件原样切出来。这一步不涉及任何语义还原纯粹是切片出错概率极低——所以一旦切片阶段就失败问题一定在包本身不在还原逻辑。4.2 逻辑层还原把 define 拆成单文件app-service.js的形态高度规律顶层是一串define(路径, function(require, module, exports, ...){ ... })。要还原成单文件核心是准确定位每个调用的起止位置然后原样截取函数体。const esprima require(esprima); const fs require(fs); const path require(path); // 从合并后的 app-service.js 中切出每个模块 function splitModules(source, outDir) { // range: true 让每个节点带上 start/end 偏移便于原样截取 const ast esprima.parseScript(source, { range: true }); let count 0; for (const stmt of ast.body) { if (stmt.type ! ExpressionStatement) continue; const call stmt.expression; // 只处理 define(xxx, function(){...}) 这种形态 if (call.type ! CallExpression) continue; if (call.callee.name ! define) continue; const [nameNode, fnNode] call.arguments; if (!nameNode || nameNode.type ! Literal) continue; if (!fnNode || fnNode.type ! FunctionExpression) continue; const modulePath nameNode.value; // 如 pages/index/index.js const [start, end] fnNode.body.range; // 函数体的花括号内部 const code source.slice(start 1, end - 1); // 去掉首尾花括号 const target path.join(outDir, modulePath); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, code, utf8); count; } return count; }逻辑说明先整文件解析成 AST再遍历顶层语句只认define调用拿到第一个参数的字符串作为输出路径第二个参数的函数体范围作为内容区间。range是关键参数没有它就只能靠正则猜边界遇到函数体里嵌套字符串或模板字面量必然切错。mkdirSync带recursive: true是为了自动补出多级目录否则pages/index/这种路径会因为没有父目录而写失败。这段代码只做切分不做美化。切出来的文件往往一行几千字符可读性差后续要接一层美化。我一般把切分和美化分成两个阶段因为切分结果要留作对比基准——美化会改变缩进和换行出问题时不好定位是切错了还是美化错了。还有一个细节部分包的模块路径不在第一个参数里而是通过变量拼接或者干脆省略。遇到这种情况define的数量和实际文件数会对不上需要退回去看app-config.json里的页面列表用页面路径反推缺失模块。4.3 视图层还原把 $gwx 的调用序列翻译回标签WXML 没有原始文本可截取它已经被编译进$gwx函数体变成一串以_n节点、_v文本、_o属性对象为前缀的调用。还原的本质是把这串调用按执行顺序翻译回标签树。// 示意片段真实实现见仓库 wuWxml.js这里只展示翻译思路 const NODE_CALL /_n\(\s*[]([\w-])[]/g; // 节点名如 view、text const TEXT_CALL /_v\(\s*([^)]*)\)/g; // 文本节点内容 function translate(expr) { const tags []; let match; // 按出现顺序扫描遇到 _n 就压入一个标签遇到 _v 补一个文本节点 while ((match NODE_CALL.exec(expr)) ! null) { tags.push({ type: element, tag: match[1], children: [] }); } return tags; }这段是思路示意不能直接拿去跑真实包——真实调用里有嵌套、有条件分支、有循环指令wx:for会被编译成函数调用而非属性还有动态属性拼接。真正可靠的还原方式是按 AST 结构递归下降跟着函数调用层级还原父子关系而不是靠正则平铺扫描。我给这段代码的目的是让你看懂流程先拿节点名 → 再定层级 → 最后补属性与文本。排查视图问题的经验是如果还原出来的 WXML 里标签数量明显少于页面实际元素多半是分包或子模板import/include没处理如果标签对但层级全乱那是递归下降时没跟着函数调用栈走。这两种现象要区分开处理方式完全不同。4.4 样式与配置两个容易被低估的模块WXSS 在包里通常以setCssToHead([...])的形式出现参数是一个嵌套数组字符串片段和数字指令混在一起。还原时要把数组里的字符串按顺序拼起来再交给 CSS 美化器重新缩进。这里有个坑数组里的数字片段不是噪声它可能是 rpx 换算的标记或者选择器作用域指令直接丢弃会导致部分样式规则失效或者尺寸翻倍。配置模块看起来最简单把app-config.json转成app.json就行。实际处理的细节不少字段名要映射打包后的键名和开发者写的不完全一致、pages数组顺序要保持、subpackages要按分包路径重写、window和tabBar这类嵌套对象要递归展开。这份文件还原不准整个工程的目录结构就是错的后续所有页面还原都白做。所以我一般把配置还原放在最前面验证它相当于整个还原流水线的地基。5. 避坑与排查解包失败时按这五步定位5.1 头部校验失败或者解出来是空目录现象执行命令后直接抛错提示不是有效包或者不报错但输出目录里空空如也。原因多数是包被截断传输中断、从缓存里只拷了一半或者拿到的根本不是 wxapkg而是某些平台导出的中间文件。还有一种情况是包本身做了额外封装头部标记被改过。解决先用 4.1 的头部读取脚本验一遍fileCount和total确认total与indexLen bodyLen大致吻合。数字对不上就重新拿一份包别在还原逻辑上浪费时间。如果头部正常但输出为空检查命令里的路径是不是指向了分包而参数没加-s。5.2 页面存在但代码是空的分包没归位现象app.json里列了十几个页面实际只有三四个有内容其余目录只有空文件。原因分包代码在独立的 wxapkg 里只解主包当然拿不到。或者解了分包但没指定-s目录产物散落在根目录。解决先看主包解出来的app.json找到subpackages字段按里面的root路径定位分包包名逐个解。解之前清掉上一次的产物目录避免新旧文件混在一起看不出问题。5.3 还原出的 JS 语法报错编辑器一片红现象文件能打开但满屏红色波浪线import语句缺失变量未定义。原因小程序模块系统用的是自己的require和define不是标准 ES Module。还原出来的文件保留了原来的require(../../utils/api.js)写法编辑器按 Node 或 ESM 规则解析自然报错。另外美化阶段如果对某些压缩语法处理不当也可能破坏语法结构。解决把编辑器语言服务调成不校验或者给目录加个忽略配置别跟红波浪线较劲。真正要看的是语法结构是否完整——能不能看出函数调用链、条件分支是否闭合。如果出现明显的括号不匹配那是美化阶段出的问题把美化关掉重新切一次用原始片段比对。5.4 样式全丢或者尺寸明显翻倍现象WXSS 还原出来只有零星几条规则或者页面元素尺寸比线上大了一倍。原因样式被拆成数组片段拼接时漏掉了部分片段或者 rpx 相关的数值指令被当成无用数据丢弃了。解决回到app-wxss.js原文搜setCssToHead看参数数组的完整长度再对比还原后的规则数量。数量差得多说明拼接逻辑漏了分支。尺寸翻倍基本可以确定是 rpx 处理没做检查wuWxss.js里对数值片段的处理分支。5.5 npm install 卡住或原生依赖编译失败现象装依赖阶段长时间无响应或者报node-gyp相关错误。原因部分解析或压缩类依赖带原生模块需要本地编译环境网络不稳也会导致下载卡死。解决先换 Node LTS 版本重试很多时候是版本太新导致预编译包不匹配。仍失败就检查本机有没有编译工具链。实在装不上可以先只跑切片阶段不依赖重型解析库把原始文件切出来手动分析别让环境问题挡住主线。提示排查顺序永远是包本身 → 切片 → 逻辑层 → 视图层 → 样式从底往上查。反过来查你会在样式细节里泡两小时最后发现是包没解全。6. 二次开发把它接进自己的分析流水线单次手动解包只适合临时看一个项目真正有价值的是把它变成可重复的流程。我一般做三件事。第一件是批量化。写个小脚本扫目录下所有 wxapkg按包名建输出目录逐个调用入口脚本日志落到文件里。这样一次处理几十个版本的包出问题能直接翻日志定位是哪个包、哪一步失败。第二件是加指纹比对。同一项目不同版本的包解完之后先算文件级哈希再对比差异能快速看出这次改动集中在哪些页面。相比人肉 diff 两个几千行的合并文件这一步省下的时间非常可观。import hashlib, os, json def snapshot(root): 对还原产物做一次文件指纹快照用于版本间对比 result {} for dirpath, _, filenames in os.walk(root): for name in filenames: full os.path.join(dirpath, name) rel os.path.relpath(full, root) # 相对路径作为 key with open(full, rb) as f: result[rel] hashlib.md5(f.read()).hexdigest() return result old snapshot(./out/v1) new snapshot(./out/v2) changed [k for k in new if k in old and old[k] ! new[k]] added [k for k in new if k not in old] print(json.dumps({changed: changed, added: added}, ensure_asciiFalse, indent2))这段代码不复杂但实用。snapshot返回相对路径 → 内容哈希的映射两个版本一比就能拿到变更文件清单。rel用相对路径而不是绝对路径是为了跨机器、跨目录也能对比。实际用的时候我会把结果按目录聚合先看pages/下哪几个页面动了再去细读具体文件比无差别全量 diff 高效得多。第三件是给还原链路加断言。每解一个包自动检查几个硬指标app.json里页面数和解出来的页面目录数是否一致、app-service.js的define数量是否等于切出的 JS 文件数、WXSS 规则条数是否大于零。任一不满足就打印警告并保留中间产物。这套断言救过我很多次——它不会告诉你哪里错了但会明确告诉你这次结果不可信避免拿着残缺工程去分析业务逻辑。改造时有两点要注意。一是别动切片层那部分逻辑最稳改了只会引入新问题所有增强都放在切片之后。二是保留原始中间产物美化、转换这些都是可回溯的加工步骤原始片段一旦被覆盖出问题就只能重新解包。至于验证方法我的习惯是拿一个自己写的、结构已知的包当回归用例每次改完跑一遍对比页面数、文件数、关键页面结构三项对得上才算改对了。从那以后我每次解完包都不会直接打开代码而是先跑一遍目录比对和断言确认产物可信再动手读。这个习惯让我少走了很多弯路。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?