前言
本篇文章主要是记录和学习Vue
和React
的Diff
算法,其实关于两者笔者都有去了解但是并没有总结,借此篇文章来做一个总结。
React diff算法
可以参考我以前的文章,在原先的beginWork
中提到,在update
时,即更新组件的时候,他会将当前组件与该组件在上次更新的时候的Fiber节点进行比较,比较的过程就是我们所称为的diff
算法。
React中diff算法有三个预设,如下:
- 只对同级元素进行
Diff
。如果一个DOM节点
在前后两次更新中跨越了层级,那么React
不会尝试复用他。 - 两个不同类型的元素会产生出不同的树。如果元素由
div
变为p
,React会销毁div
及其子孙节点,并新建p
及其子孙节点。 - 开发者可以通过
key prop
来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:
// 更新前
<div>
<p key="a">a</p>
<h3 key="b">b</h3>
</div>
// 更新后
<div>
<h3 key="b">b</h3>
<p key="a">a</p>
</div>
如果我们我们没有设置key值,React
会认为div的第一个子节点由p标签变为了h标签,并销毁而创建,如果设置了key值,则可以复用节点,只是交换顺序。
我们从Diff
的入口函数reconcileChildFibers
出发,该函数会根据newChild
(即JSX对象
)类型调用不同的处理函数
// 根据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);
}
我们可以从同级的节点数量将Diff分为两类:
- 当
newChild
类型为object
、number
、string
,代表同级只有一个节点 - 当
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节点,接下来判断是否可复用
// 首先比较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,并返回 ...省略
}
从代码可以看出,React通过先判断key
是否相同,如果key
相同则判断type
是否相同,只有都相同时一个DOM节点
才能复用。
多节点Diff
这里我们考虑三种情况
- 节点更新
// 之前
<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</div>
<li key="1">1<li>
</ul>
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.节点位置发生变化
// 之前
<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算法的逻辑会经过两轮遍历
- 第一轮遍历处理更新节点
- 第二轮处理不属于更新的节点
第一轮遍历
- 遍历
newChildren
和oldFiber
, 判断Dom节点是否可复用 - 如果不可复用,则可能是以下两种情况引起的
key
不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。key
相同type
不同导致不可复用,会将oldFiber
标记为DELETION
,并继续遍历
- 如果
newChildren
或者olderFiber
某一个先遍历完则也是跳出第一轮遍历
第二轮遍
- 比较理想的结果是
newChildren
和oldFiber
同时遍历完,这样意味着只存在节点更新,这样遍历第一遍就可以了,diff算法结束 newChildren
遍历完,olderFiber
没有遍历完,这样意味着本次更新删除了某些节点,这样的话遍历剩下的olderFiber
,并标记为Deletion
newChildren
没有遍历完,olderFiber
遍历完,这样意味着本次更新新增了一些节点,这样的话需要遍历剩下的newChildren
为生成的workInProgress fiber
依次标记Placement
newChildren
和oldFiber
都没有遍历完,那就要从下面考虑,也是diff
算法的核心 首先,我们要确定标记节点是否移动,因此我们会用到几个名词,lastPlacedIndex
,遍历到最后一个可复用节点的下标,oldIndex
为oldFiber
中节点的下标。
如果oldIndex < lastPlacedIndex
,代表本次更新该节点需要向右移动。
lastPlacedIndex
初始为0
,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex
,则lastPlacedIndex = oldIndex
。我们从下面的例子考虑:
如下所示 每个字母代表一个节点,字母的值代表节点的`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节点被标记为移动
以上也相对比较好理解,就是比较顺序判断是否移动
vue diff算法
这里讲的是对应vue2的diff算法,
vue中diff算法也是同层比较,当数据变化触发Xsetter
,调用Dep.notify
,通知wathcer
去调用update
,然后调用patch
,就是vue响应式原理的那一部分,可以参考我以前的文章,然后给真是DOM打补丁。
patch
对比是否为同一类型的标签,如果相同则继续比较,不是的话就将整个节点替换为新的节点.
function patch(oldVnode, newVnode) {
// 比较是否为一个类型的节点
if (sameVnode(oldVnode, newVnode)) {
继续进行深层比较
patchVnode(oldVnode, newVnode)
} else {
// 不同
const oldEl = oldVnode.el // 旧虚拟节点的真实DOM节点
const parentEle = api.parentNode(oldEl) // 获取父节点
createEle(newVnode) // 创建新虚拟节点对应的真实DOM节点
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点
// 设置null,释放内存
oldVnode = null
}
}
return newVnode
}
sameVnode
判断节点是否为同一类型节点
function sameVnode(oldVnode, newVnode) {
return (
oldVnode.key === newVnode.key && // key值是否一样
oldVnode.tagName === newVnode.tagName && // 标签名是否一样
oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是否相同
)
}
patchVnde
当为同类型节点调用patchVnde
function patchVnode(oldVnode, newVnode) {
const el = newVnode.el = oldVnode.el // 获取真实DOM对象
// 获取新旧虚拟节点的子节点数组
const oldCh = oldVnode.children, newCh = newVnode.children
// 如果新旧虚拟节点是同一个对象,则终止
if (oldVnode === newVnode) return
// 如果新旧虚拟节点是文本节点,且文本不一样
if (oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text) {
// 则直接将真实DOM中文本更新为新虚拟节点的文本
api.setTextContent(el, newVnode.text)
} else {
// 否则
if (oldCh && newCh && oldCh !== newCh) {
// 新旧虚拟节点都有子节点,且子节点不一样
// 对比子节点,并更新
updateChildren(el, oldCh, newCh)
} else if (newCh) {
// 新虚拟节点有子节点,旧虚拟节点没有
// 创建新虚拟节点的子节点,并更新到真实DOM上去
createEle(newVnode)
} else if (oldCh) {
// 旧虚拟节点有子节点,新虚拟节点没有
//直接删除真实DOM里对应的子节点
api.removeChild(el)
}
}
}
updateChildren
这个是diff算法比较中要的点之一 这样我们也举个例子
// 之前
abcd
// 之后
acdb
首先是调用首尾指针法 然后会进行互相进行比较,总共有五种比较情况:
- 1、
oldStart 和 newStart
使用sameVnode方法
进行比较,sameVnode(oldS, newS)
- 2、
oldStart 和 newEend
使用sameVnode方法
进行比较,sameVnode(oldS, newE)
- 3、
oldEend 和 newStart
使用sameVnode方法
进行比较,sameVnode(oldE, newS)
- 4、
oldEend 和 newEnd
使用sameVnode方法
进行比较,sameVnode(oldE, newE)
- 5、如果以上逻辑都匹配不到,再把所有旧子节点的
key
做一个映射到旧节点下标的key -> index
表,然后用新vnode
的key
去找出在旧节点中可以复用的位置。
第一步
oldStart = a oldEnd = d
newStart = a oldEnd = b
此时oldStart
= newStart
, a节点不需要移动
第二步
oldStart = b oldEnd = d
newStart = c oldEnd = b
此时oldStart
= oldEnd
此时b往后移动,接下来就一样了。
总结
在此总结了vue
和react
的diff
算法,可能有些地方不对的地方希望大家能够指正,以后需要多加研究。