我把React的渲染原理讲给你听
本文分析源码版本为17.0.1
。虽然React
现在已经迭代到18+
版本了,相信大多数项目还是没有跟进更新的,所以个人认为学习老的版本并不过时。因此后续内容在未特殊说明情况下,默认为Sync
模式。
一、铺垫

首先回想一下,如果不用任何框架,用原生js创建这样一颗dom树应该怎么处理。 为了减少dom操作,我们会先创建最底层的元素存放到变量中,然后依次创建其父元素,直至创建到最顶层的div,最后将顶层的div插入到dom中即可,这样避免了多次的dom插入。 React其实也是这样, 先来看一下我们常用的入口写法
ReactDOM.render(<App />, document.getElementById("root"));
在React 17及之前版本,我们都是通过以上方式将React组件注册到视图中。作为高级动物的我们是能一眼看出来哪个是最底层的元素(叶子节点),然后一层层向上创建父元素直至顶层元素(根节点)。但是对于一个程序来说,最开始是没办法知道哪个是叶子节点,所以只能通过入口提供的根节点向下遍历, 直到找到叶子节点然后创建对应的元素。 所以对于React框架来说, 是有两个方向的流程的, 自上而下查找子元素、自下而上创建dom元素,分别对应两个遍历流程beginWork
、completeWork
后面会详分析两个流程,这里先大概了解。在进入这两个流程之前先看下render函数中做了哪些处理。
二、准备工作
我认为在进入beginWork
、completeWork
流程之前,先做些准备工作为这俩流程做铺垫。
(为了方便描述,暂且称React的顶层组件为根组件
,需要挂在到的dom元素称为根元素
即上文代码的div#root
)
如果要遍历App
组件,必须得标记一个起点,方便在后续创建到根节点时执行插入dom的操作。那么起点怎么标记呢?有的同学就说,App
就是呀! 试想一下,如果根组件命名不是App
而是Root
或者别的名字,只认识App
那就不行了。所以React内部加了一个"组件"HostRoot
用来标示组件的开始(HostRoot
并不是一个组件,只是特殊的值, 在创建根组件Fiber
时, 作为tag使用)
(Fiber
是个Class类型, 每个组件节点后续都会创建为fiber对象, 大概结构如下)
记录了根组件HostRoot
还要记录根元素div#root
,因为组件dom创建完后要插入到div#root
下,并不是body
下。
同时在这里还注册了事件,为啥要注册事件呢?对于dom事件,在jquery时代我们就知道,不要在每个元素上绑定事件,尽量通过代理绑定的形式绑定到父元素上。 没错React也一样,在17版本之前,所有的事件都是绑定到document上, 17之后都是绑定到挂载的根元素上即这里的div#root
。我们在写代码时,虽然在React组件上绑定了像onClick
、onFocus
、onScroll
等事件,最终都是通过代理的形式触发事件的执行的。React的事件系统也很精彩后面单独抽出一篇文章来分析。 这里只需知道也是在开始遍历之前,先把事件在根组件上注册了。
整个过程的主要函数调用流程为

在实例化ReactDomBlockingRoot时又创建了根组件对应的fiber对象即上面所说tag为HostRoot
的Fiber
我们称为RootFiber
, 同时为了维护RootFiber
和div#root
的关系创建了一个对象叫FiberRoot
。 此外对于div#root
、FiberRoot
、RootFiber
三个对象上都有字段指向彼此,这样在不同的场景下,都能很容易根据一方找到另外两个。实例化的主要函数流程调用如下图,可以看到通过调用listenToAllSupportedEvents
在div#root
上注册了事件

三者之间的关系如下
准备工作做完开从根组件向下遍历查找子组件了
自上而下、自下而上遍历执行
在没遍历执行beginWork
之前,react也不知道后续的组件结构会是啥样,所以在beginWork
时每遇到一个组件时都要记录下来,同时要记录父组件和子组件、组件与组件间的关系,这样才能保证后续创建出来的dom树不会错乱掉。react内部对于每个组件都会创建成Fiber
对象,通过Fiber
记录组件间的关系,最后构成一个Fiber
链表结构。 父组件parentFiber.child
指向第一个子组件对应的fiber,子组件的fiber.return
指向父组件,同时子组件的fiber.sibling
指向其右边的相邻兄弟节点的fiber, 构成一个fiber
树。如下图
还需要说明的是,
beginWork
的遍历并不是先查找完某一层所有的子元素再进行下一层的查找, 而是只查父元素的第一个子元素, 然后继续查找下一层的子元素, 如果没有子元素才会查找兄弟元素,兄弟元素查找完再查找父元素的兄弟元素, 类似于二叉树的前序遍历。所以对于上图的结构, 遍历顺序如下:
App->Comp1->Comp3->div1->div2->div3->div4->Comp2->div5
beginWork
beginWork
主要的功能就是遍历查找子组件,建立关系树。 那么怎么查找子组件呢,我们只分析class组件和函数式组件。 对于函数式组件,会执行组件对应的函数,注册hooks,同时拿到函数return的结果,即为该组件的child;对于class组件,会先实例化class,在这个阶段也会调用class的静态方法getDerivedStateFromProps
以及实例的componentWillMount
方法最后执行render
方法拿到对应的child。 在mount
阶段和update
阶段, beginWork
的执行逻辑也有区别的。 我们都知道为了减少重排和重绘,react帮助我们找出那些有变化的节点,只做这些节点的更新。 在mount
阶段,因为在这之前没有创建节点,所以每个节点的fiber都是新建的;在update
阶段, 会通过diff算法判断当前节点是否需要变更,如果需要变更会重新创建新的fiber对象并复用部分老的fiber对象属性,如果不需要变更则直接clone老的fiber对象;如果diff对比后老的fiber存在,新的fiber不存在,则会给fiber打上Deletion
标签标示该元素需要删除; 如果老的fiber不存在,新的fiber存在说明是新创建的元素,则给fiber打上Placement
标签。 beginWork
大概流程如下

completeWork
completeWork
阶段主要执行dom节点的创建或者标记变更。在mount
阶段时,对于自定义组件比如class组件、函数式组件,其实不做什么特殊处理; 对于div
、p
、span
(这种组件在react内部定义为HostComponent
),就会调用document.createElement
方法创建dom元素存放到该节点fiber对象的stateNode字段上;对于父元素是HostComponent
的情况,先创建父元素的dom节点parentInstance
, 然后调用parentInstance.appendChild(child)
方法将子元素挂在该节点上。 在update
阶段,如果老的fiber
存在则不会重新创建dom元素,而是给该元素打上Update
标签;如果是新的元素和mount
阶段一样创建新的dom元素。 大概流程如下
此外在react内部, beginWork
和completeWork
是交替进行,这是为什么呢? 试想一下, 如果不交替运行,beginWork
执行完之后只记录了关系, 然后再想通过completeWork
创建dom元素,是不是又得从根组件开始遍历一遍,这样就至少需要遍历两遍。react通过合适的时机切换执行beginWork
、completeWork
只需遍历一遍就可以完成所有操作了。那么在什么时机切换呢?还记得我们一开始说,用原生js创建dom时先创建最底层的元素, react也是,在遍历执行beginWork
到最底层元素时即下图的div1
,该元素已经没有子元素了, 开始执行completeWork
创建dom节点, 执行完div1
的completeWork
又切换成执行div2
的beginWork
,div2
也没有子节点,所以进而执行div2
的completeWork
; div3
也同样先执行beginWork
再执行completeWork
, 和div1
、div2
不同的是, div3
已经没有右边的兄弟元素了, 转向执行父元素Comp3的completeWork
, 然后再执行div4
的beginWork
。所以beginWork
和completeWork
的执行顺序是动态切换的

在beginWork
和completeWork
时, 分别维护了一个指针workInProgress
、completeWork
指向当前正在执行的work的节点, 执行完当前节点指针执行下一个节点, 通过判断workInProgress
是否为null进行beginWork => completeWork
的切换, 通过判断fiber.sibling
是否为null进行completeWork => beginWork
的切换。
整个遍历流程的主要函数调用如下

经过beginWork
、completeWork
, 每个组件节点的dom元素都创建完成或是被打上了对应的标签。在mount
阶段,根组件下已经挂载了所有子元素节点的dom, 那么只需要将根组件dom节点插入到div#app
下即可;update
阶段组件fiber都被打上了标记,哪个元素需要删除,哪个需要更新都在下个阶段这些;这些操作在commit
流程中进行。
Commit阶段
上面说了对于dom元素挂在到根标签div#root
上以及一些元素的删除、更新等都是在commit阶段进行。 此外我们声明的一些useLayoutEffect、useEffect等hooks,以及组件的生命周期也会在该阶段运行。 commit又分为3个阶段分别为commitBeforeMutationEffects
、commitMutationEffects
、commitLayoutEffects
1. commitBeforeMutationEffects
个人认为该阶段主要是为后面两个阶段做一些准备工作
对于不同组件,处理逻辑不同。 对于HostRoot
根组件,在mount
时会清除根节点div#root
已有的子元素,为了插入App的dom做准备; 对于函数式组件,在这个阶段会通过react-scheduler
以普通优先级调用useEffect
但是不会立刻执行,可简单认为在这里加了一个延时器执行useEffect
; 对于class组件会调用静态方法getSnapshotBeforeUpdate
, 即组件被提交到dom之前的方法
2. commitMutationEffects
在这个阶段,主要是根据组件上打的对应标签,执行不同的逻辑; 比如mount
阶段,App组件对应的dom节点就会挂在到div#root
上了,此时页面就可以看到对应的元素了;在update
时,会根据被打的标签执行对应的Update
、Deletion
、Placement
等; 同时在该阶段,如果存在useLayoutEffect
的回调即组件被销毁的函数也会在该阶段执行
3. commitLayoutEffects
因为上个阶段已经把组件的dom元素挂在到页面中去了, 这个阶段主要是执行组件的mount
生命周期函数,比如函数组件的useLayoutEffect
、componentDidMount
;
以上三个阶段执行完,如果没有更高优先级的任务(比如在didMount生命周期里有调用setState), 则第一阶段延迟执行的函数会调用useEffect
; 如果有则会进入update
阶段,重新执行beginWork
、completeWork
、commit
。 其实可以发现useEffect
和componentDidMount
的执行时机还是有区别的。
整个commit的主要函数调用流程如下

这样整个react的渲染和更新流程基本结束
写在最后
本文是阅读react
源码后加上个人理解输出的文章, 如果错误之处,还望指出。
转载自:https://juejin.cn/post/7134230942901600263