在上一篇推文中,我们详细讲解了【Vue跨端框架的原理】。下面,我们将从React跨端框架,进入到小程序跨端原理的世界,讲解这些跨端框架的核心原理,深入到源码底层去分析,揭开他们神秘的面纱。
1
类 React 框架存在一个最棘手的问题:如何把灵活的 jsx 和动态的 react 语法转为静态的小程序模板语法。
为了解决问题,不同的的团队实践了不同的方案,大体上可以把所有的类 React 框架分类两类:
Taro 1/2
, 去哪儿的 Nanachi
,淘宝的rax
Taro Next
,蚂蚁的 remax
所谓静态编译,就是上面说的这些框架会把用户写的业务代码解析成 AST 树,然后通过语法分析强行把用户写的类 react 的代码转换成可运行的小程序代码。
如下图所示的Taro1版本或者2版本的逻辑图,整个跨端的核心逻辑是落在编译过程中的抽象语法树转化中做的。
Taro 1/2 在编译的时候,使用 babel-parser 将 Taro 代码解析成抽象语法树,然后通过 babel-types对抽象语法树进行一系列修改、转换操作,最后再通过 babel-generate 生成对应的目标代码。
有过 Babel 插件开发经验的同学应该对上面流程十分熟悉了,无非就是调用 babel 提供的 API 匹配不同的情况,然后修改 AST 树。
下面我们来举一个例子,如果我们使用 Taro 1/2 框架来写小程序页面组件,很可能是长成下面这样:
可以看到上面组件非常像一个 React 组件,你需要定义一个 Component
和 render
方法,并且需要返回一段 JSX
。
这段的代码,会在 Taro1/2 编译打包的时候,被框架编译成小程序代码。具体来说, render
方法中的 JSX
会被提取出来,经过一系列的重重转换,转换成小程序的静态模板,其他 JS 的部分则会保留成为小程序页面的定义,如下图所示:
这听上去是一件很美好的事情,但是现实很骨感,为啥呢?
JSX 的语法过于灵活。
JSX 的灵活是一个双刃剑,它可以让我们写出非常复杂灵活的组件,但是也增加了编译阶段框架去分析和优化的难度。
你在使用 JavaScript 的时候,编译器不可能hold住所有可能发生的事情,因为 JavaScript 太过于动态化。你想用静态的方式去分析它是非常复杂一件事情,我们只要稍微在上面的图中例子中加入一点动态的写法,这些框架就可能编译失败。
虽然这块很多框架已经做了很多尝试,但从本质上来说,框架很难通过这种方式对其提供安全的优化。
这也是 React 团队花了3 年的时候搞出来 fiber 的意义, React 的优化方案并不是在编译时优化,而是在运行时通过时间分片不阻塞用户的操作让页面感觉快起来。
所以,React 解决不了的问题,这些小程序跨端框架同样也解决不了。
他们都会告诉开发者要去避免很多的动态写法。比如说 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 团队从活跃的社区中受到了启发 ( ~~抄了人家的 remax ~~),完全重写了 Taro 的架构,带来了 Taro Next 版本。
接下来,我们会一点点揭开 React 运行时跨端框架的面纱。Taro Next
和 Remax
原理相似,Remax 已经比较稳定了,下面会着重讲解 Remax 的原理,Taro Next 放在最后作为比较。
你需要对 React 的基本原理有一定的了解。
在深入阅读本文之前,先要确保你能够理解以下几个基本概念:
通过 JSX
或者 React.createElement
来创建 Element,比如:
JSX
会被转义译为:
React.createElement
最终构建出类似这样的对象:
React 16版本带来了全新的 fiber 的架构,代码拆分也非常清晰,大体上可以拆分成这三大块:
Reconciler
和 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的方法,诸如我们很熟悉的 createElement
、appendhild
,那么就创建/更新浏览器中的 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,讲解的比较细致
React 16 版本Fiber 架构之后,更新过程被分为两个阶段:
这两个阶段按照render
为界,可以将生命周期函数按照两个阶段进行划分:
constructor
componentWillMount
废弃componentWillReceiveProps
废弃static getDerivedStateFromProps
shouldComponentUpdate
componentWillUpdate
废弃render
getSnapshotBeforeUpdate()
componentDidMount
componentDidUpdate
componentWillUnmount
创建一个自定义渲染器只需两步:
ReactDOM.render()
方法宿主配置 HostConfig,这是react-reconciler
要求宿主平台提供的一些适配器方法和配置项。这些配置项定义了如何创建节点实例、构建节点树、提交和更新等操作。下文会详细介绍这些配置项
渲染函数就比较套路了,类似于 ReactDOM.render()
方法,本质就是调用了 ReactReconcilerInst
的两个方法 createContainer
和 updateContainer
容器既是 React 组件树挂载的目标
(例如 ReactDOM 我们通常会挂载到 #root
元素,#root
就是一个容器)、也是组件树的 根Fiber节点(FiberRoot)
。根节点是整个组件树的入口,它将会被 Reconciler 用来保存一些信息,以及管理所有节点的更新和渲染。
HostConfig
支持非常多的参数,这些参数非常多,而且处于 API 不稳定的状态,大家稍微了解一下即可,不用深究。另外,没有详细的文档,你需要查看源代码或者其他渲染器实现。
如果感兴趣的同学可以移步这篇文章 react 渲染器了解一下?。常见配置可以按照下面的阶段
来划分:
通过上面代码,我们可以知道 HostConfig
配置比较丰富,涉及节点操作、挂载、更新、调度、以及各种生命周期钩子, Reconciler
会在不同的阶段调用配置方法。比如说在协调阶段会新建节点,在提交阶段会修改子节点的关系。
为了思路清晰,我们按照 【协调阶段】——【提交阶段】—— 【提交完成】这三个阶段来看,我们接下来先看一下协调阶段。
在协调阶段, Reconciler
会调用 HostConfig
配置里面的 createInstance
和createTextInstance
来创建节点。我们接下俩看看 Remax
源码是怎么样子的
大家可以回想一下,如果是原本的 ReactDOM 中的话,上面两个方法应该是通过 javascript 原生的 API document.createElement
和 document.createTextNode
来创建浏览器环境的中的DOM节点
。
因为在小程序的环境中,我们没有办法操作小程序的原生节点,所以Remax 在这里,不是直接去改变 DOM,而创建了自己的 VNode
节点。
你可能会感到惊讶,还能这样玩,不是说好要操作平台的节点嘛,这样不会报错吗?
原因是,React 的 Reconciler 调和器在调度更新时,不关心 hostConifg 里你新建的一个节点到底是啥,也不会改写你在 hostConifg 中定义的节点属性。
所以自定义渲染器Renderer中一个节点可以是一个 DOM 节点,也可以是自己定义的一个普通 javascript 对象,也可以是 VR 设备上的一个元素。
总而言之,React 的 Reconciler 调度器并不关心自定义渲染器 Renderer 中的节点是什么形状的,只会把这个节点透传到 hostConfig
中定义的其他方法中,比如说 appendChild
、removeChild
、insertBefore
这些方法中。
上面 Remax 的代码中创建了自己的 VNode
节点, VNode
的基本结构如下:
友情提示:这里的
VNode
是 Remax 中自己搞出来的一个对象,和 React 或者 Vue 中的 virtual dom 没有半毛钱的关系
可以看到,VNode
其实通过 children
和 parent
组成了一个树状结构,我们把它称为一颗镜像树
(Mirror Tree),这颗镜像树最终会渲染成小程序的界面。VNode
就是镜像树中的虚拟节点
,主要用于保存一些节点信息。
所以, Remax
在 HostConfig 配置的方法中,并没有真正的操作 DOM 节点,而是先构成一颗镜像树
(Mirror Tree), 然后再同步到渲染进程
中,如下图绿色的方框所示的那样,我们会使用 React 构成一个镜像树的 Vnode Tree,然后交给小程序平台把这个树给渲染出来。
提交阶段也就是 commit 阶段,react 会把 effect list 中存在的变更同步到渲染环境的 DOM 节点上去,会分别调用 appendChild
、removeChild
、insertBefore
这些方法
下面我们看,Remax 源码里面究竟是如何实现这些方法的。
如果是原生的浏览器环境中,appendChild 比较简单,直接调用 javascript 原生操作 DOM 的方法即可。如果是小程序的环境中,你得自己实现 hostConfig 中定义的 VNode 节点上的 appendChild 的方法,源码实现如下:
上面代码中,并没有直接操作小程序的 DOM ,而是操作存内存中的 VNode 组成的镜像树:
requestUpdate
这个方法,下面会有详细的讲到。removeChild
方法和上面是同一个套路,先是修改了 VNode 镜像树上的节点关系,然后调用了 requestUpdate
这个方法
insertBefore
方法和上面是同一个套路,先是修改了 VNode 镜像树上的节点关系,然后调用了 requestUpdate
这个方法
上面介绍的这些方法,都是对节点位置关系的更新,比如说子节点位置的移动啊之类的。
现实中肯定也会有一些更新是不涉及到节点移动,而是比如说,节点上的属性发生了变化、节点的文本发生了变化,Reconciler 就会在协调阶段调用下面的这些方法。
上面调用了 node.update 方法,定义如下
真神奇鸭,最后还是调用了 requestUpdate
方法,殊途同归的感觉。
上面的方法中,最后都调用了神奇的 requestUpdate
方法,我们看一下这个方法里面做了什么
requestUpdate 方法定义如下:
没想到吧, 这个requestUpdate方法那么简单。
this.updateQueue
这个数组里面,暂存起来,之后会在【提交完成阶段】派上大用场。在这个阶段之前,Remax 构成的 VNode镜像树的这个JSON 数据还是在 Remax 世界中被管理和维护,接下来,我们会看如何更新 小程序的世界中。
React 会在提交完成阶段执行 hostConfig
中定义的 resetAfterCommit
方法,这个方法原本是用React 想来做一些善后的工作。但是Remax在这个resetAfterCommit
方法做了一个及其重要的工作,那就是同步镜像树到小程序** data**。
接下来我们来看 resetAfterCommit
方法的源码
上面代码的意思是, 通过之前缓存的updateQueue 计算出来 updatePayload
, updatePayload
是一个什么东东呢?我们可以通过 debug 断点来一览它的风采。
在某一次更新之后的断点:
updatePayload 是一个 javascript 的对象,对象的 key 是数据在小程序世界中的路径,对象的 value 就是要更新的值。
小程序的 setData 是支持这样的写法:setData({ root.a.b.c: 10 }), key 可以表达层次关系
在第一次 mount 时的断点:
在第一次 mount
时,Remax
运行时初始化时会通过小程序的 setData
初始化小程序的 JSON 树状数据。
然后,Remax
运行时在数据发生更新时,就会通过小程序的 setData
去更新上面小程序的 JSON 树状数据。
那么,剩下最后一个问题,现在我们知道了,小程序实例上有了一个 JSON 的树状对象,如何渲染成小程序的页面呢?
如果在浏览器环境下,这个问题非常简单,JavaScript
可以直接创建 DOM
节点,只要我们实现使用递归,便可完成从 VNode
到 DOM
的还原,渲染代码如下:
但在小程序环境中,不支持直接创建 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 树状数据,每一个节点上保存了一些信息。
我们来看 base.wxml 里面是什么内容,发现 base.wxml 内容超级多,有3000多行。如下图:
这个 base.wxml
文件是固定的,每一次打包都会生成那么代码,代码中定义了好几种的小程序的 template
类型,然后重复定义了好几遍,只是 name 名字的值不同。这是为了兼容某一些小程序平台不允许 <template>
组件自己嵌套自己,用来模拟递归嵌套的。
我们回到刚才的那一行代码,有一个名字是 REMAX_TPL
的模板组件。
<template is="REMAX_TPL" data={{root: root}}> </template>复制代码
REMAX_TPL
的模板组件定义在base.wxml
里面,如下所示:
上面代码,首先遍历了 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
可以看到,Remax 会根据每个子元素的类型选择对应的模板来渲染子元素,然后在每个模板中又会去遍历当前元素的子元素,以此把整个节点树递归遍历出来。
在第一次 mount
时,Remax
运行时初始化时会通过小程序的 setData
初始化小程序的 JSON 树状数据, 在小程序加载完毕后, Remax 通过递归模板的形式,把JSON 树状数据渲染为小程序的页面,用户就可以看到页面啦。
然后,Remax
运行时在数据发生更新时,就会通过小程序的 setData
去更新上面小程序的 JSON 树状数据, JSON 树状数据被更新了,小程序自然会触发更新数据对应的那块视图的渲染。
Remax 创造性的用递归模板的方式,用相对静态的小程序模板语言实现了动态的模板渲染的特性。
3
看到这里,我们已经对 remax 这种类 react 的跨端框架整体流程有了大概的了解
Taro Next 的原理和 Remax 是很像的,这里我就偷懒一下,直接把 Taro 团队在 GMTC大会上的 ppt 贴过来了,高清版本的 ppt 可以点击这个链接下载:程帅-小程序跨框架开发的探索与实践-GMTC 终稿.pdf
下面发现和 remax 是很像的。
Taro 团队实现了 taro-react 包,用来连接 react-reconciler
和 taro-runtime
的 BOM/DOM API
Taro-react 就做了两件事情:
hostConfig
配置,我们上面已经介绍过了ReactDOM.render
)方法,我们上面也已经介绍过了在更新的过程中,同样是在 appendChild、 insertBefore、removeChild 这些方法里面调用了 enqueueUpdate
方法(人家 remax 叫updateQueue)
渲染的话,和 Remax 的做法一样,基于组件的 template 动态 “递归” 渲染整棵树。
具体流程为先去遍历 Taro DOM Tree
( 对应 Remax 中叫镜像树 )根节点的子元素,再根据每个子元素的类型选择对应的模板来渲染子元素,然后在每个模板中又会去遍历当前元素的子元素,以此把整个节点树递归遍历出来。
基本上和 remax 一样,换汤不换药。
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有