小程序原理解析(三)

1,132 阅读4分钟

编译原理

微信开发者工具和微信客户端都无法直接运行小程序的源码,因此我们需要对小程序的源码进行编译。代码编译过程包括本地预处理、本地编译和服务器编译。为了快速预览,微信开发者工具模拟器运行的代码只经过本地预处理、本地编译,没有服务器编译过程,而微信客户端运行的代码是额外经过服务器编译的。

微信官方提供了 wcc 和 wcsc 两个编译工具,wcc 编译器可以将 wxml 文件编译成 JS 文件,wcsc 编译器可以将 wxss 文件编译成 JS 文件。

编译 WXML

我们这里一步步去研究微信官方编译器,先研究看看 wcc 做了什么事情。

例如编译 wxml 为 JS:

index.wxml:

<view>
  <text class="window">{{ text }}</text>
</view>

借助 miniprogram-compiler 转化:

const fs = require("fs");
const miniprogramCompiler = require("miniprogram-compiler");

const path = require("path");
let compileResult = miniprogramCompiler.wxmlToJs(path.join(__dirname));
fs.writeFileSync("index.wxml.js", compileResult);

编译之后的代码为:

window.__wcc_version__ = 'v0.5vv_20181221_syb_scopedata';
window.__wcc_version_info__ = {
  customComponents: true,
  fixZeroRpx: true,
  propValueDeepCopy: false
};
var $gwxc;
var $gaic = {};
$gwx = function(path, global) {
  ...
}
return $gwx;

我们深入 miniprogramCompiler.wxmlToJs 源码最终会发现调用的是 wcc,这个正是微信开发工具下的编译工具。

通过上述编译生存的代码我们发现,调用 $gwx 函数会再生成一个有返回值的函数,于是我们执行如下代码:

$gwx("index.wxml")();

得出如下内容:

{
  "tag": "wx-page",
  "children": [
    {
      "tag": "wx-view",
      "attr": {},
      "children": [
        {
          "tag": "wx-text",
          "attr": { "class": "window" },
          "children": [""],
          "raw": {},
          "generics": {}
        }
      ],
      "raw": {},
      "generics": {}
    }
  ]
}

这是一个类似 Virtual DOM 的对象,交给了 WAWebivew 来渲染,标签名为 wx-view、wx-text。

上面的 wx-text 是没有绑定数据的,那么上面的 Virtual DOM 是怎么变成真实的 DOM 呢?gwx是一个闭包函数,gwx 是一个闭包函数,gwx 函数第一个参数是 path,页面 wxml 文件的路径;global 参数应该是顶层对象。

$gwx = function (path, global) {
  ...
  if (path && e_[path]) {
    window.__wxml_comp_version__ = 0.02;
    return function (env, dd, global) {
      $gwxc = 0;
      var root = { tag: "wx-page" };
      root.children = [];
      var main = e_[path].f;
      cs = [];
      if (typeof global === "undefined") global = {};
      global.f = $gdc(f_[path], "", 1);
      if (
        typeof window.__webview_engine_version__ != "undefined" &&
        window.__webview_engine_version__ + 1e-6 >= 0.02 + 1e-6 &&
        window.__mergeData__
      ) {
        // 合并 Data 数据
        env = window.__mergeData__(env, dd);
      }
      try {
        main(env, {}, root, global);
        _tsd(root);
        if (
          typeof window.__webview_engine_version__ == "undefined" ||
          window.__webview_engine_version__ + 1e-6 < 0.01 + 1e-6
        ) {
          return _ev(root);
        }
      } catch (err) {
        console.log(cs, env);
        console.log(err);
        throw err;
      }
      return root;
    }
  }
}

window.__webview_engine_version__ 大于等于 0.02 版本的使用 window.__mergeData__ 进行数据 merge,这里可以推测 dd 参数是新数据,env 是当前数据。这里的 window.__mergeData__ 是在 WAWebview 中定义的:

var E = window.__virtualDOM__;
...
window.__mergeData__ = E.getMergeDataFunc();

这里的 window.__virtualDOM__ 是基础库中 Virtual DOM 的实现。

如果我们直接执行下面的代码:

// $gwx 是 WXML 编译后得到的函数,根据页面路径获取页面结构生成函数
const generateFunction = $gwx("pages/index/index.wxml");
const virtualTree = generateFunction({
  text: 'Hello World'
});
console.log(virtualTree);

可以得到如下的 Virtual DOM 结构:

{
  "tag": "wx-page",
  "children": [
    {
      "tag": "wx-view",
      "attr": {},
      "children": [{
        "tag": "wx-text",
        "attr": {
          "class": "name"
        },
        "children": ["Hello World"],
        "raw": {},
        "generics": {}
      }],
      "raw": {},
      "generics": {}
    }
  ]
}

setData 的机制如下:

通信原理

小程序逻辑层和渲染层的通信会由 Native (微信客户端)做中转,逻辑层发送网络请求也经由 Native 转发。 视图层组件:

内置组件中有部分组件是利用到客户端原生提供的能力,既然需要客户端原生提供的能力,那就会涉及到视图层与客户端的交互通信。这层通信机制在 iOS 和安卓系统的实现方式并不一样,iOS 是利用了 WKWebView 的提供 messageHandlers 特性,而在安卓则是往 WebView 的 window 对象注入一个原生方法,最终会封装成 WeiXinJSBridge 这样一个兼容层,主要提供了调用(invoke)和监听(on)这两种方法。

我们知道微信小程序逻辑层没有浏览器的 DOM/BOM,视图层的更新借助于 Virtual DOM。用 JS 对象模拟 DOM 树 -> 比较两棵虚拟 DOM 树的差异 -> 把差异应用到真正的 DOM 树上,状态更新的时候,通过对比前后 JS 对象变化,进而改变视图层的 Dom 树。实际上,在视图层与客户端的交互通信中,开发者只是间接调用的,真正调用是在组件的内部实现中。开发者插入一个原生组件,一般而言,组件运行的时候被插入到 DOM 树中,会调用客户端接口,通知客户端在哪个位置渲染一块原生界面。在后续开发者更新组件属性时,同样地,也会调用客户端提供的更新接口来更新原生界面的某些部分。

逻辑层接口:

逻辑层与客户端原生通信机制与渲染层类似,不同在于,iOS 平台可以往 JavaScripCore 框架注入一个全局的原生方法,而安卓方面则是跟渲染层一致的。

同样地,开发者也是间接地调用到与客户端原生通信的底层接口。一般我们会对逻辑层接口做层封装后才暴露给开发者,封装的细节可能是统一入参、做些参数校验、兼容各平台或版本问题等等。

启动流程

通过分析 WAPageFrame.html 文件,我们可以得到小程序的启动执行流程大致如下:

第一步:初始化全局变量

var __wxRoute,
  __wxRouteBegin,
  __wxAppCurrentFile__,
  __wxAppData = {},
  __wxAppCode__ = {},
  __vd_version_info__ = {},
  Component = function() {},
  Behavior = function() {},
  definePlugin = function() {},
  requirePlugin = function() {};
global = {};
var $gwx,
  __workerVendorCode__ = {},
  __workersCode__ = {},
  __WeixinWorker = (WeixinWorker = {});

var __wxConfig = {
  // ...
};

var __devtoolsConfig = {
  // ...
};
  • __wxRoute: 用于指向当前正在加载的页面路径
  • __wxRouteBegin: 用于标志 Page 的正确注册
  • __wxAppCurrentFile__: 用于指向当前正在加载的 JS 文件
  • __wxAppData: 小程序每个页面的 data 域对象,如下:
  • __wxAppCode__: 在开发者工具中分为两类值,json 类型和 wxml 类型。以 .json 结尾的,其 key 值为开发者代码中对应的 json 文件的内容,.wxml 结尾的,其 key 值为通过调用 $gwx('./pages/index/index.wxml') 将得到一个可执行函数,通过调用这个函数可得到一个标识节点关系的 JSON 树。
  • Component: 自定义组件构造器
  • Behavior: 自定义组件 behavior 构造器
  • definePlugin: 自定义插件的构造器
  • global: 全局对象
  • __WeixinWorker: 多线程构造器
  • __wxConfig: 对象是根据全局配置和页面配置生成的配置对象,如下:
{
  "pages": ["pages/index/index", "pages/logs/logs"],
  "resizable": false,
  "debug": false,
  "widgets": [],
  "customClose": false,
  "workers": "",
  "navigateToMiniProgramAppIdList": [],
  "cloud": false,
  "global": {
    "window": {
      "backgroundTextStyle": "light",
      "navigationBarBackgroundColor": "#fff",
      "navigationBarTitleText": "WeChat",
      "navigationBarTextStyle": "black"
    }
  },
  "page": {
    "pages/index/index.html": { "window": { "usingComponents": {} } },
    "pages/logs/logs.html": {
      "window": {
        "navigationBarTitleText": "查看启动日志",
        "usingComponents": {}
      }
    }
  },
  "networkTimeout": {
    "request": 60000,
    "uploadFile": 60000,
    "connectSocket": 60000,
    "downloadFile": 60000
  },
  "ext": {},
  "extAppid": "",
  "mainPlugins": {},
  "__warning__": "",
  "entryPagePath": "pages/index/index.html",
  "tabBar": { "list": [] },
  "appType": 0,
  "urlCheck": true,
  "wxAppInfo": {
    "maxRequestConcurrent": 10,
    "maxUploadConcurrent": 10,
    "maxDownloadConcurrent": 10,
    "maxWorkerConcurrent": 1
  },
  "accountInfo": {
    "appId": "",
    "nickname": "赞同技术",
    "icon": ""
  },
  "platform": "devtools",
  "appLaunchInfo": { "scene": 1001, "path": "pages/index/index", "query": {} },
  "env": {
    "USER_DATA_PATH": "http://usr"
  },
  "envVersion": "develop"
}

第二步:加载框架(WAService.js) 我们调用的 API 等核心主要是 __waServiceInit__ 部分,主要结构如下:

__waServiceInit__ = function() {
  var bta = function(n) {
      // ...
    }({
      // ...
    }),
    cta = function(n) {
    }([
      // ...
    ]).default,
    dta = cta.wx;

  "undefined" != typeof __exportGlobal__ && (__exportGlobal__.wx = dta);

  var eta = function(n) {
      // ...
    }([
      // ...
    }]),
    gta = function(n) {
      // ...
    }([
      // ...
    ]),
    hta = function(n) {
    }([function(e, t, n) {}]);

  hta.Page, hta.Component, hta.Behavior, hta.App, hta.getApp, hta.getCurrentPages;
  "undefined" != typeof __exportGlobal__ && (
    __exportGlobal__.Page = hta.Page,
    __exportGlobal__.Component = hta.Component,
    __exportGlobal__.Behavior = hta.Behavior,
    __exportGlobal__.__webview_engine_version__ = .02,
    __exportGlobal__.App = hta.App,
    __exportGlobal__.getApp = hta.getApp,
    __exportGlobal__.getCurrentPages = hta.getCurrentPages,
    __exportGlobal__.__pageComponent = null);

  var qta = function(n) {
      // ...
    }([
      // ...
    ]),
    qta.definePlugin, qta.requirePlugin;

  // 定义 define require 方法
  !function(e) {
  }();

  var tta = require;
  if (__exportGlobal__.definePlugin = qta.definePlugin,
    __exportGlobal__.requirePlugin = qta.requirePlugin,
    "function" == typeof __passWAServiceGlobal__) {
    var uta = {};
    uta.__appServiceEngine__ = hta,
    uta.__appServiceSDK__ = cta,
    uta.__virtualDOM__ = gta,
    uta.__subContextEngine__ = __subContextEngine__,
    uta.Reporter = Reporter,
    uta.exparser = eta,
    uta.WeixinJSBridge = WeixinJSBridge,
    uta.Protect = Protect,
    __passWAServiceGlobal__(uta)
	}
};

__waServiceInit__ 中定义了框架核心对象,如:__appServiceEngine____virtualDOM__exparser。其中__appServiceEngine__ 提供了框架最基本的对外接口,如 AppPageComponentBehaviorgetAppgetCurrentPages 等方法;exparser 提供了框架底层的能力,如实例化组件,数据变化监听,View 层与逻辑层的交互等;__virtualDOM__ 则起着连接 __appServiceEngine__ 和 exparser 的作用,如对开发者传入 Page 方法的对象进行格式化再传入 exparser 的对应方法处理。

第三步:业务代码的加载

在小程序中,开发者的 JavaScript 代码会被打包为 AMD 规范的 JS 模块:

define("pages/index/index.js", function(
  require,
  module,
  exports,
  window,
  document,
  frames,
  self,
  location,
  navigator,
  localStorage,
  history,
  Caches,
  screen,
  alert,
  confirm,
  prompt,
  fetch,
  XMLHttpRequest,
  WebSocket,
  webkit,
  WeixinJSCore,
  Reporter,
  print,
  URL,
  DOMParser,
  upload,
  preview,
  build,
  showDecryptedInfo,
  cleanAppCache,
  syncMessage,
  checkProxy,
  showSystemInfo,
  openVendor,
  openToolsLog,
  showRequestInfo,
  help,
  showDebugInfoTable,
  closeDebug,
  showDebugInfo,
  __global,
  loadBabelMod,
  WeixinJSBridge
) {
  "use strict";

  // index.js 代码

});

AMD 规范接口,通过 define 定义一个模块,使用 require 来应用一个模块。上面说了 WAService 里定义了两个方法:require 和 define 用来定义和使用业务代码。首先 define 限制了模块可使用的其他模块,如 windowdocument;其次 require 在使用模块时只会传入 require 和 module,也就是说参数中的其他模块在定义的模块中都是 undefined,这也是不能在小程序中获取一些浏览器环境对象的原因。

在小程序中,JavaScript 代码的加载方式和在浏览器中也有些不同,其加载顺序是首先加载项目中其他 js 文件(非注册程序和注册页面的 js 文件),其次是注册程序的 app.js,然后是自定义组件 js 文件,最后才是注册页面的 js 代码。而且小程序对于在 app.js 以及注册页面的 js 代码都会加载完成后立即使用 require 方法执行模块中的程序。其他的代码则需要在程序中使用 require 方法才会被执行。

第四步:加载 app.js 与注册程序

在 app.js 加载完成后,小程序会使用 require('app.js') 注册程序,即对 App 方法进行调用。App 方法是对 __appServiceEngine__.App 方法的引用。

// 注册 app.js,初始化 App
require("app.js");

var decodePathName = decodeURI("pages/index/index");
__wxAppCode__[decodePathName + ".json"] = { usingComponents: {} };
__wxAppCode__[decodePathName + ".wxml"] = $gwx("./" + decodePathName + ".wxml");

var decodePathName = decodeURI("pages/logs/logs");
__wxAppCode__[decodePathName + ".json"] = {
  navigationBarTitleText: "查看启动日志",
  usingComponents: {}
};
__wxAppCode__[decodePathName + ".wxml"] = $gwx("./" + decodePathName + ".wxml");

var decodePathName = decodeURI("pages/index/index");
__wxRoute = decodePathName;
__wxRouteBegin = true;
__wxAppCurrentFile__ = decodePathName + ".js";
require(decodePathName + ".js");

var decodePathName = decodeURI("pages/logs/logs");
__wxRoute = decodePathName;
__wxRouteBegin = true;
__wxAppCurrentFile__ = decodePathName + ".js";
require(decodePathName + ".js");

下图是框架对于 App 方法调用时的处理流程:

App() 函数用来注册一个小程序,接收一个 object 对象参数,其指定小程序的生命周期函数等,getApp() 函数可以用来获取到小程序实例。App() 和 getApp() 的函数声明如下:

declare const App: App.AppConstructor;
declare const getApp: App.GetApp;

完整的参考:lib.wx.app.d.ts

微信小程序 App 和 getApp 的源码混淆了,内部实现不方便我们去追踪,这里我们先不深究,后面参考 wepet、hera 等框架我们再看一下微信小程序早期版本实现。 第五步:加载自定义组件代码以及注册自定义组件

自定义组件在 app.js 之后被加载,小程序会在这个过程中加载完所有的自定义组件,并且是加载完成后自动注册,只有注册完成后才会加载下一个自定义组件的代码。

下图是框架对于 Component 方法处理流程:

Component() 的函数声明如下:

declare function Component(options: BaseComponent): void;

完整的参考:lib.wx.component.d.ts

第六步:加载页面代码和注册页面

加载页面代码的处理流程和加载自定义组件一样,都是加载完成后先注册页面,然后才会加载下一个页面。

下图是注册一个页面时框架对于 Page 方法的处理流程:

Page() 和 getCurrentPages() 的函数声明如下:

declare const Page: Page.PageConstructor;
declare const getCurrentPages: Page.GetCurrentPages;

完整的参考:lib.wx.page.d.ts

第七步:等待页面 Ready 和 Page 实例化

严格来讲是等待浏览器 Ready,小程序虽然有部分原生的组件,不过本质上还是一个 web 程序。在小程序中切换页面或打开页面时会触发 onAppRoute 事件,小程序框架通过 wx.onAppRoute 注册页面切换的处理程序,在所有程序就绪后,以 entryPagePath 作为入口使用 appLaunch 的方式进入页面。

参考

从源码看微信小程序启动过程

浅析微信小程序 App() 和 Page() 函数的内部实现

微信小程序性能优化之一