微信小程序架构解析及第三方框架原理解析
前言
名词解释:
JSCore:JavaScriptCore是WebKit的内置JavaScript引擎JavaScriptCore是一个C++实现的开源项目JSCore的组成部分:Lexer词法分析器,将脚本源码分解成一系列的TokenParser语法分析器,处理Token并生成相应的语法树LLInt低级解释器,执行Parser生成的二进制代码Baseline JIT基线JIT(``just in time实施编译)DFG低延迟优化的JITFTL高通量优化的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)
渲染页面结构
所有的视图(wxml 和 wxss)都是单独的 Webview 来承载,称之为 AppView,下面逻辑层也是通过 webView 加载的,所以一个小程序打开至少就会有 2 个 webview 进程,所以因为每个视图都是一个独立的 webview ,考虑到性能消耗,小程序不允许打开超过 5 个层级的页面
逻辑层(App Service)
逻辑处理、数据请求、接口调用,运行在 JavaScriptCore 引擎
逻辑处理的 JS 代码全部加载到一个 Webview 里面,称之为 AppService
视图层和逻辑层通过系统层的 JSBridage 进行通信,逻辑层把数据变化通知到视图层,触发视图层页面更新,视图层把触发的事件通知到逻辑层进行业务处理。
基础库
WAService.js
提供逻辑层基础的 API 能力,wx 对象下的 api
Foundation: 基础模块WeixinJSBridge: 消息通信模块WeixinNativeBuffer: 原生BufferWeixinWorker:Worker线程JSContext:JS Engine ContextProtect:JS保护的对象__subContextEngine__: 提供App、Page、Component、Behavior、getApp、getCurrentPages等方法
WAWebview.js
wx 对象下的 api,大部分都是处理 UI 显示相关的方法
提供视图层基础的
API能力
Foundation: 基础模块WeixinJSBridge: 消息通信模块exparser: 组件系统模块__virtualDOM__:Virtual DOM模块__webViewSDK__:WebView SDK模块Reporter: 日志上报模块(异常和性能统计数据)
在此基础上,AppView 有一个 html 模板文件,通过这个模板文件加载具体的页面,这个模板主要就一个方法,$gwx,主要是返回指定 page 的 VirtualDOM。
在打包的时候,会事先把所有页面的 WXML 转换为 ViirtualDOM 放到模板文件里。
Foundation 模块
基础模块提供环境变量 env、发布订阅 EventEmitter、配置/基础库/通信桥 Ready 事件。
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 | 自定义消息转发 |
编译原理
为了快速预览,微信开发者工具模拟器运行的代码只经过本地预处理、本地编译,没有服务器编译过程,而微信客户端运行的代码是额外经过服务器编译的。
微信官方提供了 wcc 和 wcsc 两个编译工具,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安卓则是往WebView的window对象注入一个原生方法,最终会封装成WeiXinJSBridge这样一个兼容层,主要提供了调用(invoke)和监听(on)这两种方法。
- 拿到新数据之后生成虚拟
dom =>diff =>生成新 dom - 如果用到原生组件,会调用
Native接口,告知Native在这渲染一个原生组件,后续更新也同样
- 逻辑层接口:
- 通信与视图层类似,只不过
iOS平台可以往JavaScripCore框架注入一个全局的原生方法,而安卓方面则是跟渲染层一致的。
- 通信与视图层类似,只不过
- 组件触发事件(带上
webviewID),调用WeixinJSBridge的接口,publish到native,然后native再分发到AppService层指定webviewID的Page注册事件处理方法。
与浏览器 H5 的区别
- 线程隔离:
- 微信小程序:渲染层和逻辑层分离,运行在不同的线程中。
- H5 网页:UI 线程和 JS 线程共享主线程,JS 线程可能会阻塞 UI 渲染。
- 数据通信:
- 微信小程序:通过框架提供的机制在渲染层和逻辑层之间传递数据。
- H5 网页:直接操作 DOM 来更新 UI,JS 代码可以直接访问和修改 DOM。
- 运行环境:
- 微信小程序:运行在微信客户端的环境中,有一定的权限和限制。
- 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;
},
// ...
};
VNode是Remax自己实现的对象
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)
base.wxml :
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)