Twlig / issuesBlog

MIT License
3 stars 0 forks source link

Diff算法 #111

Open Twlig opened 2 years ago

Twlig commented 2 years ago

Diff算法

为了防止概念混淆,这里再强调下

一个DOM节点在某一时刻最多会有4个节点和他相关。

  1. current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点
  2. workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点
  3. DOM节点本身。
  4. JSX对象。即ClassComponentrender方法的返回结果,或FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息。

Diff算法的本质是对比1和4,生成2。

Diff的瓶颈以及React如何应对

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

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

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

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:
// 更新前
<div>
  <p key="ka">ka</p>
  <h3 key="song">song</h3>
</div>

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

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

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

Diff是如何实现的

我们从Diff的入口函数reconcileChildFibers出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数。

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

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

单节点Diff

image

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {
  const key = element.key;
  let child = currentFirstChild;

  // 首先判断是否存在对应DOM节点
  while (child !== null) {
    // 上一次更新存在DOM节点,接下来判断是否可复用

    // 首先比较key是否相同
    if (child.key === key) {

      // key相同,接下来比较type是否相同

      switch (child.tag) {
        // ...省略case

        default: {
          if (child.elementType === element.type) {
            // type相同则表示可以复用
            // 返回复用的fiber
            return existing;
          }

          // type不同则跳出switch
          break;
        }
      }
      // 代码执行到这里代表:key相同但是type不同
      // 将该fiber及其兄弟fiber标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key不同,将该fiber标记为删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 创建新Fiber,并返回 ...省略
}

可能会奇怪为啥,key相同,type不同需要全部删除。这是因为,咱们此次更新的是单节点。当key相同(唯一性),就说明对上了上一次的节点,后续的兄弟节点不可能对上,但是由于type不同,这个节点不能复用,就需要删除。当key不同,只是当前节点没对上,后续的兄弟节点还有可能对上。

练习题

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

请判断如下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分为:第一轮遍历,第二轮遍历两种。第一遍是找出待更新的DOM,第二遍是找出待新增,删除,移动的DOM。(更新的场景更多,所以react官方先判断更新)

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

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

第一轮遍历

第一轮遍历步骤如下:

  1. let i = 0,遍历newChildren,将newChildren[i]oldFiber比较,判断DOM节点是否可复用。
  2. 如果可复用,i++,继续比较newChildren[i]oldFiber.sibling,可以复用则继续遍历。
  3. 如果不可复用,分两种情况:
  1. 如果newChildren遍历完(即i === newChildren.length - 1)或者oldFiber遍历完(即oldFiber.sibling === null),跳出遍历,第一轮遍历结束。

第二轮遍历

对于第一轮遍历的结果,我们分别讨论:

1 . newChildrenoldFiber同时遍历完

那就是最理想的情况:只需在第一轮遍历进行组件更新 。此时Diff结束。

2. newChildren没遍历完,oldFiber遍历完

已有的DOM节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement

3. newChildren遍历完,oldFiber没遍历完

意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion

4. newChildrenoldFiber都没遍历完

这意味着有节点在这次更新中改变了位置。需要移动位置。这是Diff算法最精髓也是最难懂的部分。我们接下来会重点讲解。

处理移动的节点

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

我们需要使用key

为了快速的找到key对应的oldFiber,我们将所有还未处理的oldFiber存入以key为key,oldFiber为value的Map中。

const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

Demo1

在Demo中我们简化下书写,每个字母代表一个节点,字母的值代表节点的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 === 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节点被标记为移动

原文链接https://react.iamkasong.com/