作者:嚴康 劉小夕
近年新出的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
上的具體實現。