深刻剖析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…

相关文章
相关标签/搜索