我把React的渲染原理讲给你听

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

一、铺垫

image.png

首先回想一下,如果不用任何框架,用原生js创建这样一颗dom树应该怎么处理。 为了减少dom操作,我们会先创建最底层的元素存放到变量中,然后依次创建其父元素,直至创建到最顶层的div,最后将顶层的div插入到dom中即可,这样避免了多次的dom插入。 React其实也是这样, 先来看一下我们常用的入口写法

ReactDOM.render(<App />, document.getElementById("root"));
复制代码

在React 17及之前版本,我们都是通过以上方式将React组件注册到视图中。作为高级动物的我们是能一眼看出来哪个是最底层的元素(叶子节点),然后一层层向上创建父元素直至顶层元素(根节点)。但是对于一个程序来说,最开始是没办法知道哪个是叶子节点,所以只能通过入口提供的根节点向下遍历, 直到找到叶子节点然后创建对应的元素。 所以对于React框架来说, 是有两个方向的流程的, 自上而下查找子元素、自下而上创建dom元素,分别对应两个遍历流程beginWorkcompleteWork 后面会详分析两个流程,这里先大概了解。在进入这两个流程之前先看下render函数中做了哪些处理。

二、准备工作

我认为在进入beginWorkcompleteWork流程之前,先做些准备工作为这俩流程做铺垫。

(为了方便描述,暂且称React的顶层组件为根组件,需要挂在到的dom元素称为根元素即上文代码的div#root)

如果要遍历App组件,必须得标记一个起点,方便在后续创建到根节点时执行插入dom的操作。那么起点怎么标记呢?有的同学就说,App就是呀! 试想一下,如果根组件命名不是App而是Root或者别的名字,只认识App那就不行了。所以React内部加了一个"组件"HostRoot用来标示组件的开始(HostRoot并不是一个组件,只是特殊的值, 在创建根组件Fiber时, 作为tag使用)

(Fiber是个Class类型, 每个组件节点后续都会创建为fiber对象, 大概结构如下)

image.png

记录了根组件HostRoot还要记录根元素div#root,因为组件dom创建完后要插入到div#root下,并不是body下。

同时在这里还注册了事件,为啥要注册事件呢?对于dom事件,在jquery时代我们就知道,不要在每个元素上绑定事件,尽量通过代理绑定的形式绑定到父元素上。 没错React也一样,在17版本之前,所有的事件都是绑定到document上, 17之后都是绑定到挂载的根元素上即这里的div#root。我们在写代码时,虽然在React组件上绑定了像onClickonFocusonScroll等事件,最终都是通过代理的形式触发事件的执行的。React的事件系统也很精彩后面单独抽出一篇文章来分析。 这里只需知道也是在开始遍历之前,先把事件在根组件上注册了。

整个过程的主要函数调用流程为

image.png

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

image.png

三者之间的关系如下

image.png

准备工作做完开从根组件向下遍历查找子组件了

自上而下、自下而上遍历执行

在没遍历执行beginWork之前,react也不知道后续的组件结构会是啥样,所以在beginWork时每遇到一个组件时都要记录下来,同时要记录父组件和子组件、组件与组件间的关系,这样才能保证后续创建出来的dom树不会错乱掉。react内部对于每个组件都会创建成Fiber对象,通过Fiber记录组件间的关系,最后构成一个Fiber链表结构。 父组件parentFiber.child指向第一个子组件对应的fiber,子组件的fiber.return指向父组件,同时子组件的fiber.sibling指向其右边的相邻兄弟节点的fiber, 构成一个fiber树。如下图

image.png 还需要说明的是, 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大概流程如下

image.png

completeWork

completeWork阶段主要执行dom节点的创建或者标记变更。在mount阶段时,对于自定义组件比如class组件、函数式组件,其实不做什么特殊处理; 对于divpspan(这种组件在react内部定义为HostComponent),就会调用document.createElement方法创建dom元素存放到该节点fiber对象的stateNode字段上;对于父元素是HostComponent的情况,先创建父元素的dom节点parentInstance, 然后调用parentInstance.appendChild(child)方法将子元素挂在该节点上。 在update阶段,如果老的fiber存在则不会重新创建dom元素,而是给该元素打上Update标签;如果是新的元素和mount阶段一样创建新的dom元素。 大概流程如下

image.png

此外在react内部, beginWorkcompleteWork是交替进行,这是为什么呢? 试想一下, 如果不交替运行,beginWork执行完之后只记录了关系, 然后再想通过completeWork创建dom元素,是不是又得从根组件开始遍历一遍,这样就至少需要遍历两遍。react通过合适的时机切换执行beginWorkcompleteWork只需遍历一遍就可以完成所有操作了。那么在什么时机切换呢?还记得我们一开始说,用原生js创建dom时先创建最底层的元素, react也是,在遍历执行beginWork到最底层元素时即下图的div1,该元素已经没有子元素了, 开始执行completeWork创建dom节点, 执行完div1completeWork又切换成执行div2beginWorkdiv2也没有子节点,所以进而执行div2completeWorkdiv3也同样先执行beginWork再执行completeWork, 和div1div2不同的是, div3已经没有右边的兄弟元素了, 转向执行父元素Comp3的completeWork, 然后再执行div4beginWork。所以beginWorkcompleteWork的执行顺序是动态切换的

image.png

beginWorkcompleteWork时, 分别维护了一个指针workInProgresscompleteWork指向当前正在执行的work的节点, 执行完当前节点指针执行下一个节点, 通过判断workInProgress是否为null进行beginWork => completeWork的切换, 通过判断fiber.sibling是否为null进行completeWork => beginWork的切换。

整个遍历流程的主要函数调用如下

image.png

经过beginWorkcompleteWork, 每个组件节点的dom元素都创建完成或是被打上了对应的标签。在mount阶段,根组件下已经挂载了所有子元素节点的dom, 那么只需要将根组件dom节点插入到div#app下即可;update阶段组件fiber都被打上了标记,哪个元素需要删除,哪个需要更新都在下个阶段这些;这些操作在commit流程中进行。

Commit阶段

上面说了对于dom元素挂在到根标签div#root上以及一些元素的删除、更新等都是在commit阶段进行。 此外我们声明的一些useLayoutEffect、useEffect等hooks,以及组件的生命周期也会在该阶段运行。 commit又分为3个阶段分别为commitBeforeMutationEffectscommitMutationEffectscommitLayoutEffects

1. commitBeforeMutationEffects

个人认为该阶段主要是为后面两个阶段做一些准备工作

对于不同组件,处理逻辑不同。 对于HostRoot根组件,在mount时会清除根节点div#root已有的子元素,为了插入App的dom做准备; 对于函数式组件,在这个阶段会通过react-scheduler以普通优先级调用useEffect但是不会立刻执行,可简单认为在这里加了一个延时器执行useEffect; 对于class组件会调用静态方法getSnapshotBeforeUpdate, 即组件被提交到dom之前的方法

2. commitMutationEffects

在这个阶段,主要是根据组件上打的对应标签,执行不同的逻辑; 比如mount阶段,App组件对应的dom节点就会挂在到div#root上了,此时页面就可以看到对应的元素了;在update时,会根据被打的标签执行对应的UpdateDeletionPlacement等; 同时在该阶段,如果存在useLayoutEffect的回调即组件被销毁的函数也会在该阶段执行

3. commitLayoutEffects

因为上个阶段已经把组件的dom元素挂在到页面中去了, 这个阶段主要是执行组件的mount生命周期函数,比如函数组件的useLayoutEffectcomponentDidMount

以上三个阶段执行完,如果没有更高优先级的任务(比如在didMount生命周期里有调用setState), 则第一阶段延迟执行的函数会调用useEffect; 如果有则会进入update阶段,重新执行beginWorkcompleteWorkcommit。 其实可以发现useEffectcomponentDidMount的执行时机还是有区别的。

整个commit的主要函数调用流程如下

image.png

这样整个react的渲染和更新流程基本结束

写在最后

本文是阅读react源码后加上个人理解输出的文章, 如果错误之处,还望指出。

猜你喜欢

转载自juejin.im/post/7134230942901600263