react Diff读书笔记

236 阅读5分钟

Diff

卡老师牛逼!请看卡老师的 原文 不要在我这里浪费时间了。

三个限制

  1. 只对同级元素进行 Diff
  2. 两个不同类型的元素会产生不同的树
  3. 可通过 key 来暗示哪些子元素在不同的渲染下能保持稳定

根据同级节点数量,将 Diff 分成两类:

  1. 当 newChild 类型为 object,number,string,表示同级只有一个节点
  2. 当 newChild 类型为 Array,同级有多个节点

单个节点

当且仅当节点的 key 相同 且 type 也相同时,节点元素可复用。如:

<!-- before  -->
<div key="xxx">123</div>
<!-- after  -->
<div key="xxx">456</div>

当 key 相同, type 不同,标记删除该节点以及兄弟fiber

<!-- before  -->
<div key="xxx">123</div>
<!-- after  -->
<p key="xxx">456</p>

当 key 不同,标记删除该节点

<!-- before  -->
<div key="xxx">123</div>
<!-- after  -->
<div key="aaa">456</div>

多个节点

节点变动情况有以下三种:

  1. 节点更新(节点属性,节点类型)
  2. 节点新增/删除
  3. 节点位置变动

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

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

  1. 处理更新的节点
  2. 处理剩下不属于更新的节点

第一轮遍历

  1. let i = 0,遍历 newChildren, 将 newChildren 与 oldFiber 比较,判断 DOM 节点 是否可复用
  2. 如果可复用,i++,继续比较 newChildren 与 oldFible ,可复用则继续遍历
  3. 如不可复用,分两种情况:
    • key 不同导致不可复用,立即跳出整个遍历,第一轮遍历结束
    • key 相同 type 不同导致不可复用,将 oldFiber 标记为 DELETION,并继续遍历
  4. 如果 newChildren 遍历完(即 i === newChildren.length - 1)或者 oldFiber 遍历完(即 oldFiber.sibling === null),跳出遍历,第一轮遍历结束

第二轮遍历

当第一轮遍历完,有以下四种情况:

  1. newChildren 和 oldFiber 同时遍历完

    最理想情况:只需要在第一轮遍历进行组件更新即可。此时 Diff 结束。

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

    已有的 DOM 节点都复用了,此时还有新加入的节点,意味着本次更新有新节点插入。只需要遍历剩下的 newChildren 为之生成的 workInProgress fiber 以此标记 Placement 即可。

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

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

  4. newChildren 和 oldFiber 都没遍历完

    这个就比较头秃了。这意味着有节点在此次更新中改变了位置。

处理移动的节点

找到它

节点改变了位置,意味着,不能用位置索引 i 来对比前后节点了,那么如何将同一个节点在两次更新中对应上呢?

我们需要用 key

为了快速找到 key 对应的 oldFible ,将未处理的 oldFible 存入 以 key 为 key,oldFiber 为 value 的 Map 中。

const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

然后继续遍历剩下的 newChildren ,通过 newChildren[i].key 就能快速地在 existingChildren 中找到 key 相同的 oldFible了。

参照物

节点移动是以什么为参照物的呢?

我们的参照物:最后一个可复用的节点在 oldFible 中的位置索引(lastPlacedIndex)。

移动策略

由于本次更新中节点是按 newChildren 的顺序排列的。在遍历 newChildren 过程中,每个遍历到的可复用节点,一定是当前遍历到的所有可复用节点中最靠右的那个,即一定在 lastPlacedIndex 对应的可复用的节点 在本次更新中位置的后面。(描述1)

那么我们只需要比较遍历到的可复用节点在上次更新时是否也在 lastPlacedIndex 对应的 oldFiber 后面,就能知道两次更新中这两个节点的相对位置有没有改变。(描述2)

用 oldIndex 表示遍历到的可复用节点在 oldFiber 中的位置的位置索引。如果 oldIndex < lastPlacedIndex 代表本次更新该节点需要往右移动。(描述3)

lastPlacedIndex 初始为 0 ,每遍历一个可复用的节点,如果 oldIndex >= lastPlacedIndex,则 lastPlacedIndex = oldFiber。(描述4)

看一个例子,例子中,每个字母代表一个节点,字母值代表节点的 key

// old
abcd
// new
dabc
// 第一轮遍历开始
a 跟 d 的 key 不同,跳出第一轮遍历
// 第一轮遍历结束
// 第二轮遍历开始
遍历 newChildren (dabc):
d:
oldIndex 3 lastPlacedIndex 0
根据描述4:oldIndex > lastPlacedIndex。d 位置保持不变,此时 lastPlacedIndex 3
此时 old -> abcd

a:
oldIndex 0 lastPlacedIndex 3
根据描述3: oldIndex < lastPlacedIndex。a 位置向右移动,此时 lastPlacedIndex 3
此时 old -> bcda

b:
oldIndex 1 lastPlacedIndex 3
根据描述3: oldIndex < lastPlacedIndex。b 位置向右移动,此时 lastPlacedIndex 3
此时 old -> cdab

c:
oldIndex 2 lastPlacedIndex 3
根据描述3: oldIndex < lastPlacedIndex。c 位置向右移动,此时 lastPlacedIndex 3
此时 old -> dabc
// 第二轮遍历结束

其实描述3,描述4就是描述2的详细说明。简单点就是:节点新位置比老位置大的时候,就将这个节点往右移动。

再看一个例子:

// old 
abcd
// new 
acdb
//第一轮遍历开始
a key 跟 a key 相同,继续
b key 跟 c key 不同,跳出遍历
// 第一轮遍历结束
//第二轮遍历开始
遍历 newChildren (cdb):
c:
oldIndex 2 lastPlacedIndex 0
根据描述3: oldIndex > lastPlacedIndex。c 位置不变,此时 lastPlacedIndex 2
此时 old -> abcd

d:
oldIndex 3 lastPlacedIndex 2
根据描述3: oldIndex > lastPlacedIndex。d 位置不变,此时 lastPlacedIndex 3
此时 old -> abcd

b:
oldIndex 1 lastPlacedIndex 3
根据描述3: oldIndex > lastPlacedIndex。b 位置向右移动,此时 lastPlacedIndex 1
此时 old -> acdb

从第一个例子可以看到(abcd -> dabc),变动次数比 第二个例子(abcd -> acdb)要多。可以明显看到,从后面将一个元素排到前面去,并不是将该元素抽出来,直接放到前面去,而是,将该元素前的元素一个个往右移动(移到该元素后面)。所以我们要尽量避免将后面元素排到前面去的这种操作。

参考

[React技术揭秘--第五章 Diff 算法](