在上一篇文章中,我们详细介绍了如何获取某一个小程序的.wxapkg包,以及分析了.wxapkg包的结构,最后通过脚本解压获取包中的文件:小程序“编译”后的代码文件和资源文件,但是由于这些文件大部分被混淆了,可读性很差,所以本文将进一步分析,尽可能地把.wxapkg包的内容还原为“编译”前的内容。

注:本文包含一部分源码分析,由于手机屏幕较小,阅读体验可能不佳,建议在电脑上浏览。

特别感谢:下文使用的还原工具来自于 GitHub 上的开源项目wxappUnpacker,在此特别感谢原作者的无私贡献。

概览

我们知道,前端 Web 网页编程采用的是HTML + CSS + JS这样的组合,其中HTML是用来描页面的结构,CSS用来描述页面的样子,JS通常用来处理页面逻辑和用户的交互。类似地,在小程序中也有同样的角色,一个小程序工程主要包括如下几类文件:

例如知识小集的小程序源码工程结构如下:

然而,根据上一篇文章介绍,对知识小集 小程序的.wxapkg解包后得到如下文件:

主要包括app-config.json,app-service.js,page-frame.html,*.html,资源文件等,但这些文件已经被“编译混淆”并重新整合压缩反编译小程序,微信开发者工具并不能识别它们,我们无法直接对它们进行调试/编译运行。

所以,我们先尝试分析一下从.wxapkg提取出来的各个文件内容的结构及其用途,然后介绍如何用脚本工具把它们一键还原为“编译”前的源码,并在微信开发者工具中跑起来。

文件分析

本节主要以知识小集 小程序的.wxapkg解包后的源码文件为例,进行分析。

你也可以跳过本节的分析,直接看下一节介绍用脚本“反编译”还原源码。

app-config.json

小程序工程主要包括工具配置project.config.json,全局配置app.json以及页面配置page.json三类 JSON 配置文件。其中:

project.config.json主要用于对开发者工具进行个性化配置以及包括小程序项目工程的一些基础配置,所以它不会被“编译”到.wxapkg包中;

app.json是对当前小程序的全局配置,包括了小程序的所有页面路径、界面表现、网络超时时间、底部 tab 等;

page.json用于对每一个页面的窗口表现进行配置,页面中配置项会覆盖app.json的window中相同的配置项。

因此“编译”后的文件app-config.json其实就是app.json和各个页面的配置文件的汇总,它的内容大致如下:

{
 "page": { // 各页面配置
   "pages/index/index.html": { // 某一页面地址
     "window": { // 某一页面具体配置
       "navigationBarTitleText": "知识小集",
       "enablePullDownRefresh": true
     }
   },
   // 此处省略...
 },
 "entryPagePath": "pages/index/index.html", // 小程序入口地址
 "pages": ["pages/index/index", "pages/detail/detail", "pages/search/search"], // 页面列表
 "global": { // 全局页面配置
   "window": {
     "navigationBarTextStyle": "black",
     "navigationBarTitleText": "知识小集",
     "navigationBarBackgroundColor": "#F8F8F8",
     "backgroundColor": "#F8F8F8"
   }
 }
}

通过与原工程app.json和各页面配置page.json内容的对比,我们可以得出app-config.json汇总文件的简单整合规律,很容易把它拆分成“编译”前对应的各json文件。

app-service.js

在小程序项目中JS文件负责交互逻辑,主要包括app.js,每个页面的page.js,开发者自定义的JS文件和引入的第三方JS文件,在“编译”后所有这些JS文件都会被汇总到app-service.js文件中,它的结构如下:

// 一些全局变量的声明
var __wxAppData = {};
var __wxRoute;
var __wxRouteBegin;
var __wxAppCode__ = {};
var global = {};
var __wxAppCurrentFile__;
var Component = Component || function(){};
var definePlugin = definePlugin || function(){};
var requirePlugin = requirePlugin || function(){};
var Behavior = Behavior || function(){};

// 小程序编译基础库版本
/*v0.6vv_20180125_fbi*/
global.__wcc_version__='v0.6vv_20180125_fbi';
global.__wcc_version_info__={"customComponents":true,"fixZeroRpx":true,"propValueDeepCopy":false};

// 工程中第三方或者自定义的一些 JS 源码
define("utils/util.js", function(require, module, exports, window,document,frames,self,location,navigator,localStorage,history,Caches,screen,alert,confirm,prompt,XMLHttpRequest,WebSocket,Reporter,webkit,WeixinJSCore) {
 "use strict";
 // ... 具体源码内容
});

// ...

// app.js 源码定义
define("app.js", function(...) {
 "use strict";
 // ... app.js 源码内容
});
require("app.js");

// 每个页面对应的 JS 源码定义
__wxRoute = 'pages/index/index'; // 页面路由地址
__wxRouteBegin = true;
define("pages/index/index.js", function(...){
 "use strict";
 // ... page.js 源码内容
});
require("pages/index/index.js");

在这个文件中,原有小程序工程中的每个JS文件都被define方法定义声明,定义中包含JS文件的路径和内容,如下:

define("path/to/xxx.js", function(...){
 "use strict";
 // ... xxx.js 源码内容
});

因此,我们同样很容易提取这些JS文件源码,并恢复至相应的路径位置中。当然,这些JS文件中的内容经过混淆压缩,我们可以使用UglifyJS这样的工具进行美化,但仍很难还原一些原始变量名,不过基本不影响正常阅读和使用。

page-frame.html

在小程序中使用WXML文件描述页面的结构,WXSS文件描述页面的样式。工程中有一个app.wxss文件用于定义一些全局的样式,会自动被import到各个页面中;另外每个页面也都分别包含page.wxml和page.wxss用于描述其页面的结构和样式;同时,我们也会自定义一些公共的xxxCommon.wxss样式文件和公共的xxxTemplate.wxml模板文件供一些页面复用,一般在各自页面的page.wxss和page.wxml中去import。

当“编译”小程序后,所有的.wxml文件和app.wxss及公共xxxCommon.wxss样式文件的将被整合到page-frame.html文件中,而每个页面的page.wxss样式文件,将分别单独在各自的路径下生成一个page.html文件。

page-frame.html文件的内容结构如下:


<html lang="zh-CN">
 <head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
   <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">
   <link rel="icon" href="data:image/ico;base64,aWNv">
   <script>
     // 一些全局变量的声明
     var __pageFrameStartTime__ = Date.now();
     var __webviewId__;
     var __wxAppCode__ = {};
     var __WXML_GLOBAL__ = {
       entrys: {},
       defines: {},
       modules: {},
       ops: [],
       wxs_nf_init: undefined,
       total_ops: 0
     };
     // 小程序编译基础库版本
     /*v0.6vv_20180125_fbi*/
     window.__wcc_version__ = 'v0.6vv_20180125_fbi';
     window.__wcc_version_info__ = {
       "customComponents": true,
       "fixZeroRpx": true,
       "propValueDeepCopy": false
     };
     var $gwxc
     var $gaic = {}
     $gwx = function(path, global) {
       // $gwx 方法定义(最核心)
     }
     var BASE_DEVICE_WIDTH = 750;
     var isIOS = navigator.userAgent.match("iPhone");
     var deviceWidth = window.screen.width || 375;
     var deviceDPR = window.devicePixelRatio || 2;
     function checkDeviceWidth() {
       // checkDeviceWidth 方法定义
     }
     checkDeviceWidth()
     var eps = 1e-4;
     function transformRPX(number, newDeviceWidth) {
       // transformRPX 方法定义
     }
     var setCssToHead = function(file, _xcInvalid) {
       // setCssToHead 方法定义
     }
     setCssToHead([])(); // 先清空 Head 中的 CSS
     setCssToHead([...]); // 设置 app.wxss 的内容到 Head 中,其中 ... 为小程序工程中 app.wxss 的内容
     var __pageFrameEndTime__ = Date.now()
         
</script>
 </head>
 <body>
   <div></div>
 </body>
</html>

相比其他文件,page-frame.html比较复杂,微信把.wxml和部分.wxss直接“编译”并混淆成JS代码放入上述文件中,然后通过调用这些JS代码来构造Virtual-Dom,进而渲染页面。

其中最核心的是$gwx和setCssToHead这两个方法。

$gwx用于通过JS代码生成所有.wxml文件,其中每个.wxml文件的内容结构都在$gwx方法中被定义好并混淆了,我们只要传给它页面的.wxml路径参数,即可获取到每个.wxml的内容,再简单加工一下即可还原成“编译”前的内容。

在$gwx中有一个x数组用于存储当前小程序都有哪些.wxml文件,例如,知识小集 小程序的x值如下:

var x = ['./pages/detail/detail.wxml', '/towxml/entry.wxml', './pages/index/index.wxml', './pages/search/search.wxml', './towxml/entry.wxml', '/towxml/renderTemplate.wxml', './towxml/renderTemplate.wxml'];

此时我们可以在Chrome中打开page-frame.html文件,然后在Console中输入如下命令,即可得到index.wxml的内容(输出一个JS对象,通过遍历这个对象即可还原出.wxml的内容)

$gwx("./pages/index/index.wxml")

setCssToHead方法用于根据几段被拆分的样式字符串数组生成.wxss代码并设置到HTML的Head中,同时,它还将所有被import引用的.wxss文件(公共xxxCommon.wxss样式文件)所对应的样式数组内嵌在该方法中的_C变量中,并标记哪些文件引用了_C中数据。另外在page-freme.html文件的末尾,调用了该方法生成全局app.wxss的内容设置到Head中。

因此,我们可以在每个调用setCssToHead方法的地方提取相应.wxss的内容并还原。

对于page-freme.html文件中$gwx和setCssToHead这两个方法更详细的分析,可以参考这篇文章。

此外,checkDeviceWidth方法顾明思议,用于检测屏幕的宽度,其检测结果将用于transformRPX方法中将rpx单位转换为px像素。

rpx的全称是responsive pixel,它是小程序自己定义的一个尺寸单位反编译小程序,可以根据当前设备屏幕宽度进行自适应。小程序中规定,所有的设备屏幕宽度都为750rpx,根据设备屏幕实际宽度的不同,1rpx所代表的实际像素值也不一样。

*.html

上面提到,每个页面的page.wxss样式文件,“编译”后将分别在各自的所在路径下生成一个page.html文件,每个page.html的结构如下:

<style></style>
<page></page>
<script>
 var __setCssStartTime__ = Date.now();
 setCssToHead([...])() // 设置 search.wxss 的内容
 var __setCssEndTime__ = Date.now();
 document.dispatchEvent(new CustomEvent("generateFuncReady", {
   detail: {
     generateFunc: $gwx('./pages/search/search.wxml')
   }
 }))
</script>

在该文件中通过调用setCssToHead方法将.wxss样式内容设置到Head中,所以同样地,我们可以根据setCssToHead的调用参数提取每个页面的page.wxss。

资源文件

小程序工程中的图片、音频等资源文件在“编译”后将直接被拷贝到.wxapkg包中,其原始的路径也保留不变,因此我们可以直接使用。

“反编译”

在上一节,我们完成了.wxapkg包几乎所有文件内容的简要分析。现在我们介绍一下如何通过node.js脚本帮我们还原出小程序的源码。

在这里需要再次感谢wxappUnpacker作者提供的还原工具,让我们可以“站在巨人的肩膀上”轻松地去完成“反编译”。它的使用如下:

同时,作者还提供了一键解包并还原的脚本,你只需要提供一个小程序的.wxapkg文件,然后执行如下命令:

node wuWxapkg.js [-d] <path/to/.wxapkg>

此脚本就会自动将.wxapkg文件解包,并将包中相关的已被“编译/混淆”的文件自动地恢复原状(包括目录结构)。

PS: 此工具依赖uglify-es,vm2,esprima,cssbeautify,css-tree等node.js包,所以你可能需要npm install xxx安装这些依赖包才能正确执行。

更详细的用法及相关问题请查阅该开源项目的 GitHub repo。

最后,我们在微信开发者工具中新建一个空小程序工程,并将上述还原后的相关目录文件导入工程,即可编译运行起来,如下图为知识小集 小程序的.wxapkg包还原后的代码工程:

以上,大功告成!

总结

本文详细分析了.wxapkg解包后的各文件结构,并介绍了如何通过脚本“一键还原”得到任意小程序的源码。

对于一些简单的,且使用微信官方介绍的原生开发方式开发的小程序,用上述工具基本可以直接还原得到可运行的源码,但是对于一些逻辑复杂,或者使用WePY、Vue等一些框架开发的小程序,还原后的源码可能会有一些小问题,需要我们人肉去分析解决。

后续

本文对小程序源码“编译”后的各文件内容结构及用途的分析相对比较零散,而且没有对各文件的依赖关系及加载逻辑进行研究,后续我们再写一些文章讲解微信客户端是如何解析加载小程序.wxapkg包并运行起来。

参考链接

限 时 特 惠: 本站每日持续更新海量各大内部创业教程,加站长微信免费获取积分,会员只需38元,全站资源免费下载 点击查看详情
站 长 微 信: thumbxmw