小程序框架Remax原理分析

1,151 阅读4分钟

在 remax,taro3 这类重运行时小程序框架出来前,主要流行的方案是提前将 vue 或者 react 代码编译成 wxml,受限于小程序的语法,我们在开发的过程当中语法会受到很多限制,vue 可能还好一点,像 react 这样比较灵活的框架在语法转换时对框架作者也是一场噩梦。

当然这也是框架开发者的无奈之举,其主要原因是小程序的运行环境和传统的浏览器环境有着很大的不同。处于性能和安全性的考量,微信小程序使用两个线程分别来做逻辑层和渲染层。逻辑层含有 jsCore,可以执行 js 代码,渲染层负责具体 ui 的渲染,但不能直接执行 js。两个线程之间通过 setData 的方式进行通信。所以除了利用 setData 的方式以外,我们并不能通过 js 直接操作小程序的 dom 结构,这就导致了没法直接使用 react/vue 这类框架了。

01.11cd5f0e.jpg

有没有什么办法可以把 react/vue 跑起来,不受语法限制的去开发小程序呢?答案肯定是有的,kbone/remax/taro3 等都已经验证了这种方案的可行性,主要的途径有两种:

  • 利用 react-reconciler 可以自定义渲染器的特性,连接小程序的渲染
  • 模拟 web 环境下的 BOM/DOM api

第一种是 remax 采用的方案,第二种是 taro3 和 kbone 采用的方案,今天我们主要来讲一下前者。

react-reconciler

首先我们需要了解的是 react,react-reconciler,react-dom 分别做了哪些事情。用过 react 框架的同学肯定知道,我们在文件中写的 jsx 代码最终会被 babel 转换成 React.createElement 的形式,也就是创建了一个 React 元素。另外,我们还可以将一个 class 继承 React.Component,这样它就变成了一个 React 组件。于是,利用 react 包我们可以:

  • 创建 react element
  • 创建 react component 实例(instance)

但是光有这些描述页面结构的 element 和 instance,如何渲染呢?

ReactDOM

我们通常会写 ReactDOM.render(element, container, callback), 通过这种方式将虚拟 dom 渲染出来。react-reconciler 扮演的角色入下图所示:

image.png

  • reconciler 的职责是维护虚拟 dom 树,内部实现了 diff/fiber 算法,决定什么时候更新以及要更新什么。
  • react-dom 里面定义了使用哪些具体环境的 api,比如浏览器的 appendChild,removeChild 等。然后定义成具体的 hostConfig,传递给 reconciler。

总而言之,reconfiler 是指挥官,负责下达指令,指令具体该如何执行由 react-dom 负责。

实现小程序层的渲染器

有了以上认知,我们只要针对小程序环境实现一个渲染器就可以了。当然前提是充分了解自定义渲染器的技术细节,可以参考这篇官方文档来学习:

zh-hans.reactjs.org/docs/implem…

vnode

remax 使用 vnode 来描述小程序内部的 dom 信息,大概的格式如下:

interface VNode {
  type: string;
  props: object;
  text: string;
  children: VNode[];
  appendChild(node: VNode): void;
  removeChild(node: VNode): void;
  insertBefore(newNode: VNode, referenceNode: VNode): void;
}

首次渲染的时候,整个 vnode 的信息会通过 setData 接口传递到渲染层,然后通过微信小程序提供的 template 语法,将整个视图渲染出来。

数据更新

当页面更新的时候,如果每次一点点改动都重新将整个 vnode tree 都重新通过 setData 传递,效率一定十分低下。这里 remax 的解决方案是更新发生时,只传递更新的部分:

  applyUpdate() {
    const action = {
      type: 'splice',
      payload: this.updateQueue.map(update => ({
        path: stringPath(update.path),
        start: update.start,
        deleteCount: update.deleteCount,
        item: update.items[0],
      })),
    };

    // 通过setData通知渲染进程
    this.context.setData({ action });
    this.updateQueue = [];
  }

而完整的 vnode tree 在渲染层维护着。当更新过来后,渲染层只要把变更同步到整个树上,再次触发模板渲染就可以了。这里有个知识点,微信小程序内,渲染层可以执行的代码称之为 wxs,和 js 之间并不能完全画等号。wxs 不能直接操作逻辑层的数据,可以监听逻辑层传送过来的数据。更多的细节可以查看微信官方文档。

模板递归

我们知道微信小程序的模板语法是不支持递归的,那改如何渲染我们的 vnode tree 呢?

这里的解决方案是提前将一定层级的模板提前构建好,比如 remax 默认是渲染 20 层。如果超过这个限制对于性能的影响还是非常大的。Taro3 的做法是超过限制的层级会使用自定义组件来实现递归,因为自定义组件是支持递归的。下面用一个简单的例子来演示一下:

const vnodes = {
  type: "text",
  value: "depth 1 value",
  child: {
    type: "text",
    value: "depth 2 value",
    child: {
      type: "text",
      value: "depch 3 value",
    },
  },
};
<template name="TPL_BASE">
    <template is="TPL_1" data="{{d: d, i: 1}}" />
</template>

<wxs module="h">
module.exports = {
    get: function(i){
        return i + 1
    }
}
</wxs>

<template name="TPL_1">
    <view wx:if="d && d.value"><text>{{d.value}}</text></view>
    <template is="{{'TPL_' + h.get(i)}}" data="{{d:d.child, i: h.get(i)}}" />
</template>
<template name="TPL_2">
    <view wx:if="d && d.value"><text>{{d.value}}</text></view>
    <template is="{{'TPL_' + h.get(i)}}" data="{{d:d.child, i: h.get(i)}}" />
</template>
<template name="TPL_3">
    <view wx:if="d && d.value"><text>{{d.value}}</text></view>
    <template is="{{'TPL_' + h.get(i)}}" data="{{d:d.child, i: h.get(i)}}" />
</template>

这里举例只是渲染三层,当然多层也是一样的。这部分的工作在构建阶段就已经做好了。