聲明式UI框架在類小程序運行的原理
近年新出的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上的具體實現。