阅读 5795

【2万字长文】深入浅出主流的几款小程序跨端框架原理

目前,小程序在用户规模及商业化方面都取得了极大的成功。微信、支付宝、百度、字节跳动等平台的小程序日活都超过了3亿。

我们在开发小程序时仍然存在诸多痛点:小程序孱弱简陋的原生开发体验,注定会出现小程序增强型框架,来提升开发者开发体验;各家厂商小程序API碎片化的现状,注定会有多端框架会成为标配,由跨端框架肩负跨平台移植挑战。

正是因为开发者对于提升小程序开发效率有着强烈需求,小跨端框架发展到如今已经百花齐放、百家争鸣:除了美团的 mpvue 、网易的 megalo 、滴滴的 chameloen 已经趋于稳定,京东的 Taro开始探索 taro next, Hbuilder 的uni-app 产品和生态持续完善,微信新推出了支持H5和微信小程序的 kbone 框架,蚂蚁金服的 remax

上述的这么多跨端框架纷繁复杂,我们可以从下面两个维度进行分类:

小程序跨端框架的分类

按语法分类

从框架的语法来说,可以分为下面两类:

  • Vue 语法

  • React 语法 / 类 React 语法

主流的跨端框架基本遵循 React、Vue 语法,这也比较好理解,可以复用现有的技术栈,降低学习成本。

remaxTaro nextTaro 1/2megalompvueuni-appchameloen
语法reactreact类 react (nerve)vuevuevue类 vue
厂家蚂蚁金服京东京东网易考拉美团Hbuilder滴滴

按实现原理分类

从实现原理上,开源社区的跨端框架大致分为下面两类:

  • compile time** 编译时**

  • runtime** 运行时**

compile time** 编译时**的跨端框架,主要的工作量在编译阶段。他们框架约定了一套自己的 DSL ,在编译打包的过程中,利用 babel 工具通过 AST 进行转译,生成符合小程序规则的代码。

这种方式容易出现 BUG ,而且开发限制过多。早期的** Taro 1.0 和 2.0** 的版本就是采用的这种方案,下文会有更具体的介绍。

而另外一种runtime** 运行时模式**, 跨端框架真正的在小程序的逻辑层中运行起来 React 或者是 Vue 的运行时,然后通过适配层,实现自定义渲染器。这种方式比静态编译有天然的优势,所以 Taro 的最新 Next 版本和 Remax 采用的是这种方案。

写在小程序跨端原理之前

通过上文我们知道小程序跨端框架目前有很多嘛,各个大厂都会有自己的一套,百花齐放。文章篇幅有限,如果要分别拆开讲清楚他们各家实现的细节,是一件很困难同时很费时间的事情。

所以,下文会尝试梳理一下主流小程序一些共用性的通用实现原理, 尽量会屏蔽忽略掉各家实现一些细枝末节的细节差异,也不会在文章中贴大段的源码分析,而是通过伪代码来代替。

下面,我们会从** Vue 跨端框架**和 React 跨端框架两个大方向,进入到小程序跨端原理的世界,讲解这些跨端框架的核心原理,深入到源码底层去分析,揭开他们神秘的面纱。

Vue 跨端框架

当你使用 megalompvue 这些 Vue 跨端框架时,看上去,我们写的是vue 的代码,然后打包编译之后就可以运行在小程序内,是不是很神奇?这些框架背后做了哪些事情呢?

实际上,这些** Vue的跨端框架 **核心原理都差不多,都是把 Vue 框架拿过来强行的改了一波,借助了 vue 的能力。比如说,vue 的编译打包流程(也就是vue-loader的能力), vue 的响应式双向绑定、虚拟dom、diff 算法。上面这些东西跨端框架都没有修改,直接哪来用的。

那么哪些部分是这些跨端框架自己新加的东西呢?

涉及到 Vue 框架中操作DOM节点的代码

这些跨端框架,把原本Vue框架中原生 javascript 操作 DOM 的方法,替换成小程序平台的 setData()。为什么要这样呢?不着急,下文会有比较详细的讲解。

不着急,慢慢来,我们先从一个最简单的问题开始。

vue 到 小程序

首先我们来看,一个 vue 的单文件,究竟做了啥,怎么就能跑在小程序里面了?

我们知道,对于微信小程序来说,需要有 4份文件:.wxml.wxss.js.json

(上面是去微信小程序官网截的图)

而对于一个 Vue 组件来说,一个 vue 文件有三个部分组成:template, script, style

那么,这些跨端框架把Vue 单文件中的 <template><script><style>这三个部分对应的代码,拆一拆,分别处理编译一下,分到 .wxml.wxss.js.json 这 4 份文件中,如下图所示:

我们分别从<template><script><style>这三个部分来讨论:

  • <style> 部分是最简单的。一般来说,在 h5 环境中的 css 样式,大部分都可以直接挪到 .wxss,需要处理的部分比较少,除了少部分不支持的属性和 小程序的单位转换
  • <template>** 转换到 **.wxml稍微复杂一点。我们需要把 h5 的标签啊、vue特殊的语法替换成小程序的标签、小程序特殊的语法。替换的工作我们称为 模板替换 ,下文会有一个章节用来介绍。
  • 最难的是<script>** 到 **.js, 涉及到 vue 的运行时 如何和 小程序的实例通讯的问题,这一部分会用比较多的章节去介绍。

接下来,我们先看模板替换 ,也就是template 生成 .wxml 文件的过程。

<template>.wxml

Vue 是采用 template 语法的,各大厂商的小程序也是采用了 template 语法。从 Vue 的 template 转变成小程序的 template 相对比较简单,React 的 jsx 转变为小程序的 template 就相对比较棘手啦。

Vue 的 template 与小程序的 template 大体是上的语法很类似,但是还是有不一样的地方。例如小程序里没有 <div> 标签,而是小程序厂商提供的 <view> 标签等等

因此我们需要把 Vue 模版转换为微信小程序的 .wxml

例如上图所示,<div> 标签需要转换成 <view> 标签,一些 vue 中的语法也需要进行转化成对应小程序平台的语法。

再比如说,在 Vue 里面绑定事件常用 @methodName 的语法, 转成小程序模版则需要用 bind,同时用 tap 事件替换 click 事件。

除了这个,还有一些vue的模板语法,也需要转成小程序的模板语法

Vue 和小程序插值表达式则是一样的,采用了双花括号,可以不需要做任何转化

上面展示的这些模板替换,都只是替换为微信小程序的语法。转化为其他小程序平台的语法也是类似的思路,如下图所示:

那么,模板的转化具体是如何实现的呢? 我们的第一想法是通过正则来匹配,但是要写出匹配出所有情况的正则是非常困难的。

实际上,mpvuemegalouni-app 的框架是采用了 ast 来解析转化模板的。

模板替换过程其实就是两侧对齐语法的过程,把语法不一致的地方改成一样的,是一个 case by case 的过程,只要匹配到不同情况下语法即可,比较费功夫但是难度系数不是很高。

接下来我们看如何把 <script> 中的内容,挪到小程序 .js 中呢?

<script>.js

我们在 .vue 单文件中的 script 部分中, 通常会写下面的代码,我们会写一个 Vue 的配置项对象传入到 Vue 构造函数中,然后 new Vue() 会实例化出来一个 vue 实例。

new Vue({
  data(){},
  methods: {},
  components: {}  
})
复制代码

上面的代码是完全可以跑在小程序的逻辑层里面的,只要引入vue 即可,毕竟 Vue 大部分就是纯粹的 javascript。也就是说,小程序的渲染层里面是完全可以直接运行起来 Vue 的运行时和 React 的运行时的。

但是这样还不够,小程序平台还规定,要在小程序页面中调用 Page() 方法生成一个 page 实例, Page() 方法是小程序官方提供的 API。

在一个小程序的页面中,是必须有 Page() 方法的。微信小程序会在进入一个页面时,扫描该页面中的 .js 文件,如果没有调用 Page() 这个方法,页面会报错。

如下图所示,我们在 <script> 中写的是 new Vue() 这样子的代码,而微信想要的是 Page()

那么,应该怎么解决呢?

Vue 跨端框架他们拓展了 Vue 的框架,把 Vue2.0 的源码直接拷贝过来,改了里面的初始化方法,在初始化方法中调用了 Page() 方法, 如下面伪代码所示:

new Vue() {};
Vue.init = () => { 
  // 在 vue 初始化的时候,调用了 page() 方法
  Page()
}   
复制代码

在 vue 实例化的时候,会调用 init 方法,在 init 方法里面会调用 Page() 函数,生成一个小程序的 page 实例。

这样,我们在一个小程序页面中,就会同时存在一个 vue 的实例,和一个小程序的 page 实例,他们是如何融合起来一起工作的呢?他们之间是如何做到数据管理的? 如何进行通讯的呢?

接下来就涉及到 Vue 框架的核心流程了,为了方便一些不了解 Vue 同学,同时也为了更好的深入理解下面讲的内容,接下来会稍微讲一丢丢 vue 的核心流程。

  • ( 真的只有一丢丢 )

Vue 的核心流程

如下图左侧所示,简单来说, 一个 .vue 的单文件由三部分构成: template, script, style

我们先看上图中的橙黄色的路径,也就是 template 部分的处理过程。

如下图所示,template 模板部分会在编译打包的过程中,被 vue-loader 调用 compile 方法通过词法分析生成一个 ast 对象,然后调用代码生成器,经过遍历 AST 树递归的拼接字符串操作,最终生成一段 render 函数, render函数最后会存在打包生成的dist 文件中。

可以看下面这个例子,一段简单的 template 模板如下所示:

<div class="ctl-view" @click="handleClick">
  {{ a }}
</div>
复制代码

经过编译之后,通过 ast 进行分析,生成的 render 函数如下:

_c("div", 
    { staticClass: "ctl-view", on: { click: _vm.handleClick } },
    [_vm._t("default")]
)
复制代码

render 函数会在第一次 mount时,或者Vue 维护的 data 有更新产生的时候会被执行。

那么执行下面这段 render 函数会拿到什么呢?

上面图中蓝色圆圈中的 _c 方法是创建元素类型的vnode, 而 _v 方法是创建 文本类型的vnode。

Render 函数中会调用这些方法创建不同类型的vnode,最终的产物是生成好的虚拟DOM树 vnode tree,对应上面图中 render 函数的下一个阶段 vnode。

虚拟DOM树是对真实DOM树的抽象,树中的节点被称作 vnodevnode 有一个特点, 它保存了这个DOM节点用到了哪些数据 ,这一点非常重要。

Vue拿到 虚拟dom树之后,就可以去和上次老的虚拟dom树patch diff 对比。

这一步的目的是找出,我们应该怎么样改动现存的老的DOM树,代价才最小。

patch 阶段之后,如果是运行在浏览器环境中, vue 实例就会使用真实的原生 javascript 操作DOM的方法(比如说 insertBefore , appendChild 之类的),去操作DOM节点,更新浏览器页面的视图。

接下来,我们再来看一下上面图中,蓝色的线条的路径。

在new Vue 的时候,Vue 在初始化的时候会对数据 data 做响应式的处理,当有数据发生更新时,最后会调用上文的 render 函数,生成最新的虚拟DOM树。

接着对比老的虚拟DOM 树进行 patch, 找出最小修改代价的vnode 节点进行修改。

上面介绍的流程就是 vue 的整体流程啦。

(如果有不理解的地方,不重要,也不需要担心会阻塞下文的阅读)

我们要关心的是,下面的类 vue 小程序跨端框架的核心流程。接下来一起来看吧。

类 vue 小程序跨端框架的核心流程

在进一步讲解之前,我们先思考一个问题。上图中,Vue 在 diff 之后就是操作了原生的 DOM 元素,但是各家厂商的小程序不支持原生DOM操作,因此也就没有修改视图节点的能力。那么我们怎么样才能更新小程序的视图呢?

下面这张图代表了类 vue 小程序跨端框架的核心流程图。

咋一看这张图,会发现和上面Vue的图是很像的。毕竟 megalompvue 等小程序框架,本质都是对 vue 的拓展(copy过来改了改)

仔细和上面的 vue 的核心流程图一对比,我们发现,小程序跨端框架的流程图替换掉 vue 原本的 DOM 操作,替换为新增的绿色的setData 操作, 同时还多了一个绿色框框中的的 Page() 方法。

Page() 方法上文有介绍过原因

setData() 是小程序官方提供的 API,用来修改小程序 page 实例上的数据,从而会更新小程序的视图。

『替换掉 vue 原本的 DOM 操作』这一个点比较容易理解,因为小程序容器并没有提供操作小程序节点的 API 方法,这是因为小程序隔离了渲染进程 (渲染层)和逻辑进程 (逻辑层),如下图所示:

在小程序容器中,逻辑层到渲染层的更新,只能通过 setData() 来实现。

不管是 mpvuemegalo ,还是uniapp,这些类 vue 跨端框架,都是通过这种方法来更新视图的。而且,在未来可预见的几年里,只要小程序厂商不提供修改小程序节点的 API 方法,小程序跨端框架更新 DOM 节点仍然会通过 setData 这种 API

好了,到了这一步,我们已经知道了,跨端框架替换了 Vue 框架中 **JS 操作DOM 原生节点的 API **为 **setData() **来更新小程序的页面。

但是我们还是不知道具体背后做了什么,接下来,看一个具体的例子:

new Vue({
  data(){
    return {
      showToggle: true
    }
  }
})


// 下面是经过 模板替换 之后的代码
<view wx:if="{{showToggle}}">
</view>
复制代码

在上面的例子中,showToggle 这个变量代表的数据是维护在Vue 实例上的。

在页面初始化的时候,我们的小程序跨端框架就开始执行了,它会先实例化一个Vue 实例,然后调用小程序官方的 Page() API 生成了小程序的page 实例,并在在 Vue 的 mounted 中会把数据同步到小程序的 page 实例上。

因此在实际页面打开之后,会同时存在小程序原生的Page 实例和 Vue 实例。vue 实例上有数据(我们的 data 本来就是定义在 vue 里面的),小程序Page 实例上也有数据(小程序实例上没数据没法渲染页面对吧)。

当 Vue 中的数据发生变化时,会触发 Vue 响应式的逻辑,走 上图中Vue 更新的那一套逻辑:重新执行 render 函数 👉🏻 得出一份最新的 Vnode 树 👉🏻 接下来 Vue去 diff 新旧两个 Vnode 的树,找出修改 DOM 节点最高效的操作。注意!接下来不是调用操作 DOM 的 API, 而是调用小程序的 setData() API 方法, 👉🏻 修改小程序实例上的对应的数据, 从而让小程序渲染层层去更新视图。

这一套流程下来我们发现,通俗来讲,数据是归 Vue 管。 Vue 是一个双向数据绑定的框架,小程序也是一个双向数据绑定的东西,这两个东西放一块,通过跨端框架的运行时来做中间桥接,把数据同步到小程序中。

事件归到小程序容器管,小程序触发各种事件,比如说滚动,事件点击,小程序容器捕获到事件后,会去调用在 Vue 注册的对应的事件处理函数。

上面介绍的模型,是一个通用的Vue 小程序跨端框架的实现。 Vue 的小程序跨端框架基本上思路是一致的。有了这些理解和认识,我们再来看一下各家小程序框架是如何实现的:

Mpvue 模型

下面是 mpvue 官方网站上的一张原理图:

从右到左来看,当 Vue 上数据变化时,会通过 mpVue 运行时来通知小程序的实例,从而更新小程序 page 视图。从左到右,当小程序的渲染层容器触发了事件后,会通过跨端框架运行时来找到注册的 vue 的事件回调函数

Uni-app 模型

我们接着来看,下面是 uni-app 的官网的原理图,和上面的图像素级别的相似啊

从右到左来看,当 Vue 上数据变化时,会通过uni-app运行时来通知小程序的实例,从而更新小程序 page 视图。从左到右,当小程序的渲染层容器触发了事件后,会通过跨端框架运行时来找到注册的 vue 的事件回调函数

Megalo 模型

下面是 megao 官方的一张原理图,这两张图和上面看似长的不一样,但表达的的意思是一样的。

小结

在这个小节中,重点部分有跨端框架模板替换、 vue 的核心流程、跨端框架替换了 Vue 的 javascript 操作真实 DOM 的 API 等。

至此,一个 vue 跨端框架的核心流程就已经走完了。这个流程中,一些跨端框架会进行优化,不同的跨端框架会采用不同的优化策略,下面我们以网易的 Megalo 为例探讨。

网易考拉 Megalo 的优化

现在,我们先假设 Vue 中维护的数据小程序中维护的数据一模一样,数据结构完全相同。那么当 Vue 中维护的数据发生变化时,直接把**Vue 中维护的数据 **原模原样的同步到小程序中,如下所示,紫色的部分是直接同步过去的数据。

// vue 中维护的数据
new Vue({
  data(){
    return {
      showToggle: true,
      bigObj1: {},
      bigObj2: {}
    }
  }
})


// 小程序中维护的数据
{
      showToggle: true,
      bigObj1: {},
      bigObj2: {}
 }
复制代码

这样有一个问题是,小程序 setData 是有性能问题的,如果频繁地进行调用或者一次型更新大量数据,容易造成页面卡顿。

为了降低更新频率的问题,我们可以通过加一个截流函数进行限制。

那么怎样减少数据更新的量呢?

上面的代码中, bigObj1bigObj2 是非常非常巨大的对象,但是小程序页面中完全没有用,那么同步bigObj1bigObj2 对于 setData 来说就是巨大的浪费。

事实上,很多框架都对此做了优化,我们来看 megalo 是怎么做的。

假设我们写的 Vue 实例上的数据是这样的:

然后假设我们写的 Vue 的 template 是这样写:

上面我们写的 <template> 模板代码在编译的时候会有两个作用,一方面把模板替换为小程序的标签,另一方面通过 vue-loader 编译生成 render 函数,如下图所示:

如果我们仔细观察上面编译出来的 render 函数,会发现 megalo 做了一些手脚,多了一些参数,这些参数是 megalo 自己加上去的标记。

这些标记被 uglify 了,很难懂是什么意思,下面介绍一下:

h_: 是独一无二的 id, 是一个累加的数字。只有当前节点依赖了 data 数据时才有 h_
f_: v-for 循环时的 index
c_: 父亲组件的 id
复制代码

执行上面这段 megalo 生成的 render 函数,同样会生成一个 vnode 树用于 patch。其中的某一些** vnode 节点会稍显特殊,当一个 vnode节点依赖了vue 实例上的 data 数据时,该 vnode 节点** 的 attrs 属性上就会有 h_f_c_ 的值。

如下图所示的那样,只有依赖了 data 数据的那三个节点,才会有 h_f_c_ 的值。

在第一次 mount 的时候,megalo 同步到小程序实例上的数据不是原本 vue 上的数据,那是什么呢?

megalo 会对上面生成的蓝色的 vnode 树的结构进行摘取 + 扁平化的处理,变成一维数组,然后同步到小程序实例上。

如上图所示,先把**用到 data 的 Vnode 节点(上图带有属性的那三个节点)**摘取出来再打平成一维数组。

这样的好处是:当 vue 实例上的数据有很多时,只有那些真正在模板中使用到的数据,才会在编译的阶段被拼接到 render() 函数中,然后出现在生成的 Vnode 树上(在编译阶段做的事情),之后才有资格被 megalo 摘出来扁平化压缩成一维数组,最后同步到小程序实例上。

那些多余的没有用的数据,是永远不会出现在Vnode 树上的。

为啥要扁平化为一维数组呢?

这是因为 Vnode 层级关系可能是很深很复杂的,如果把这种复杂的层级关系也维护在小程序实例中,会比较麻烦。

小程序 setData 方法支持传入一个对象,对象的 key 是可以有层次关系的,比如说下面代码中的 0 表示父组件的 id, 1 表示节点上的id

setData({ '$root.0.h.1.t', 'a' })
复制代码

如果我们把节点打平,标识一个唯一的 id, 就可以只维护一个扁平化的一维数组。

这样,当 Vue 的 Vnode tree 上某一个 Vnode 节点发生了变动时,我们需要同步更新小程序上的数据,不需要关心那个 Vnode 节点在树上的哪个位置,只需要知道那个节点上的id ——也就是** attrs.h_ 值和父组件的 id ——也就是 attrs.c_** 值 ,然后拼出上面 setData 的 key 路径——$root.0.h.1.t,就可以精准且方便修改小程序实例上的数据了

计算 vnode 节点上的id 是通过 getHid(), 计算父组件的 id 是通过 getVMId() 方法

getHid()

getHid() 方法本质上是返回节点上的** attrs.h_ **的值, 上面说过了 **h_ **是独一无二的 id, 是一个累加的数字。如果模板中有 v-if 循环的话,则返回 attrs.h_ + '-' + attrs.f_ 的值。

getVMId()

getVMId() 方法本质上是返回节点上的 attrs.c_ 的值,也就是所在的组件的 id。 Vue 中组件是有层级关系的,在小程序中数据被打平了怎么表示到底是修改哪一层的组件的数据呢?

Megalo 通过拼接v 字符来表示组件的层级

0v0 表示是第二层组件,也就是 Vnode 中第一个组件中的第一个子组件

0v1 也表示是第二层组件,也就是 Vnode 中第一个组件中的第二个子组件

0v0v0 表示是第三层组件,也就是 Vnode 中第一个组件中的第一个子组件中的第一个孙子组件

只要能算出正确的类似如$root.0.h.1.t 的 key 路径,我们就可以正确的把 Vnode 树,更新到微信小程序上。

模板改造

当然,模板部分也要配合上面扁平化的修改,差值的 `{{}}` 中,应该在编译的时候替换为适配扁平化的数据

小结

总结一下,Megalo 小程序实例上数据,既不是 vue 实例上原模原样的数据,也不是 vue 生成那颗 Vnode 树,而是 Megao 从 Vnode 树上摘取后再经过扁平化压缩后得到的数据结构。这样可以带来性能上的提升。

类 React 跨端框架

类 React 框架存在一个最棘手的问题: 如何把灵活的 jsx 和动态的 react 语法转为静态的小程序模板语法。

为了解决问题,不同的的团队实践了不同的方案,大体上可以把所有的类 React 框架分类两类:

  • 静态编译型。 代表有:京东的 Taro 1/2 , 去哪儿的 Nanachi,淘宝的rax
  • 运行时型。代表有: 京东的 Taro Next ,蚂蚁的 remax

静态编译型小程序框架

所谓静态编译,就是上面说的这些框架会把用户写的业务代码解析成 AST 树,然后通过语法分析强行把用户写的类 react 的代码转换成可运行的小程序代码。

如下图所示的Taro 1版本或者2版本的逻辑图,整个跨端的核心逻辑是落在编译过程中的抽象语法树转化中做的。

Taro 1/2 在编译的时候,使用 babel-parser 将 Taro 代码解析成抽象语法树,然后通过 babel-types 对抽象语法树进行一系列修改、转换操作,最后再通过 babel-generate 生成对应的目标代码。

有过 Babel 插件开发经验的同学应该对上面流程十分熟悉了,无非就是调用 babel 提供的 API 匹配不同的情况,然后修改 AST 树。

下面我们来举一个例子,如果我们使用 Taro 1/2 框架来写小程序页面组件,很可能是长成下面这样:

可以看到上面组件非常像一个 React 组件,你需要定义一个 Componentrender 方法,并且需要返回一段 JSX

这段的代码,会在 Taro1/2 编译打包的时候,被框架编译成小程序代码。具体来说, render 方法中的 JSX 会被提取出来,经过一系列的重重转换,转换成小程序的静态模板,其他 JS 的部分则会保留成为小程序页面的定义,如下图所示:

这听上去是一件很美好的事情,但是现实很骨感,为啥呢?

JSX 的语法过于灵活。

JSX 的灵活是一个双刃剑,它可以让我们写出非常复杂灵活的组件,但是也增加了编译阶段框架去分析和优化的难度。

你在使用 JavaScript 的时候,编译器不可能hold住所有可能发生的事情,因为 JavaScript 太过于动态化。你想用静态的方式去分析它是非常复杂一件事情,我们只要稍微在上面的图中例子中加入一点动态的写法,这些框架就可能编译失败。

虽然这块很多框架已经做了很多尝试,但从本质上来说,框架很难通过这种方式对其提供安全的优化。

这也是 React 团队花了3 年的时候搞出来 fiber 的意义, React 的优化方案并不是在编译时优化,而是在运行时通过时间分片不阻塞用户的操作让页面感觉快起来。

所以,React 解决不了的问题,这些小程序跨端框架同样也解决不了。

他们都会告诉开发者要去避免很多的动态写法。比如说 Taro 1 /2 版本的文档里面就给出了非常清晰的提示

Taro 1/2 的弯路

Taro 发展到了2019年,他们终于意识到了上面问题的紧迫性: JSX 适配工作量大,很难追上 react 的更新。

这些问题归根到底,很大一部分是 Taro 1/2 的架构问题。Taro 1/2 用 穷举 的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大,完全就是堆人力去适配 jsx ,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法

于此同时,蚂蚁金服的@边柳在第三届 SEE Conf 介绍了 Remax ,走了不同于静态编译的一条路,推广的口号是 『使用真正的 React 来构建小程序』。因为 Taro 1/2是假的 React,只是在开发时遵循了 React 的语法,在代码编译之后实际运行时的和 React 并没有半毛钱关系,因此也没法支持 React 最新的特性。

Taro 团队从活跃的社区中受到了启发,完全重写了 Taro 的架构,带来了 Taro Next 版本。

接下来,我们会一点点揭开 React 运行时跨端框架的面纱。Taro NextRemax 原理相似,Remax 已经比较稳定了,下面会着重讲解 Remax 的原理,Taro Next 放在最后作为比较。

你需要对 React 的基本原理有一定的了解。

React 前置知识

在深入阅读本文之前,先要确保你能够理解以下几个基本概念:

Element

通过 JSX 或者 React.createElement 来创建 Element,比如:

<button class='button button-blue'>
  <b>
    OK!
  </b>
</button>
复制代码

JSX 会被转义译为:

React.createElement(
  "button",
  { class: 'button button-blue' },
  React.createElement("b", null, "OK!"))
复制代码

React.createElement 最终构建出类似这样的对象:

{
  type: 'button',
  props: {
    className: 'button button-blue',
    children: {
      type: 'b',
      props: {
        children: 'OK!'
      }
    }
  }
}
复制代码

**Reconciler 调和器 **& Renderer 渲染器

React 16版本带来了全新的 fiber 的架构,代码拆分也非常清晰,大体上可以拆分成这三大块:

  • React component API 代码量比较少

  • Reconciler 调和器 代码量非常大,是fiber 调度的核心

  • **Renderer 渲染器,**负责具体到某一个平台的渲染,最常见的 ReactDOM 就是 web 浏览器平台的自定义渲染器

ReconcilerRenderer 的关系可以通过下图缕清楚

  • Reconciler 调和器的职责是负责 React 的调度和更新,内部实现了 Diff/Fiber 算法,决定什么时候更新、以及要更新什么。

  • **Renderer自定义渲染器,**负责具体到哪一个平台的渲染工作,它会提供宿主组件、处理事件等等。

Renderer 自定义渲染器里面定义了一堆方法,是提供给 React 的 reconciler 使用的。React 的 reconciler 会调用渲染器中的定义一系列方法来更新最后的页面。

我们接下来会重点介绍Renderer自定义渲染器, 暂且先不管 Reconciler 调和器 ,就先认为它是一个React 提供的黑盒。这个黑盒里面帮我们做了时间分片、任务的优先级调度和 fiber 节点 diff 巴拉巴拉一系列的是事情,我们都不关心。我们只需要知道 Reconcier 调和器在做完 current fiber tree 和 workIn progress fiber tree 的 diff 工作后,收集到 effects 准备 commit 到真实的 DOM 节点,是调用了的自定义渲染器中提供的方法。

如果在自定义渲染器中,你调用了操作 WEB 浏览器 web DOM的方法,诸如我们很熟悉的 createElementappendhild,那么就创建/更新浏览器中的 web 页面;如果渲染器中你调用了iOS UI Kit API,那么则更新 ios ,如果渲染器中调用了 Android UI API, 则更新 Android。

Renderer 自定义渲染器有很多种,我们最常见的ReactDOM就是一个渲染器,不同的平台有不同的 React 的渲染器,其他还有很多有意思的自定义渲染器,可以让 React 用在TV 上,Vr 设备上等等,可以点击这个链接进行了解: github.com/chentsulin/…

事实上,Remax 和 Taro Next 相当于是自己实现了一套可以在 React 中用的,且能渲染到小程序页面的自定义渲染器。

总结来说,React 核心调度工作是在 Reconciler 中完成;『画』到具体的平台上,是自定义渲染器的工作。

关于React 渲染器的基本原理,如果对这个话题感兴趣的同学推荐观看前 React Team 成员 Sophie Alpert 在 React Conf 上分享的《Building a Custom React Renderer》,也特别推荐这个系列的文章 Beginners guide to Custom React Renderers,讲解的比较细致

Fiber 架构的两个阶段

React 16 版本Fiber 架构之后,更新过程被分为两个阶段:

  • 协调阶段(Reconciliation Phase) 这个阶段 Reconciler 调度器会根据事件切片,按照任务的优先级来调度任务,最终会找出需要更新的节点。协调阶段是可以被打断的,比如有优先级更高的事件要处理时。

  • 提交阶段(Commit Phase) 将协调阶段计算出来的需要处理的**副作用(Effects)**一次性执行,也就是把需要做出的更改,一下子应用到 dom 节点上,去修改真实的 DOM 节点。这个阶段必须同步执行,不能被打断

这两个阶段按照render为界,可以将生命周期函数按照两个阶段进行划分:

  • 协调阶段
    • constructor
    • componentWillMount 废弃
    • componentWillReceiveProps 废弃
    • static getDerivedStateFromProps
    • shouldComponentUpdate
    • componentWillUpdate 废弃
    • render
    • getSnapshotBeforeUpdate()
  • 提交阶段
    • componentDidMount

    • componentDidUpdate

    • componentWillUnmount

自定义渲染器 Rerender

创建一个自定义渲染器只需两步:

  1. 宿主配置HostConfig,也就是下图中绿色方框 HostConfig 的配置

  2. 实现渲染函数,类似于 ReactDOM.render() 方法

宿主配置 HostConfig,这是react-reconciler要求宿主平台提供的一些适配器方法和配置项。这些配置项定义了如何创建节点实例、构建节点树、提交和更新等操作。下文会详细介绍这些配置项

const Reconciler = require('react-reconciler');
const HostConfig = {
  // ... 实现适配器方法和配置项
};
复制代码

渲染函数就比较套路了,类似于 ReactDOM.render() 方法,本质就是调用了 ReactReconcilerInst 的两个方法 createContainerupdateContainer

// 创建Reconciler实例, 并将HostConfig传递给Reconcilerconst 


MyRenderer = Reconciler(HostConfig);  


// 假设和ReactDOM一样,接收三个参数 
// render(<MyComponent />, container, () => console.log('rendered')) 


export function render(element, container, callback) {  // 创建根容器  
if (!container._rootContainer) {    container._rootContainer = ReactReconcilerInst.createContainer(container, false);  }   // 更新根容器  
return ReactReconcilerInst.updateContainer(element, container._rootContainer, null, callback); 
}
复制代码

容器既是 React 组件树挂载的目标(例如 ReactDOM 我们通常会挂载到 #root 元素,#root 就是一个容器)、也是组件树的 根Fiber节点(FiberRoot)。根节点是整个组件树的入口,它将会被 Reconciler 用来保存一些信息,以及管理所有节点的更新和渲染。

Remax 的自定义渲染器

HostConfig 支持非常多的参数,这些参数非常多,而且处于 API 不稳定的状态,大家稍微了解一下即可,不用深究。另外,没有详细的文档,你需要查看源代码或者其他渲染器实现。

如果感兴趣的同学可以移步这篇文章 react 渲染器了解一下?。常见配置可以按照下面的阶段来划分:

通过上面代码,我们可以知道 HostConfig 配置比较丰富,涉及节点操作、挂载、更新、调度、以及各种生命周期钩子, Reconciler 会在不同的阶段调用配置方法。比如说在协调阶段会新建节点,在提交阶段会修改子节点的关系。

为了思路清晰,我们按照 【协调阶段】——【提交阶段】—— 【提交完成】这三个阶段来看,我们接下来先看一下协调阶段。

协调阶段

在协调阶段, Reconciler 会调用 HostConfig 配置里面的 createInstancecreateTextInstance 来创建节点。我们接下俩看看 Remax 源码是怎么样子的

const HostConfig = {
  // 创建宿主组件实例
  createInstance(type: string, newProps: any, container: Container) {
    const id = generate();
    // 预处理props, remax会对事件类型Props进行一些特殊处理
    const props = processProps(newProps, container, id);
    return new VNode({
      id,
      type,
      props,
      container,
    });
  },


  // 创建宿主组件文本节点实例
  createTextInstance(text: string, container: Container) {
    const id = generate();
    const node = new VNode({
      id,
      type: TYPE_TEXT,
      props: null,
      container,
    });
    node.text = text;
    return node;
  },
}
复制代码

大家可以回想一下,如果是原本的 ReactDOM 中的话,上面两个方法应该是通过 javascript 原生的 API document.createElementdocument.createTextNode 来创建浏览器环境的中的DOM节点

因为在小程序的环境中,我们没有办法操作小程序的原生节点,所以Remax 在这里,不是直接去改变 DOM,而创建了自己的 VNode 节点。

你可能会感到惊讶,还能这样玩,不是说好要操作平台的节点嘛,这样不会报错吗?

原因是,React 的 Reconciler 调和器在调度更新时,不关心 hostConifg 里你新建的一个节点到底是啥,也不会改写你在 hostConifg 中定义的节点属性。

所以自定义渲染器Renderer中一个节点可以是一个 DOM 节点,也可以是自己定义的一个普通 javascript 对象,也可以是 VR 设备上的一个元素。

总而言之,React 的 Reconciler 调度器并不关心自定义渲染器 Renderer 中的节点是什么形状的,只会把这个节点透传到 hostConfig 中定义的其他方法中,比如说 appendChildremoveChildinsertBefore 这些方法中。

上面 Remax 的代码中创建了自己的 VNode 节点, VNode 的基本结构如下:

interface VNode {
  id: number;
  container: Container;
  children: VNode[];
  mounted: boolean;
  type: string | symbol;
  props?: any;
  parent: VNode | null;
  text?: string;
  appendChild(node: VNode): void;
  removeChild(node: VNode): void;
  insertBefore(newNode: VNode, referenceNode: VNode): void;
  toJSON(): RawNode;
}
复制代码

友情提示,这里的 VNode 是 Remax 中自己搞出来的一个对象,和 React 或者 Vue 中的 virtual dom 没有半毛钱的关系

可以看到,VNode 其实通过 childrenparent 组成了一个树状结构,我们把它称为一颗镜像树(Mirror Tree),这颗镜像树最终会渲染成小程序的界面。 VNode** 就是镜像树中的**虚拟节点,主要用于保存一些节点信息。

所以, Remax在 HostConfig 配置的方法中,并没有真正的操作 DOM 节点,而是先构成一颗镜像树(Mirror Tree), 然后再同步到渲染进程中,如下图绿色的方框所示的那样,我们会使用 React 构成一个镜像树的 Vnode Tree,然后交给小程序平台把这个树给渲染出来。

提交阶段

提交阶段也就是 commit 阶段,react 会把 effect list 中存在的变更同步到渲染环境的 DOM 节点上去,会分别调用 appendChildremoveChildinsertBefore 这些方法

const HostConfig = {


  // 用于初始化(首次)时添加子节点
  appendInitialChild: (parent: VNode, child: VNode) => {
    parent.appendChild(child, false);
  },


  // 添加子节点
  appendChild(parent: VNode, child: VNode) {
    parent.appendChild(child, false);
  },


  // 插入子节点
  insertBefore(parent: VNode, child: VNode, beforeChild: VNode) {
    parent.insertBefore(child, beforeChild, false);
  },


  // 删除节点
  removeChild(parent: VNode, child: VNode) {
    parent.removeChild(child, false);
  },


  // 添加节点到容器节点,一般情况我们不需要和appendChild特殊区分
  appendChildToContainer(container: any, child: VNode) {
    container.appendChild(child);
    child.mounted = true;
  },


  // 插入节点到容器节点
  insertInContainerBefore(container: any, child: VNode, beforeChild: VNode) {
    container.insertBefore(child, beforeChild);
  },


  // 从容器节点移除节点
  removeChildFromContainer(container: any, child: VNode) {
    container.removeChild(child);
  },
}
复制代码

下面我们看,Remax 源码里面究竟是如何实现这些方法的。

appendChild

如果是原生的浏览器环境中,appendChild 比较简单,直接调用 javascript 原生操作 DOM 的方法即可。如果是小程序的环境中,你得自己实现 hostConfig 中定义的 VNode 节点上的 appendChild 的方法,源码实现如下:

VNode.prototype.appendChild = function (node) {
  // 把 node 挂载到 child 链表上 
  // firstChild指针指向链表的开头
  // lastChild 指针指向链表的结尾
  if (!this.firstChild) {
    this.firstChild = node;
  }


  if (this.lastChild) {
    this.lastChild.nextSibling = node;
    node.previousSibling = this.lastChild;
  }


  this.lastChild = node;


  // 如果节点已经挂载了,则调用 requestUpdate 方法,传入一些参数
  if (this.isMounted()) {
    this.container.requestUpdate({
      type: 'splice',
      path: this.path,
      start: node.index,
      id: node.id,
      deleteCount: 0,
      children: this.children,
      items: [node.toJSON()],
      node: this
    });
  }
};
复制代码

上面代码中,并没有直接操作小程序的 DOM ,而是操作存内存中的 VNode 组成的镜像树:

  1. 把入参 node 挂载到 child 链表上 ;
  2. 最后调用了 requestUpdate 这个方法,下面会有详细的讲到。

removeChild

removeChild 方法和上面是同一个套路,先是修改了 VNode 镜像树上的节点关系,然后调用了 requestUpdate 这个方法

insertBefore

insertBefore 方法和上面是同一个套路,先是修改了 VNode 镜像树上的节点关系,然后调用了 requestUpdate 这个方法

上面介绍的这些方法,都是对节点位置关系的更新,比如说子节点位置的移动啊之类的。

现实中肯定也会有一些更新是不涉及到节点移动,而是比如说,节点上的属性发生了变化、节点的文本发生了变化,Reconciler 就会在协调阶段调用下面的这些方法。

commitUpdate

commitUpdate: function (node, updatePayload, type, oldProps, newProps) {
    // 处理一下 props
    node.props = processProps(newProps, node, node.id);
    node.update(updatePayload);
},
复制代码

上面调用了 node.update 方法,定义如下

VNode.prototype.update = function (payload) {
    if (this.type === 'text' || !payload) {
        this.container.requestUpdate({
            type: 'splice',
            // root 不会更新,所以肯定有 parent
            path: this.parent.path,
            start: this.index,
            id: this.id,
            deleteCount: 1,
            items: [this.toJSON()],
            node: this,
        });
        return;
    }
    for (var i = 0; i < payload.length; i = i + 2) {
        var _a = __read(toRawProps(payload[i], payload[i + 1], this.type), 2), propName = _a[0], propValue = _a[1];
        var path = __spread(this.parent.path, ['nodes', this.id.toString(), 'props']);
        if (RuntimeOptions.get('platform') === 'ali') {
            path = __spread(this.parent.path, ["children[" + this.index + "].props"]);
        }
        this.container.requestUpdate({
            type: 'set',
            path: path,
            name: propName,
            value: propValue,
            node: this,
        });
    }
};
复制代码

真神奇鸭,最后还是调用了 requestUpdate 方法,殊途同归的感觉。

上面的方法中,最后都调用了神奇的 requestUpdate 方法,我们看一下这个方法里面做了什么

requestUpdate

requestUpdate 方法定义如下:

没想到吧, 这个requestUpdate方法那么简单。

  1. 接受一个对象作为参数

  2. 然后把接收的参数 update 推入到 this.updateQueue 这个数组里面,暂存起来,之后会在【提交完成阶段】派上大用场。

提交完成阶段

在这个阶段之前,Remax 构成的 VNode镜像树的这个JSON 数据还是在 Remax 世界中被管理和维护,接下来,我们会看如何更新 小程序的世界中。

React 会在提交完成阶段执行 hostConfig 中定义的 resetAfterCommit 方法,这个方法原本是用React 想来做一些善后的工作。但是Remax 在这个resetAfterCommit 方法做了一个及其重要的工作,那就是同步镜像树到小程序**** data

接下来我们来看 resetAfterCommit 方法的源码

  resetAfterCommit: function resetAfterCommit(container) {
    container.applyUpdate();
  },
复制代码
AppContainer.prototype.applyUpdate = function () {
  this.context._pages.forEach(function (page) {
    page.container.applyUpdate();
  });
};
复制代码
Container.prototype.applyUpdate = function () {
    // 省略了 其他的逻辑
   
    var updatePayload = this.updateQueue.reduce(function (acc, update) {
         //  通过之前缓存的updateQueue 计算出来 updatePayload
        return acc;
    }, {});
    
    // 小程序的setData 终于出现了!!!! 把 updatePayload 同步到小程序的逻辑层
    this.context.setData(updatePayload, function () {
        nativeEffector.run();
    });
    this.updateQueue = [];
};
复制代码

上面代码的意思是, **通过之前缓存的updateQueue 计算出来 updatePayload, **updatePayload 是一个什么东东呢?我们可以通过 debug 断点来一览它的风采。

在某一次更新之后的断点:

updatePayload 是一个 javascript 的对象,对象的 key 是数据在小程序世界中的路径,对象的 value 就是要更新的值。

小程序的 setData 是支持这样的写法: setData({ root.a.b.c: 10 }), key 可以表达层次关系

在第一次 mount 时的断点:

我们可以在开发者工具中看到小程序实例上的数据,大概长下面这个样子。

{
  "root": {
    "children": [
      7
    ],
    "nodes": {
      "7": {
        "id": 7,
        "type": "view",
        "props": {
          "class": "app___2lhPP",
          "hover-class": "none",
          "hover-stop-propagation": false,
          "hover-start-time": 50,
          "hover-stay-time": 400
        },
        "children": [
          4,
          6
        ],
        "nodes": {
          "4": {
            "id": 4,
            "type": "button",
            "props": {
              "bindtap": "$$REMAX_METHOD_4_onClick",
              "hover-class": "button-hover",
              "hover-start-time": 20,
              "hover-stay-time": 70
            },
            "children": [
              3
            ],
            "nodes": {
              "3": {
                "id": 3,
                "type": "plain-text",
                "text": " click me"
              }
            }
          },
          "6": {
            "id": 6,
            "type": "view",
            "props": {
              "hover-class": "none",
              "hover-stop-propagation": false,
              "hover-start-time": 50,
              "hover-stay-time": 400
            },
            "children": [
              5
            ],
            "nodes": {
              "5": {
                "id": 5,
                "type": "plain-text",
                "text": ""
              }
            }
          }
        }
      }
    }
  },
  "modalRoot": {
    "children": []
  },
  "__webviewId__": 31
}
复制代码

在第一次 mount 时,Remax** 运行时**初始化时会通过小程序的 setData 初始化小程序的 JSON 树状数据

然后,Remax** 运行时在数据发生更新时,就会通过小程序的 setData更新**上面小程序的 JSON 树状数据

那么,剩下最后一个问题,现在我们知道了,小程序实例上有了一个 JSON 的树状对象,如何渲染成小程序的页面呢?

从 JSON 数据到小程序渲染

如果在浏览器环境下,这个问题非常简单,JavaScript 可以直接创建 DOM 节点,只要我们实现使用递归,便可完成从 VNodeDOM 的还原,渲染代码如下:

function render(vnode) {
  if (typeof vnode === 'string') {
    return document.createTextNode(vnode)
  }


  const props = Object.entries(vnode.props)
  const element = document.createElement(vnode.type)


  for (const [key, value] of props) {
    element.setAttribute(key, value)
  }


  vnode.children.forEach((node) => {
    element.appendChild(render(node))
  })


  return element
}
复制代码

但在小程序环境中,不支持直接创建 DOM ,仅支持模板渲染,该如何处理?

上文中,我们讲到类 Vue 的小程序框架的模板是从 Vue 的 template 部分转成的;

类 React 的运行时小程序框架,jsx 很难转成模板,只有一个 Vnode 节点组成的镜像树。

如果我们去看 Remax 打包之后的模板代码,也会发现空空如也,只有三行代码,第一行引用了一个 **base.wxml **文件,第二行是一个叫 REMAX_TPL 的模板

<template is="REMAX_TPL" data={{root: root}}>  </template>
复制代码

第二行代码表示使用 REMAX_TPL 模板,传入的数据是 root, root 是小程序实例上维护的数据,就是上面我们提到的小程序的 JSON 树状数据,每一个节点上保存了一些信息,如下所示:

{
 "root": {
 "children": [
  7
 ],
  "nodes": {
  "7": {
   "id": 7,
    "type": "view",
    "props": {
    "class": "app___2lhPP",
     "hover-class": "none",
     "hover-stop-propagation": false,
     "hover-start-time": 50,
     "hover-stay-time": 400
   },
   "children": [
    4,
    6
   ],
    "nodes": {
    "4": {
     "id": 4,
      "type": "button",
      "props": {
      "bindtap": "$$REMAX_METHOD_4_onClick",
       "hover-class": "button-hover",
       "hover-start-time": 20,
       "hover-stay-time": 70
     },
     "children": [
      3
     ],
      "nodes": {
      "3": {
       "id": 3,
        "type": "plain-text",
        "text": " click me"
      }
     }
    },
    "6": {
     "id": 6,
      "type": "view",
      "props": {
      "hover-class": "none",
       "hover-stop-propagation": false,
       "hover-start-time": 50,
       "hover-stay-time": 400
     },
     "children": [
      5
     ],
      "nodes": {
      "5": {
       "id": 5,
        "type": "plain-text",
        "text": "sss"
      }
     }
    }
   }
  }
 }
},
 "modalRoot": {
 "children": []
},
 "__webviewId__": 2
}
复制代码

我们来看 base.wxml 里面是什么内容,发现 base.wxml 内容超级多,有3000多行。如下图:

这个 base.wxml 文件是固定的,每一次打包都会生成那么代码,代码中定义了好几种的小程序的 template 类型,然后重复定义了好几遍,只是 name 名字的值不同。这是为了兼容某一些小程序平台不允许 <template> 组件自己嵌套自己,用来模拟递归嵌套的。

我们回到刚才的那一行代码,有一个名字是 REMAX_TPL 的模板组件。

<template is="REMAX_TPL" data={{root: root}}>  </template>
复制代码

REMAX_TPL 的模板组件定义在base.wxml 里面,如下所示:

<template name="REMAX_TPL">
 <block wx:for="{{root.children}}" wx:key="*this">
  <template is="REMAX_TPL_1_CONTAINER" data="{{i: root.nodes[item], a: ''}}"/>
 </block>
</template>


<template name="REMAX_TPL_1_CONTAINER" data="{{i: i}}">
 <template is="{{_h.tid(i.type, a)}}" data="{{i: i, a: a + ',' + i.type, tid: 1}}"/>
</template>
复制代码

上面代码,首先遍历了 root 数据中的 children 数组,遍历到每一项的话,用名字是 REMAX_TPL_1_CONTAINER 的模板组件继续渲染数据中的 root.[item] 属性

REMAX_TPL_1_CONTAINER 的模板组件的定义,其实是用当前数据的节点的类型——也就是调用 _h.tid(i.type, a) 方法来算出节点类型,可能是 text, button ——找到节点类型对应的 template 模板,再次递归的遍历下去。

_h.tid 的方法定义如下,其实就是拼接了两个值: 1. 递归的深度deep的值,2.** 节点的 type**

tid = function (type, ancestor) {
var items = ancestor.split(',');
var depth = 1;


for (var i = 0; i< items.length; i++) {
if (type === items[i]) {
depth = depth + 1;
}
}


var id = 'REMAX_TPL_' + depth + '_' + type;
return id;
}
复制代码

可以看到,Remax 会根据每个子元素的类型选择对应的模板来渲染子元素,然后在每个模板中又会去遍历当前元素的子元素,以此把整个节点树递归遍历出来。

总结一下:

在第一次 mount 时,Remax 运行时初始化时会通过小程序的 setData 初始化小程序的 JSON 树状数据, 在小程序加载完毕后, Remax 通过递归模板的形式,把JSON 树状数据渲染为小程序的页面,用户就可以看到页面啦。

然后,Remax 运行时在数据发生更新时,就会通过小程序的 setData更新上面小程序的 JSON 树状数据, JSON 树状数据被更新了,小程序自然会触发更新数据对应的那块视图的渲染。

Remax 创造性的用递归模板的方式,用相对静态的小程序模板语言实现了动态的模板渲染的特性。

总结

看到这里,我们已经对 remax 这种类 react 的跨端框架整体流程有了大概的了解

Taro Next 的实现原理

Taro Next 的原理和 Remax 是很像的,这里我就偷懒一下,直接把 Taro 团队在 GMTC大会上的 ppt 贴过来了,高清版本的 ppt 可以点击这个链接下载:程帅-小程序跨框架开发的探索与实践-GMTC 终稿.pdf

下面发现和 remax 是很像的。

Taro 团队实现了 taro-react 包,用来连接 react-reconcilertaro-runtime 的 BOM/DOM API

Taro-react 就做了两件事情:

  1. 实现 hostConfig 配置,我们上面已经介绍过了

  2. 实现 render 函数(类似于 ReactDOM.render)方法,我们上面也已经介绍过了

在更新的过程中,同样是在 appendChild、 insertBefore、removeChild 这些方法里面调用了 enqueueUpdate 方法(人家 remax 叫updateQueue)

渲染的话,和 Remax 的做法一样,基于组件的 template 动态 “递归” 渲染整棵树。

具体流程为先去遍历 Taro DOM Tree ( 对应 Remax 中叫镜像树 )根节点的子元素,再根据每个子元素的类型选择对应的模板来渲染子元素,然后在每个模板中又会去遍历当前元素的子元素,以此把整个节点树递归遍历出来。

基本上和 remax 一样,换汤不换药。

参考资料

Taor 1/2 官方文档中关于 JSX 支持程度补充说明

用Vue.js开发微信小程序:开源框架mpvue解析

自己写个React渲染器: 以 Remax 为例(用React写小程序)

Remax 官网的原理分析

「2019 JSConf.Asia - 尤雨溪」在框架设计中寻求平衡

知乎 Remax 开发博客

一起脱去小程序的外套 - 微信小程序架构解析

Beginners guide to Custom React Renderers

Remax - 使用真正的 React 构建小程序

react 渲染器了解一下?

Taro Next 架构揭秘 | GMTC《小程序跨框架开发的探索与实践》万字无删减

Sophie Alpert 在 React Conf 上分享的《Building a Custom React Renderer》