How React Works (一)首次渲染

8,732 阅读17分钟

一、前言

     本文将会通过一个简单的例子,结合React源码(v 16.4.2)来说明 React 是如何工作的,并且帮助读者理解 ReactElement、Fiber 之间的关系,以及 Fiber 在各个流程的作用。看完这篇文章有助于帮助你更加容易地读懂 React 源码。初期计划有以下几篇文章:

  1. 首次渲染
  2. 事件机制
  3. 更新流程
  4. 调度机制

二、核心类型解析

     在正式进入流程讲解之前,先了解一下 React 源码内部的核心类型,有助于帮助我们更好地了解整个流程。为了让大家更加容易理解,后续的描述只抽取核心部分,把 ref、context、异步、调度、异常处理 之类的简化掉了。   

1. ReactElement

  我们写 React 组件的时候,通常会使用JSX来描述组件。<p></p>这种写法经过babel转换后,会变成以 React.createElement(type, props, children)形式。而我们的例子中,type会是两种类型:functionstringfunction一般是指ReactComponentconstructor或者函数式组件的function,而string类型的就是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 React的DOM容器,把整个React渲染到这个DOM内部
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)FiberNodeUpdateQueue

  通过调用ReactRoot.render,然后进入packages/react-reconciler/src/ReactFiberReconciler.jsupdateContainer -> updateContainerAtExpirationTime -> scheduleRootUpdate一系列方法调用,为这次初始化创建一个Update,把<App />这个 ReactElement 作为 Update 的payload.element的值,然后把 Update 放到 (HostRoot)FiberNode 的 updateQueue 中。

然后调用scheduleWork -> performSyncWork -> performWork -> performWorkOnRoot,期间主要是提取当前应该进行初始化的 (HostFiber)FiberNode,后续正式进入算法执行阶段。

五、渲染调度算法 - 执行阶段

  由于本次是初始化,所以需要调用packages/react-reconciler/src/ReactFiberScheduler.jsrenderRoot方法,生成一棵完整的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.effectTagCallback(初始化回调)),会先将 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.rendercallback。在执行完上面的生命周期函数后,就开始遍历这个 effect 链表,把 callback 都执行一次。

4.2 HostRoot

  操作和 ClassComponent 处理的第二部分一致。

4.3 HostComponent

  这部分主要是处理初次加载的 HostComponent 的获取焦点问题,如果组件有autoFocus这个 props ,就会获取焦点。      

七、小结

  本文主要讲述了ReactDom.render的内部的工作流程,描述了 React 初次渲染的内在流程:

  1. 创建基础对象: ReactRoot、FiberRoot、(HostRoot)FiberNode
  2. 创建 HostRoot 的镜像,通过镜像对象来做初始化
  3. 初始化过程,通过 ReactElement 引导 FiberNode Tree 的创建
  4. 父子 FiberNode 通过childreturn连接
  5. 兄弟 FiberNode 通过sibling连接
  6. FiberNode Tree 创建过程,深度优先,到底之后创建兄弟节点
  7. 一旦到达叶子节点,就开始创建 FiberNode 对应的 实例,例如对应的 DomElement 实例、ReactComponent 实例,并将实例通过FiberNode.stateNode创建关联。
  8. 如果当前创建的是 ReactComponent 实例,则会调用调用getDerivedStateFromPropscomponentWillMount方法
  9. DomElement 创建之后,如果 FiberNode 子节点中有创建好的 DomElement,就马上 append 到新创建的 DomElement 中
  10. 构建完成整个FiberNode Tree 后,对应的 DomElement Tree 也创建好了,后续进入提交过程
  11. 在创建 DomElement Tree 的过程中,同时会把当前的副作用不断往上传递,在提交阶段里面,会找到这种标记,并把刚创建完的 DomElement Tree 装载到容器 DomElement中
  12. 双缓冲的两棵树 FiberNode Tree 角色互换,原来的 workInProgress 转正
  13. 执行对应 ReactComponent 的装载后生命周期方法componentDidMount
  14. 其他回调调用、autoFocus 处理

 下一篇文章将会描述 React 的事件机制(但据说准备要重构),希望我不会断耕。

写完第一篇,React 版本已经到了 16.5.0 ……