Rax 小程序编译时方案原理解析(二)

3,148 阅读6分钟

截至本文发稿,Rax 小程序运行时方案已经发布。本文内容主要仍是讲解编译时方案原理,特此说明。

接上文 Rax 转小程序链路原理解析(一),讲解完工程设计上的原理后,本篇讲一讲 Rax 代码通过编译时引擎转换为小程序代码的流程以及其运行机制。

总体介绍

编译,是一种利用编译程序从源语言编写的源程序产生目标程序的过程或者动作,完整的流程是从高级语言转换成计算机可以理解的二进制语言的过程。本文的编译只考虑上述流程的前半部分,也就是从高级语言 A (Rax)到高级语言 B (小程序 DSL)之间的转换,后续转换成二进制语言等部分不在讨论范围内。编译的流程分为以下几个阶段:词法分析(tokenizing)、语法分析(parsing)、代码生成(generate)。这当中,最重要的概念是抽象语法树(Abstract Syntax Tree, AST),也是我们的编译器要与之打交道最重要的部分。通过将源代码转换为 AST,然后按照我们的目的对其进行修改,最后再将 AST 生成为代码即可。

编译过程

在 Rax 小程序编译时方案中,我们采用 Babel 作为编译工具处理 tokenizing、parsing 和 generate,而具体的修改 AST 的逻辑放在 jsx-compiler。关于 jsx-compiler,其整体架构如下:

jsx-compiler
jsx-compiler 按照职责划分模块,然后以洋葱模型为基础组织这些模块。举例来说,condition 模块负责处理条件表达式,下面这段 JSX 代码:

<View>{foo ? <View /> : <Text />}</View>

将被处理到小程序的 axml 模板中变成:

<View><block a:if={foo}><View /></block><block a:else><Text /></block></View>

当然,这只是最简单的情况,JSX 写法千变万化,对应的我们也要写大量的代码用来识别这些写法,这也是 Taro 等 React DSL 编译型小程序框架的普适的痛点。很多用户在抱怨用 Rax 编译时方案写小程序时会遇到莫名的报错,实际上就是 jsx-compiler 没有识别到当前的 JSX 语法,导致出现了不可预期的错误。经过长时间的迭代,Rax 已经增加了很多语法 case 的处理,只要你根据文档编写合乎规范的代码,就基本不会有问题。未来,我们也会为 Rax 小程序编译时方案编写专门的 lint 插件帮助检测不支持的语法,从而提升开发效率。

对于 jsx-compiler 修改 AST 的过程与其他编译工具并无太大差异,读者可以利用 AST Explorer 这一神器来体验一下肢解 JavaScript 代码然后再将其按照自己喜好组装起来的过程。实际上, jsx-compiler 的定位比较像当前正流行的一个说法——『工具人』,即开发者希望最终产出什么样的代码,那我就用 jsx-compiler 去处理并生成就好,重要的是生成的代码在小程序环境下究竟怎么正常运作。

运行机制

我们以 Rax 脚手架生成的代码为基础,一个简单的 Rax 页面级组件如下:

import { createElement } from 'rax';
import View from 'rax-view';
import Text from 'rax-text';

import './index.css';

import Logo from '../../components/Logo';

export default function Home() {
  function clickMe() {
    console.log('clicked');
  }
  const uri = '//gw.alicdn.com/tfs/TB1MRC_cvb2gK0jSZK9XXaEgFXa-1701-1535.png';

  return (
    <View className="home">
      <Logo src={uri}/>
      <Text onClick={clickMe} className="title">Welcome to Your Rax App</Text>
      <Text className="info">More information about Rax</Text>
      <Text className="info">Visit https://rax.js.org</Text>
    </View>
  );
}

代码不多,但实际上已经包括了很多关键要素:样式引用、组件引用、组件 props 传递等。看一下这段代码被 jsx-compiler 编译后的产物:

  • js 代码:
import { createPage as __create_page__ } from "../../npm/jsx2mp-runtime";

function Home() {
  function clickMe() {
    console.log('clicked');
  }

  const uri = '//gw.alicdn.com/tfs/TB1MRC_cvb2gK0jSZK9XXaEgFXa-1701-1535.png';

  this._updateChildProps("9", {
    "src": uri
  });

  this._updateChildProps("10", {
    "onClick": clickMe,
    "className": "title",
    "class": "title"
  });

  this._updateChildProps("11", {
    "className": "info",
    "class": "info"
  });

  this._updateChildProps("12", {
    "className": "info",
    "class": "info"
  });

  this._updateData({
    "_d0": uri
  });

  this._updateMethods({
    "_e0": clickMe
  });
}

let __def__ = Home;
Page(__create_page__(__def__, {
  events: ["_e0"]
}));
  • axml 代码
<block a:if="{{$ready}}">
  <view class="__rax-view 浙江省杭州市余杭区五常街道富力新线公园3-3-802">
    <c-d25621 src="{{_d0}}" __tagId="{{__tagId}}-9" />
    <rax-text onClick="_e0" className="title" __tagId="{{__tagId}}-10" class="title">Welcome to Your Rax App</rax-text>
    <rax-text className="info" __tagId="{{__tagId}}-11" class="info">More information about Rax</rax-text>
    <rax-text className="info" __tagId="{{__tagId}}-12" class="info">Visit https://rax.js.org</rax-text>
  </view>
</block>
  • json 代码
{
  "usingComponents": {
    "c-d25621": "../../components/Logo/index",
    "rax-text": "../../npm/rax-text/lib/miniapp/index"
  }
}

光从产物来看,我们可以非常笼统地认为 JSX 被映射成了 axml,而 import 组件的语句则被处理成了 json 里的 usingComponents。但是 js 代码则不太好理解。实际上,为了抹平小程序的 Page/Component 与 Rax 组件的差异,我们提供了运行时垫片,即代码中的 jsx2mp-runtime 来作了二者的桥接。下面就来看一下 jsx2mp-runtime 的具体实现。

App

在阐述小程序 Page/Component 的更新机制之前,先看一下 App 的实现。小程序通过 App 函数进行顶层应用的声明,而在 Rax 应用中,启动逻辑被收敛至 rax-app 提供的 runApp 函数中。jsx2mp-runtime 中同样声明了 runApp 函数。看一下 app.js 编译前后的代码:

// src/app.js
import { runApp } from 'rax-app';
import appConfig from './app.json';
runApp(appConfig);

// dist/app.js
import { runApp } from "./npm/jsx2mp-runtime";
import appConfig from "./app.config.js";
runApp(appConfig);

显而易见,runApp 内部最终一定是调用了 App 函数的。此外,runApp 还做了以下事情:

  • 将用户通过 useAppXXX hooks 注册的生命周期钩子函数注入其中
  • 导入 app.json 中用户配置的路由信息,并将其存储在内存中

这样,App 即可正常运行。

Bridge

下面是关键的 Page/Component 实现。同 App 一样,小程序的页面和自定义组件也是声明式的,二者通过分别调用 PageComponent 函数实现实例的创建,除了生命周期略有不同外,写法上并无太大差异。从上面的编译后代码可以看出,jsx2mp-runtime 分别使用 createPagecreateComponent 函数返回页面和组件的声明配置。这两个函数底层则调用了 createConfig 并在其中做了生命周期的对应处理。关键的操作发生在 mount 阶段(以阿里小程序声明周期为例,即 page 的 onLoad 生命周期和 component 的 didMount 声明周期),此时我们创建一个 Rax Component 实例并将其与小程序的 Page/Component 实例进行绑定,形成如下的关系:

Jietu20200625-173829
Rax Component 实例来自哪里?在使用 Rax 编写 class component 时,需要继承 Rax.Component,小程序环境上也不例外,不过继承的父类变成了 jsx2mp-runtime 中实现的小程序版本的 Rax Component Class,其将在下一章节进行介绍。如果用户编写的是 function component,在内部也会被转换为 class component,编译后的函数体将放到 render 函数中,因此后续的实现并无二致。

下面就来看一下 Rax Component Class 的实现。

Rax Component Class

jsx2mp-runtime 实现的 Rax Component Class 基于小程序环境完成了一套特有的渲染机制。对外,仍旧暴露了 componentDidMountcomponentDidUpdaterender 等标准 Rax/React 方法,而在内部,其通过 trigger 方法以 event bus 的形式收拢生命周期的触发,避免了散落在多处的回调执行。下面还是从渲染流程说起。

挂载

上一节中提到 Page/Component 实例在 mount 阶段创建了 Rax Component 实例,在绑定完成后就会调用实例上的 _mountComponent 方法。该方法中将先后触发 'componentWillMount' 和 'render' 生命周期。

渲染

渲染时,Rax Component 实例会调用自身的 render 函数(class component)或 function component 本身(实际上在内部,function component 被转换为 class component 后,其函数体就是 render 函数的内容)。回到上面的编译后代码,一次 render 过程调用了实例上的 _updateData_updateMethods_updateChildProps 等诸多方法。下面是他们各自的作用:

_updateData

_updateData 的参数是由 jsx-compiler 收集到的 JSX 中使用到的变量。那么显然 _updateData 底层调用了 setData 来更新视图层数据。实际情况会复杂一些,我们在更新数据之前做了大量的优化工作,以此提升整体的性能。在之前的介绍文章中有提到,Rax 小程序编译时方案在一些场景下能够达到超越原生的性能,原因就在这里。对于开发者可能感知不到的可以优化的细节,Rax 在框架层面做了,这里我们用了 Data diff/$spliceData(阿里小程序,针对长列表场景)/$batchedUpdates(阿里小程序,页面级可用,批量传输数据)等方式,提升了 setData 这一最耗时的方法的性能。

_updateMethods

_updateData 类似,_updateMethods 的参数也是由 jsx-compiler 从 JSX 中用到的方法提取而来,其作用总结来说就是将所有代理后的方法 merge 到 Page/Component 实例的 methods 对象中去,如此便能在模板中使用。至于方法为什么要代理?在循环等场景中,如果产生了临时循环变量,需要在模板中声明,绑定的方法中是无法用到这些临时变量的,。因此方法代理会收集模板上的这些临时变量(由 jsx-compiler 收集分析并写入模板)作为参数传给绑定的方法,以绕过这一限制。此外,方法代理还有阻止事件冒泡等功能。

_updateChildProps

_updateChildProps 用于通知子组件更新。注意其第一个参数是一个字符串数字,与模板中传递给子组件的 __tagId 存在对应关系(参考上方的示例代码)。在每一个 Rax Component 实例创建时,我们会为其分配一个 instanceId,其来自父组件传递的 props 中的 __tagId 值或通过全局变量自增生成唯一值,然后 instanceId 与实例的对应关系会被写入内存。_updateChildProps 调用时,再根据 instanceId 去到内存中找到该子组件实例,更新子组件的 props 值后,将子组件实例推入更新队列即可。

更新

无论是 class component 调用 setState 还是 function component 调用 hooks 的 set 函数触发更新,执行的操作都是将该实例推入更新队列。每个宏任务结束后,更新将被批量执行。每个实例的更新大致会经历以下步骤:

  • 触发 componentWillReceiveProps 生命周期
  • 调用 getDerivedStateFromProps(如果存在)
  • 调用 shouldComponentUpdate(如果存在)并判断是否继续执行更新
  • 触发 componentWillUpdate 生命周期
  • 触发更新(即上一节所介绍的过程)
  • 触发 componentDidUpdate 生命周期

可以看到,完整的 Rax/React 生命周期都在其中,因此,对于 shouldComponentUpdate/memo 等对组件更新的优化,在 Rax 小程序编译时方案中都是可行的,而这这往往是很多 Rax 小程序开发者疏于关注的点。

其他

基于这套运行机制,我们接着实现了 hooks、render props、context 等语法能力,在小程序的限制下尽量做到了对齐 Web 的 Rax 开发体验。至于上述能力是怎么实现的,这里不再赘述,感兴趣的小伙伴可自行翻阅源码。

总结

以上就是 Rax 小程序编译时方案的所有技术原理介绍。欢迎大家访问 Rax 的 GitHub (github.com/alibaba/rax) 以了解更多内容。接下来,我们预计将推出 Rax 小程序运行时方案的介绍文章,敬请期待~-