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

上个月蚂蚁金服前端发布了一个新的框架 Remax, 口号是使用真正的、完整的 React 来开发小程序.

对于原本的 React 开发者来说 ‘Learn once, write anywhere’ , 和 ReactNative 开发体验差不多,而对于小程序来说则是全新的开发体验

Taro号称是‘类React’的开发方案,但是它是使用静态编译的方式实现,边柳 在它的 《Remax - 使用真正的 React 构建小程序》文章中也提到了这一点:

所谓静态编译,就是使用工具把代码语法分析一遍,把其中的 JSX 部分和逻辑部分抽取出来,分别生成小程序的模板和 Page 定义。

这种方案实现起来比较复杂,且运行时并没有 React 存在。


相比而言,Remax 的解决方案就简单很多,它不过就是新的React渲染器.


因为 Remax 刚发布不久,核心代码比较简单,感兴趣的可以去 github 观摩贡献

可以通过 CodeSandbox 游乐场试玩自定义Renderer: Edit react-custom-renderer

文章看起来比较长,好戏在后头,一步一步来 🦖


文章大纲


关于React的一些基本概念

创建一个 React 自定义渲染器,你需要对React渲染的基本原理有一定的了解。所以在深入阅读本文之前,先要确保你能够理解以下几个基本概念:

1. 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!'
}
}
}
}

也就是说 Element 就是一个普通的对象,描述用户创建的节点类型、props 以及 children。这些 Elements 组合成树,描述用户视图


2. Component

可以认为是 Element 的类型,它有两种类型:

  • Host Component: 宿主组件,这是由渲染的平台提供的‘内置’组件,例如ReactDOM 平台下面的 DOM 节点,如 divspan… 这些组件类型为字符串

  • Composite Component: 复合组件,这是一种用户自定义的组件封装单位。通常包含自定义的逻辑、状态以及输出 Element 树。复合类型可以为类或函数

const DeleteAccount = () => (
<div>
<p>Are you sure?</p>
<DangerButton>Yep</DangerButton>
<Button color='blue'>Cancel</Button>
</div>
);


3. Instance

当 React 开始渲染一个 Element 时,会根据组件类型为它创建一个‘实例’,例如类组件,会调用new操作符实例化。这个实例会一直引用,直到 Element 从 Element Tree 中被移除。

首次渲染: React 会实例化一个 MyButton 实例,调用挂载相关的生命周期方法,并执行 render 方法,递归渲染下级

render(<MyButton>foo</MyButton>, container)


更新: 因为组件类型没有变化,React 不会再实例化,这个属于‘节点更新’,React 会执行更新相关的生命周期方法,如shouldComponentUpdate。如果需要更新则再次执行render方法

render(<MyButton>bar</MyButton>, container)


卸载: 组件类型不一样了, 原有的 MyButton 被替换. MyButton 的实例将要被销毁,React 会执行卸载相关的生命周期方法,如componentWillUnmount

render(<button>bar</button>, container)


4. Reconciler & Renderer

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

Reconciler 的职责是维护 VirtualDOM 树,内部实现了 Diff/Fiber 算法,决定什么时候更新、以及要更新什么

Renderer 负责具体平台的渲染工作,它会提供宿主组件、处理事件等等。例如ReactDOM就是一个渲染器,负责DOM节点的渲染和DOM事件处理。



5. Fiber 的两个阶段
React 使用了 Fiber 架构之后,更新过程被分为两个阶段(Phase)

  • 协调阶段(Reconciliation Phase) 这个阶段 React 会找出需要更新的节点。这个阶段是可以被打断的,比如有优先级更高的事件要处理时。
  • 提交阶段(Commit Phase) 将上一个阶段计算出来的需要处理的副作用(Effects)一次性执行了。这个阶段必须同步执行,不能被打断


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

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


没理解?那么下文读起来对你可能比较吃力,建议阅读一些关于React基本原理的相关文章。


就目前而言,React 大部分核心的工作已经在 Reconciler 中完成,好在 React 的架构和模块划分还比较清晰,React官方也暴露了一些库,这极大简化了我们开发 Renderer 的难度。开始吧!


自定义React渲染器

React官方暴露了一些库供开发者来扩展自定义渲染器:

  • react-reconciler - 这就是 React 的协调器, React 的核心所在。我们主要通过它来开发渲染器。
  • scheduler - 合作调度器的一些 API 。本文不会用到

需要注意的是,这些包还是实验性的,API可能不太稳定。另外,没有详细的文档,你需要查看源代码或者其他渲染器实现;本文以及扩展阅读中的文章也是很好的学习资料。


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

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

const Reconciler = require('react-reconciler');

const HostConfig = {
// ... 实现适配器方法和配置项
};


第二步:实现渲染函数,类似于ReactDOM.render() 方法

// 创建Reconciler实例, 并将HostConfig传递给Reconciler
const 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 用来保存一些信息,以及管理所有节点的更新和渲染。

关于 Fiber 架构的一些细节可以看这些文章:


HostConfig 渲染器适配

HostConfig 支持非常多的参数,完整列表可以看这里. 下面是一些自定义渲染器必须提供的参数:

interface HostConfig {
/**
* 用于分享一些上下文信息
*/
// 获取根容器的上下文信息, 只在根节点调用一次
getRootHostContext(rootContainerInstance: Container): HostContext;
// 获取子节点的上下文信息, 每遍历一个节点都会调用一次
getChildHostContext(parentHostContext: HostContext, type: Type, rootContainerInstance: Container): HostContext;


/**
* 节点实例的创建
*/
// 普通节点实例创建,例如DOM的Element类型
createInstance(type: Type, props: Props, rootContainerInstance: Container, hostContext: HostContext, internalInstanceHandle: OpaqueHandle,): Instance;
// 文本节点的创建,例如DOM的Text类型
createTextInstance(text: string, rootContainerInstance: Container, hostContext: HostContext, internalInstanceHandle: OpaqueHandle): TextInstance;
// 决定是否要处理子节点/子文本节点. 如果不想创建则返回true. 例如ReactDOM中使用dangerouslySetInnerHTML, 这时候子节点会被忽略
shouldSetTextContent(type: Type, props: Props): boolean;

/**
* 节点树构建
*/
// 如果节点在*未挂载*状态下,会调用这个来添加子节点
appendInitialChild(parentInstance: Instance, child: Instance | TextInstance): void;
// **下面都是副作用(Effect),在’提交‘阶段被执行**
// 添加子节点
appendChild?(parentInstance: Instance, child: Instance | TextInstance): void;
// 添加子节点到容器节点(根节点)
appendChildToContainer?(container: Container, child: Instance | TextInstance): void;
// 插入子节点
insertBefore?(parentInstance: Instance, child: Instance | TextInstance, beforeChild: Instance | TextInstance): void;
// 插入子节点到容器节点(根节点)
insertInContainerBefore?(container: Container, child: Instance | TextInstance, beforeChild: Instance | TextInstance,): void;
// 删除子节点
removeChild?(parentInstance: Instance, child: Instance | TextInstance): void;
// 从容器节点(根节点)中移除子节点
removeChildFromContainer?(container: Container, child: Instance | TextInstance): void;

/**
* 节点挂载
*/
// 在完成所有子节点初始化时(所有子节点都appendInitialChild完毕)时被调用, 如果返回true,则commitMount将会被触发
// ReactDOM通过这个属性和commitMount配置实现表单元素的autofocus功能
finalizeInitialChildren(parentInstance: Instance, type: Type, props: Props, rootContainerInstance: Container, hostContext: HostContext): boolean;
// 和finalizeInitialChildren配合使用,commitRoot会在’提交‘完成后(resetAfterCommit)执行, 也就是说组件树渲染完毕后执行
commitMount?(instance: Instance, type: Type, newProps: Props, internalInstanceHandle: OpaqueHandle): void;

/**
* 节点更新
*/
// 准备节点更新. 如果返回空则表示不更新,这时候commitUpdate则不会被调用
prepareUpdate(instance: Instance, type: Type, oldProps: Props, newProps: Props, rootContainerInstance: Container, hostContext: HostContext,): null | UpdatePayload;
// **下面都是副作用(Effect),在’提交‘阶段被执行**
// 文本节点提交
commitTextUpdate?(textInstance: TextInstance, oldText: string, newText: string): void;
// 普通节点提交
commitUpdate?(instance: Instance, updatePayload: UpdatePayload, type: Type, oldProps: Props, newProps: Props, internalInstanceHandle: OpaqueHandle): void;
// 重置普通节点文本内容, 这个需要和shouldSetTextContent(返回true时)配合使用,
resetTextContent?(instance: Instance): void;

/**
* 提交
*/
// 开始’提交‘之前被调用,比如这里可以保存一些状态,在’提交‘完成后恢复状态。比如ReactDOM会保存当前元素的焦点状态,在提交后恢复
// 执行完prepareForCommit,就会开始执行Effects(节点更新)
prepareForCommit(containerInfo: Container): void;
// 和prepareForCommit对应,在提交完成后被执行
resetAfterCommit(containerInfo: Container): void;


/**
* 调度
*/
// 这个函数将被Reconciler用来计算当前时间, 比如计算任务剩余时间
// ReactDOM中会优先使用Performance.now, 普通场景用Date.now即可
now(): number;
// 自定义计时器
setTimeout(handler: (...args: any[]) => void, timeout: number): TimeoutHandle | NoTimeout;
// 取消计时器
clearTimeout(handle: TimeoutHandle | NoTimeout): void;
// 表示一个空的计时器,见👆clearTimeout的签名
noTimeout: NoTimeout;

// ? 功能未知
shouldDeprioritizeSubtree(type: Type, props: Props): boolean;
// 废弃
scheduleDeferredCallback(callback: () => any, options?: { timeout: number }): any;
// 废弃
cancelDeferredCallback(callbackID: any): void;


/**
* 功能开启
*/
// 开启节点修改,一般渲染器都会开启,不然无法更新节点
supportsMutation: boolean;
// 开启持久化 ?
supportsPersistence: boolean;
// 开启hydrate,一般用于服务端渲染
supportsHydration: boolean;

/**
* 杂项
*/
// 获取可公开的节点实例,即你愿意暴露给用户的节点信息,用户通过ref可以获取到这个对象。一般自定义渲染器原样返回即可, 除非你想有选择地给用户暴露信息
getPublicInstance(instance: Instance | TextInstance): PublicInstance;

// ... 还有很多参数,由于一般渲染器不会用到,暂时不讲了
}


如果按照Fiber的两个阶段来划分的话,接口分类是这样的:

| 协调阶段                 | 开始提交         | 提交阶段                  | 提交完成         |
|-------------------------|----------------|--------------------------|-----------------|
| createInstance | prepareCommit | appendChild | resetAfterCommit|
| createTextInstance | | appendChildToContainer | commitMount |
| shouldSetTextContent | | insertBefore | |
| appendInitialChild | | insertInContainerBefore | |
| finalizeInitialChildren | | removeChild | |
| prepareUpdate | | removeChildFromContainer | |
| | | commitTextUpdate | |
| | | commitUpdate | |
| | | resetTextContent | |


通过上面接口定义可以知道 HostConfig 配置比较丰富,涉及节点操作、挂载、更新、调度、以及各种生命周期钩子, 可以控制渲染器的各种行为.

看得有点蒙圈?没关系, 你暂时没有必要了解所有的参数,下面会一点一点展开解释这些功能。你可以最后再回来看这里。


宿主组件

React中有两种组件类型,一种是宿主组件(Host Component), 另一种是复合组件(CompositeComponent). 宿主组件是平台提供的,例如 ReactDOM 平台提供了 divspanh1… 等组件. 这些组件通常是字符串类型,直接渲染为平台下面的视图节点。

复合组件,也称为自定义组件,用于组合其他复合组件宿主组件,通常是类或函数。

渲染器不需要关心复合组件的处理, Reconciler 交给渲染器的是一颗宿主组件树

当然在 Remax 中,也定义了很多小程序特定的宿主组件,比如我们可以这样子使用它们:

function MyComp() {
return <view><text>hello world</text></view>
}


Reconciler 会调用 HostConfigcreateInstancecreateTextInstance 来创建宿主组件的实例,所以自定义渲染器必须实现这两个方法. 看看 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;
},

// 判断是否需要处理子节点。如果返回true则不创建,整个下级组件树都会被忽略。
// 有一些场景是不需要创建文本节点的,而是由父节点内部消化。
// 举个例子,在ReactDOM中,如果某个节点设置了dangerouslySetInnerHTML,那么它的children应该被忽略,
// 这时候 shouldSetTextContent则应该返回true
shouldSetTextContent(type, nextProps) {
return false
}
}

在 ReactDOM 中上面两个方法分别会通过 document.createElementdocument.createTextNode 来创建宿主组件(即DOM节点)。


上面是微信小程序的架构图(图片来源: 一起脱去小程序的外套 - 微信小程序架构解析)。

因为小程序隔离了渲染进程逻辑进程Remax 是跑在逻辑进程上的,在逻辑进程中无法进行实际的渲染, 只能通过setData方式将更新指令传递给渲染进程后,再进行解析渲染

所以Remax选择在逻辑进程中先构成一颗镜像树(Mirror Tree), 然后再同步到渲染进程中,如下图:


上面的 VNode 就是镜像树中的虚拟节点,主要用于保存一些节点信息,不做任何特殊处理, 它的结构如下:

export default class VNode {
id: number; // 唯一的节点id
container: Container;
children: VNode[]; // 子节点
mounted = false; // 节点是否已经挂载
type: string | symbol; // 节点的类型
props?: any; // 节点的props
parent: VNode | null = null; // 父节点引用
text?: string; // 如果是文本节点,这里保存文本内容
path(): Path // 节点的路径. 同步到渲染进程后,通过path恢复到树中
// 子节点操作
appendChild(node: VNode, immediately: boolean)
removeChild(node: VNode, immediately: boolean)
insertBefore(newNode: VNode, referenceNode: VNode, immediately: boolean)

update() // 触发同步到渲染进程
toJSON(): string
}

VNode 的完整代码可以看这里


镜像树的构建和操作

要构建出完整的节点树需要实现HostConfigappendChildinsertBeforeremoveChild 等方法, 如下, 这些方法都比较容易理解,所以不需要过多解释。

const HostConfig = {
// ...

// 支持节点修改
// 有些静态渲染的场景,例如渲染为pdf文档,这时候可以关闭
// 当关闭时,只需要实现appendInitiaChild
supportsMutation: true,

// 用于初始化(首次)时添加子节点
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);
},
}


节点更新

上一节讲的是树结构层面的更新,当节点属性变动或者文本内容变动时,也需要进行更新。我们可以通过下列 HostConfig 配置来处理这类更新:

const HostConfig = {
/**
* 更新相关
*/
// 可以在这里比对props,如果props没有变化则不进行更新,这和React组件的shouldComponentUpdate差不多
// **返回’空‘则表示不更新该节点, 这时候commitUpdate则不会被调用**
prepareUpdate(node: VNode, type: string, oldProps: any, newProps: any) {
oldProps = processProps(oldProps, node.container, node.id);
newProps = processProps(newProps, node.container, node.id);
if (!shallowequal(newProps, oldProps)) {
return true;
}
return null;
},

// 进行节点更新
commitUpdate(
node: VNode,
updatePayload: any,
type: string,
oldProps: any,
newProps: any
) {
node.props = processProps(newProps, node.container, node.id);
node.update();
},

// 进行文本节点更新
commitTextUpdate(node: VNode, oldText: string, newText: string) {
if (oldText !== newText) {
node.text = newText;
// 更新节点
node.update();
}
},
}

Ok, 这个也比较好理解。
对于普通节点更新,Reconciler 会先调用 prepareUpdate, 确定是否要更新,如果返回非空数据,Reconciler 就会将节点放入 Effects 链中,在提交阶段调用 commitUpdate 来执行更新。
文本节点更新则直接调用 commitTextUpdate,不在话下.


副作用提交

React 的更新的两个阶段这个概念非常重要,这个也体现在HostConfig上:

const HostConfig = {
// Reconciler说,我要开始提交了,你提交前要做什么,就在这做吧
// 比如ReactDOM会在这里保存当前DOM文档的选中状态和焦点状态, 以及禁用事件处理。因为DOM更新可能会破坏这些状态
prepareForCommit: () => {},

// Reconciler说,我已经提交完了
// ReactDOM会在这里恢复提交前的DOM文档的选中状态和焦点状态
resetAfterCommit: () => {},




// 在协调阶段,当一个节点完成'创建'后调用。如果有子节点,则在所有子节点appendInitialChild完成后调用
// 返回一个boolean值表示’完成提交‘后是否要调用commitMount. 通俗讲就是告诉Reconciler,当前节点完成’挂载‘后要执行某些东西
// ReactDOM会使用这个钩子来处理带有autofoucs属性的节点,在commitMount中实现自动获取焦点
finalizeInitialChildren: () => false,

// 和finalizeInitialChildren配合使用,如果前者返回true,在Reconciler完成提交后,对应节点的commitMount会被执行
commitMount: () => {},
}


将上文讲到的所有钩子都聚合起来,按照更新的阶段和应用的目标(target)进行划分,它们的分布是这样的:


那么对于 Remax 来说, 什么时候应该将’更新’提交到渲染进程呢?答案是上图所有在提交阶段的方法被调用时。

提交阶段原意就是用于执行各种副作用的,例如视图更新、远程方法请求、订阅… 所以 Remax 也会在这个阶段收集更新指令,在下一个循环推送给渲染进程。


HostConfig执行流程总结

回顾一下自定义渲染器各种方法调用的流程, 首先看一下挂载的流程:

假设我们的组件结构如下:

const container = new Container()
const MyComp = () => {
return (
<div>
<span>hello world</span>
</div>
)
}

render(
<div className="root">
<MyComp />
<span>--custom renderer</span>
</div>,
container,
() => {
console.log("rendered")
},
)

React 组件树的结构如下(左图),但对于渲染器来说,树结构是右图。
自定义组件是React 层级的东西,渲染器只需要关心最终需要渲染的视图结构, 换句话说渲染器只关心宿主组件:


挂载会经历以下流程:

通过上面的流程图,可以很清晰看到每个钩子的调用时机。


同理,我们再来看一下节点更新时的流程. 我们稍微改造一下上面的程序,让它定时触发更新:

const MyComp = () => {
const [count, setCount] = useState(1)
const isEven = count % 2 === 0
useEffect(() => {
const timer = setInterval(() => {
// 递增计数器
setCount(c => c + 1)
}, 10000)

return () => clearInterval(timer)
}, [])

return (
<div className="mycomp" style={{ color: isEven ? "red" : "blue" }}>
{isEven ? <div>even</div> : null}
<span className="foo">hello world {count}</span>
</div>
)
}


下面是更新的流程:


MyCompcount 由1变为2时,MyComp 会被重新渲染,这时候新增了一个div 节点(红色虚框), 另外 hello world 1 也变成了 hello world 2

新增的 div 节点创建流程和挂载时一样,只不过它不会立即插入到父节点中,而是先放到Effect链表中,在提交阶段统一执行。

同理hello world {count}文本节点的更新、以及其他节点的 Props 更新都是放到Effect链表中,最后时刻才更新提交. 如上图的 insertBeforecommitTextUpdatecommitUpdate.

另外一个比较重要的是 prepareUpdate 钩子,你可以在这里告诉 Reconciler,节点是否需要更新,如果需要更新则返回非空值,这样 commitUpdate 才会被触发。


同步到渲染进程

React 自定义渲染器差不多就这样了,接下来就是平台相关的事情了。
Remax 目前的做法是在触发更新后,通过小程序 Page 对象的 setData 方法将更新指令传递给渲染进程;
渲染进程侧再通过 WXS 机制,将更新指令恢复到树中; 最后再通过模板机制,将树递归渲染出来。

整体的架构如下:


先来看看逻辑进程侧是如何推送更新指令的:

// 在根容器上管理更新
export default class Container {
// ...
// 触发更新
requestUpdate(
path: Path,
start: number,
deleteCount: number,
immediately: boolean,
...items: RawNode[]
) {
const update: SpliceUpdate = {
path, // 更新节点的树路径
start, // 更新节点在children中的索引
deleteCount,
items, // 当前节点的信息
};
if (immediately) {
this.updateQueue.push(update);
this.applyUpdate();
} else {
// 放入更新队列,延时收集更新指令
if (this.updateQueue.length === 0) {
setTimeout(() => this.applyUpdate());
}
this.updateQueue.push(update);
}
}

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 = [];
}
}


逻辑还是比较清楚的,即将需要更新的节点(包含节点路径、节点信息)推入更新队列,然后触发 setData 通知到渲染进程


渲染进程侧,则需要通过 WXS 机制,相对应地将更新指令恢复到渲染树中:

// 渲染树
var tree = {
root: {
children: [],
},
};

// 将指令应用到渲染树
function reduce(action) {
switch (action.type) {
case 'splice':
for (var i = 0; i < action.payload.length; i += 1) {
var value = get(tree, action.payload[i].path);
if (action.payload[i].item) {
value.splice(
action.payload[i].start,
action.payload[i].deleteCount,
action.payload[i].item
);
} else {
value.splice(action.payload[i].start, action.payload[i].deleteCount);
}
set(tree, action.payload[i].path, value);
}
return tree;
default:
return tree;
}
}


OK, 接着开始渲染, Remax 采用了模板的形式进行渲染:

<wxs src="../../helper.wxs" module="helper" />
<import src="../../base.wxml"/>
<template is="REMAX_TPL" data="{{tree: helper.reduce(action)}}" />

Remax 为每个组件类型都生成了一个template,动态’递归’渲染整颗树:

<template name="REMAX_TPL">
<block wx:for="{{tree.root.children}}" wx:key="{{id}}">
<template is="REMAX_TPL_1_CONTAINER" data="{{i: item}}" />
</block>
</template>

<wxs module="_h">
module.exports = {
v: function(value) {
return value !== undefined ? value : '';
}
};
</wxs>

<!-- 按照层级生成模板 -->
<% for (var i = 1; i <= depth; i++) { %>
<%var id = i; %>
<!-- 生成组件模板 -->
<% for (let component of components) { %>
<%- include('./component.ejs', {
props: component.props,
id: component.id,
templateId: id,
}) %>
<% } %>
<template name="REMAX_TPL_<%=id%>_plain-text" data="{{i: i}}">
<block>{{i.text}}</block>
</template>
<!-- 把动态选择模板的逻辑放入一个模板内,可以提升性能问题 -->
<template name="REMAX_TPL_<%=id%>_CONTAINER" data="{{i: i}}">
<template is="{{'REMAX_TPL_<%=id%>_' + i.type}}" data="{{i: i}}" />
</template>
<% } %>


限于小程序的渲染机制,以下因素可能会影响渲染的性能:

  • 进程IPC。更新指令通过IPC通知到渲染进程,频繁更新可能会影响性能. ReactNative 中涉及到 Native 和 JS引擎之间的通信,也是存在这个问题的。
    所以小程序才有了 WXS 这类方案,用来处理复杂的视图交互问题,比如动画。未来 Remax 也需要考虑这个问题
  • Reconciler这一层已经进行了 Diff,到渲染进程可能需要重复再做一遍?
  • 基于模板的方案,局部更新是否会导致页面级别重新渲染?和小程序原生的自定义组件相比性能如何?


总结

本文以 Remax 为例,科普一个 React 自定义渲染器是如何运作的。对于 Remax,目前还处于开发阶段,很多功能还不完善。至于性能如何,笔者还不好做评论,可以看官方给出的初步基准测试。有能力的同学,可以参与代码贡献或者 Issue 讨论。

最后谢谢边柳对本文审校和建议。


扩展阅读