微信小程序架构解析及第三方框架原理解析

前言

名词解释:

  • JSCore:
    • JavaScriptCoreWebKit 的内置 JavaScript 引擎
    • JavaScriptCore 是一个 C++实现的开源项目
    • JSCore 的组成部分:
      • Lexer 词法分析器,将脚本源码分解成一系列的 Token
      • Parser 语法分析器,处理 Token 并生成相应的语法树
      • LLInt 低级解释器,执行 Parser 生成的二进制代码
      • Baseline JIT 基线 JIT(``just in time 实施编译)
      • DFG 低延迟优化的 JIT
      • FTL 高通量优化的 JIT

打包后

Project
   ├── app-config.json                  // 小程序工程全部json配置信息
   ├── app-service.js                   // JS业务逻辑打包到此处
   ├── page-frame.html                  // 视图的模板文件
   └── pages														// 各个页面
         ├── index.html                 // index 页面
         └── logs.html                  // logs 页面

小程序架构

运行环境

不同端运行环境不同

运行环境 逻辑层 渲染层
Android V8 Chromium 定制内核
iOS JavaScriptCore WKWebView
小程序开发者工具 NWJS Chrome WebView

视图层(View)

渲染页面结构

所有的视图(wxmlwxss)都是单独的 Webview 来承载,称之为 AppView,下面逻辑层也是通过 webView 加载的,所以一个小程序打开至少就会有 2 个 webview 进程,所以因为每个视图都是一个独立的 webview ,考虑到性能消耗,小程序不允许打开超过 5 个层级的页面

逻辑层(App Service)

逻辑处理、数据请求、接口调用,运行在 JavaScriptCore 引擎

逻辑处理的 JS 代码全部加载到一个 Webview 里面,称之为 AppService

视图层逻辑层通过系统层JSBridage 进行通信,逻辑层把数据变化通知到视图层,触发视图层页面更新,视图层把触发的事件通知到逻辑层进行业务处理。

基础库

WAService.js

提供逻辑层基础的 API 能力,wx 对象下的 api

  1. Foundation: 基础模块
  2. WeixinJSBridge: 消息通信模块
  3. WeixinNativeBuffer: 原生 Buffer
  4. WeixinWorker: Worker 线程
  5. JSContext: JS Engine Context
  6. Protect: JS 保护的对象
  7. __subContextEngine__: 提供 App、Page、Component、Behavior、getApp、getCurrentPages 等方法

WAWebview.js

wx 对象下的 api,大部分都是处理 UI 显示相关的方法

提供视图层基础的API能力

  1. Foundation: 基础模块
  2. WeixinJSBridge: 消息通信模块
  3. exparser: 组件系统模块
  4. __virtualDOM__: Virtual DOM 模块
  5. __webViewSDK__: WebView SDK 模块
  6. Reporter: 日志上报模块(异常和性能统计数据)

在此基础上,AppView 有一个 html 模板文件,通过这个模板文件加载具体的页面,这个模板主要就一个方法,$gwx,主要是返回指定 pageVirtualDOM
在打包的时候,会事先把所有页面的 WXML 转换为 ViirtualDOM 放到模板文件里。

Foundation 模块

基础模块提供环境变量 env、发布订阅 EventEmitter、配置/基础库/通信桥 Ready 事件。
alt text

exparser

完整的实现小程序里的组件

exparser 会维护整个页面的节点树相关信息,包括节点的属性、事件绑定等,相当于一个简化版的 Shadow DOM 实现。
小程序中,所有节点树相关的操作都依赖于 Exparser,包括 WXML 到页面最终节点树的构建、createSelectorQuery 调用和自定义组件特性等。实例化组件,数据变化监听,View 层与逻辑层的交互等

Virtual DOM 模块

接口与 virtual-dom 类似,这里特别的地方在于它 diff 和生成的并不是原生 DOM,而是各种模拟了 DOM 接口的 wx-element 对象。

WeixinJSBridge 模块

WeixinJSBridge 提供了视图层 JS 与 Native、视图层与逻辑层之间消息通信的机制,提供了如下几个方法:

方法名 作用
invoke JS 调用 Native API
invokeCallbackHandler Native 传递 invoke 方法回调结果
on JS 监听 Native 消息
publish 视图层发布消息
subscribe 订阅逻辑层的消息
subscribeHandler 视图层和逻辑层消息订阅转发
setCustomPublishHandler 自定义消息转发

编译原理

为了快速预览,微信开发者工具模拟器运行的代码只经过本地预处理、本地编译,没有服务器编译过程,而微信客户端运行的代码是额外经过服务器编译的。
微信官方提供了 wccwcsc 两个编译工具,wcc 编译器可以将 wxml 文件编译成 JS 文件,wcsc 编译器可以将 wxss 文件编译成 JS 文件。

编译 WXML

看看 wcc 做了什么事情。

例子

<!-- index.wxml -->
<view>
  <text class="window">{{ text }}</text>
</view>

可以看miniprogram-compiler中如何生成$gwx

// 调用
$gwx("index.wxml")({
  // 数据,例如:
  text: "hello world",
});

调用$gwx生成类似 Virtual DOM 的对象

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

编译 wxss

WXSS同样会经过编译,最终的编译产物为wxss.js,不同于WXML通过script标签的形式插入到渲染层,wxss.js则是通过eval的方式注入到渲染层代码中。

通信原理

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

  • 视图层组件:
    • 内置组件中有部分组件是利用到客户端原生提供的能力,所以就会涉及到视图层与客户端的交互通信。
    • ios 和安卓的实现并不一样:
      • ios 利用 WKWebView 的提供 messageHandlers
      • 安卓则是往 WebViewwindow 对象注入一个原生方法,最终会封装成 WeiXinJSBridge 这样一个兼容层,主要提供了调用(invoke)和监听(on)这两种方法。
    • 拿到新数据之后生成虚拟 dom =>diff =>生成新 dom
    • 如果用到原生组件,会调用 Native 接口,告知 Native 在这渲染一个原生组件,后续更新也同样
  • 逻辑层接口:
    • 通信与视图层类似,只不过 iOS 平台可以往 JavaScripCore 框架注入一个全局的原生方法,而安卓方面则是跟渲染层一致的。
  • 组件触发事件(带上 webviewID),调用 WeixinJSBridge 的接口,publishnative,然后 native 再分发到 AppService 层指定 webviewIDPage 注册事件处理方法。

与浏览器 H5 的区别

  1. 线程隔离
    • 微信小程序:渲染层和逻辑层分离,运行在不同的线程中。
    • H5 网页:UI 线程和 JS 线程共享主线程,JS 线程可能会阻塞 UI 渲染。
  2. 数据通信
    • 微信小程序:通过框架提供的机制在渲染层和逻辑层之间传递数据。
    • H5 网页:直接操作 DOM 来更新 UI,JS 代码可以直接访问和修改 DOM。
  3. 运行环境
    • 微信小程序:运行在微信客户端的环境中,有一定的权限和限制。
    • H5 网页:运行在浏览器环境中,受制于浏览器的安全和权限机制。

第三方框架

参考:https://blog.csdn.net/Y0W1as5eg37urFdS/article/details/115222188

按语法分类

从框架的语法来说,可以分为下面两类:

  • Vue 语法
  • React 语法 / 类 React 语法

主流的跨端框架基本遵循 React、Vue 语法,这也比较好理解,可以复用现有的技术栈,降低学习成本。

按实现原理分类

从实现原理上,开源社区的跨端框架大致分为下面两类:

  • compile time 编译时
  • runtime 运行时

简单来说就是:

  • 编译时是将React/Vue写法通过babel转成AST,将AST转成小程序识别的代码
  • 运行时是通过适配层,真正将React/Vue逻辑放在内部执行

编译时有一些缺陷:总得来说就是无法 cover 住所有情况

  • 当 React/Vue 的版本更新时,需要去及时修改编译
  • 编译时毕竟是静态的无法覆盖掉一些动态复杂场景
  • 例如Taro 1/2用 穷举 的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大,完全就是堆人力去适配 jsx ,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法

运行时实现原理

Remax

通过 react-reconciler 实现,文档
通过自定义 HostConfig,将原本 ReactFiberConfigDOM.js操作BOM/DOM API,替换为自己的实现的API,例如

const MyRenderer = Reconciler(HostConfig);
MyRenderer.createContainer(document.getElementById("root"));

const HostConfig = {
  createContainer() {
    // ...
  },
  // React中mutation阶段HostFiber会执行
  commitUpdate: function (node, updatePayload, type, oldProps, newProps) {
    // 处理一下 props
    node.props = processProps(newProps, node, node.id);
    node.update(updatePayload);
  },
  // react在completeWork中,会调用createInstance,将虚拟dom转换为真实dom
  createInstance(type: string, newProps: any, container: Container) {
    const id = generate();
    // 预处理props, remax会对事件类型Props进行一些特殊处理
    const props = processProps(newProps, container, id);
    return new VNode({
      id,
      type,
      props,
      container,
    });
  },

  // 创建宿主组件文本节点实例
  createTextInstance(text: string, container: Container) {
    const id = generate();
    const node = new VNode({
      id,
      type: TYPE_TEXT,
      props: null,
      container,
    });
    node.text = text;
    return node;
  },
  // ...
};

VNodeRemax自己实现的对象

interface VNode {
  id: number;
  container: Container;
  children: VNode[];
  mounted: boolean;
  type: string | symbol;
  props?: any;
  parent: VNode | null;
  text?: string;
  appendChild(node: VNode): void;
  removeChild(node: VNode): void;
  insertBefore(newNode: VNode, referenceNode: VNode): void;
  toJSON(): RawNode;
}

原本 React 中是要在 Reconciler 中操作真实 Dom 的,但是Remax改写,变成了操作 VNode。那么增删改查怎么操作 node 呢?例如:

VNode.prototype.appendChild = function (node) {
  // 把 node 挂载到 child 链表上
  // firstChild指针指向链表的开头
  // lastChild 指针指向链表的结尾
  if (!this.firstChild) {
    this.firstChild = node;
  }
  if (this.lastChild) {
    this.lastChild.nextSibling = node;
    node.previousSibling = this.lastChild;
  }
  this.lastChild = node;
  // 如果节点已经挂载了,则调用 requestUpdate 方法,传入一些参数
  if (this.isMounted()) {
    this.container.requestUpdate({
      type: "splice",
      path: this.path,
      start: node.index,
      id: node.id,
      deleteCount: 0,
      children: this.children,
      items: [node.toJSON()],
      node: this,
    });
  }
};

其余操作都跟这些类似,都会调用 requestUpdate 方法。

Continue.prototype.requestUpdate = function (update) {
  this.updateQueue.push(update);
};

就是简单的将 update 放入更新队列,在 resetAfterCommit 中将 this.updateQueue 通过 reduce 计算出 updatePayload,然后调用小程序的setData(updatePayload)

小程序的 setData 是支持这样的写法:setData({ root.a.b.c: 10 }), key 可以表达层级路径

// updatePayload 例如:
updatePayload = {
  "root.nodes.7.nodes.6.nodes.5": {
    id: 5,
    text: "5",
    type: "plain-text",
  },
  "root.nodes.7": {
    children: [4, 6],
    id: 7,
    nodes: {
      4: {},
      6: {},
    },
    props: {},
    text: undefined,
    value: "view",
  },
  // ...
};

Remax 维护的小程序上的 data 大概长这个样子:

{
  "root": {
    "children": [
      7
    ],
    "nodes": {
      "7": {
        "id": 7,
        "type": "view",
        "props": {
          "class": "app___2lhPP",
          "hover-class": "none",
          "hover-stop-propagation": false,
          "hover-start-time": 50,
          "hover-stay-time": 400
        },
        "children": [
          4,
          6
        ],
        "nodes": {
          "4": {
            "id": 4,
            "type": "button",
            "props": {
              "bindtap": "$$REMAX_METHOD_4_onClick",
              "hover-class": "button-hover",
              "hover-start-time": 20,
              "hover-stay-time": 70
            },
            "children": [
              3
            ],
            "nodes": {
              "3": {
                "id": 3,
                "type": "plain-text",
                "text": " click me"
              }
            }
          },
          "6": {
            "id": 6,
            "type": "view",
            "props": {
              "hover-class": "none",
              "hover-stop-propagation": false,
              "hover-start-time": 50,
              "hover-stay-time": 400
            },
            "children": [
              5
            ],
            "nodes": {
              "5": {
                "id": 5,
                "type": "plain-text",
                "text": ""
              }
            }
          }
        }
      }
    }
  },
  "modalRoot": {
    "children": []
  },
  "__webviewId__": 31
}

到现在这一步可知 Remax 通过自定义的 reconciler,将节点描述为一个虚拟 dom 对象,并保存在小程序的 data 中,这样如果有节点更新,就更新对应的虚拟 dom,然后 setData()到小程序上。那么如何通过虚拟渲染出小程序的节点呢?

Remax 打包之后有一个模板(index.wxml)
alt text

base.wxml :
alt text
name='REMAX_TPL' REMAX_TPL 的模板组件定义在base.wxml 里面:

<template name="REMAX_TPL">
 <block wx:for="{{root.children}}" wx:key="*this">
  <template is="REMAX_TPL_1_CONTAINER" data="{{i: root.nodes[item], a: ''}}"/>
 </block>
</template>

<template name="REMAX_TPL_1_CONTAINER" data="{{i: i}}">
 <template is="{{_h.tid(i.type, a)}}" data="{{i: i, a: a + ',' + i.type, tid: 1}}"/>
</template>

逻辑就是通过 root.children 遍历,然后递归使用 REMAX_TPL_1_CONTAINER模板,REMAX_TPL_1_CONTAINER 通过 i.type 判断是哪种类型,然后调用对应的模板。

// tid:
tid = function (type, ancestor) {
  var items = ancestor.split(",");
  var depth = 1;

  for (var i = 0; i < items.length; i++) {
    if (type === items[i]) {
      depth = depth + 1;
    }
  }

  var id = "REMAX_TPL_" + depth + "_" + type;
  return id;
};

也就是生成REMAX_TPL_1_XXX(button\text\view)

参考