# 关于小程序的探索
# 开始的方案
当时公司准备开始内部自研小程序技术方案,整个开发方式都是和wx小程序保持一致的,业务开发是也是模版、样式、逻辑、配置等等,基本确定了对外的接入方式。
由于在此之前已经有一版hybrid方案采用的也是双线程方案,业务逻辑层和视图渲染层隔离,从这个视角看架构与小程序是一样的。但是它的逻辑层其实又与小程序不太一样,逻辑层做的事情比较多,把整个应用的执行全部放在了逻辑层,形象的比喻那就是一个webview版渲染的ReactNative😓。
因此这版小程序的最终运行机制是要往hybrid上靠,通俗易懂的解释就是将小程序硬转译为React,对小程序一直保持一些关注所以第一时间想到的就是Taro,记得Taro有个convert功能 (opens new window)大致符合需求将小程序转成类React,小程序整体方案基本定了,后面可以开始着手踩其中的坑了。
# 遇到的问题
- 静态转译不能解决的问题
taro的convert功能没有覆盖所有的小程序写法,比如template深度嵌套、template传值方式等等问题,这些问题其实还好可以魔改一下。
业务代码中的Page、Component等函数调用语法是转译的关键语法,一旦业务方的小程序使用了类似wepy这样的第三方框架,或者自己封装了Page、Component等函数调用,静态转译将在指定代码中找不到关键字。因此静态转译不是银弹,这一版小程序对业务开发者使用上存在很大的限制,并不是一个标准的小程序方案。
// myPage.js
function myPage(config) {
// do sth
Page(config)
}
// page1.js
import myPage from '@common/myPage'
myPage({})
- 逻辑层过重
将整个React应用全部放在了逻辑层,其中也包含了内置组件,这样带来了一些问题。
原本内置组件是受信任的,由于也在逻辑层中,导致这些组件也受到了DOM、BOM的限制,自己硬给自己设了一道坎。为了让内置组件恢复正常能力,逻辑层的内置组件可以不去实现具体的功能,只负责事件的接收传递,在渲染层通过web-component去实现一个具体功能的组件。
这种方式对于框架开发者来说体验还是太差了。
因此这个方案不是一个可持续发展的方案,需要改变一下方向。
# 再次探索小程序
经过上一阶段的踩坑,基于taro做小程序的运行时方案肯定是不太正确的。因此想做小程序就得与业界对齐,探索业界是怎么实现的。
小程序整体架构主要由基础库、编译器、宿主容器组成
# 渲染层
主要职责: 承载模版和样式的展示功能
包含模块: UI框架、内置组件
UI框架: React、Vue等目前主流框架
- 微信小程序Exparser(未开源)
- 支付宝小程序React
- 百度小程序swan.js
- 字节小程序Yaw 类React(未开源)
内置组件:
- 基于UI框架实现
- 基于web-component实现(字节小程序基于Polymer)
# 逻辑层
主要职责: API提供、核心逻辑执行以及路由/事件管理
包含模块:
- App、Page以及Component等通用方法
- 路由
- API
# 例如支付宝小程序
webview渲染基于React、React-DOM,内置组件为React组件。
业务代码的模版文件与样式文件会打包到webview,例如:
// index.webview.js
Component({
usingComponents: {
},
get render() {
return require('root/components/a/a.axml')
},
get style() {
return require('root/components/a/a.acss')
}
})
Page({
usingComponents: {
'com-a': 'root/components/a'
},
get render() {
return require('root/pages/index/index.axml')
},
get style() {
return require('root/pages/index/index.acss')
}
})
业务代码的逻辑文件会打包到service,例如:
// index.service.js
require('root/app.js');
require('root/components/a/a.js');
require('root/pages/index/index.js');
# 实现
# 选型
UI框架的选择使用比较熟悉React,内置组件部分期望做到比较纯粹、隔离,所以web-component是合适的选择,为了组件开发方便选择了Polymer。
# 自定义渲染器
先看下小程序源码到最终处理后能运行的产物是怎样的, 以页面文件举例:
root/pages/index/index.wxml:
<view style="width: 100rpx">
<button class="btn" bind:tap="onTap">click</button>
</view>
<custom-component></custom-component>
root/pages/index/index.wxss:
.btn {
color: red;
}
root/pages/index/index.json:
{
usingComponents: {
"custom-component": "/components/custom-component/index"
}
}
biz/index.webview.js:
const jsx = (data, ctx) => {
// 自定义组件的的加载
const CustomComponent = ctx.resolveComponent('custom-component')
return (
<>
<tiny-view style="width: 100rpx">
<tiny-button class="btn" bind:tap={ctx.eventBinder('onTap')} >click</tiny-button>
</tiny-view>
<CustomComponent />
</>
)
}
const css = () => `
.btn {
color: red;
}
`
window.app['pages/index/index'] = {
usingComponents: {
"custom-component": "/components/custom-component/index"
},
render() {
return jsx();
},
style() {
return css();
}
}
逻辑文件的编译过程中,为了能够在执行Page或Component调用时有对应的标识,可以执行前添加注册逻辑,如下:
biz/index.service.js:
// 加载app.js
require('app.js')
// 加载自定义组件
$global.currentPageConfig = {
is: "/components/custom-component/index",
usingComponents: {}
};
require('components/custom-component/index.js')
// 加载页面
$global.currentPageConfig = {
is: "pages/index/index",
usingComponents: {
""
}
};
require('pages/index/index.js')
同理自定义组件部分的编译处理也与上面类似。
到这里有没有注意到一个问题,tmplate转jsx后为什么style属性和事件绑定没有使用React的事件系统呢,这个问题其实一开始是React+ReactDOM实现的,但是后期完善的过程中被ReactDOM限制了很多,比如bind:longpress这样的事件在ReactDOM中没有一一对应的,这里需要自己实现一下小程序事件系统以及style等属性的处理(如:rpx2px、样式作用域),具体方案可以使用react-reconciler编写一个自定义渲染器,也可以基于社区的mini React库修改渲染器部分,如基于Nervjs的实现成本就很低。
# 执行流程
小程序的业务代码最终运行产物已经定了,接下来在实现基础库之前,可以梳理一下小程序的整个运行过程。
# JSBridge
整个流程中可以看到小程序的双线程模型,涉及到大量的通信过程
- WebView -> Service
- Service -> WebView
- WebView -> Native
- Service -> Native
所以需要设计一套合适的小程序JSBridge方案, 这是基础库的重要一部分。