深刻剖析Alita:解密如何把RN轉換成微信小程序

5月底咱們正式對外開源了業內首個React Native轉微信小程序引擎Alita項目( github.com/areslabs/al…)。 這個項目的發起是由於團隊內部有大量使用React Native開發的業務模塊,大部分業務都有移植到H5和微信小程序的需求。因此咱們開始思考如何經過技術的方式來實現把React Native代碼轉換微信小程序的。通過內部孵化和大量業務落地驗證,最終咱們對社區貢獻了Alita引擎。她的定位很是明確,咱們不造新框架,Alita必須低侵入性而且只作好一件事情,就是把你的React Native代碼轉換並運行在微信小程序端(將來可能覆蓋更多端)。javascript

開源社區其實也一直在致力於打通React和微信小程序,涌現出了不少優秀的框架(同是京東凹凸實驗室出品的Taro就是很是出色的框架),咱們發現大部分雖然基於React,可是提供了新的框架和新語法規則,對React Native的處理比較少。更重要的是現有框架對React語法採用的是編譯時處理方案,對JSX語法限制比較大(後面文章會詳細分析)。咱們對Alita的指望是不能對JSX語法有太多限制,不能有侵入性,不給React Native的開發者帶來太多的負擔。因此最終Alita引擎沒有基於任何現有的編譯時方案,而是另闢溪路,走了一條頗具開創性的運行期處理方案html

拋開技術細節,針對Alita的使用者有2點必須瞭解:1)若是你打算轉換複雜的RN應用,須要特別注意,微信小程序包有大小限制,不能超過4M。2)Alita不能直接把原生組件/第三方組件轉換成小程序代碼。不過,Alita提供了擴展這些組件的方式,這點很像在RN上提供原平生臺組件。另外,咱們近期會推出alita-ui,這個UI庫包含了社區經常使用的RN組件,能夠直接被Alita轉換引擎支持。java

Talk is cheap. Show me the code.react

直接上乾貨!接下來咱們從純技術的角度剖析一下Alita引擎的核心部分:如何實現運行期處理JSXgit

現有社區方案的侷限

在剖析Alita以前,咱們先來看一下現有的社區方案,咱們說現有方案對JSX如今比較大,那他們是怎麼作的呢?他們主要是經過在編譯階段React代碼轉換爲等效的微信小程序代碼,來把React代碼運行在微信小程序端。 舉個例子,好比React邏輯表達式:github

xx && <Text>Hello</Text>
複製代碼

將會被轉換爲等效的小程序wx:if指令:web

<Text wx:if="{{xx}}">Hello</Text>
複製代碼

那麼這種編譯階段處理的方式有什麼問題呢,經過下面的React代碼看下。redux

class App extends React.Component {
    render () {
        const a = <Text>Hello</Text>
        const b = a

        return (
            <View> {b} </View>
        )
    }
}
複製代碼

這裏聲明瞭變量aconst a = <Text>Hello</Text>,變量bconst b = a。 咱們看下編譯方案(這裏以Taro1.3爲例)對上面代碼的轉換過程。小程序

這個例子不是特別複雜,卻報錯了。微信小程序

要想理解上面的代碼爲何報錯,咱們首先要理解編譯階段。本質上來講在編譯階段,代碼其實就是‘字符串’,而編譯階段處理方案,就須要從這個‘字符串’中分析出必要的信息(經過AST,正則等方式)而後作對應的等效轉換處理。

而對於上面的例子,須要作什麼等效處理呢?須要咱們在編譯階段分析出bJSX片斷:b = a = <Text>Hello</Text>,而後把<View>{b}</View>中的{b}等效替換爲<Text>Hello</Text>。然而在編譯階段要想肯定b的值是很困難的,有人說能夠往前追溯來肯定b的值,也不是不能夠,可是考慮一下 因爲b = a,那麼就先要肯定a的值,這個a的值怎麼肯定呢?須要在b能夠訪問到的做用域鏈中肯定a,然而a可能又是由其餘變量賦值而來,循環往復,期間一旦出現不是簡單賦值的狀況,好比函數調用,三元判斷等運行時信息,追溯就宣告失敗,要是a自己就是掛在全局對象上的變量,追溯就更加無從談起。

因此在編譯階段 是沒法簡單肯定b的值的。

咱們再仔細看下上圖的報錯信息:a is not defined

爲何說a未定義呢?這是涉及到另一個問題,咱們知道<Text>Hello</Text>,其實等效於React.createElement(Text, null, 'Hello'),而React.createElement方法的返回值就是一個普通JS對象,形如

// ReactElement對象
{
   tag: Text,
   props: null,
   children: 'Hello'
   ...
}
複製代碼

可是,咱們剛說了編譯階段須要對JSX作等效處理,須要把JSX轉換爲wxml,因此<Text>Hello</Text>這個JSX片斷被特殊處理了,a再也不是一個普通js對象,這裏咱們看到a變量甚至丟失了,這裏暴露了一個很嚴重的問題:代碼語義被破壞了

Alita處理方案

編譯時解決方案,更加像一個加強的模版,只要你遵循相關約定和語法限制,從工程角度來講徹底是能夠構建出完整的應用的。可是站在更高的抽象上,React帶來的自己就是對UI的從新思考, 不管是UI as code, 仍是React is "value UI"

Alita並不改變React語義,她採用的是一種動態處理JSX的方案,那接下來,咱們就一步步揭祕Alita的運行時方案,沿着下面原理圖。咱們從兩個方面說明:Alita編譯階段,Alita運行時。

編譯階段

歸納的來講,靜態編譯階段主要作兩個事情:

  1. 轉譯React代碼,使之能夠被小程序識別,具體的好比用React.createElement替換JSX,好比async/await語法處理等等。
  2. 枚舉並標識獨立JSX片斷,生成小程序wxml文件。

爲了直觀的代表Alita與社區其餘編譯時方案的不一樣,假定有一下JSX片斷,咱們看下Alita靜態編譯作的事情。

const x = <Text>x</Text>

const y = (
	<View> <Button/> <Image/> <View> <Text>HI</Text> </View> </View>
)
複製代碼

通過Alita編譯階段以後:

const x = React.createElement(Text, {uuid: "000001"}, "x");
const y = React.createElement(
    View,
    {uuid: "000002"},
    React.createElement(Button, null),
    React.createElement(Image, null),
    React.createElement(
        View,
        null,
        React.createElement(Text, null, "HI")
    )
);
複製代碼

每個獨立JSX片斷,都會被uuid惟一標識。同時生成 wxml文件

<template name="000001">
	<Text>x</Text>
</template>

<template name="000002">
	<View>
		<Button/>
		<Image/>
		<View>
			<Text>HI</Text>
		</View>
	</View>
</template>

<!--佔位template-->
<template is="{{uiDes.name}}" data="{{...uiDes}}"/>
複製代碼

用小程序template包裹獨立JSX片斷,其name屬性就是上文的uuid。最後,須要在結尾生成一個佔位模版:<template is="{{uiDes.name}}" data="{{...uiDes}}"/>

[template is]的動態性配合uuid標識將是運行時處理JSX的關鍵,下文會繼續說起。

編譯階段到這裏就結束了。

Alita運行時

關於Alita運行時,核心是內嵌的mini-react,這是一個適用微信小程序而且五臟俱全的React。讓咱們先簡單回顧一下React的渲染過程:

遞歸(React16.x引入Fiber以後,再也不使用遞歸的方式了)的構建組件樹結構,建立組件實例,執行組件對應生命週期,context計算,ref等等。當state有更新時,再次調用相應生命週期,判斷節點是否複用(virtual-dom)等。此外,在執行過程當中會調用瀏覽器DOM APIappendChildremoveChild, insertBefore等等方法)不斷操做原生節點,最終生成UI視圖。

Alita mini-react的執行過程基本和這一致,也會遞歸構建組件樹,調用生命週期等等,區別在於Alita沒法調用DOM API,熟悉微信小程序開發的同窗都知道,微信小程序屏蔽了DOM API。那麼沒有了DOM API,只剩小程序的wxml靜態模版,怎麼實現動態化處理React語法呢?

還記得編譯階段生成的uuid嗎?每個uuid表明了一個獨立的JSX片斷,在ReactDOM.render遞歸執行階段,Alita會收集聚合JSX片斷的uuid屬性,生成並返回uiDes數據結構,這個uiDes數據包含了全部要渲染的片斷信息,這份數據會傳遞給小程序,而後小程序把uiDes 設置給佔位模版<template is="{{uiDes.name}}" data="{{...uiDes}}"/>,遞歸渲染出最終的視圖。

下面咱們看一段相對複雜的React代碼,咱們將以這段代碼,完整的說明Alita的運行過程:

class App extends React.Component {

    getHeader() {
        return (
            <View> <Image/> <Text>Header</Text> </View>
        )
    }

    f(a) {
        if (!this.props.xx) {
            return a
        }

        return null
    }

    render() {
        const a = <Text>Hello World</Text>
        const b = this.f(a)

        return <View> {this.getHeader()} {b} </View>
    }
}
複製代碼

首先用uuid標識獨立JSX片斷,並用babel轉義以上代碼,以下:

class App extends React.Component {
    getHeader() {
        return React.createElement(
            View, 
            {uuid: "000001"},
            React.createElement(Image, null),
            React.createElement(Text, null, "Header")
        );
    }

    f(a) {
        if (!this.props.xx) {
            return a;
        }

        return null;
    }

    render() {
        const a = React.createElement(Text, {uuid: "000002"}, "Hello World");
        const b = this.f(a);
        return React.createElement(View, {uuid: "000003"}, this.getHeader(), b);
    }

}
複製代碼

同時提取獨立JSX片斷生成wxml文件:

<template name="000001">
    <View>
        <Image/>
        <Text>Header</Text>
    </View>
</template>

<template name="000002">
	<Text>Hello World</Text>
</template>

<template name="000003">
	<View>
		<template is="{{child001.name}}" data="{{...child001}}"/>
		<template is="{{child003.name}}" data="{{...child002}}"/>
	</View>
</template>

<!--佔位template-->
<template is="{{uiDes.name}}" data="{{...uiDes}}"/>
複製代碼

以上過程都是在編譯階段就處理完畢的,如今讓咱們考慮一下ReactDOM.render(<App/>, parent)執行過程(這裏使用ReactDOM.render只是方便理解):

  1. ReactDOM.render 判斷出App是自定義組件,建立其實例,執行componentWillMount等生命週期。遞歸處理render方法返回的ReactElement對象,即:React.createElement(View, {uuid: "000003"}, this.getHeader(), b);

  2. 處理最外層 View,收集uuid,生成UI描述:uiDes = {name: "000003"}

  3. 遍歷children

  4. 處理第一個孩子節點:this.getHeader(),它的值是React.createElement(Text,{name: "000001"}, "Header"),遞歸處理這個值,因爲Text是基本元素,遞歸終止,第一個孩子處理結束。此時uiDes的值以下:

    uiDes = {
    	name: "000003",
    	
    	child001: {
    	    name: "000001"
    	}
    }
    複製代碼
  5. 處理第二個孩子節點,b。當this.props.xxtrue的時候b就是null,直接忽略。 這裏並無傳遞xx屬性,因此b = a = React.createElement(Text, {name: "000002"}, "Hello World")Text是基本元素,遞歸終止,第二個孩子處理結束,此時uiDes的值以下:

    uiDes = {
    	name: "000003",
    	
    	child001: {
    	    name: "000001"
    	},
    	
    	child002: {
    	    name: "000002"
    	}
    }
    複製代碼
  6. children遍歷結束。

  7. 微信小程序獲取到uiDes,設置到下面的佔位模版,渲染對應視圖,首先是外層000003模版,而後是其兩個孩子節點,分別是000001模版,000002模版,最終渲染出完整視圖。

    <template is="{{uiDes.name}}" data="{{...uiDes}}"/>
    複製代碼

在這整個過程當中,你的全部JS代碼都是運行在React過程中的,語義徹底一致JSX片斷也不會被任何特殊處理,只是簡單的React.createElement調用。最終會輸出一個uiDes數據到小程序,小程序經過這個uiDes渲染出視圖。另外因爲這裏的React過程只是純js運算,不涉及DOM操做,執行是很是迅速的,一般只有幾ms,也就是說mini-react的開銷是很小的。

以上可見AlitaJSX片斷的處理是動態的,你能夠在任何地方,任何函數出現任何JSX片斷, 最終代碼執行結果會肯定渲染哪個片斷,只有執行結果的片斷的uuid會被寫入uiDes。這和編譯時方案的靜態識別有着本質的區別。

另外因爲上層運行仍是React,因此Alita在支持redux等庫上有自然的優點。

總結

咱們須要一種更加React的方式處理小程序。Alita在這個方向上更進了一些,Alita源碼是徹底公開的,咱們也會不斷提供剖析Alita原理的文章,但願給社區帶來一個新的思路,也給開發者提供一個新的選擇,另外讓更多的的開發者理解Alita的原理,也是但願更多的人可以參與進來, "The world needs more heroes!!」。

Alita能夠轉換React應用嗎?基於咱們內部需求,咱們只處理了React Native代碼。可是React語法處理是相通的,把Alita擴展到轉換React應用並非很困難,不過暫時咱們尚未擴展的排期,咱們下一步的計劃是優化/重構內部在使用的RN轉H5工具,最終的形態應該是基於RN開發生態,經過Alita轉換引擎支持實現全端的覆蓋。

額外提一句,Flutter也是能夠運行在Web端的,而微信小程序的運行環境就是web,那麼基於Alita運行時方案,是否是能夠幻想一下Flutter與微信小程序的融合呢。

Github: github.com/areslabs/al…

相關文章
相關標籤/搜索