漫谈 react 系列(二): 用二叉树的中序遍历搞懂 fiber tree 的协调过程

3,328 阅读25分钟

前言

谈到 react 的工作过程,不得不提的一定会有 fiber tree 的协调过程diff 算法比较更新副作用的处理这些内容。在之前的 漫谈 react 系列(一):初探 react 的工作过程 中,我们只是对 react 的工作过程做了简单的初步梳理,并没有对其核心内容做详细说明。花大量的篇幅来介绍一些基本知识,主要是为本文做铺垫,以便能帮助大家更好的理解本文。

本文将在 漫谈 react 系列(一):初探 react 的工作过程 基础上,进一步详细介绍 fiber tree 的整个协调过程diff 算法,以及副作用的处理。文章篇幅较长,并且使用了大量的图片来帮助理解,可能需要花费大家一定的时间来阅读,希望大家阅读以后能有一些收获。

文章的目录列表如下:

协调 - Reconcile

在之前的文章 漫谈 react 系列(一):初探 react 的工作过程 中,我们已经讲到一次 react 更新,本质是 fiber tree 结构的更新变化。其实,fiber tree 结构的更新,用更专业的术语来讲,其实是 fiber tree 的协调 - ReconcileReconcile, 中文意思调和、使和谐一致。协调 fiber tree,就是调整 fiber tree 的结构,使其和更新以后的 jsx 模板结构dom tree 保持一致。

fiber tree 协调时,存在两颗树:current fiber treeworkInProgress fiber tree。整个协调过程发生在 workInProgress fiber tree 中。

fiber tree 在协调的时候,主要做三件事情:

  • 为 workInProgress fiber tree 生成 fiber node

  • 找到发生变化的 fiber node,更新 fiber node, 标记副作用 - effect

  • 收集带 effect 的 fiber node

那这个过程是怎样进行的呢?

在回答这个问题前,我们先来回顾一个在学生时代耳熟能详的知识点 - 二叉树的中序遍历

4.png

fiber tree 的协调过程,也可以用二叉树的中序遍历来理解:

5.png

上面的那一段代码是不是和二叉树的中序遍历几乎一模一样。如果光用代码看,大家还不太理解的话,那我们就这个过程拆解开来,用一张图解来说明:

6.png

7.png

8.png

9.png

结合上面的图解,理解起来就应该比较容易了吧。

实际上 reactfiber tree 协调过程的源代码实现并非如此,但是思想和过程却是类似的。如果你也有兴趣看 react 源码中的协调过程,可以以二叉树的中序遍历过程为指导,这样理解起来就非常方便了!

workInProgress fiber tree 中的节点

workInProgress fiber tree 作为一颗新树,生成 fiber node 的方式有种:

  • 克隆(浅拷贝) current fiber node
  • 新建一个 fiber node
  • 直接复用 current fiber node

不同的创建方式,导致相关的 dom 操作也不相同:

  • 如果是克隆(浅拷贝) current fiber node,意味着原来的 dom 节点可以直接复用,只需要更新 dom 节点的属性,或者移动 dom 节点
  • 如果是新建一个 fiber node,需要新增加一个 dom 节点;
  • 如果是直接复用 current fiber node, 那么对应的 dom 节点完全不用做任何处理

再归纳一下,就是两种,要么复用,要么新建(克隆也是新建)。那么到底什么时候该复用,什么时候该创建呢?

在回答这个问题之前,我们先看一个非常简单的例子: demo

Dec-01-2021 20-47-39.gif

点击修改按钮,调用 setName、setVisible。Component 组件传入的 name 发生了变化,需要更新,而 Component2 组件由于传入的 name 不符合 compare 的条件,不需要更新。

在这个例子中,Component 组件的子节点需要重新创建,而 Component2 组件的子节点会全部复用 current fiber node,图解如下:

29.png

之所以会出现复用 current fiber node 的情况,是因为组件 Component2 不需要更新,对应的函数方法没有执行,没有返回新的 react element

同样的的情况也适用于类组件。当类组件不需要更新时(shouldComponentUpdate 返回 false), render 方法不需要执行,不会返回新的 react element,子节点直接复用 current fiber node

因此,在一次 react 更新中,只要组件以及子组件渲染方法(类组件的 render、函数组件方法)都没有触发,没有返回新的 react element子节点就可以直接复用 current fiber node

相反,只要组件的渲染方法被触发,返回新的 react element,那么就需要根据新的 react element 为子节点创建 fiber node

在日常开发过程中,我们可以通过合理使用 ShouldComponentUpdateReact.memo,阻止不必要的组件重新 render,通过直接复用 current fiber node,加快 workInProgress fiber tree 的协调,达到优化的目的。

除了复用 current fiber node 外,workInProgress fiber tree 的其他节点都是新建的,而新建节点的过程就和我们常常提及的 diff 算法有关了。

diff 算法

fiber tree 的协调过程中,如果组件节点的 render 方法被触发,返回新的 react element,那么组件节点的子节点都需要新建

新建节点有两种方式:

  • 如果能在 current fiber tree 中找到匹配节点,那么可以通过克隆(浅拷贝) current fiber node 的方式来创建新的节点;

  • 相反,如果无法在 current fiber tree 找到匹配节点,那么就需要重新创建一个新的节点;

这里其实很好理解。新建一个节点时,如果发现和原来的节点差距不大,那么就可以照着原来的节点先创建一个,然后再简单的修改一下;如果发现要创建的节点和原来的节点差距很大,那就只能重新创建一个了。

而我们经常谈及的 diff 算法就是用来判断是否需要通过克隆 current fiber node 的方式来创建一个 fiber node

如何判断一个 current fiber node 是可克隆的

有一点我们需要先搞清楚,做 diff 比较的双方,分别是 workInProgress fiber tree 中用于构建 fiber node 的 react elementcurrent fiber tree 中的 fiber node,即 react elementcurrent fiber node 做比较,比较两者的 keytype,根据比较结果来决定如何为 workInProgress fiber tree 创建 fiber node

key 非常好理解,它就是 jsx 模板中元素上的 key 属性,如:

<Component key="A" name="xxxx" />  //  key="A"
<div>...</div>  // key=undefined
<button>按钮</button>  // key=undefined
<React.Fragment>...</React.Fragment>  // key=undefined

jsx 模板转化为 react element 后,元素的 key 属性会作为 react elementkey 属性。同样的,react element 转化为 fiber node 以后,react elementkey 属性也会作为 fiber nodekey 属性。

type 理解起来稍微有点复杂。jsx 中的元素,概括来讲,可以分为三种类型:

  • 组件, 如 "< Component />";
  • dom 节点,如 "< div > ... </ div>" 、 "< button >...</ button >";
  • react 提供的元素,如 "<React.Fragment />"、 "<React.Suspense>...</React.Suspense>";

不同的元素类型,导致 type 也不相同,如下:

<Component key="A" name="xxxx" />  //  type = Component, 是一个函数
<div>...</div>  // key=undefined   // type = "div", 是一个字符串
<button>按钮</button>  // type="button", 是一个字符串
<React.Fragment>...</React.Fragment>  // type = React.Fragment, 是一个数字(react 内部定义的);

jsx 模板转化为 react element 以后,react elementtype 属性会根据 jsx 元素的类型赋不同的值,可能是组件函数,也可能是 dom 标签字符串,还可能是数字react element 转化为 fiber node 以后,react elementtype 属性也会作为 fiber nodetype 属性。

综上,判断拷贝 current fiber node 的逻辑,概括来就是:

  • reactElement.key === currentFiberNode.key && reactElement.type === currentFiberNode.type, current fiber node 可以克隆;

  • reactElement.key !== currentFiberNode.key, current fiber node 不可克隆;

  • reactElement.key === currentFiberNode.key && reactElement.type !== currentFiberNode.type, current fiber node 不可克隆;

diff 算法的核心思想

diff 算法的核心思想:

  • 已匹配的父节点的直接子节点进行比较,不跨父节点比较;

    即对克隆生成的 workInProgress fiber node 和对应的 current fiber node 这两个节点的子节点进行比较。

    注意,是已匹配的父节点的直接子节点进行比较,而不是同层的节点进行比较哈!!!很多文章里面写的都是同一层级的节点进行比较,这是不够准确的。

  • 通过比较 keytype 来判断是否需要克隆 current fiber node。只有 keytype 都相等,才克隆 current fiber node 作为新的节点,否则就需要新建一个节点。

    key 值和节点类型 - type,key 的优先级更高。如果 key 值不相同,那么节点不可克隆。

  • 同一个父节点的所有子节点key 要保证唯一性

子元素是列表时为什么要使用 key

相信大家在日常开发中,也遇到过下面的警告信息吧:

image.png

如果子元素是一个列表且没有给每一个子元素定义 key,那么 react 就会打印上述警告信息。

那使用 key 有什么意义呢?

使用 key 最大的意义,就是当列表元素只是发生位置变化时,只需要做 dom 的移动操作,不需要做多余的新增删除操作。

我们来看一个示例: list:

Sep-27-2021 18-15-39.gif

在示例中,我们定义了两个组件 Componet1Component2。其中, Component1 中的列表元素定义了 keyComponent2 中的列表元素没有定义 key。点击标题元素,调整列表元素的顺序。

为了能监听到 dom 元素是否发生了移动新增删除,我们重写了 createElementappendChildinsertBeforeremoveChild 方法。

当我们点击 Component1 的标题时,元素位置发生变动,控制台只打印 insertBefore,说明 dom 元素只发生了移动

而我们点击 Component2 的标题时,元素位置发生变动,控制台打印了 createElementremoveChildinsertBefore,说明 dom 元素发生了创建删除

由此可见,如果要渲染的子元素是一个列表,给子元素指定 key 值,在一定程度上能起到优化的目的。

diff 算法的比较过程

react elementcurrent fiber node 的比较,涉及的情形包括:

  • single react element VS current fiber node list

  • react element list VS current fiber node list

在 current fiber tree 中,兄弟节点之间通过 sibling 指针相连,是一个链表,因此在比较时会作为一个 list。如果只有一个 fiber node,sibling 指向 null,列表中只有一个元素

single react element VS current fiber node list

single react elementcurrent fiber node list 的比较非常简单,就是遍历 current fiber node list,比较每个 current fiber nodereact elementkey 值和节点类型 - type。只有 keytype 相等,react elementcurrent fiber node 才能匹配。

没有 key 值,那么 key 为 undefined,undefined === undefined,key 值相等,比较 type。另外,在卡颂大佬的 单节点 diff 一文中对有更详细的比较细节,大家可以去看看,更进一步了解。

最后的比较结果有两种:

  • current fiber node list 中某个节点匹配 react element

    直接克隆(浅拷贝) current fiber node,作为 react element 对应的 workInProgress fiber nodeworkInProgress fiber nodealternate 指针指向 current fiber node

    current fiber node list中剩余的未匹配节点,全部标记 Deletion

  • current fiber node list 中没有节点可匹配 react element

    没有可匹配的 current fiber node,就需要为 react element 重新创建一个新的 fiber node 作为 workInProgress fiber nodeworkInProgress fiber nodealternate 指针指向 null

    current fiber node 列中所有的节点,全部标记 Deletion

图示:

10.png

react element list VS current fiber node list

react element listcurrent fiber node list 的比较稍微要复杂一些。

当只有一个 react element 时,创建 workInProgress fiber node 只需要考虑是否是克隆(浅拷贝) current fiber node 还是从零开始创建一个新的 fiber node。但如果 react element 有多个时,我们还需要考虑 wokrInProgress fiber node 相对于克隆(浅拷贝)的 current fiber node 是否发生了移动。

image.png 那如何判断一个克隆(浅拷贝)自 current fiber node 的 workInProgress fiber node 是否发生了移动

答案是:通过列表下标 index

current fiber node listworkInProgress fiber node list 作为一个列表,都有对应的下标 oldIndexnewIndex。当 workInProgress fiber nodecurrent fiber node 建立克隆(浅拷贝)匹配关系时,newIndexoldIndex 也是对应的。在上面图例中, workInProgress fiber node - C 的 newIndex 为 0,对应的 current fiber node - C 的 oldIndex 为 2。

workInProgress fiber node 是按序生成的,在生成过程中会使用一个称为 lastPlacedIndex 的指针来定位未发生移动 current fiber node。通过 lastPlasedIndexoldIndex 比较,就可以知道节点是否发生了移动

  • 如果 lastPlacedIndex > oldIndex,说明 oldIndex 小的节点跑到了 oldIndex 大的节点后面,发生了移动;

  • 如果 lastPlacedIndex < oldIndex, 说明节点没有发生移动,此时要更新 lastPlacedIndex

我们结合上面图例来说明一下节点的移动过程:

  1. workInProgress fiber node - C 匹配 current fiber node - C,newIndex 为 0,oldIndex 为 2;节点 C 未发生移动, lastPlacedIndex 为 2;

  2. workInProgress fiber node - B 匹配 current fiber node - B,newIndex 为 1, oldIndex 为 1, oldIndex < lastPlacedIndex,节点 B 发生了移动, 移动到了节点 C 之后;

  3. workInProgress fiber node - A 匹配 current fiber node - A,newIndex 为 2, oldIndex 为 0, oldIndex < lastPlacedIndex,节点 A 发生了移动,移动到了节点 C 之后;

  4. workInProgress fiber node - D 匹配 current fiber node - D,newIndex 为 3, oldIndex 为 3, oldIndex > lastPlacedIndex,节点 D 未发生了移动,将 lastPlacedIndex 更新为 3;

知道了如何判断节点是否发生了移动,接下来我们就来看看 react 是如何比较 react element listcurrent fiber node list 的。

在比较过程中,react 会按序遍历 react element list,从 current fiber node list 中寻找可匹配的节点。比较时,会使用 key 值出现不相等这个条件作为分水岭,将整个过程被分为两个阶段

  • 第一阶段,key 值未出现不相等

    此时,react element listcurrent fiber node list齐头并进的方式一同遍历type 相同,匹配,克隆 current fiber nodetype 不相同,不匹配,新建 fiber node,并给 current fiber nodeDeletion 标记。

    当出现 key 值不一样,结束第一阶段,进入第二阶段;

  • 第二阶段,key 值出现不匹配

    此时根据剩下的 current fiber node list,生成一个 Mapkeycurrent fiber nodekey(没有 key,则使用 index),valuecurrent fiber node,继续遍历剩余的 react element,从 Map 中找到匹配的 current fiber node

    如果可以找到,克隆 current fiber node,否则新建 fiber nodeMap未被匹配的节点全部标记 Deletion

整个比较过程,卡颂大佬的多节点 diff 有更细节的描述,大家可以结合大佬的文章一起来理解。

直接这样讲,大家可能会一脸懵逼 😳,那么我们就通过一个辅助示例来展示一下整个过程, 帮助大家理解上面的两个阶段。

首先,我们先看示例及结果:

13.png

从上图中,我们可以清楚的看到 workInProgress fiber node 中,哪些是新增的,哪些是克隆(浅拷贝)自 current fiber node,哪些发生了移动,哪些是需要删除的。

接下来,我们就来分析一下中间经历的过程:

14.png

15.png

16.png

17.png

18.png

这样,经过 diff 算法比较,workInProgress fiber tree 中需要新建的节点就建立了。接下来我们就需要判断新建的节点是否发生了变化。如果节点发生了变化,那就需要对节点做更新操作,并收集更新操作引发的 effect

如何判断一个新建的节点是否发生了变化

通过上一节 diff 算法的学习,我们知道新建 workInProgress fiber node 的方式有两种:克隆(浅拷贝) current fiber node重新创建一个 fiber node

如果一个 workInProgress fiber node 是重新新建的,那么我们也可以很肯定的说这个 fiber node 是发生了变化的 - 从无到有

而如果一个 workInProgress fiber node 是克隆(浅拷贝)自 current fiber node,那我们就需要一定的策略来判断 workInProgress fiber node 是否发生了变化。

在讲解判断策略之前,我们需要先来一些准备知识:pendingProps & memoizedPropsfiber node 的 tag

pendingProps & memoizedProps

漫谈 react 系列(一):初探 react 的工作过程 中,我们已经了解了 jsx -> react element -> fiber node 中间的转化过程。在这个过程中,jsx 元素上定义的所有属性(除 key 以外),会收集到 react elementprops 属性中。

如下:

// jsx
<Component name="xxx" age="18" address="xxxx" onClick={() => {...}} />

// react element
{
    type: Component,
    key: undefined,
    props: {
        name: 'xxx',
        age: "18",
        address: "xxxx",
        onClick: () => {...}
    }
}

react element 转化为 workInProgress fiber node 后, react elementprops 属性会赋值给 workInProgress fiber nodependingProps。当 workInProgress fiber node 结束处理后, pendingProps 属性值再赋值给 memoizedProps。由于 workInProgress fiber node 会作为下一次更新的 current fiber node,所以我们可以通过 currentFiberNode.memoizedProps 来获取上一次更新时 jsx 元素的属性值。

// workInProgress fiber node 刚创建时
workInProgressFiberNode.pendingProps: { name: 'xxx', age: "18", address: "xxxx", onClick: () => {...}};

// workInProgress fiber node 完成创建
workInProgressFiberNode.memoizedProps: { name: 'xxx', age: "18", address: "xxxx", onClick: () => {...}};

// fiber node - 下一次更新
currentFiberNode.memoizedProps = { name: 'xxx', age: "18", address: "xxxx", onClick: () => {...} };

pendingProps 是本次更新生成的 workInProgress fiber nodepropsmemoizedProps 是更新之前 current fiber nodeprops,通过比较 pendingPropsmemoizedProps,就可以知道 workInProgress fiber node 是否发生了变化。

fiber node 的 tag

在前面的 diff 算法一节中,我们知道每个 fiber node 都有自己的类型 - type组件类型dom 节点类型react 特殊元素类型

根据 type 的不同,react 会给 fiber node 打不同的 tag

  • 组件类型 - 如果是类组件tagClassComponent;如果是函数组件tagFunctionComponent
  • dom 节点类型 - tagHostComponent
  • react 特殊元素类型 - tagFragmentSuspenseComponent 等;

不同的 tag,在 workInProgress fiber node 确定发生变化时要做的处理也不同。

判断节点是否发生变化的策略

所有类型的节点,都会通过比较 workInProgress fiber nodependingProps 是否和 current fiber nodememoizedProps 相等,来判断 workInProgress fiber node 是否发生了变化。如果 workInProgressFiberNode.pendingProps !== currentFiberNode.memoizedProps,那么就说明 workInProgress fiber node 发生了变化,需要更新。

通常,只要 workInProgress fiber node 是根据新的 react element 创建的,那么 workInProgress fiber nodependingProps 肯定和 current fiber nodememoizedProps 不相等。

每一次 react element 创建时,props 都是一个新的对象,导致生成的 workInProgress fiber nodependingProps 也是一个新的对象。而 current fiber nodememoizedProps 是上一次更新生成的 react elementprops,两个对象完全不一样。

这也就解释了当子节点是一个组件时,尽管 props 中的属性一样,属性值也一样,组件依旧需要触发 render 方法。这是因为子组件对应的 fiber node 是新建的,尽管 props 看起来没有发生变化,但实际上已经是一个新的对象了。props 发生了变化,节点就要更新,就会触发 render 方法。

另外,如果节点是组件类型,还需要查看组件的 state 是否发生了变化。只要组件的 propsstate 有一个发生了变化,节点都是需要更新的。

综上,判断节点是否发生变化的策略为:

  • 节点只要是重新创建的而不是克隆自 current fiber node,那么节点就百分之百发生了变化,需要更新;

  • 节点克隆自 current fiber node,需要比较 props 是否发生了变化,如果 props 发生了变化,节点需要更新;

  • 节点克隆自 current fiber node,且是组件类型,还可以比较 state 是否发生了变化,如果 state 发生了变化,节点需要更新;

节点发生变化以后的处理方式

首先,workInProgress fiber node 的创建方式不同,发生变化需要更新时的处理逻辑也不相同:

  • 节点是重新创建而非克隆自 current fiber node,需要做 mount 操作;

  • 节点是通过克隆 current fiber node 创建的,需要做 update 操作;

另外,workInProgress fiber nodetag 不同,更新时的处理逻辑也不相同。在日常的开发中,我们接触最多的类型是组件类型dom 类型,因此本文就重点介绍这两种类型的节点发生更新时候的处理逻辑,其他类型的节点,我们会在以后单独介绍。

组件类型的 fiber node

组件类型又可分为:类组件 - ClassComponent函数组件 - FunctionComponent

这两种类型的节点的 mountupdate 操作过程如下:

  • 类组件 - ClassComponent

    19.png

  • 函数组件 - FunctionComponent

    20.png

dom 节点类型的 fiber node

21.png

副作用 - effect

workInProgress fiber tree 在协调过程中,只要有节点发生变化做了更新操作,就会产生副作用 - effect副作用,指的就是 dom 节点的新增移动删除属性更新,组件 componentDidMountcomponentDidUpdate 等生命周期方法的执行等。

fiber tree 协调结束以后,会进入 commit 阶段。在 commit 阶段, react 的主要工作就是处理协调过程中产生的所有的 effect

effect 的类型

react 中定义了很多类型的 effect,如下:

  • Placement
  • Update
  • PlacementAndUpdate
  • Ref
  • Deletion
  • Snapshot
  • Passive
  • Layout
  • ...

(我们只列举了我们日常开发中会经常接触的一些 effect,未列举的 effect 有些我们可能接触的较少,有些是会在后面的文章中讲解。)

根据上面 effect 的名称,我们基本上也能猜到这些 effect 代表什么意思:

  • Placement放置的意思,只针对 dom 类型fiber node,表示节点需要做移动或者添加操作。

    fiber node 标记 Placement 时,就需要在 commit 阶段做如下操作:

    • 如果节点是添加操作,我们需要通过 createElement / appendChild / insertBefore 这些原生 API 添加一个新的 dom 节点;

    • 如果节点是移动操作,我们需要通过 appendChild / insertBefore 来移动一个已经存在的 dom 节点;

  • Update更新的意思,针对所有类型的 fiber node,表示 fiber node 需要做更新操作。

    fiber node 需要标记 Update 的情形,常见如下:

    • dom 类型的节点的 props 发生了变化;
    • 类组件需要 mount,且定义了 componentDidMount
    • 类组件propsstate 发生了变化需要 update,且定义 componentDidUpdate
    • 函数组件需要 mount,且使用了 useEffectuseLayoutEffect
    • 函数组件propsstate 发生了需要 update,且定义了 useEffectuseLayoutEffect
    • ...

    fiber node 标记了 Update,那么就需要在 commit 阶段做如下操作:

    • dom 类型的节点,更新 dom 节点的属性;
    • 类组件节点,如果是 mount,触发 componentDidMount;如果是 update,触发 componentDidUpdate
    • 函数组件节点,触发上一次 useEffectuseLayoutEffect 返回的 destory,并执行本次的 callback
  • PlacementAndUpdate, 放置并更新的意思,只针对 dom 类型fiber node,表示节点发生了移动props 发生了变化。

    fiber node 标记了 PlacementAndUpdate,需要在 commit 阶段通过 appendChild / insertBefore 来移动一个已经存在的 dom 节点,并修改 dom 节点的属性

  • Ref, 表示节点存在 ref,需要初始化 / 更新 ref.current

    fiber node 标记了 Ref,需要在 commit 阶段将类组件实例dom 元素赋值给 ref.current

  • Deletion删除的意思,针对所有类型的 fiber node,表示 fiber node 需要移除

    fiber node 标记了 Deletion,就需要在 commit 阶段做如下操作:

    • 使用 removeChild 移除要删除的 dom 节点;
    • 触发要移除的类组件子组件componetWillUnMount 生命周期方法;
    • 触发要移除的函数组件子组件的上一次更新执行 useEffectuseLayoutEffect 生成的 destory 方法;
    • ref.current 引用置为 null
  • Snapshot快照的意思,主要是针对类组件 fiber node

    类组件 fiber node 发生了 mount 或者 update 操作,且定义了 getSnapshotBeforeUpdate 方法,就会标记 Snapshot

    fiber node 标记了 Snapshot,就需要在 commit 阶段触发 getSnapshotBeforeUpdate 方法。

  • Passive,主要针对函数组件 fiber node,表示函数组件使用了 useEffect

    函数组件节点发生 mount 或者 update 操作,且使用了 useEffect hook,就会给 fiber node 标记 Passive

    fiber node 标记了 Passive,就需要在 commit 阶段做如下操作:

    • 先执行上一次 useEffect 返回的 destory
    • 异步触发本次更新时 useEffectcallback
  • Layout,主要针对函数组件 fiber node,表示函数组件使用了 useLayoutEffect

    函数组件节点发生 mount 或者 update 操作,且使用了 useLayoutEffect hook,就会给 fiber node 标记 Layout

    fiber node 标记了 Layout,就需要在 commit 阶段做如下操作:

    • 先执行上一次 useLayoutEffect 返回的 destory;

    • 同步触发本次更新时 useLayoutEffectcallback

react 使用二进制数来声明 effect,如 Placement2 (0000 0010)Update4 (0000 0100)。一个 fiber node 可同时标记多个 effect,如函数组件 props 发生变化且使用了 useEffect hook,那么就可以使用 Placement | Update = 516(位运算符) 来标记。

effect 的收集

如果一个 fiber node 被标记了 effect,那么 react 就会在这个 fiber node 完成协调以后,将这个 fiber node 收集起来。当整颗 fiber tree 完成协调以后,所有被标记 effectfiber node 都被收集到一起来。

收集的 fiber node 采用单链表结构存储,firstEffect 指向第一个标记 effectfiber nodelastEffect 标记最后一个 fiber node,节点之间通过 nextEffect 指针连接。

由于 fiber tree 协调时采用的顺序是深度优先,协调完成的顺序是子节点子节点兄弟节点父节点,所以收集带 effect 标记的 fiber node 时,顺序也是子节点子节点兄弟节点父节点

这也就可以解释为什么 componentDidUpdatecomponentDidMount 生命周期方法的执行顺序为先子节点后父节点

22.png

effect 的处理

fiber tree 协调完成, 带 effectfiber node 收集完毕,接下来要做的就是要处理带 effectfiber node

effect 的处理分为三个阶段,这三个阶段按照从前到后的顺序为:

  1. before mutation 阶段 (dom 操作之前)
  2. mutation 阶段 (dom 操作)
  3. layout 阶段 (dom 操作之后)

不同的阶段,处理的 effect 种类也不相同。在每个阶段,react 都会从 effect 链表的头部 - firstEffect 开始,按序遍历 fiber node, 直到 lastEffect

before mutation 阶段

before mutation 阶段的主要工作是处理带 Snapshot 标记的 fiber node

firstEffect 开始遍历 effect 列表,如果 fiber nodeSnapshot 标记,触发 getSnapshotBeforeUpdate 方法。

mutation 阶段

mutation 阶段的主要工作是处理带 Deletion 标记的 fiber node 和带 PlacementPlacementAndUpdateUpdate 标记的 dom 类型的 fiber node

在这一阶段,涉及到 dom 节点的更新新增移动删除,组件节点删除导致的 componentWillUnmountdestory 方法的触发,以及删除节点引发的 ref 引用的重置。

dom 节点的更新操作比较简单,主要操作如下:

  • 通过原生的 API setAttributeremoveArrribute 修改 dom 节点的 attr;
  • 直接修改 dom 节点的 style
  • 直接修改 dom 节点的 innerHtmltextContent

dom 节点的新增移动处理起来,稍微有点复杂。

如果新增(移动)的节点是父节点的最后一个子节点,那么可以直接使用 appendChild 方法。但如果不是最后一个节点,就需要使用 insertBefore 方法了。使用 insertBefore 方法,必须要提供一个用于定位dom 节点。

整个过程,我们通过图解来说明:

image.png

其中,节点 A、C 需要移动, E、F 需要新增。

新增 E 的过程:

25.png

移动 A 的过程:

26.png

移动 C 的过程:

27.png

新增 F 的过程:

28.png

标记 Deletion 的节点处理的时候也比较复杂,考虑的情况也比较多:

  • 如果节点是 dom 节点,通过 removeChild 移除;
  • 如果节点是组件节点,触发 componentWillUnmountuseEffectdestory 方法的执行;
  • 如果标记 Deletion 的节点的子节点中有组件节点深度优先遍历子节点,依次触发子节点的 componentWillUnmountuseEffectdestory 方法的执行;
  • 如果标记 Deletion 的节点及子节点关联了 ref 引用,要将 ref 引用置空,及 ref.current = null(也是深度优先遍历);

这也就可以解释为什么 comonentWillUnmountdestory 方法的触发顺序是先父节点后子节点

layout 阶段

layout 阶段的主要工作是处理带 update 标记的组件节点和带 ref 标记的所有节点

工作内容如下:

  • 如果类组件节点是 mount 操作,触发 componentDidMount;如果是 update 操作,触发 componentDidUpdate
  • 如果函数组件节点时 mount 操作,触发 useLayoutEffectcallback;如果是 update 操作,先触发上一次更新生成的 destory,再触发这一次的 callback
  • 异步调度函数组件的 useEffect
  • 如果组件节点关联了 ref 引用,要初始化 ref.current;

注意,useEffectcallbackdestory 的触发都是异步的,是在浏览器渲染完成以后才会触发。

写在最后

写到这里,本文就结束了。

最后我们再对本文做一个总结:

  • 每次 react 更新,fiber tree 都会进行协调,找到发生变化的 fiber node,标记并收集 effect。等 fiber tree 协调结束后,处理收集的 effect

  • fiber tree 协调时采用的深度优先遍历

  • workInProgress fiber tree 的节点的生成方式有三种:复用 current fiber node克隆 current fiber node新建 fiber node

  • 协调时,如果组件节点及子组件的 render 方法没有触发,子节点可直接复用 current fiber node,否则要通过 diff 算法来决定如何创建子节点;

  • diff 算法只比较已匹配的父节点子节点,不跨父节点比较;

  • diff 比较时,如果 react elementcurrent fiber nodekeytype 都一致,那么就可以拷贝 current fiber node,否则要重新创建一个 fiber node

  • fiber tree 协调结束以后,会使用一个单链表收集标记 effectfiber node。链表中 fiber node 的顺序为子节点子节点的兄弟节点父节点

参考资料

传送门