React-差异对比(diff)和commit的大体流程

By
写代码

一转眼距离上篇的React-Fiber已经一个月了。这段时间依旧在继续写React-native,虽然写的东西看起来跟以前差别不大,和起初接触的新鲜感已经不同,更多的内容需要深挖代码才能懂。不过只要保持好奇心,有意思的东西就会不断出现。

就像一遍一遍地对源代码debug。当我们看的比较多的时候……就可能陷入代码细节,无法弄清楚自己到底在搞啥。就像沉迷于基建忘记主线的死亡搁浅,或者沉迷打牌的巫师3,又或者是沉迷捡垃圾忘记找鹅几的辐射4。不好意思偏题了,我的意思是,需要跳出来思考一下不至于偏离主旨。

本篇继续Fiber的内容。整理一下“改版”后的差异对比(diff)和拆分出的commit的大体流程。

综前文所述,reconciliation的任务是在渲染进行之前,把render(函数式组件是return)的变更后内容合并到fiber节点上。

从整体上来看,React就像一个函数,输入是state/props,输出是UI的渲染结果;当把App拆分成细小的组件,React对于组件的定义,也可以看作小的函数。组件实例化时,转化成了对应的Fiber节点。虽然执行结果在呈现给用户之前,还需要多次获取函数的返回结果。

之后还需要在fiber节点上合并、覆盖的过程,以保持呈现结果的一致性。这个听起来好像git职责的过程,就是差异对比(diff)。

差异对比(diff)

首先来回答一个问题:差异对比主要完成什么工作?

差异对比可以看作是在workInProgress这个“草稿”上对节点的操作:对每次更新(如setState)将影响到的Fiber节点,“对比”节点属性和状态,以保持一致状态。

按照经验,我们可以料想到,一个更新可能会带来许多节点的状态变更,叫做side-effect(见附录)。有的是需要简单更新text内容,有的带来的是节点树结构变化(如添加、删除等);有些节点不动,可能只是渲染了一些静态内容,后续也没有变更。

这时就需要有一个规则,以最快速(计算量小)的办法,又不至于使App的状态不一致(如执行了setState有的节点却没更新上)。又“快”又“稳”地达到更新节点数的目的。

这就是差异对比完成的工作。

差异对比如何进行?

差异对比发生在每次update时,涵盖我们常用的操作:setState、hooks调用等。

每次执行update时,都会有差异对比(diff)操作,对比每个节点,并依次在firstEffect/lastEffect以链表结构记录更新的节点。

如前文记录,第一次渲染时,虽然构建了完整的树结构,但是只有根节点(HostRoot)创建了alternate节点。其他子节点(child属性)的alternate均为null。当首次update执行时,才会为每个节点附加alternate节点。

对比过程发生在workLoopSync(或workLoop)中。

遍历流程:

为了不至于使调用栈过深,又要达到“异步”的目的,遍历过程充分利用了链表的优势,保存一个current指针,一个节点处理完成,把current指向current.child,继续处理。当优先任务来的时候,还可以保存现场。首次update时,会依次(父-子-兄弟)记录所有Host节点。除首次以外的update,记录的内容仅为有变更(新增、更新、删除)的节点。

fiber diff

遍历流程

节点树的遍历是从顶点顺着最左(严格来说是第一个)子树向下的。纵向过程中不处理兄弟节点,当自上而下遍历到最后一个节点(即child为空),才开始遍历兄弟节点(sibling):由于兄弟关系是靠链表存储,当child为空的时候,即表示本层最后一个节点,此时需要向上回溯一层,遍历该层的sibling节点(树),由于sibling可能是另外一棵子树,所以会从头开始重复这个过程。

这里为什么需要回溯一层,取上层的sibling呢?

因为所有的叶子节点,都是可渲染的DOM或文本节点。对于它们的遍历,没有什么意义。

我们可以把节点分为两种:一种是class组件/function组件,运行声明周期函数/useXxx,并返回render/return。另外一种是Host组件(包裹DOM节点),进行DOM/文本变更。在Fiber里以不同的type区分。

DOM节点或者文本节点,都会有一个HostComponent(tag:5)或者HostText(tag:6)包裹。这就像我们写组件,无论套多少层,到最内层肯定是一个可渲染的DOM或者Text文本。而这个容器(HostXX)类节点是遍历时需要经过的,也是某次遍历的终点,执行到这里,意味着可以直接获取函数的render内容并放置到stateNode上。

按照如上顺序向上回溯,直到顶端。

执行这个反向遍历时,会更新fiber节点的stateNode。具体流程:

从子节点开始,向下遍历,由于只能处理可挂载节点,所以直到获取到HostComponent/HostText,才执行appendInitialChildappendChild操作)。否则:子节点存在则继续向下,然后是兄弟节点,然后向上一层。即先向下,再向上,直到遍历到当前的fiber节点。

stateNode存储了组件的实例,可能是DOM元素或者是自定义组件(class/function)实例。

遍历完成(即returnFiber为HostRoot)之后,会把HostRoot的workInProgress存入FiberRootNode的finishedWork。供commit使用。

Commit部分

渲染操作在commitRoot里进行。这部分是同步的。

总体来说,commit操作的是FiberRootNode.finishedWork(差异对比的结果),处理逻辑是:只操作有变动的节点。并在最后把根节点的current替换为workInProgress节点。

root.finishedWork里实际保存了root.current.alternate,同时,这个操作的对象主要是firstEffect。怎么有点绕?root.current.alternate存储了待处理的fiberNode(HostRoot的alternate),而这个fiberNode节点包含firstEffect属性。核心操作流程如下:

那么,firstEffect存储了什么呢?存储了第一个有变更的组件节点(比如class或functional组件)。并且,由于整个树都是链表结构,所以这个effect也是链式的,用一个nextEffect指针关联。由于遍历有顺序,所以存储顺序整体也是父-子-兄弟。

而这个firstEffect链表如何生成,就涉及到了update执行时的差异对比(diff)操作。这个后面叙述。

commit部分的操作分为Placement/PlacementAndUpdate/Update/Deletion四种。

Placement表示组件需要放置,由于前面的流程中,已经在stateNode里生成了可以使用的DOM组件,所以这里会直接放置,入口是HostComponent(如前文所述,以tag标记)。

Update表示组件已经存在,但是需要更新,这个更新只涉及到要操作的组件。

Deletion是需要删除。如何判断操作类型,见文末附录。

附录

side-effect(副作用)是指什么?

side-effect(副作用)是指函数外的一些东西的执行,影响了函数的输出内容。这些“额外的东西”可能是来自网络请求(获取了新数据),或者是更改了函数闭包内的变量值,或是给数组参数push了一个新成员。这么看起来,似乎React组件的都会受到side-effect影响。

有没有无side-effect的函数呢?有,纯函数。他们接受指定的参数,永远会返回同样的值。在React组件里,有一类pureComponent(函数式组件可以用useMemo转化)就是这种效果。

Fiber里用effectTag来标记节点的effect类型,需要采取的操作。

如何判断操作类型?

操作类型是根据effectTag进行的,在差异对比的时候放入的。effectTag跟组件状态有关(比如Update是4,Delete是8,有Ref是128,Passive是512)。这些状态可以叠加(用或门|)。在使用的地方,与对应的标志位取与(用与门&),即可知道当前组件的状态。

如何使用effectTag呢?在commit过程中,可以看到这样的代码

可以说,就是根据这些状态位,来决定对该节点应该采取什么操作的。

相关的标志位在ReactSideEffectTags.js,有兴趣可以查看。

TODO:

-> 生命周期函数调用?

-> performWorkUntilDeadline怎么运行?

0

Comments: 1

  1. 给大佬跪了

    11月30日

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

:razz: