认识
一、认识
React Diff
发生在 React BeginWork
阶段, 分别对不同类型的 workInProgress.tag
处理时, 最后都会调用 reconcileChildren
来处理 workInProgress.child
, 最后返回 workInProgress.child
。React Diff
根据 newChild
类型, 分别处理, 处理如下:
-
newChild
为对象, 说明子节点为单节点, 进行单节点Diff
-
newChild
为数组, 说明子节点为多节点, 进行多节点Diff
无论单节点 Diff
还是多节点 Diff
, 都会比较对应的新旧节点, 根据 key
和 type
比较结果来决定旧节点是否可以复用, 如果不可以复用, 创建新的 Fiber
节点, 将旧节点标记为 ChildDeletion
。如果可以复用, 则复用旧元素。后续在 commit
阶段根据 Fiber.flags
进行对应的 DOM
操作。
React Diff
由于 Fiber
架构是一个单向链表结构, 采用了从左往右单向遍历的方式来对比新旧节点。新节点为 ReactElement
类型, 旧节点为 Fiber
类型。第一轮从左往右对比, 寻找可复用的节点。遍历过程中会因为 key
不同或者新旧节点有一个遍历完导致跳出循环,第一轮遍历循环结束。第一轮循环结束后, 记录最后可复用 oldFiber
的索引位置 lastPlacedIndex
。紧接着标记新增节点和删除节点。随后构建以 oldFiber.key
为 key
, oldFiber
为 value
的 Map
结构, 继续遍历剩余的新节点, 逐个在 Map
中寻找可复用的节点。 如果找到可复用的节点,则将 oldIndex
与 lastPlacedIndex
比较,如果 oldIndex
与 lastPlacedIndex
小,则该节点需要右移,将新的 Fiber
节点标记为 Placement
。否则,将 lastPlacedIndex
更新为 oldIndex
。遍历完 newChildren
后,map
中还有节点剩余,则那些节点属于多余的节点,需要标记为删除
二、节点结构
2.1 旧节点
const oldFiber: ReactFiber = {
return: 父节点,
sibling: 兄弟节点,
child: 第一个孩子节点,
}
2.2 新节点
const newFiber: ReactElement = [
fiber1,
fiber2,
……
]
2.3 新旧对比
React Diff
目前的对比方式是:
-
newChildren[i]
与oldFiber
对比 -
newChildren[i++]
与oldFiber.sibling
对比
从已上可以得知: Fiber
链表的数据结构的特点: 就是任何一个位置的 Fiber
节点,都可以非常容易知道它的父 Fiber
, 第一个子元素的 Fiber
,和它的兄弟节点 Fiber
。却不容易知道它前一个 Fiber
节点是谁,这就是 React
中单向链表 Fiber
节点的特点。
三、单节点细节
3.1 key 相同 type 相同
key
相同 type
相同, 复用当前旧节点
3.2 key 相同 type 不同
key
相同 type
不同, 不存在任何复用的可能性, 标记所有旧节点为 ChildDeletion
3.3 key 不同 type 相同
key
不同 type
相同, 当前节点不可复用, 标记当前旧节点为 ChildDeletion
, 继续遍历
3.4 key 不同 type 不同
key
不同 type
不同, 不存在任何复用的可能性, 标记所有旧节点为 ChildDeletion
四、多节点细节
多节点 Diff
优先级: React
团队发现,在日常开发中,相较于新增和删除,更新组件发生的频率更高。所以Diff
会优先判断当前节点是否属于更新。因此, Diff
算法的整体逻辑会经历两轮遍历:
-
第一轮遍历: 处理更新的节点。
-
第二轮遍历: 处理剩下的不属于更新的节点。
4.1 一轮遍历
-
newIdx = 0
遍历newChildren
, 比较newChildren[newIndex]
和oldFiber
, 判断DOM
节点是否可以复用 -
如果可以复用, 继续循环遍历比较
newChildren[newIndex++]
和oldFiber.sibling
-
如果不可以复用, 分两种情况:
-
key
不同导致不可复用, 立即跳出整个遍历, 第一轮遍历结束 -
key
相同type
不同导致不可复用, 会将oldFiber
标记为DELETION
,并继续遍历
-
-
如果
newChildren
遍历完(即i=== newChildren.length - 1
)或者oldFiber
遍历完(即oldFiber.sibling === null
),跳出遍历,第一轮遍历结束。 -
第一轮遍历结束, 会有两种结果:
-
因为不可以复用, 结束的循环遍历, 此时,
newChildren
没有遍历完,oldFiber
也没有遍历完。 -
因为
newChildren
遍历完或者oldFiber
遍历完 或者它们同时遍历完成
-
-
第一轮循环遍历结束, 记录最后一个可复用的节点在
oldFiber
中的位置索引lastPlacedIndex
4.2 二轮遍历
-
newChildren
与oldFiber
同时遍历完: 只需在第一轮遍历进行组件更新。此时Diff
结束。 -
newChildren
没遍历完,oldFiber
遍历完: 已有的DOM
节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren
为生成的workInProgress fiber
依次标记Placement
。 -
newChildren
遍历完,oldFiber
没遍历完: 意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber
,依次标记Deletion
。 -
newChildren
与oldFiber
都没遍历完: 这意味着有节点在这次更新中改变了位置, 这是Diff
算法最精髓也是最难懂的部分。处理移动节点 逻辑如下:-
将所有还未处理的
oldFiber
存入以key
为key
,oldFiber
为value
的Map
中: 这样可以快速的找到key
对应的oldFiber
// Add all children to a key map for quick lookups.
const existingChildren = mapRemainingChildren(returnFiber, oldFiber); -
接下来遍历剩余的
newChildren
,通过newChildren[i].key
就能在existingChildren
中找到key
相同的oldFiber
。 -
此时
lastPlacedIndex
变量记录着最后一个可复用的节点在oldFiber
中的位置索引, 通过newChildren[i].key
从existingChildren
Map
中拿到oldFiber
中的index
并取名为oldIndex
-
比较
oldIndex
与lastPlacedIndex
-
如果
oldIndex >= lastPlacedIndex
: 代表该可复用节点不需要移动, 并将lastPlacedIndex = oldIndex
, 返回老Fiber
的位置即 -
如果
oldIndex < lastPlacedIndex
: 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要移动, 给新Fiber
节点打上一个Placement
的标记,并继续返回上一次协调返回的位置
-
-
五、思考
5.1 React Diff 中 key 的作用?
React Diff Key
作为新旧元素的唯一标识, 当对比新旧元素时, 首先要对比的就是 key
是否相同。如果 key
不相同或者 key
不存在, 那么认为此时的旧元素不可复用, 直接将旧元素删除, 随后重新创建 Fiber
节点。
5.2 React Diff 为什么不能用随机数做 key?
通过随机数设定的 key
, 则会产生无序性,可能会导致所有的 key
都匹配不上,然后舍弃掉之前所有构建出来的 fiber
节点,再重新创建新的节点。
5.3 React Diff 最好不要使用数组的下标做为 key ?
数组下标相对随机数来说,比较稳定一些。但数组下标对应的组件并不是一成不变的,只要在数组的前面或者中间插入元素时,该下标对应的元素就发生变化。虽然 key
没变,但对应的元素已经发生变化了。但是在 Diff
时, key
相同, type
相同会复用旧元素, 导致元素渲染错误。
因此, 数组下标作为 key
的场景是: 只有则初始时渲染一次,后续不再更新列表,只是对某个具体元素进行更新或事件的处理等, 或者没有新增、删除操作等的列表。
5.4 React Diff 为什么不采用双端对比来优化呢?
React Fiber
目前的结构如下:
// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;
React Diff
目前的对比方式是:
-
newChildren[i]
与oldFiber
对比 -
newChildren[i++]
与oldFiber.sibling
对比
从已上可以得知: Fiber
链表的数据结构的特点: 就是任何一个位置的 Fiber
节点,都可以非常容易知道它的父 Fiber
, 第一个子元素的 Fiber
,和它的兄弟节点 Fiber
。却不容易知道它前一个 Fiber
节点是谁,这就是 React
中单向链表 Fiber
节点的特点。
React
不能通过双端对比进行 Diff
算法优化是因为目前 Fiber
上没有设置反向链表,而且想知道就目前这种方案能持续多久,如果目前这种模式不理想的话,那么也可以增加双端对比算法。
5.5 为什么 Vue 不需要使用 Fiber 或者 时间分片?
-
首先时间分片是为了解决
CPU
进行大量计算的问题,因为React
本身架构的问题,在默认的情况下更新会进行过多的计算,就算使用React
提供的性能优化API
,进行设置,也会因为开发者本身的问题,依然可能存在过多计算的问题。 -
Vue
通过响应式依赖跟踪,在默认的情况下可以做到只进行组件树级别的更新计算,而默认下React
是做不到的(据说React
已经在进行这方面的优化工作了),再者Vue
是通过template
进行编译的,可以在编译的时候进行非常好的性能优化,比如对静态节点进行静态节点提升的优化处理,而通过JSX
进行编译的React
是做不到的。 -
React
为了解决更新的时候进行过多计算的问题引入了时间分片,但同时又带来了额外的计算开销,就是任务协调的计算,虽然React
也使用最小堆等的算法进行优化,但相对Vue
还是多了额外的性能开销,因为Vue
没有时间分片,所以没有这方面的性能担忧。 -
根据研究表明,人类的肉眼对
100
毫秒以内的时间并不敏感,所以时间分片只对于处理超过100
毫秒以上的计算才有很好的收益,而Vue
的更新计算是很少出现100
毫秒以上的计算的,所以Vue
引入时间分片的收益并不划算。
六、React vs Vue2.0 vs Vue3.0
6.1 相同点
-
在进行更新
Diff
对比的时候,都是优先处理简单的场景,再处理复杂的场景。 -
在处理第一轮循环剩余的节点,都需要把节点处理
key - value
的Map
数据结构,方便在往后的比对中可以快速通过节点的key
取到对应的节点。同样在比对两个新老节点是否相同时,key
是否相同也是非常重要的判断标准。所以不同是React
, 还是Vue
,在写动态列表的时候,都需要设置一个唯一值key
,这样在diff
算法处理的时候性能才最大化。
6.2 不同点
key - value
的Map
结构:
-
React.js
: 构建以oldFiber.key
为key
,oldFiber
为value
的Map
结构 -
Vue.js 2.0
: 剩余旧节点构建以oldVNode.key
为key
,oldVNode
索引为value
的Map
结构 -
Vue.js 3.0
: 遍历剩余新节点, 构建以newChild.key
为key
, 以当前遍历位置i
为value
的剩余新节点映射表
-
处理逻辑:
React
中是先处理左边部分,左边部分处理不了,再进行复杂部分的处理;Vue2
则先进行首尾、首首、尾尾部分的处理,然后再进行中间复杂部分的处理;Vue3
则先处理首尾部分,然后再处理中间复杂部分,Vue2
和Vue3
最大的区别就是在处理中间复杂部分使用了最长递增子序列算法找出稳定序列的部分。 -
对静态节点的处理不一样: 由于
Vue
是通过template
模版进行编译的,所以在编译的时候可以很好对静态节点进行分析然后进行打补丁标记,然后在Diff
的时候,Vue2
是判断如果是静态节点则跳过过循环对比,而Vue3
则是把整个静态节点进行提升处理,Diff
的时候是不过进入循环的,所以Vue3
比Vue2
的Diff
性能更高效。而React
因为是通过JSX
进行编译的,是无法进行静态节点分析的,所以React
在对静态节点处理这一块是要逊色的。 -
对比之后的更新时机:
Vue2
和Vue3
的比对和更新是同步进行的,这个跟React15
是相同的,就是在比对的过程中,如果发现了那些节点需要移动或者更新或删除,是立即执行的,也就是React
中常讲的不可中断的更新,如果比对量过大的话,就会造成卡顿,所以React16
起就更改为了比对和更新是异步进行的,所以React16
以后的Diff
是可以中断,Diff
和任务调度都是在内存中进行的,所以即便中断了,用户也不会知道。 -
对比算法:
Vue2
和Vue3
都使用了双端对比算法,而React
的Fiber
由于是单向链表的结构,所以在React
不设置由右向左的链表之前,都无法实现双端对比