近年新出的UI框架,包括React,Flutter, SwiftUI等在内都采用了声明式的方法构建UI,其中基于React的RN,Flutter都是多端框架,可以一套代码多端复用。但是在国内“端”还有一个小程序,所以在国内的跨端,必须要兼顾到小程序。
本文将探讨一种将声明式UI语法在类小程序平台运行的通用方式,这是一种等效运行的方式,对原语法少有限制。
“Talk is cheap. Show me your code !”,基于这个原理,我们分别在 React Native
端,Flutter
端进行了实践,这两个项目的代码都托管在了github
,欢迎关注star。RN端的实践Alita, 在 Flutter
端的实践 flutter_mp。
先来看下这两个项目:
RN端的实践:Alita
Alita的代码托管在github alita,除了使用下文将要说明的方式处理了React语法以外,Alita还对齐处理了 React Native 的组件/API,可以把你的 React Native 代码运行在微信小程序平台,Alita的侵入性很低,使用与否,并不会对你的原有React Native开发方式造成太大影响。另外由于React Native本身就可以运行在Android, IOS,web(react-native-web),在加上Alita可以打造出适配全端的大前端框架。
看下Alita
示例效果:
Flutter端的实践:flutter_mp
flutter_mp的代码托管在github flutter_mp,由于精力时间有限,flutter_mp
还处于很早期的阶段。首先我们根据本文阐述的方式生成wxml文件,配合一个极小的Flutter运行时(只存在到Widget层),最终把Flutter的渲染部分替换成小程序环境。
看下flutter_mp
示例效果:
下面我们探讨把声明式UI运行在类小程序平台的通用方式,这是一种底层渲染机制,他不限于上层是React或是Flutter或是其他,也不限于底层渲染是微信小程序或是支付宝小程序等。
两种UI构建方式
首先我们看一下两种不同的UI构建方式。
小程序wxml文件
出于未知原因的考虑,小程序框架虽然最终的运行环境是webview,但是它禁用了DOM API,这直接导致React
,Vue
等前端流行框架无法直接在小程序端运行。替代性的,在小程序上构建UI需要采用一种更加静态的方式—- wxml
文件,可以看成是一种支持变量绑定的 html
:
<view>Hello World</view>
<view>{{txt}}</view>
<view wx:if="{{condition}}">{{txt}}</view>
由于 wxml
文件需要预先定义,且阉割了所有的DOM API,所以小程序“动态”构建UI的能力几乎为0。
React/Flutter等声明式“值UI”
声明式的方式构建UI主要在于“描述界面而不是操作界面”,从这个角度 html
, wxml
都属于“声明式”的方式。 React
/ Flutter
和html/wxml有什么不同呢?
我们先看一个 React
的例子:
class App extends React.Component {
f() {
return <Text>f</Text>
}
render() {
var a = <Text>HelloWorld</Text>
return (
<View>
{a}
{this.f()}
</View>
)
}
}
在组件的 render
方法内,声明了一个 var a = <Text>HelloWorld</Text>
,this.f()
返回了另一个 Text
标签,最后通过 View
将他们组合起来。
对比前面的 wxml
方法,可以看出 JSX
非常灵活,UI标签可以出现在任何地方,进行任意自由组合。本质来说这里暗含了一个 “值UI” 的概念。思考一下,我们在写 var a = <Text>HelloWorld</Text>
的时候,并没有把 <Text>HelloWorld</Text>
当成UI标签特殊对待,它更像是一个普通的“值”,它可以用来初始化一个变量,也可以作为函数的返回值。我们是在以“编程”的方式构建UI,“编程”的方式赋予了我们构建UI时极强的能力和灵活性。
我们看下Dan Abramov(React作者之一)的论述:
Flutter Widget的设计灵感来源于 React
,同样是声明式“值UI”,所以本文准确的标题应该叫 “声明式值UI框架在类小程序运行的原理”。
我们从“值UI”的角度考虑如下的组件:
class App extends Component {
f() {
if (this.state.condition1) {
return <Text> condition1 </Text>
}
if (this.state.condition2) {
return <Text> condition2 </Text>
}
...
}
render() {
var a = this.state.x ? <Text>X</Text> : <Text>Y</Text>
return (
<View>
{a}
{this.f()}
</View>
)
}
}
换算成”UI“值的形式(假设有一个UI类型的构造函数):
class App extends Component {
f() {
if (this.state.condition1) {
return UI("Text", "condition1")
}
if (this.state.condition2) {
return UI("Text", "condition2")
}
...
}
render() {
var a = this.state.x ? UI("Text", "X") : UI("Text", "Y")
return UI("View", a, this.f())
}
}
当 state
取不同值的时候:
- 当
state = {x: false, condition1: true}
时:render
结果UI("View", UI("Text", "Y"), UI("Text", "condition1"))
- 当
state = {x: true, condition2: true}
时:render
结果UI("View", UI("Text", "X"), UI("Text", "condition2"))
- 等等
上面的App组件,随着 state
的改变,render
返回的“大UI值”理所当然的随着改变,这个“大UI值”由其他“小UI值”组合而成。请注意这里的“UI”只是“普通”的一个数据结构,故而这里可以是一个与平台无关的纯JS过程,这个过程不管是在浏览器,还是RN,还是小程序都是一样的。不一样的地方在于:把这个声明式构建出来的“大UI值”数据结构渲染到实际平台的方式是不一样的。
- 在浏览器:
ReactDOM.render()
,将会遍历这个“大UI值”,调用DOM API渲染出实际视图 - 在Native端:表示
大UI值
的数据通过 js-native 的bridge
,传递到native
,native
根据这份数据填充原生视图 - 在小程序端:怎么在小程序上渲染出这个
大UI值
表示的实际视图呢???
小程序wxml等效表达“值UI”的方式
前文说了构建“大UI值”的构建过程是平台无关的,主要问题在于如何利用小程序静态的 wxml
渲染出这个“大UI值”,也就是下图的渲染部分
首先,一块“UI值” 在小程序上是有等效概念的,小程序上表示“一块”这个概念的是 template
, 比如 UI("Text", "X")
, 可以等效为:
<template name="00001">
<text>X</text>
</template>
比较难处理的是“UI值”之间的动态绑定,如下:
render() {
var a = this.state.x ? UI("Text", "X"): UI("Text", "Y")
return UI("View", a, this.f())
}
对于 UI("View", a, this.f())
这样的“一块UI值”要怎么对应呢?这里的 a, this.f()
是一个运行期才能确定的值,且随着 state
的变化而变化,这样的一个“UI值”,如何用template表示呢? 这里我们使用一个占位 tempalte
来表达动态的未知。
<template name="00002">
<View>
<template is="{{some dynamic value1}}"/>
<template is="{{some dynamic value2}}"/>
</View>
</template>
我们用形如<template is="{{some dynamic value}}"/>
这样的占位template
表达一个运行时动态确定的“UI值”,利用is
属性的动态性来表达“UI”值的动态组合。
这里 is
属性的“一丢丢动态性”将成为使用 wxml
构建整个“值UI”的基石。
总结一下,以上的工作:
- 每一个“UI值”,用
template
对应 - “UI值”动态组合的地方,使用占位
<template is=/>
替代,
实际上基于这两点构建的 wxml
文件,已经具备了表达组件所有render结果 的能力,只需要在不同 state
下,赋予占位 template
正确的 is
值即可(是个嵌套过程),这里有些跳跃,思考一下。
比如以上面的App组件为例,生成的 wxml
文件大致如下:
<template name="00001">
<Text> condition1 </Text>
</template>
<template name="00002">
<Text> condition2 </Text>
</template>
<template name="00003">
<Text> X </Text>
</template>
<template name="00004">
<Text> Y </Text>
</template>
<view>
<template is="{{child1.templateName}}" data="{{... child1}}" />
<template is="{{child2.templateName}}" data="{{... child2}}" />
</view>
- 当
state = {x: false, condition1: true}
时,只需要生成如下的数据:
data = { child1: { templateName: "00004" }, child2: { templateName: "00001" } } - 当
state = {x: true, condition2: true}
时,只需要生成如下的数据:
data = { child1: { templateName: "00003" }, child2: { templateName: "00002" } }
随着state
的改变,data
数据结构也在不断改变,最终会把此 state
对应的所有 is
值设置到对应 template
上。更进一步的,当组件树结构越来越复杂,data
结构也会嵌套越来越深。当上面的 a 变量
如下的时候
var a = this.state.x ? <View>{this.f()}</View> : <Text>Y</Text>
这里 a 变量
的<View>{this.f()}</View>
本身包含了另一个“动态”组合{this.f()}
, 这个时候产生的 data:
data = {
child1: {
templateName: "00003"
child1: {
templateName ... //
}
},
child2: {
templateName: "00002"
}
}
随着data
在template
上的一步一步展开,所有的”UI值“组合关系将通过is属性被正确设置,这是一个嵌套过程。
那么现在的问题变成了如何在不同的 state
下,构造出正确的 data
结构。
这正是 ReactMiniProgram.render
的工作。类比 ReactDOM.render
遍历组件树构建DOM节点的行为, ReactMiniProgram.render
在执行过程中,遍历整个组件树,不断收集聚合构建出正确的渲染data
数据,最终把这部分数据传递给小程序,小程序根据这份数据渲染出最终的视图。
上文虽然大部分针对 React
在讨论,但是 Flutter
其实是一样的情况,他们都是“声明式值UI”,处理“值UI”的方式是完全一样的,只不过最后的底层渲染部分换成了小程序wxml的方式。
总结一下这个通用方式的完整过程:首先根据上层语法生成 wxml
文件,在 wxml
文件生成的过程中,由于不会做任何语义上的推断和转化,所以并不存在语法损耗。同时上层存在一个“运行时”,这个“运行时”运行的仍然是原平台代码,负责对“UI值”的处理,最终构建出一个表达“大UI值”的 data
结构,这是一个纯JS过程。然后把这个 data
数据传递到小程序,配合之前生成的 wxml
文件,渲染出小程序版本的视图。
总结
template
is
属性的动态性是在小程序上等效构建“声明式值UI”的基石,且这种方式不会对上层语法的语义进行推测转化,所以是相对无损的。
Alita
和 flutter_mp
分别是这种渲染方式在React 和Flutter上的具体实现。