阅读 6171
React源码揭秘3 Diff算法详解

React源码揭秘3 Diff算法详解

欢迎订阅 React技术揭秘 代码参照React 16.13.1

什么是Diff

在前两篇文章中我们分别介绍了 React 的首屏渲染流程组件更新流程,其中

  • 首屏渲染会渲染一整棵 DOM 树
  • 组件更新会根据变化的状态局部更新 DOM 树

那么 React 如何知道哪些 DOM 节点需要被更新呢?

上一篇文章这里我们讲到,在render阶段的beginWork函数中,会将上次更新产生的 Fiber 节点与本次更新的 JSX 对象(对应ClassComponentthis.render方法返回值,或者FunctionComponent执行的返回值)进行比较。根据比较的结果生成workInProgress Fiber,即本次更新的 Fiber 节点。

用通俗的语言讲

React 将上次更新的结果与本次更新的值比较,只将变化的部分体现在 DOM 上

这个比较的过程,就是 Diff。本篇文章主要讲解Rect Diff 算法的内部实现,对 Diff 的简单讲解请参考React 文档

Diff的瓶颈以及React如何应对

由于 Diff 操作本身也会带来性能损耗,React文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量。

如果在 React 中使用了该算法,那么展示 1000个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。

为了降低算法复杂度,React的diff会预设三个限制:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。

  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。

  3. 开发者可以通过 key属性 来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:

// 更新前
<div>
    <p key="ka">ka</p>
    <h3 key="song">song</h3>
</div>

// 更新后
<div>
    <h3 key="song">song</h3>
    <p key="ka">ka</p>
</div>

复制代码

如果没有key,React会认为div的第一个子节点由p变为h3,第二个子节点由h3变为p。这符合限制2的设定,会销毁并新建。

但是当我们用key指明了节点前后对应关系后,React知道key === "ka"p在更新后还存在,所以DOM节点可以复用,只是需要交换下顺序。

这就是React为了应对算法性能瓶颈做出的三条限制。

Diff是如何实现的

接下来我们看看Diff的具体实现。我们从Diff的入口函数reconcileChildFibers出发,接着再看看不同类型的Diff是如何实现的。

Diff函数入口函数简介

让我们稍稍看下Diff的入口函数,不要被代码长度吓到喽 ,其实逻辑很简单——在函数内部,会根据newChild类型调用不同的处理函数。

// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // ...其他case
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 调用 reconcileSingleTextNode 处理
  }

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
  }

  // 一些其他情况调用处理函数

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}
复制代码

这里的newChild参数就是本次更新的 JSX 对象(对应ClassComponentthis.render方法返回值,或者FunctionComponent执行的返回值)

不同类型的Diff是如何实现的

我们可以从同级的节点数量将Diff分为两类:

  1. newChild类型为objectnumberstring,代表同级只有一个节点
  2. newChild类型为Array,同级有多个节点

接下来,我们分别讨论。

情况一:同级只有一个节点的Diff

对于单个节点,我们以类型object为例,会进入reconcileSingleElement

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // 对象类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // ...其他case
    }
  }
复制代码

这个函数会做如下事情:

其中第二步判断DOM节点是否可以复用,让我们通过代码看看是如何判断的:

不要怕,逻辑也很简单

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  
  // 首先判断是否存在对应DOM节点
  while (child !== null) {
    // 上一次更新存在DOM节点,接下来判断是否可复用
    if (child.key === key) {
    
    // ️同学看这里,首先比较key是否相同
      switch (child.tag) {
        // ...省略case
        
        default: {
          if (child.elementType === element.type) {
            // ️同学看这里,key相同后再看type是否相同
            // 如果相同则表示可以复用
            return existing;
          }
          
          // type不同则跳出循环
          break;
        }
      }
      //  key不同或type不同都代表不能复用,会到这里
      // 不能复用的节点,被标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 创建新Fiber,并返回
}
复制代码

还记得我们刚才提到的,React预设的限制么,

从代码可以看出,React通过先判断key是否相同,如果key相同则判断type是否相同,只有都相同时一个DOM节点才能复用。

课间练习题

让我们来做几道习题巩固下吧:

请判断如下JSX对象对应的DOM元素是否可以复用:

// 习题1 更新前
<div>ka song</div>
// 更新后
<p>ka song</p>

// 习题2 更新前
<div key="xxx">ka song</div>
// 更新后
<div key="ooo">ka song</div>

// 习题3 更新前
<div key="xxx">ka song</div>
// 更新后
<p key="ooo">ka song</p>

// 习题4 更新前
<div key="xxx">ka song</div>
// 更新后
<div key="xxx">xiao bei</div>

复制代码

老师公布答案啦:

习题1: 未设置key prop默认 key = null;,所以更新前后key相同,都为null,但是更新前typediv,更新后为ptype改变则不能复用。

习题2: 更新前后key改变,不需要再判断type,不能复用。

习题3: 更新前后key改变,不需要再判断type,不能复用。

习题4: 更新前后keytype都未改变,可以复用。children变化,DOM的子元素需要更新。

你是不是都答对了呢

情况二:同级有多个元素的Diff

刚才我们介绍了单一元素的Diff,现在考虑我们有一个FunctionComponent

function List () {
    return (
        <ul>
            <li key="0">0</li>
            <li key="1">1</li>
            <li key="2">2</li>
            <li key="3">3</li>
        </ul>
    )
}
复制代码

他的返回值JSX对象的children属性不是单一元素,而是包含四个对象的数组

这种情况下,reconcileChildFibersnewChild参数为Array,在函数内部对应如下情况:

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
  }
复制代码

接下来我们来看看,React如何处理同级多个元素的Diff。

同级多个节点Diff详解

整体概括

首先看下,我们需要处理的情况:

  • 情况1 节点更新
// 情况1 节点更新

// 之前
<ul>
    <li key="0" className="before">0<li>
    <li key="1">1<li>
</ul>

// 之后情况1 节点属性变化
<ul>
    <li key="0" className="after">0<li>
    <li key="1">1<li>
</ul>

// 之后情况2 节点类型更新
<ul>
    <div key="0">0<li>
    <li key="1">1<li>
</ul>
复制代码
  • 情况2 节点新增或减少
// 情况2 节点新增或减少

// 之前
<ul>
    <li key="0">0<li>
    <li key="1">1<li>
</ul>

// 之后情况1 新增节点
<ul>
    <li key="0">0<li>
    <li key="1">1<li>
    <li key="2">2<li>
</ul>

// 之后情况2 删除节点
<ul>
    <li key="1">1<li>
</ul>
复制代码
  • 情况3 节点位置变化
// 情况3 节点位置变化

// 之前
<ul>
    <li key="0">0<li>
    <li key="1">1<li>
</ul>

// 之后
<ul>
    <li key="1">1<li>
    <li key="0">0<li>
</ul>
复制代码

同一次同级多个元素的Diff,一定属于以上三种情况中的一种或多种。

该如何设计算法呢

如果让我设计一个Diff算法,我首先想到的方案是:

  1. 判断当前节点的更新属于哪种情况
  2. 如果是新增,执行新增逻辑
  3. 如果是删除,执行删除逻辑
  4. 如果是更新,执行更新逻辑

按这个方案,其实有个隐含的前提——不同操作的优先级是相同的

但React团队发现,在日常开发中,相对于增加和删除,更新组件发生的频率更高。所以React Diff会优先判断当前节点是否属于更新。

值得注意的是,在我们做数组相关的算法题时,经常使用双指针从数组头和尾同时遍历以提高效率,但是这里却不行。

虽然本次更新的JSX对象newChildren为数组形式,但是和newChildren中每个值进行比较的是上次更新的Fiber节点,Fiber节点的同级节点是由sibling指针链接形成的链表。

newChildren[0]oldFiber比较,newChildren[1]oldFiber.sibling比较。

单链表无法使用双指针,所以无法对算法使用双指针优化。

基于以上原因,Diff算法的整体逻辑会经历两轮遍历。

第一轮遍历:处理更新的节点。

第二轮遍历:处理剩下的不属于更新的节点。

第一轮遍历

第一轮遍历步骤如下:

  1. 遍历newChildreni = 0,将newChildren[i]oldFiber比较,判断DOM节点是否可复用。
  2. 如果可复用,i++,比较newChildren[i]oldFiber.sibling是否可复用。可以复用则重复步骤2。
  3. 如果不可复用,立即跳出整个遍历。
  4. 如果newChildren遍历完或者oldFiber遍历完(即oldFiber.sibling === null),跳出遍历。

当我们最终完成遍历后,会有两种结果:

结果一: 如果是步骤3跳出的遍历,newChildren没有遍历完,oldFiber也没有遍历完。

举个栗子

如下代码中,前2个节点可复用,key === 2的节点type改变,不可复用,跳出遍历。

此时oldFiber剩下key === 2未遍历,newChildren剩下key === 2key === 3未遍历。

// 之前
            <li key="0">0</li>
            <li key="1">1</li>
            <li key="2">2</li>
            
// 之后
            <li key="0">0</li>
            <li key="1">1</li>
            <div key="2">2</div>
            <li key="3">3</li>
复制代码

结果二: 如果是步骤4跳出的遍历,可能newChildren遍历完,或oldFiber遍历完,或他们同时遍历完。

再来个

// 之前
            <li key="0" className="a">0</li>
            <li key="1" className="b">1</li>
            
// 之后情况1 newChildren与oldFiber都遍历完
            <li key="0" className="aa">0</li>
            <li key="1" className="bb">1</li>
            
// 之后情况2 newChildren没遍历完,oldFiber遍历完
            <li key="0" className="aa">0</li>
            <li key="1" className="bb">1</li>
            <li key="2" className="cc">2</li>
            
// 之后情况3 newChildren遍历完,oldFiber没遍历完
            <li key="0" className="aa">0</li>
复制代码

带着这两种结果,我们开始第二轮遍历。

第二轮遍历

对于结果二,聪明的你想一想,newChildren没遍历完,oldFiber遍历完意味着什么?

老的DOM节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren依次执行插入操作(Fiber.effectTag = Placement;)。

同样的,我们举一反三。newChildren遍历完,oldFiber没遍历完意味着什么?

意味着多余的oldFiber在这次更新中已经不存在了,所以需要遍历剩下的oldFiber,依次执行删除操作(Fiber.effectTag = Deletion;)。

那么结果一怎么处理呢?newChildrenoldFiber都没遍历完,这意味着有节点在这次更新中改变了位置。

接下来,就是Diff算法最精髓的部分!!!! 打起精神来,我们胜利在望

处理位置交换的节点

由于有节点交换了位置,所以不能再用位置索引对比前后的节点,那么怎样才能将同一个节点在两次更新中对应上呢?

你一定想到了,我们需要用key属性了。

为了快速的找到key对应的oldFiber,我们将所有还没处理的oldFiber放进以key属性为key,Fiber为value的map

const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
复制代码

再遍历剩余的newChildren,通过newChildren[i].key就能在existingChildren中找到key相同的oldFiber

接下来是重点哦,敲黑板

在我们第一轮和第二轮遍历中,我们遇到的每一个可以复用的节点,一定存在一个代表上一次更新时该节点状态的oldFiber,并且页面上有一个DOM元素与其对应。

那么我们在Diff函数的入口处,定义一个变量

let lastPlacedIndex = 0;
复制代码

该变量表示当前最后一个可复用节点,对应的oldFiber在上一次更新中所在的位置索引。我们通过这个变量判断节点是否需要移动。

是不是有点绕, 不要怕,老师的栗子又来啦

这里我们简化一下书写,每个字母代表一个节点,字母的值代表节点的key


// 之前
abcd

// 之后
acdb

===第一轮遍历开始===
a(之后)vs a(之前)  
key不变,可复用
此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0
所以 lastPlacedIndex = 0;

继续第一轮遍历...

c(之后)vs b(之前)  
key改变,不能复用,跳出第一轮遍历
此时 lastPlacedIndex === 0;
===第一轮遍历结束===

===第二轮遍历开始===
newChildren === cdb,没用完,不需要执行删除旧节点
oldFiber === bcd,没用完,不需要执行插入新节点

将剩余oldFiber(bcd)保存为map

// 当前oldFiber:bcd
// 当前newChildren:cdb

继续遍历剩余newChildren

key === c 在 oldFiber中存在
const oldIndex = c(之前).index;
即 oldIndex 代表当前可复用节点(c)在上一次更新时的位置索引
此时 oldIndex === 2;  // 之前节点为 abcd,所以c.index === 2
比较 oldIndex 与 lastPlacedIndex;

如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
并将 lastPlacedIndex = oldIndex;
如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动

在例子中,oldIndex 2 > lastPlacedIndex 0则 lastPlacedIndex = 2;
c节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:bd
// 当前newChildren:db

key === d 在 oldFiber中存在
const oldIndex = d(之前).index;
oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3
则 lastPlacedIndex = 3;
d节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:b
// 当前newChildren:b

key === b 在 oldFiber中存在
const oldIndex = b(之前).index;
oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
则 b节点需要向右移动
===第二轮遍历结束===

最终acd 3个节点都没有移动,b节点被标记为移动

复制代码

相信你已经明白了节点移动是如何判断的。如果还有点懵逼,正常的~~ 我们再看一个栗子~~

// 之前
abcd

// 之后
dabc

===第一轮遍历开始===
d(之后)vs a(之前)  
key改变,不能复用,跳出遍历
===第一轮遍历结束===

===第二轮遍历开始===
newChildren === dabc,没用完,不需要执行删除旧节点
oldFiber === abcd,没用完,不需要执行插入新节点

将剩余oldFiber(abcd)保存为map

继续遍历剩余newChildren

// 当前oldFiber:abcd
// 当前newChildren dabc

key === d 在 oldFiber中存在
const oldIndex = d(之前).index;
此时 oldIndex === 3; // 之前节点为 abcd,所以d.index === 3
比较 oldIndex 与 lastPlacedIndex;
oldIndex 3 > lastPlacedIndex 0
则 lastPlacedIndex = 3;
d节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:abc
// 当前newChildren abc

key === a 在 oldFiber中存在
const oldIndex = a(之前).index; // 之前节点为 abcd,所以a.index === 0
此时 oldIndex === 0;
比较 oldIndex 与 lastPlacedIndex;
oldIndex 0 < lastPlacedIndex 3
则 a节点需要向右移动

继续遍历剩余newChildren

// 当前oldFiber:bc
// 当前newChildren bc

key === b 在 oldFiber中存在
const oldIndex = b(之前).index; // 之前节点为 abcd,所以b.index === 1
此时 oldIndex === 1;
比较 oldIndex 与 lastPlacedIndex;
oldIndex 1 < lastPlacedIndex 3
则 b节点需要向右移动

继续遍历剩余newChildren

// 当前oldFiber:c
// 当前newChildren c

key === c 在 oldFiber中存在
const oldIndex = c(之前).index; // 之前节点为 abcd,所以c.index === 2
此时 oldIndex === 2;
比较 oldIndex 与 lastPlacedIndex;
oldIndex 2 < lastPlacedIndex 3
则 c节点需要向右移动

===第二轮遍历结束===

复制代码

可以看到,我们以为从 abcd 变为 dabc,只需要将d移动到前面。

但实际上React保持d不变,将abc分别移动到了d的后面。

从这点可以看出,考虑性能,我们要尽量减少将节点从后面移动到前面的操作。

相信经过这么多多多栗子,你已经懂了Diff原理,为自己鼓鼓掌吧

全部带注释代码见这里

总结

我们前三篇文章分别讲解了

至此,整个React的渲染逻辑就完结了。

在之后的章节中,我们会一起实现异步调度器Scheduler,再用调度器来为我们的React做时间切片。

文章分类
前端
文章标签