作者:严康 刘小夕
近年新出的UI框架,包括React,Flutter, SwiftUI等在内都采用了声明式的方法构建UI,其中基于React的RN,Flutter都是多端框架,可以一套代码多端复用。但是在国内“端”还有一个小程序,所以在国内的跨端,必须要兼顾到小程序。
本文将探讨一种将声明式UI语法在类小程序平台运行的通用方式,这是一种等效运行的方式,对原语法少有限制。
“Talk is cheap. Show me your code !”。
基于这个原理,我们分别在 React Native 端,Flutter 端进行了实践,这两个项目的代码都托管在了github,欢迎关注star。
RN端的实践Alita:github.com/areslabs/al…
Flutter 端的实践 flutter_mp:github.com/areslabs/fl…。
先来看下这两个项目:
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示例效果:
| RN | 微信小程序 |
|---|---|
Flutter端的实践:flutter_mp
flutter_mp的代码托管在github flutter_mp,由于精力和时间有限,flutter_mp还处于很早期的阶段。首先我们根据下文阐述的方式生成 wxml 文件,配合一个极小的 Flutter 运行时(只存在到 Widget 层),最终把 Flutter 的渲染部分替换成小程序环境。
flutter_mp示例效果:
| Flutter | 微信小程序 |
|---|---|
下面我们探讨把声明式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 上的具体实现。