背景介绍
RND,全称 React Node Desktop,起源于 RN 在爱奇艺 PC 端的实现,采用 React JS framework + node.JS runtime + native UI engine 架构,目标是成为最轻量的 JS 开发桌面应用的跨平台方案。目前爱奇艺 PC 客户端的大多数页面都是基于 RND 开发的。
传统的 JS 开发 native 应用的方案都是将 native 组件注入到 JS,JS 会按照 native 的开发模式开发应用,更多的是开发语言从 C++换到了 JS,开发思想还是 native 的。React JS 带来了全新的开发思路,非常好地隔离了 JS 层和 native 层,业务开发基于 React JS 开发范式而不用受 native 约束。为了适配自研的 lyra 引擎以及为业务层提供更方便的开发设施,团队对 React JS Framework 做了深度的适配,接下来将带着大家深入了解 React JS Framework,帮助大家理解这个优雅的 view 层框架。
React Fiber
撰写本篇文章时 RN 的最新版本是 0.57.8,团队适配的 RN 版本是 0.51.0,它依赖的 React 版本是 16.0.0。本文主要针对 0.51.0 进行说明,0.51.0 与 0.57.8 的差别不大,基本原理是一样的。React 16 除了将备受争议的 BSD+Patents 协议改为 MIT 协议之外,还带来了许多新特性,比如:
允许在 render 函数中返回节点数组
提供更好的错误处理机制:componentDidCatch
支持自定义 DOM 属性
但最关键的一点还是 React16 是一次重写,在保持 API 不变的情况下,将核心架构改为了 Fiber。在介绍 React 的 Fiber 架构之前,首先介绍几个概念:
React 为每种类型的节点都分配了一个数字编号,具体为:
每一个控件都会对应一个 fiber 对象节点,所有的 fiber 节点就构成了 virtualdom 树,fiber 对象的结构和表示的意义如下:
一个完整的 virtualdom 树的结构如下:
节点树主要分为创建和更新两个过程,每个过程都可以分为以下四个阶段:
节点的创建
初始化阶段
RN 程序的入口如下:
其中 Index 是需要渲染的根控件,runApplication 函数会调用 ReactNative.render 函数,将业务的根节点包裹在 AppContainer 控件中传递给该函数:
其中 RootComponent 就是注册的根控件,AppContainer 是 RN 提供的自定义控件。在该函数中会创建一个 HostRoot 节点,该节点挂载在 root 对象的 current 属性上,root 对象就是整个节点树的根。接着会调用 addTopLevelUpdate 主动生成一个 update,待更新数据就是传给 ReactNative.render 的参数,这个过程可以看做是 RN 内部主动调用了 setState 函数。然后调用 scheduleUpdate 函数将待更新的根节点保存在 nextScheduledRoot 变量中。在 RN 中更新都是从根节点开始的,无论 setState 函数在哪个控件上调用。最后调用 performWork 函数进入 workloop 阶段。
workloop 阶段
该阶段包含的主要函数及其调用关系如下:
首先根据 HostRoot 节点创建待操作节点(注意:RN 不是直接处理当前节点,而是处理当前节点的拷贝,也就是节点的 alternate 属性),然后从该节点开始根据节点类型处理当前节点,也就是 beginWork 中的各种 update 方法,并且生成下一个待处理的节点,赋值给 nextUnitOfWork。
如果 nextUnitOfWork 不为空,就对其进行处理,否则执行 completeUnitOfWork,然后依次遍历处理该节点的兄弟节点和父节点的兄弟节点,直到父节点为空,循环结束。不难发现 RN 是按照深度优先来创建和更新节点的。具体的创建顺序如下图所示:
节点的 update 操作包含了对新节点的创建和已有节点更新两种情况,alternate 为空是创建。节点的处理主要包括三种:自定义节点、根节点以及基础控件节点。节点的创建和更新都是在响应节点的 update 函数中。下面主要针对这三种节点进行说明:
1. updateHostRoot
HostRoot 节点不暴露给业务层,是 RN 内部使用的。前面说过在创建阶段 addTopLevelUpdate 函数中会生成一个 update,保存在节点的 updateQueue 属性中,就是通过判断这个属性是否为空来区分是创建节点还是更新节点。如果 updateQueue 不为空,则取出 AppContainer,开始创建 AppContainer 的 fiber 节点;如果 updateQueue 为空(更新阶段)就直接调用 bailoutOnAlreadyFinishedWork 获取已经创建好的 AppContainer 的 fiber 节点返回。
2. updateClassComponent
ClassComponent 是复合控件,也就是通过 React.createClass(es5 写法)函数创建的控件。控件的创建阶段主要执行三个函数:
a. constructClassInstance:
从 fiber 节点的 type 属性中取得控件的构造函数,然后创建一个实例,保存在控件 fiber 节点的 stateNode 属性中;
b. mountClassInstance:
执行实例的 componentWillMount 函数,如果实例的 componentDidMount 存在,更新 effectTag,待所有子节点处理完毕后再执行;
c. finishClassComponent:
先执行实例的 render 函数,然后根据 render 函数中的返回值执行 reconcileChildren 函数创建对应的 fiber 节点。
在更新阶段则会调用下面两个函数:
a. updateClassInstance:
判断控件是否需要更新 shouldUpdate;
根据 updateQueue,计算新的 state;
存在生命周期函数时标记 effectTag
b. finishClassComponent:
不需要更新时 cloneChildFibers;
需要更新时执行 instance.render,然后执行 reconcileChildren
3. updateHostComponent
这个函数中做的工作很少,在创建阶段调用 reconcileChildren 函数创建子节点的 fiber 节点并返回,更新阶段调用 cloneChildFibers 函数复制子节点并返回。
在处理节点过程中,如果遇到节点的子节点为空,那么就会调用 completeUnitOfWork 函数。该函数根据节点类型进行相应处理:
如果是 HostComponent,在创建阶段就会创建实例(createInstance),生成_nativeTag,生成 createView 命令,并且将子节点的 HostComponent 添加到实例的 children 属性中,发送 setChildren 命令添加节点,在更新阶段则标记是否需要更新。
如果是 ClassComponent 节点则无需处理,因为在 update 阶段已经处理完毕。根据节点的 effectTag 值,向节点的 firstEffect、nextEffect、lastEffect 赋值,节点的 firstEffect、nextEffect、lastEffect 组成一个单链表结构,父节点会继承子节点的相应属性值,这些值会在接下来的 commitAllWork 阶段被处理。
commitAll
Workworkloop 阶段执行完毕就进入到 commitAllWork 阶段。该阶段会调用以下两个函数:
commitAllHostEffects。由节点的 firstEffect 开始遍历,根据 effectTag 值进行相应的操作,节点更新、插入、删除等。
commitAllLifeCycles。改变 root.current 的值;执行生命周期函数:componentDidMount、componentDidUpdate 等;执行 ref 函数。
节点的更新
节点更新阶段的处理流程如下图:
节点的 update 函数中已经包含了节点的创建和更新两种情况。这里主要说一下更新的流程和初始化阶段。
节点的更新一般是通过实例的 setState 函数触发的,setState 函数会调用 Updater.enqueueSetState 函数将需要更新的数据保存在 fiber 节点的 updateQueue 属性中,然后从当前节点开始向上更新父节点的优先级,更新到根节点结束。然后从根节点开始进入 workloop 和 commitAllWork 阶段。
通信机制
RND 中 JS 层与 native 层的通信与 RN 是类似的,具体的通信机制如下所示:
JS 端的 messageQueue 模块负责消息的接收和发送,JS 端产生的命令会存储在 messageQueue 中,最后通过调用 native 向 JS 注入的接口函数将命令发送到 native 端,native 端的 BatchedBridge 类负责接收处理 JS 命令。JS 端将 messageQueue 的实例挂载到 global 对象上,native 就能通过 global 对象访问到 messageQueue 中的所有实例属性和方法。native 通过 EventEmitter 类将消息发送到 JS 端,实现方式为在 JS 运行环境中获取到 messageQueue 实例动态执行代码段。类似于浏览器中的 window.eval 函数的功能。这样就完成了 JS 和 native 端的双向通信。
优化与扩展
RND 在 JS 层面还进行了一些优化和扩展,主要集中在 bundle 拆分、css3 动画支持、脚手架工具、typescript 声明文件扩展等方面。
RN 的 bundle 体积很大,在有多个页面实例时尤为突出。因此考虑将 RN 框架代码单独分离出来,形成公共的 base bundle,而将各页面的业务代码打包成各个 jobbundle,从而减少了安装包体积和线上更新时的流量消耗。
我们在 RND 中增加了 css3 动画支持,应用程序可通过在 style 中指定符合 css3 动画规范的 animation 属性,即可实现高性能的动画效果。
类似于 RN 的 react-native run-android 命令,RND 还扩展支持了 run-desktop 等脚手架命令。最后,我们也为 RND 提供了 ts 声明文件,支持开发者使用 ts 进行开发。
未来团队还将陆续为 RND 增加一些新的组件和 API,特别是与桌面开发相关的特性,例如 WindowComponent、Shell API、File API 等。
结语
至此 React JS Framework 的整个处理流程大致都说完了,本文目的是希望起到一个抛砖引玉的作用。对于框架源码的分析有助于对框架本身有更深的理解,这样才能发现其本身的优点以及缺点,才能让我们在特定的使用场景中去取舍设计方案,去迭代、去优化、去创新。
RND 在爱奇艺客户端的成功实践表明,RN 同样适用于以运营内容为主的、迭代周期密集的互联网桌面应用,JS 非常适合 UI 和业务逻辑的快速开发。随着各大 JS 引擎性能不断的优化,很多大厂都推出了基于 JS 语言的轻量级高性能 App 应用框架,可以预测在不久的将来,以内容运营为主的桌面产品上,JS 很快会成为最受开发者欢迎的语言之一。
本文转载自公众号爱奇艺技术产品团队(ID:iQIYI-TP)。
原文链接:
评论