微信小程序架构解析及第三方框架原理解析
前言
名词解释:
JSCore:
JavaScriptCore
是WebKit
的内置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)
渲染页面结构
所有的视图(wxml
和 wxss
)都是单独的 Webview
来承载,称之为 AppView,下面逻辑层也是通过
webView
加载的,所以一个小程序打开至少就会有 2 个 webview
进程,所以因为每个视图都是一个独立的 webview
,考虑到性能消耗,小程序不允许打开超过 5 个层级的页面
逻辑层(App Service)
逻辑处理、数据请求、接口调用,运行在 JavaScriptCore 引擎
逻辑处理的 JS
代码全部加载到一个 Webview
里面,称之为 AppService
视图层和逻辑层通过系统层的 JSBridage
进行通信,逻辑层把数据变化通知到视图层,触发视图层页面更新,视图层把触发的事件通知到逻辑层进行业务处理。
基础库
WAService.js
提供逻辑层基础的 API 能力,wx 对象下的 api
Foundation
: 基础模块WeixinJSBridge
: 消息通信模块WeixinNativeBuffer
: 原生Buffer
WeixinWorker
:Worker
线程JSContext
:JS Engine Context
Protect
: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)