宣告式UI框架在類小程式執行的原理

劉小夕發表於2019-11-13
作者:嚴康 劉小夕

近年新出的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 微信小程式
宣告式UI框架在類小程式執行的原理
宣告式UI框架在類小程式執行的原理

Flutter端的實踐:flutter_mp

flutter_mp的程式碼託管在github flutter_mp,由於精力和時間有限flutter_mp還處於很早期的階段。首先我們根據下文闡述的方式生成 wxml 檔案,配合一個極小的 Flutter 執行時(只存在到 Widget 層),最終把 Flutter 的渲染部分替換成小程式環境。

宣告式UI框架在類小程式執行的原理

flutter_mp示例效果:

Flutter 微信小程式
宣告式UI框架在類小程式執行的原理
宣告式UI框架在類小程式執行的原理

下面我們探討把宣告式UI執行在類小程式平臺的通用方式,這是一種底層渲染機制,他不限於上層是React或是Flutter或是其他,也不限於底層渲染是微信小程式或是支付寶小程式等。

兩種UI構建方式

首先我們看一下兩種不同的UI構建方式。

小程式wxml檔案

出於未知原因的考慮,小程式框架雖然最終的執行環境是webview,但是它禁用了DOM API,這直接導致ReactVue 等前端流行框架無法直接在小程式端執行。替代性的,在小程式上構建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主要在於“描述介面而不是操作介面”,從這個角度 htmlwxml 都屬於“宣告式”的方式。 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作者之一)的論述:

宣告式UI框架在類小程式執行的原理

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 取不同值的時候:

  1. state = {x: false, condition1: true} 時: render 結果 UI("View", UI("Text", "Y"), UI("Text", "condition1"))
  2. state = {x: true, condition2: true} 時: render 結果 UI("View", UI("Text", "X"), UI("Text", "condition2"))
  3. 等等

上面的App元件,隨著 state 的改變,render 返回的“大UI值”理所當然的隨著改變,這個“大UI值”由其他“小UI值”組合而成。請注意這裡的“UI”只是“普通”的一個資料結構,故而這裡可以是一個與平臺無關的純JS過程,這個過程不管是在瀏覽器,還是RN,還是小程式都是一樣的。不一樣的地方在於:把這個宣告式構建出來的“大UI值”資料結構渲染到實際平臺的方式是不一樣的。

  • 在瀏覽器: ReactDOM.render(),將會遍歷這個“大UI值”,呼叫DOM API渲染出實際檢視

  • 在Native端:表示大UI值的資料通過 js-native 的 bridge,傳遞到 nativenative 根據這份資料填充原生檢視

  • 在小程式端:怎麼在小程式上渲染出這個大UI值表示的實際檢視呢???

小程式wxml等效表達“值UI”的方式

前文說了構建“大UI值”的構建過程是平臺無關的,主要問題在於如何利用小程式靜態的 wxml 渲染出這個“大UI值”,也就是下圖的渲染部分

宣告式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框架在類小程式執行的原理

總結一下,以上的工作:

  1. 每一個“UI值”,用 template 對應
  2. “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>
複製程式碼
  1. state = {x: false, condition1: true} 時,只需要生成如下的資料:

     data = {
    	    child1: {
    	        templateName: "00004"
    	    },
         child2: {
             templateName: "00001"
         }
     }
    複製程式碼
  2. 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"
        }
    }
複製程式碼

隨著datatemplate上的一步一步展開,所有的”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”的基石,且這種方式不會對上層語法的語義進行推測轉化,所以是相對無損的。

Alitaflutter_mp 分別是這種渲染方式在 ReactFlutter 上的具體實現。

相關文章