How React Works (一)首次渲染
How React Works (一)首次渲染
一、前言
本文将会通过一个简单的例子,结合React源码(v 16.4.2)来说明 React 是如何工作的,并且帮助读者理解 ReactElement、Fiber 之间的关系,以及 Fiber 在各个流程的作用。看完这篇文章有助于帮助你更加容易地读懂 React 源码。初期计划有以下几篇文章:
- 首次渲染
- 事件机制
- 更新流程
- 调度机制
二、核心类型解析
在正式进入流程讲解之前,先了解一下 React 源码内部的核心类型,有助于帮助我们更好地了解整个流程。为了让大家更加容易理解,后续的描述只抽取核心部分,把 ref、context、异步、调度、异常处理 之类的简化掉了。
1. ReactElement
我们写 React 组件的时候,通常会使用JSX
来描述组件。<p></p>
这种写法经过babel转换后,会变成以 React.createElement(type, props, children)形式。而我们的例子中,type
会是两种类型:function
、string
,实际上就是App
的constructor
方法,以及其他HTML
标签。
而这个方法,最终是会返回一个 ReactElement ,他是一个普通的 Object ,不是通过某个 class 实例化二来的,大概看看即可,核心成员如下:
key | type | desc |
---|---|---|
$$typeof | Symbol|Number | 对象类型标识,用于判断当前Object是否一个某种类型的ReactElement |
type | Function|String|Symbol|Number|Object | 如果当前ReactElement是是一个ReactComponent,那这里将是它对应的Constructor;而普通HTML标签,一般都是String |
props | Object | ReactElement上的所有属性,包含children这个特殊属性 |
2. ReactRoot
当前放在ReactDom.js内部,可以理解为React渲染的入口。我们调用ReactDom.render
之后,核心就是创建一个 ReactRoot ,然后调用 ReactRoot 实例的render
方法,进入渲染流程的。
key | type | desc |
---|---|---|
render | Function | 渲染入口方法 |
_internalRoot | FiberRoot | 根据当前DomContainer创建的一个FiberTree的根 |
3. FiberRoot
FiberRoot 是一个 Object ,是后续初始化、更新的核心根对象。核心成员如下:
key | type | desc |
---|---|---|
current | (HostRoot)FiberNode | 指向当前已经完成的Fiber Tree 的Root |
containerInfo | DomContainer | 根据当前DomContainer创建的一个FiberTree的根 |
finishedWork | (HostRoot)FiberNode|null | 指向当前已经完成准备工作的Fiber Tree Root |
current、finishedWork,都是一个(HostRoot)FiberNode,到底是为什么呢?先卖个关子,后面将会讲解。
4. FiberNode
在 React 16之后,Fiber Reconciler 就作为 React 的默认调度器,核心数据结构就是由FiberNode组成的 Node Tree 。先参观下他的核心成员:
key | type | desc |
---|---|---|
实例相关 | --- | --- |
tag | Number | FiberNode的类型,可以在packages/shared/ReactTypeOfWork.js中找到。当前文章 demo 可以看到ClassComponent、HostRoot、HostComponent、HostText这几种 |
type | Function|String|Symbol|Number|Object | 和ReactElement表现一致 |
stateNode | FiberRoot|DomElement|ReactComponentInstance | FiberNode会通过stateNode绑定一些其他的对象,例如FiberNode对应的Dom、FiberRoot、ReactComponent实例 |
Fiber遍历流程相关 | ||
return | FiberNode|null | 表示父级 FiberNode |
child | FiberNode|null | 表示第一个子 FiberNode |
sibling | FiberNode|null | 表示紧紧相邻的下一个兄弟 FiberNode |
alternate | FiberNode|null | Fiber调度算法采取了双缓冲池算法,FiberRoot底下的所有节点,都会在算法过程中,尝试创建自己的“镜像”,后面将会继续讲解 |
数据相关 | ||
pendingProps | Object | 表示新的props |
memoizedProps | Object | 表示经过所有流程处理后的新props |
memoizedState | Object | 表示经过所有流程处理后的新state |
副作用描述相关 | ||
updateQueue | UpdateQueue | 更新队列,队列内放着即将要发生的变更状态,详细内容后面再讲解 |
effectTag | Number | 16进制的数字,可以理解为通过一个字段标识n个动作,如Placement、Update、Deletion、Callback……所以源码中看到很多 &= |
firstEffect | FiberNode|null | 与副作用操作遍历流程相关 当前节点下,第一个需要处理的副作用FiberNode的引用 |
nextEffect | FiberNode|null | 表示下一个将要处理的副作用FiberNode的引用 |
lastEffect | FiberNode|null | 表示最后一个将要处理的副作用FiberNode的引用 |
5. Update
在调度算法执行过程中,会将需要进行变更的动作以一个Update数据来表示。同一个队列中的Update,会通过next属性串联起来,实际上也就是一个单链表。
key | type | desc |
---|---|---|
tag | Number | 当前有0~3,分别是UpdateState、ReplaceState、ForceUpdate、CaptureUpdate |
payload | Function|Object | 表示这个更新对应的数据内容 |
callback | Function | 表示更新后的回调函数,如果这个回调有值,就会在UpdateQueue的副作用链表中挂在当前Update对象 |
next | Update | UpdateQueue中的Update之间通过next来串联,表示下一个Update对象 |
6. UpdateQueue
在 FiberNode 节点中表示当前节点更新、更新的副作用(主要是Callback)的集合,下面的结构省略了CapturedUpdate部分
key | type | desc |
---|---|---|
baseState | Object | 表示更新前的基础状态 |
firstUpdate | Update | 第一个 Update 对象引用,总体是一条单链表 |
lastUpdate | Update | 最后一个 Update 对象引用 |
firstEffect | Update | 第一个包含副作用(Callback)的 Update 对象的引用 |
lastEffect | Update | 最后一个包含副作用(Callback)的 Update 对象的引用 |
三、代码样例
本次流程说明,使用下面的源码进行分析
//index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
//App.js
import React, { Component } from 'react';
import './App.css';
class App extends Component {
constructor() {
super();
this.state = {
msg:'init',
};
}
render() {
return (
<div className="App">
<p className="App-intro">
To get started, edit <code>{this.state.msg}</code> and save to reload.
</p>
<button onClick={() => {
this.setState({msg: 'clicked'});
}}>hehe
</button>
</div>
);
}
}
export default App;
四、渲染调度算法 - 准备阶段
从ReactDom.render
方法开始,正式进入渲染的准备阶段。
1. 初始化基本节点
创建 ReactRoot、FiberRoot、(HostRoot)FiberNode,建立他们与 DomContainer 的关系。
2. 初始化(HostRoot)FiberNode
的UpdateQueue
通过调用ReactRoot.render
,然后进入packages/react-reconciler/src/ReactFiberReconciler.js
的updateContainer -> updateContainerAtExpirationTime -> scheduleRootUpdate
一系列方法调用,为这次初始化创建一个Update,把<App />
这个 ReactElement 作为 Update 的payload.element
的值,然后把 Update 放到 (HostRoot)FiberNode 的 updateQueue 中。
然后调用scheduleWork -> performSyncWork -> performWork -> performWorkOnRoot
,期间主要是提取当前应该进行初始化的 (HostFiber)FiberNode,后续正式进入算法执行阶段。
五、渲染调度算法 - 执行阶段
由于本次是初始化,所以需要调用packages/react-reconciler/src/ReactFiberScheduler.js
的renderRoot
方法,生成一棵完整的FiberNode Tree finishedWork
。
1. 生成 (HostRoot)FiberNode 的workInProgress
,即current.alternate
。
在整个算法过程中,主要做的事情是遍历 FiberNode 节点。算法中有两个角色,一是表示当前节点原始形态的current
节点,另一个是表示基于当前节点进行重新计算的workInProgress/alternate
节点。两个对象实例是独立的,相互之前通过alternate
属性相互引用。对象的很多属性都是先复制再重建
的。
第一次创建结果示意图:
这个做法的核心思想是双缓池技术(double buffering pooling technique)
,因为需要做 diff 的话,起码是要有两棵树进行对比。通过这种方式,可以把树的总体数量限制在2
,节点、节点属性都是延迟创建的,最大限度地避免内存使用量因算法过程而不断增长。后面的更新流程的文章里,会了解到这个双缓冲
怎么玩。
2. 工作执行循环
示意代码如下:
nextUnitOfWork = createWorkInProgress(
nextRoot.current,
null,
nextRenderExpirationTime,
);
....
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
刚刚创建的 FiberNode 被作为nextUnitOfWork
,从此进入工作循环。从上面的代码可以看出,在是一个典型的递归的循环写法。这样写成循环,一来就是和传统的递归改循环写法一样,避免调用栈不断堆叠以及调用栈溢出等问题;二来在结合其他Scheduler
代码的辅助变量,可以实现遍历随时终止、随时恢复的效果。
我们继续深入performUnitOfWork
函数,可以看到类似的代码框架:
const current = workInProgress.alternate;
//...
next = beginWork(current, workInProgress, nextRenderExpirationTime);
//...
if (next === null) {
next = completeUnitOfWork(workInProgress);
}
//...
return next;
从这里可以看出,这里对 workInProgress 节点进行一些处理,然后会通过一定的遍历规则
返回next
,如果next
不为空,就返回进入下一个performUnitOfWork
,否则就进入completeUnitOfWork
。
3. beginWork
每个工作的对象主要是处理workInProgress
。这里通过workInProgress.tag
区分出当前 FiberNode 的类型,然后进行对应的更新处理。下面介绍我们例子里面可以遇到的两种处理比较复杂的 FiberNode 类型的处理过程,然后再单独讲解里面比较重要的processUpdateQueue
以及reconcileChildren
过程。
3.1 HostRoot - updateHostRoot
HostRoot,即文中经常讲到的 (HostRoot)FiberNode,表示它是一个 HostRoot 类型的 FiberNode ,代码中通过FiberRoot.tag
表示。
前面讲到,在最开始初始化的时候,(HostRoot)FiberNode 在初始化之后,初始化了他的updateQueue
,里面放了准备处理的子节点。这里就做两个动作:
- 处理更新队列,得出新的state - processUpdateQueue方法
- 创建或者更新 FiberNode 的
child
,得到下一个工作循环的入参(也是FiberNode) - ChildReconciler方法
通过这两个函数的详细内容属于比较通用的部分,将在后面单独讲解。
3.2 ClassComponent - updateClassComponent
ClassComponent,即我们在写 React 代码的时候自己写的 Component,即例子中的App
。
3.2.1 创建ReactComponent
实例阶段
对于尚未初始化的节点,这个方法主要是通过FiberNode.type
这个 ReactComponent Constructor 来创建 ReactComponent 实例并创建与 FiberNode 的关系。
(ClassComponent)FiberNode 与 ReactComponent 的关系示意图:
初始化后,会进入实例的mount
过程,即把 Component render
之前的周期方法都调用完。期间,state
可能会被以下流程修改:
- 调用getDerivedStateFromProps
- 调用componentWillMount -- deprecated
- 处理因上面的流程产生的Update所调用的processUpdateQueue
3.2.2 完成阶段 - 创建 child FiberNode
在上面初始化Component实例之后,通过调用实例的render
获取子 ReactElement,然后创建对应的所有子 FiberNode 。最终将workInProgress.child
指向第一个子 FiberNode。
3.4 处理节点的更新队列 - processUpdateQueue 方法
在解释流程之前,先回顾一下updateQueue的数据结构:
从上面的结构可以看出,UpdateQueue 是存放整个 Update 单向链表的容器。里面的 baseState 表示更新前的原始 State,而通过遍历各个 Update 链表后,最终会得到一个新的 baseState。
对于单个 Update 的处理,主要是根据Update.tag
来进行区分处理。
- ReplaceState:直接返回这里的 payload。如果 payload 是函数,则使用它的返回值作为新的 State。
- CaptureUpdate:仅仅是将
workInProgress.effectTag
设置为清空ShouldCapture
标记位,增加DidCapture
标记位。 - UpdateState:如果payload是普通对象,则把他当做新 State。如果 payload 是函数,则把执行函数得到的返回值作为新 State。如果新 State 不为空,则与原来的 State 进行合并,返回一个
新对象
。 - ForceUpdate:仅仅是设置
hasForceUpdate
为 true,返回原始的 State。
整体而言,这个方法要做的事情,就是遍历这个 UpdateQueue ,然后计算出最后的新 State,然后存到workInProgress.memoizedState
中。
3.5 处理子FiberNode - reconcileChildren 方法
在 workInProgress 节点自身处理完成之后,会通过props.children
或者instance.render方法
获取子 ReactElement。子 ReactElement 可能是对象
、数组
、字符串
、迭代器
,针对不同的类型进行处理。
- 下面通过 ClassComponent 及其
数组类型 child
的场景来讲解子 FiberNode 的创建、关联流程(reconcileChildrenArray方法
):
在页面初始化阶段,由于没有老节点的存在,流程上就略过了位置索引比对、兄弟元素清理等逻辑,所以这个流程相对简单。
遍历之前render
方法生成的 ReactElement 数组,一一对应地生成 FiberNode。FiberNode 有returnFiber
属性和sibling
属性,分别指向其父亲 FiberNode和紧邻的下一个兄弟 FiberNode。这个数据结构和后续的遍历过程相关。
现在,生成的FiberNode Tree 结构如下:
图中的两个(HostComponent)FiberNode
就是刚刚生成的子 FiberNode,即源码中的<p>...</p>
与<button>...</button>
。这个方法最后返回的,是第一个子 FiberNode,就通过这种方式创建了(ClassComponent)FiberNode.child
与第一个子 FiberNode的关系。
这个时候,再搬出刚刚曾经看过的代码:
const current = workInProgress.alternate;
//...
next = beginWork(current, workInProgress, nextRenderExpirationTime);
//...
if (next === null) {
next = completeUnitOfWork(workInProgress);
}
//...
return next;
意味着刚刚返回的 child 会被当做 next
进入下一个工作循环。如此往复,会得到下面这样的 FiberNode Tree :
生成这棵树之后,被返回的是左下角的那个 (HostText)FiberNode。而重新进入beginWork
方法后,由于这个 FiberNode 并没有 child ,根据上面的代码逻辑,会进入completeUnitOfWork
方法。
注意:虽然说本例子的 FiberNode Tree 最终形态是这样子的,但实际上算法是优先深度遍历,到叶子节点之后再遍历紧邻的兄弟节点。如果兄弟节点有子节点,则会继续扩展下去。
4. completeUnitOfWork
进入这个流程,表明 workInProgress 节点是一个叶子节点,或者它的子节点都已经处理完成了。现在开始要完成这个节点处理的剩余工作。
4.1 创建DomElement,处理子DomElement 绑定关系
completeWork
方法中,会根据workInProgress.tag
来区分出不同的动作,下面挑选2个比较重要的来进一步分析:
4.1.1 HostText
此前提到过,FiberNode.stateNode
可以用于存放 DomElement Instance。在初始化过程中,stateNode 为 null,所以会通过document.createTextNode
创建一个 Text DomElement,节点内容就是workInProgress.memoizedProps
。最后,通过__reactInternalInstance$[randomKey]
属性建立与自己的 FiberNode的联系。
4.1.2 HostComponent
在本例子中,处理完上面的 HostText 之后,调度算法会寻找当前节点的 sibling 节点进行处理,所以进入了HostComponent
的处理流程。
由于当前出于初始化流程,所以处理比较简单,只是根据FiberNode.tag
(当前值是code
)来创建一个 DomElement,即通过document.createElement
来创建节点。然后通过__reactInternalInstance$[randomKey]
属性建立与自己的 FiberNode的联系;通过__reactEventHandlers$[randomKey]
来建立与 props 的联系。
完成 DomElement 自身的创建之后,如果有子节点,则会将子节点 append 到当前节点中。现在先略过这个步骤。
后续,通过setInitialProperties
方法对 DomElement 的属性进行初始化,而<code>
节点的内容、样式、class
、事件 Handler等等也是这个时候存放进去的。
现在,整个 FiberNode Tree 如下:
经过多次循环处理,得出以下的 FiberNode Tree:
之后,回到红色箭头指向的 (HostComponent)FiberNode,可以分析一下之前省略掉的子节点处理流程。
在当前 DomElement 创建完毕后,进入appendAllChildren
方法把子节点 append 到当前 DomElement 。由上面的流程可以知道,可以通过 workInProgress.child -> workInProgress.child.sibling -> workInProgress.child.sibling.sibling ....
找到所有子节点,而每个节点的 stateNode 就是对应的 DomElement,所以通过这种方式的遍历,就可以把所有的 DomElement 挂载到 父 DomElement中。
最终,和 DomElement 相关的 FiberNode 都被处理完,得出下面的FiberNode 全貌:
4.2 将当前节点的 effect 挂在到 returnFiber 的 effect 末尾
在前面讲解基础数据结构的时候描述过,每个 FiberNode 上都有 firstEffect、lastEffect ,指向一个Effect(副作用) FiberNode
链表。在处理完当前节点,即将返回父节点的时候,把当前的链条挂接到 returnFiber 上。最终,在(HostRoot)FiberNode.firstEffect
上挂载着一条拥有当前 FiberNode Tree 所有副作用的 FiberNode 链表。
5. 执行阶段结束
经历完之前的所有流程,最终 (HostRoot)FiberNode 也被处理完成,就把 (HostRoot)FiberNode 返回,最终作为finishedWork
返回到 performWorkOnRoot
,后续进入下一个阶段。
六、渲染调度算法 - 提交阶段
所谓提交阶段,就是实际执行一些周期函数、Dom 操作的阶段。
这里也是一个链表的遍历,而遍历的就是之前阶段生成的 effect 链表。在遍历之前,由于初始化的时候,由于 (HostRoot)FiberNode.effectTag
为Callback
(初始化回调)),会先将 finishedWork 放到链表尾部。结构如下:
每个部分提交完成之后,都会把遍历节点重置到finishedWork.firstEffect
。
1. 提交节点装载( mount )前的操作
当前这个流程处理的只有属于 ReactComponent 的 getSnapshotBeforeUpdate
方法。
2. 提交端原生节点( Host )的副作用(插入、修改、删除)
遍历到某个节点后,会根据节点的 effectTag 决定进行什么操作,操作包括插入( Placement )
、修改( Update )
、删除( Deletion )
。
由于当前是首次渲染,所以会进入插入( Placement )流程,其余流程将在后面的《How React Works(三)更新流程》中讲解。
2.1 插入流程( Placement )
要做插入操作,必先找到两个要素:父亲 DomElement ,子 DomElement。
2.1.1 找到相对于当前 FiberNode 最近的父亲 DomElement
通过FiberNode.return
不断往上找,找到最近的(HostComponent)FiberNode、(HostRoot)FiberNode、(HostPortal)FiberNode节点,然后通过(HostComponent)FiberNode.stateNode
、(HostRoot)FiberNode.stateNode.containerInfo
、(HostPortal)FiberNode.stateNode.containerInfo
就可以获取到对应的 DomElement 实例。
2.1.2 找到相对于当前 FiberNode 最近的所有游离子 DomElement
实际上,把目标是查找当前 FiberNode底下所有邻近的 (HostComponent)FiberNode、(HostText)FiberNode,然后通过 stateNode 属性就可以获取到待插入的 子DomElement 。
所谓所有邻近的
,可以通过这幅图来理解:
图中红框部分FiberNode.stateNode
,就是要被添加到父亲 DomElement的 子 DomElement。
遍历顺序,和之前的生成 FiberNode Tree时顺序大致相同:
a) 访问child节点,直至找到 FiberNode.type
为 HostComponent 或者 HostRoot 的节点,获取到对应的 stateNode ,append 到 父 DomElement中。
b) 寻找兄弟节点,如果有,就访问兄弟节点,返回 a) 。
c) 如果没有兄弟节点,则访问 return 节点,如果 return 不是当前算法入参的根节点,就返回a)。
d) 如果 return 到根节点,则退出。
3. 改变 workInProgress/alternate/finishedWork 的身份
虽然是短短的一行代码,但这个十分重要,所以单独标记:
root.current = finishedWork;
这意味着,在 DomElement 副作用处理完毕之后,意味着之前讲的缓冲树
已经完成任务,翻身当主人,成为下次修改过程的current
。再来看一个全貌:
4. 提交装载、变更后的生命周期调用操作
在这个流程中,也是遍历 effect 链表,对于每种类型的节点,会做不同的处理。
4.1 ClassComponent
如果当前节点的 effectTag 有 Update 的标志位,则需要执行对应实例的生命周期方法。在初始化阶段,由于当前的 Component 是第一次渲染,所以应该执行componentDidMount
,其他情况下应该执行componentDidUpdate
。
之前讲到,updateQueue 里面也有 effect 链表。里面存放的就是之前各个 Update 的 callback,通常就来源于setState
的第二个参数,或者是ReactDom.render
的 callback
。在执行完上面的生命周期函数后,就开始遍历这个 effect 链表,把 callback 都执行一次。
4.2 HostRoot
操作和 ClassComponent 处理的第二部分一致。
4.3 HostComponent
这部分主要是处理初次加载的 HostComponent 的获取焦点问题,如果组件有autoFocus
这个 props ,就会获取焦点。
七、小结
本文主要讲述了ReactDom.render
的内部的工作流程,描述了 React 初次渲染的内在流程:
- 创建基础对象: ReactRoot、FiberRoot、(HostRoot)FiberNode
- 创建 HostRoot 的镜像,通过镜像对象来做初始化
- 初始化过程,通过 ReactElement 引导 FiberNode Tree 的创建
- 父子 FiberNode 通过
child
、return
连接 - 兄弟 FiberNode 通过
sibling
连接 - FiberNode Tree 创建过程,深度优先,到底之后创建兄弟节点
- 一旦到达叶子节点,就开始创建 FiberNode 对应的 实例,例如对应的 DomElement 实例、ReactComponent 实例,并将实例通过
FiberNode.stateNode
创建关联。 - 如果当前创建的是 ReactComponent 实例,则会调用调用
getDerivedStateFromProps
、componentWillMount
方法 - DomElement 创建之后,如果 FiberNode 子节点中有创建好的 DomElement,就马上 append 到新创建的 DomElement 中
- 构建完成整个FiberNode Tree 后,对应的 DomElement Tree 也创建好了,后续进入提交过程
- 在创建 DomElement Tree 的过程中,同时会把当前的
副作用
不断往上传递,在提交阶段里面,会找到这种标记,并把刚创建完的 DomElement Tree 装载到容器 DomElement中 双缓冲
的两棵树 FiberNode Tree 角色互换,原来的 workInProgress 转正- 执行对应 ReactComponent 的装载后生命周期方法
componentDidMount
- 其他回调调用、autoFocus 处理
下一篇文章将会描述 React 的事件机制(但据说准备要重构),希望我不会断耕。
写完第一篇,React 版本已经到了 16.5.0 ……