此文内容包括以下:
介绍diff算法
- react-diff: 递增法
移动节点:移动的节点称为α,将α对应的真实的DOM节点移动到,α在新列表中的前一个VNode对应的真实DOM的后面
添加节点:在新列表中有全新的VNode节点,在旧列表中找不到的节点需要添加(通过find这个布尔值来查找)
移除节点:当旧的节点不在新列表中时,我们就将其对应的DOM节点移除(通过key来查找确定是否删除)
不足:从头到尾单边比较,容易增加比较次数
- vue2-diff: 双端比较
DOM节点什么时候需要移动和如何移动,总结如下:
- 头-头:不移动
- 尾-尾:不移动
- 头-尾: 插入到旧节点的尾节点的后面
- 尾-头:插入到旧列表的第一个节点之前
- 以上4种都不存在(特殊情况):在旧节点中找,如果找到,移动找到的节点,移动到开头;没找到,直接创建一个新的节点放到最前面
添加节点【oldEndIndex以及小于了oldStartIndex】:将剩余的节点依次插入到oldStartNode的DOM之前
移除节点【newEndIndex小于newStartIndex】:将旧列表剩余的节点删除即可
- vue3-diff: 最长递增子序列
区别
- react和vue2的比较:
- vue2双端比较解决react单端比较导致移动次数变多的问题,react只能从头到尾遍历,增加了移动次数
-
vue2和vue3的比较:都用了双端指针
-
vue3和react比较:vue3在判断是否需要移动,使用了react的递增法
几个算法看下来,套路就是找到移动的节点,然后给他移动到正确的位置。把该加的新节点添加好,把该删的旧节点删了,整个算法就结束了。
一、react-diff —— 递增法
实现原理
从头到尾遍历比较,新列表的节点在旧列表中的位置是否是递增
如果递增,不需要移动,否则需要移动。
通过key在旧节点中找到新节点的节点,所以key一定要代表唯一性。
移动节点:在旧节点中找到需要移动的VNode,我们称该VNode为α
生成的
DOM节点插入到哪里? 将α对应的真实的DOM节点移动到,α在新列表中的前一个VNode对应的真实DOM的后面。
将DOM-B移到DOM-D的后面
为什么这么移动?
首先我们列表是从头到尾遍历的。这就意味着对于当前VNode节点来说,该节点之前的所有节点都是排好序的,如果该节点需要移动,那么只需要将DOM节点移动到前一个vnode节点之后就可以,因为在新列表中vnode的顺序就是这样的。
添加节点:在新列表中有全新的VNode节点,在旧列表中找不到的节点需要添加
如何发现全新的节点?
定义一个find变量值为false。如果在旧列表找到了key 相同的vnode,就将find的值改为true。当遍历结束后判断find值,如果为false,说明当前节点为新节点
生成的
DOM节点插入到哪里?
分两种情况:
-
- 新的节点位于新列表的第一个,这时候我们需要找到旧列表第一个节点,将新节点插入到原来第一个节点之前,这个很好理解,也就是最在最前面的新节点插入第一个节点之前。
-
- 将新的真实的DOM节点移动到,
新列表中的前一个VNode对应的真实DOM的后面。移动原理同移动节点,也就是因为该节点之前已经排好序。
- 将新的真实的DOM节点移动到,
删除节点:当旧的节点不在新列表中时,我们就将其对应的DOM节点移除
实现代码
function reactDiff(prevChildren, nextChildren, parent) {
let lastIndex = 0
for (let i = 0; i < nextChildren.length; i++) {
let nextChild = nextChildren[i],
find = false;
for (let j = 0; j < prevChildren.length; j++) {
let prevChild = prevChildren[j]
if (nextChild.key === prevChild.key) {
find = true
patch(prevChild, nextChild, parent)
if (j < lastIndex) {
// 移动节点:移动到前一个节点的后面
let refNode = nextChildren[i - 1].el.nextSibling;
parent.insertBefore(nextChild.el, refNode)
} else {
// 不需要移动节点,记录当前位置,与之后的节点进行对比
lastIndex = j
}
break
}
}
if (!find) {
// 定义了find变量,插入新节点
let refNode = i <= 0
? prevChildren[0].el
: nextChildren[i - 1].el.nextSibling
mount(nextChild, parent, refNode);
}
}
//移除节点
for (let i = 0; i < prevChildren.length; i++) {
let prevChild = prevChildren[i],
key = prevChild.key,
has = nextChildren.find(item => item.key === key);
if (!has) parent.removeChild(prevChild.el)
}
}
算法优化及不足
- 时间复杂度是
O(m*n),有不足,可优化 我们可以用空间换时间,把key与index的关系维护成一个Map,从而将时间复杂度降低为O(n)
function reactdiff(prevChildren, nextChildren, parent) {
let prevIndexMap = {},
nextIndexMap = {};
for (let i = 0; i < prevChildren.length; i++) {
let { key } = prevChildren[i]
//保存旧列表key和指引i的关系
prevIndexMap[key] = i
}
let lastIndex = 0;
for (let i = 0; i < nextChildren.length; i++) {
let nextChild = nextChildren[i],
nextKey = nextChild.key,
// 通过新列表的key得到旧列表的指引
j = prevIndexMap[nextKey];
//保存新列表key和指引i的关系
nextIndexMap[nextKey] = i
if (j === undefined) {
//添加节点
let refNode = i === 0
? prevChildren[0].el
: nextChildren[i - 1].el.nextSibling;
mount(nextChild, parent, refNode)
} else {
patch(prevChildren[j], nextChild, parent)
if (j < lastIndex) {
//移动节点:移动到前一个节点的后面
let refNode = nextChildren[i - 1].el.nextSibling;
parent.insertBefore(nextChild.el, refNode)
} else {
// 不需要移动节点,记录当前位置,与之后的节点进行对比
lastIndex = j
}
}
}
//删除节点
for (let i = 0; i < prevChildren.length; i++) {
let { key } = prevChildren[i]
if (!nextIndexMap.hasOwnProperty(key)) parent.removeChild(prevChildren[i].el)
}
}
- 移动次数有不足
根据reactDiff的思路,我们需要先将DOM-A移动到DOM-C之后,然后再将DOM-B移动到DOM-A之后,完成Diff。但是我们通过观察可以发现,只要将DOM-C移动到DOM-A之前就可以完成Diff。
这是因为react只能从头到尾遍历,增加了移动次数。所以这里是有可优化的空间的,接下来我们介绍vue2.x中的diff算法——双端比较,该算法解决了上述的问题
vue2-diff —— 双端比较
实现原理
双端比较就是新列表和旧列表两个列表的头与尾互相对比,,在对比的过程中指针会逐渐向内靠拢,直到某一个列表的节点全部遍历过,对比停止。
按照以下四个步骤进行对比
- 使用旧列表的头一个节点
oldStartNode与新列表的头一个节点newStartNode对比 - 使用旧列表的最后一个节点
oldEndNode与新列表的最后一个节点newEndNode对比 - 使用旧列表的头一个节点
oldStartNode与新列表的最后一个节点newEndNode对比 - 使用旧列表的最后一个节点
oldEndNode与新列表的头一个节点newStartNode对比
通过图形记住1-4的比较顺序,先前后双竖再首尾两交叉,记住这张图就够了
具体规则和移动规则,这里是重中之重,一定要学习
- 当旧列表的头一个节点
oldStartNode与新列表的头一个节点newStartNode对比时key相同。那么旧列表的头指针oldStartIndex与新列表的头指针newStartIndex同时向后移动一位。
原本在旧列表中就是头节点,在新列表中也是头节点,
该节点不需要移动,所以什么都不需要做
- 当旧列表的最后一个节点
oldEndNode与新列表的最后一个节点newEndNode对比时key相同。那么旧列表的尾指针oldEndIndex与新列表的尾指针newEndIndex同时向前移动一位。
原本在旧列表中就是尾节点,在新列表中也是尾节点,说明
该节点不需要移动,所以什么都不需要做
- 当旧列表的头一个节点
oldStartNode与新列表的最后一个节点newEndNode对比时key相同。那么旧列表的头指针oldStartIndex向后移动一位;新列表的尾指针newEndIndex向前移动一位。
原本旧列表中是头节点,然后在新列表中是尾节点。那么
只要在旧列表中把当前的节点移动到原本尾节点的后面,就可以了
- 当旧列表的最后一个节点
oldEndNode与新列表的头一个节点newStartNode对比时key相同。那么旧列表的尾指针oldEndIndex向前移动一位;新列表的头指针newStartIndex向后移动一位。
本在旧列表末尾的节点,却是新列表中的开头节点,没有人比他更靠前,因为他是第一个,所以
只需要把当前的节点移动到原本旧列表中的第一个节点之前,让它成为第一个节点即可。
DOM节点什么时候需要移动和如何移动,总结如下:
- 头-头:不移动
- 尾-尾:不移动
- 头-尾: 插入到旧节点的尾节点的后面
- 尾-头:插入到旧列表的第一个节点之前
当然也有特殊情况,下面继续
当四次对比都没找到复用节点
我们只能拿新列表的第一个节点去旧列表中找与其key相同的节点
找节点的时候有两种情况:
- 一种在旧列表中找到了
移动找到的节点,移动到开头
DOM移动后,由我们将旧列表中的节点改为undefined,这是至关重要的一步,因为我们已经做了节点的移动了所以我们不需要进行再次的对比了。最后我们将头指针newStartIndex向后移一位。
- 另一种情况是没找到
直接创建一个新的节点放到最前面就可以了,然后后移头指针
newStartIndex。
添加节点
oldEndIndex小于了oldStartIndex,但是新列表中还有剩余的节点,我们只需要将剩余的节点依次插入到oldStartNode的DOM之前就可以了。为什么是插入oldStartNode之前呢?原因是剩余的节点在新列表的位置是位于oldStartNode之前的,如果剩余节点是在oldStartNode之后,oldStartNode就会先行对比,这个需要思考一下,其实还是与第四步的思路一样。
移除节点
当新列表的newEndIndex小于newStartIndex时,我们将旧列表剩余的节点删除即可。这里我们需要注意,旧列表的undefind。前面提到过,当头尾节点都不相同时,我们会去旧列表中找新列表的第一个节点,移动完DOM节点后,将旧列表的那个节点改为undefind。所以我们在最后的删除时,需要注意这些undefind,遇到的话跳过当前循环即可。
实现代码
function vue2diff(prevChildren, nextChildren, parent) {
let oldStartIndex = 0,
newStartIndex = 0,
oldStartIndex = prevChildren.length - 1,
newStartIndex = nextChildren.length - 1,
oldStartNode = prevChildren[oldStartIndex],
oldEndNode = prevChildren[oldStartIndex],
newStartNode = nextChildren[newStartIndex],
newEndNode = nextChildren[newStartIndex];
//循环结束条件
while (oldStartIndex <= oldStartIndex && newStartIndex <= newStartIndex) {
if (oldStartNode === undefined) {
oldStartNode = prevChildren[++oldStartIndex]
} else if (oldEndNode === undefined) {
oldEndNode = prevChildren[--oldStartIndex]
} else if (oldStartNode.key === newStartNode.key) {
// 头-头:不移动
patch(oldStartNode, newStartNode, parent)
oldStartIndex++
newStartIndex++
oldStartNode = prevChildren[oldStartIndex]
newStartNode = nextChildren[newStartIndex]
} else if (oldEndNode.key === newEndNode.key) {
// 尾-尾:不移动
patch(oldEndNode, newEndNode, parent)
oldStartIndex--
newStartIndex--
oldEndNode = prevChildren[oldStartIndex]
newEndNode = nextChildren[newStartIndex]
} else if (oldStartNode.key === newEndNode.key) {
// 头-尾: 插入到旧节点的尾节点的后面
patch(oldStartNode, newEndNode, parent)
parent.insertBefore(oldStartNode.el, oldEndNode.el.nextSibling)
oldStartIndex++
newStartIndex--
oldStartNode = prevChildren[oldStartIndex]
newEndNode = nextChildren[newStartIndex]
} else if (oldEndNode.key === newStartNode.key) {
// 尾-头:插入到旧列表的第一个节点之前
patch(oldEndNode, newStartNode, parent)
parent.insertBefore(oldEndNode.el, oldStartNode.el)
oldStartIndex--
newStartIndex++
oldEndNode = prevChildren[oldStartIndex]
newStartNode = nextChildren[newStartIndex]
} else {
//特殊情况
let newKey = newStartNode.key,
oldIndex = prevChildren.findIndex(child => child && (child.key === newKey));
if (oldIndex === -1) {
mount(newStartNode, parent, oldStartNode.el)
} else {
let prevNode = prevChildren[oldIndex]
patch(prevNode, newStartNode, parent)
parent.insertBefore(prevNode.el, oldStartNode.el)
prevChildren[oldIndex] = undefined
}
newStartIndex++
newStartNode = nextChildren[newStartIndex]
}
}
if (newStartIndex > newStartIndex) {
while (oldStartIndex <= oldStartIndex) {
if (!prevChildren[oldStartIndex]) {
oldStartIndex++
continue
}
parent.removeChild(prevChildren[oldStartIndex++].el)
}
} else if (oldStartIndex > oldStartIndex) {
while (newStartIndex <= newStartIndex) {
mount(nextChildren[newStartIndex++], parent, oldStartNode.el)
}
}
}
vue3-diff —— 最长递增子序列
mp.weixin.qq.com/s/PdG6YYi4_… 可参考里面的写法
双端比较,while循环,两端是向内靠拢的
头-头
尾-尾
j是头向内靠拢指针;
prevEnd是尾向内靠拢指针
添加节点:j > prevEnd且j <= nextEnd【证明新列表有多余的】
移除节点:j > nextEnd【证明旧列表有多余的】
上图,j > prevEnd且j <= nextEnd,只需要把新列表中j到nextEnd之间剩下的节点插入进去。
如果j > nextEnd【证明旧列表有多余的】时,把旧列表中j到prevEnd之间的节点删除
移动节点
根据新列表剩余的节点数量,创建一个source数组,并将数组填满-1。
创建数组和对象建立关系:
- 数组source【来做新旧节点的对应关系的,根据
source计算出它的最长递增子序列用于移动DOM节点】:新节点在旧列表的位置存储在该数组中, - 对象nextIndexMap【通过新列表的key去找旧列表的key】:存储当前新列表中的
节点key与指引i的关系,再通过key去旧列表中去找位置
如果旧节点在新列表中没有的话,直接删除就好
let prevStart = j,
nextStart = j,
nextLeft = nextEnd - nextStart + 1, // 新列表中剩余的节点长度
source = new Array(nextLeft).fill(-1), // 创建数组,填满-1
nextIndexMap = {}, // 新列表节点与index的映射
patched = 0; // 已更新过的节点的数量
// 保存映射关系
for (let i = nextStart; i <= nextEnd; i++) {
let key = nextChildren[i].key
nextIndexMap[key] = i
}
// 去旧列表找位置
for (let i = prevStart; i <= prevEnd; i++) {
let prevNode = prevChildren[i],
prevKey = prevNode.key,
nextIndex = nextIndexMap[prevKey];
// 新列表中没有该节点 或者 已经更新了全部的新节点,直接删除旧节点
if (nextIndex === undefind || patched >= nextLeft) {
parent.removeChild(prevNode.el)
continue
}
// 找到对应的节点
let nextNode = nextChildren[nextIndex];
patch(prevNode, nextNode, parent);
// 给source赋值
source[nextIndex - nextStart] = i
patched++
}
}
在找节点时要注意,如果旧节点在新列表中没有的话,直接删除就好。除此之外,我们还需要一个数量表示记录我们已经patch过的节点,如果数量已经与新列表剩余的节点数量一样,那么剩下的旧节点就直接删除
如果是全新的节点的话,其在source数组中对应的值就是初始的-1,通过这一步可以区分出来哪个为全新的节点,哪个是可复用的。
判断是否要移动?递增法,同react思路:如果找到的
index是一直递增的,说明不需要移动任何节点。我们通过设置一个变量move来保存是否需要移动的状态。
function vue3Diff(prevChildren, nextChildren, parent) {
//...
outer: {
// ...
}
// 边界情况的判断
if (j > prevEnd && j <= nextEnd) {
// ...
} else if (j > nextEnd && j <= prevEnd) {
// ...
} else {
let prevStart = j,
nextStart = j,
nextLeft = nextEnd - nextStart + 1, // 新列表中剩余的节点长度
source = new Array(nextLeft).fill(-1), // 创建数组,填满-1
nextIndexMap = {}, // 新列表节点与index的映射
patched = 0,
move = false, // 是否移动
lastIndex = 0; // 记录上一次的位置
// 保存映射关系
for (let i = nextStart; i <= nextEnd; i++) {
let key = nextChildren[i].key
nextIndexMap[key] = i
}
// 去旧列表找位置
for (let i = prevStart; i <= prevEnd; i++) {
let prevNode = prevChildren[i],
prevKey = prevNode.key,
nextIndex = nextIndexMap[prevKey];
// 新列表中没有该节点 或者 已经更新了全部的新节点,直接删除旧节点
if (nextIndex === undefind || patched >= nextLeft) {
parent.removeChild(prevNode.el)
continue
}
// 找到对应的节点
let nextNode = nextChildren[nextIndex];
patch(prevNode, nextNode, parent);
// 给source赋值
source[nextIndex - nextStart] = i
patched++
// 递增方法,判断是否需要移动
if (nextIndex < lastIndex) {
move = false
} else {
lastIndex = nextIndex
}
}
if (move) {
// 需要移动
} else {
//不需要移动
}
}
}
怎么移动?
一旦需要进行DOM移动,我们首先要做的就是找到source的最长递增子序列。
从后向前进行遍历source每一项。此时会出现三种情况:
- 当前的值为
-1,这说明该节点是全新的节点,又由于我们是从后向前遍历,我们直接创建好DOM节点插入到队尾就可以了。 - 当前的索引为
最长递增子序列中的值,也就是i === seq[j],这说说明该节点不需要移动 - 当前的索引不是
最长递增子序列中的值,那么说明该DOM节点需要移动,这里也很好理解,我们也是直接将DOM节点插入到队尾就可以了,因为队尾是排好序的。
function vue3Diff(prevChildren, nextChildren, parent) {
//...
if (move) {
const seq = lis(source); // [0, 1]
let j = seq.length - 1; // 最长子序列的指针
// 从后向前遍历
for (let i = nextLeft - 1; i >= 0; i--) {
let pos = nextStart + i, // 对应新列表的index
nextNode = nextChildren[pos], // 找到vnode
nextPos = pos + 1, // 下一个节点的位置,用于移动DOM
refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM节点
cur = source[i]; // 当前source的值,用来判断节点是否需要移动
if (cur === -1) {
// 情况1,该节点是全新节点
mount(nextNode, parent, refNode)
} else if (cur === seq[j]) {
// 情况2,是递增子序列,该节点不需要移动
// 让j指向下一个
j--
} else {
// 情况3,不是递增子序列,该节点需要移动
parent.insetBefore(nextNode.el, refNode)
}
}
} else {
//不需要移动: 我们只需要判断是否有全新的节点【其在source数组中对应的值就是初始的-1】,给他添加进去
for (let i = nextLeft - 1; i >= 0; i--) {
let cur = source[i]; // 当前source的值,用来判断节点是否需要移动
if (cur === -1) {
let pos = nextStart + i, // 对应新列表的index
nextNode = nextChildren[pos], // 找到vnode
nextPos = pos + 1, // 下一个节点的位置,用于移动DOM
refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM节点
mount(nextNode, parent, refNode)
}
}
}
}
小结
- 需要创建数组和对象建立关系:
- 数组source【来做新旧节点的对应关系的,根据
source计算出它的最长递增子序列用于移动DOM节点】:新节点在旧列表的位置存储在该数组中, - 对象nextIndexMap【通过新列表的key去找旧列表的key】:存储当前新列表中的
节点key与指引i的关系,再通过key去旧列表中去找位置
- 移除节点满足以下任何一个条件:
j > nextEnd- 如果旧节点在新列表中没有的话,直接删除
- 已经更新了全部的新节点,剩下的
旧节点就直接删除了【patch标记已更新过的节点的数量】
- 新增节点满足以下任何一个条件:
j > prevEnd且j <= nextEnd如果是全新的节点的话,其在source数组中对应的值就是初始的-1,新增
- 移动节点满足以下任何一个条件:
- 当前的索引不是
最长递增子序列中的值,那么说明该DOM节点需要移动
-
最长递增子序列是为了操作移动DOM
-
对比规则:
第一步:对比新老节点数组的头头和尾尾 在这一步将两头两尾相同的进行 patch 第二步:头尾 patch 结束之后,查看新老节点数组是不是有其中一方已经 patch 完了,假如是,那么就多删少补 第三步:遍历老节点,看老节点是否在新节点里面存在,假如不存在,就删除。 // 假如新的子节点都被遍历完了,那么就代表说老的数组之后的,都是需要被删除的 第四步:获取最长递增子序列
总结
介绍diff算法
- react-diff: 递增法
移动节点:移动的节点称为α,将α对应的真实的DOM节点移动到,α在新列表中的前一个VNode对应的真实DOM的后面
添加节点:在新列表中有全新的VNode节点,在旧列表中找不到的节点需要添加(通过find这个布尔值来查找)
移除节点:当旧的节点不在新列表中时,我们就将其对应的DOM节点移除(通过key来查找确定是否删除)
不足:从头到尾单边比较,容易增加比较次数
- vue2-diff: 双端比较
DOM节点什么时候需要移动和如何移动,总结如下:
- 头-头:不移动
- 尾-尾:不移动
- 头-尾: 插入到旧节点的尾节点的后面
- 尾-头:插入到旧列表的第一个节点之前
- 以上4种都不存在(特殊情况):在旧节点中找,如果找到,移动找到的节点,移动到开头;没找到,直接创建一个新的节点放到最前面
添加节点【oldEndIndex以及小于了oldStartIndex】:将剩余的节点依次插入到oldStartNode的DOM之前
移除节点【newEndIndex小于newStartIndex】:将旧列表剩余的节点删除即可
- vue3-diff: 最长递增子序列
区别
- react和vue2的比较:
- vue2双端比较解决react单端比较导致移动次数变多的问题,react只能从头到尾遍历,增加了移动次数
-
vue2和vue3的比较:都用了双端指针
-
vue3和react比较:vue3在判断是否需要移动,使用了react的递增法;react是单端比较,这样移动效率降低,vue3是使用双端比较
几个算法看下来,套路就是找到移动的节点,然后给他移动到正确的位置。把该加的新节点添加好,把该删的旧节点删了,整个算法就结束了。
此文借鉴别人的文章,梳理成自己的笔记,分别分析了react、vue2、vue3的diff算法实现原理和具体实现,同时比较了这3种算法,应对面试肯定不会害怕。当然总结它不仅仅为了以后的面试,也为了提升算法思想。
最长递增子序列可以使用动态规划方法 juejin.cn/post/696278…