Diff
卡老师牛逼!请看卡老师的 原文 不要在我这里浪费时间了。
三个限制
- 只对同级元素进行 Diff
- 两个不同类型的元素会产生不同的树
- 可通过 key 来暗示哪些子元素在不同的渲染下能保持稳定
根据同级节点数量,将 Diff 分成两类:
- 当 newChild 类型为 object,number,string,表示同级只有一个节点
- 当 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>
多个节点
节点变动情况有以下三种:
- 节点更新(节点属性,节点类型)
- 节点新增/删除
- 节点位置变动
在日常开发中,相对于 新增/删除,更新组件发生的频率更高。所以 Diff 会优先判断当前节点是否属于 更新。
基于以上原因,Diff 算法的整体逻辑会经历两轮遍历:
- 处理更新的节点
- 处理剩下不属于更新的节点
第一轮遍历
- let i = 0,遍历 newChildren, 将 newChildren 与 oldFiber 比较,判断 DOM 节点 是否可复用
- 如果可复用,i++,继续比较 newChildren 与 oldFible ,可复用则继续遍历
- 如不可复用,分两种情况:
- key 不同导致不可复用,立即跳出整个遍历,第一轮遍历结束
- key 相同 type 不同导致不可复用,将 oldFiber 标记为 DELETION,并继续遍历
- 如果 newChildren 遍历完(即 i === newChildren.length - 1)或者 oldFiber 遍历完(即 oldFiber.sibling === null),跳出遍历,第一轮遍历结束
第二轮遍历
当第一轮遍历完,有以下四种情况:
-
newChildren 和 oldFiber 同时遍历完
最理想情况:只需要在第一轮遍历进行组件更新即可。此时 Diff 结束。
-
newChildren 没遍历完,oldFiber 遍历完了
已有的 DOM 节点都复用了,此时还有新加入的节点,意味着本次更新有新节点插入。只需要遍历剩下的 newChildren 为之生成的 workInProgress fiber 以此标记 Placement 即可。
-
newChildren 遍历完了,oldFiber 没遍历完
意味着本次更新比以前的节点数量少了,证明有节点被删除了。所以只需要遍历剩下的 oldFiber ,依次标记 Deletion
-
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 算法](