此文内容包括如下:vue
介绍diff算法node
移动节点:移动的节点称为α,将α对应的真实的DOM节点移动到,α在新列表中的前一个VNode对应的真实DOM的后面
react
添加节点:在新列表中有全新的VNode
节点,在旧列表中找不到的节点须要添加(经过find这个布尔值来查找)面试
移除节点:当旧的节点不在新列表中时,咱们就将其对应的DOM节点移除(经过key来查找肯定是否删除)算法
不足:从头至尾单边比较,容易增长比较次数数组
DOM节点何时须要移动和如何移动,总结以下:markdown
添加节点【oldEndIndex
以及小于了oldStartIndex
】:将剩余的节点依次插入到oldStartNode
的DOM
以前post
移除节点【newEndIndex
小于newStartIndex
】:将旧列表剩余的节点删除便可学习
区别优化
vue2和vue3的比较:都用了双端指针
vue3和react比较:vue3在判断是否须要移动,使用了react的递增法
几个算法看下来,套路就是找到移动的节点,而后给他移动到正确的位置。把该加的新节点添加好,把该删的旧节点删了,整个算法就结束了。
从头至尾
遍历比较,新列表的节点在旧列表中的位置是不是递增 若是递增,不须要移动,不然须要移动。
经过key在旧节点中找到新节点的节点,因此key必定要表明惟一性。
生成的
DOM
节点插入到哪里?
将α对应的真实的DOM节点移动到,α在新列表中的前一个VNode对应的真实DOM的后面
。
将DOM-B移到DOM-D的后面
为何这么移动?
首先咱们列表是从头至尾
遍历的。这就意味着对于当前VNode
节点来讲,该节点以前的全部节点都是排好序的,若是该节点须要移动,那么只须要将DOM节点移动到前一个vnode
节点以后就能够,由于在新列表中vnode
的顺序就是这样的。
VNode
节点,在旧列表中找不到的节点须要添加如何发现全新的节点?
定义一个find
变量值为false
。若是在旧列表找到了key
相同的vnode
,就将find
的值改成true
。当遍历结束后判断find
值,若是为false
,说明当前节点为新节点
生成的
DOM
节点插入到哪里?
分两种状况:
新列表中的前一个VNode对应的真实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
算法——双端比较
,该算法解决了上述的问题
双端比较
就是新列表和旧列表两个列表的头与尾互相对比,,在对比的过程当中指针会逐渐向内靠拢,直到某一个列表的节点所有遍历过,对比中止。
按照如下四个步骤进行对比
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)
}
}
}
复制代码
双端比较,while循环,两端是向内靠拢的 头-头
尾-尾
j是头向内靠拢指针;
prevEnd是尾向内靠拢指针
j > prevEnd
且j <= nextEnd
【证实新列表有多余的】j > nextEnd
【证实旧列表有多余的】上图,j > prevEnd
且j <= nextEnd
,只须要把新列表中j
到nextEnd
之间剩下的节点插入进去。
若是j > nextEnd
【证实旧列表有多余的】时,把旧列表中j
到prevEnd
之间的节点删除
根据新列表剩余的节点数量,建立一个source
数组,并将数组填满-1
。
建立数组和对象创建关系:
source
计算出它的最长递增子序列
用于移动DOM节点】:新节点在旧列表的位置存储在该数组中,节点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
计算出它的最长递增子序列
用于移动DOM节点】:新节点在旧列表的位置存储在该数组中,节点key
与指引i
的关系,再经过key去旧列表中去找位置j > nextEnd
旧节点
就直接删除了【patch标记已更新过的节点的数量】j > prevEnd
且j <= nextEnd
若是是全新的节点的话,其在source数组中对应的值就是初始的-1
,新增最长递增子序列
中的值,那么说明该DOM节点须要移动最长递增子序列是为了操做移动DOM
对比规则:
第一步:对比新老节点数组的头头和尾尾 在这一步将两头两尾相同的进行 patch 第二步:头尾 patch 结束以后,查看新老节点数组是否是有其中一方已经 patch 完了,假如是,那么就多删少补 第三步:遍历老节点,看老节点是否在新节点里面存在,假如不存在,就删除。 // 假如新的子节点都被遍历完了,那么就表明说老的数组以后的,都是须要被删除的 第四步:获取最长递增子序列
介绍diff算法
移动节点:移动的节点称为α,将α对应的真实的DOM节点移动到,α在新列表中的前一个VNode对应的真实DOM的后面
添加节点:在新列表中有全新的VNode
节点,在旧列表中找不到的节点须要添加(经过find这个布尔值来查找)
移除节点:当旧的节点不在新列表中时,咱们就将其对应的DOM节点移除(经过key来查找肯定是否删除)
不足:从头至尾单边比较,容易增长比较次数
DOM节点何时须要移动和如何移动,总结以下:
添加节点【oldEndIndex
以及小于了oldStartIndex
】:将剩余的节点依次插入到oldStartNode
的DOM
以前
移除节点【newEndIndex
小于newStartIndex
】:将旧列表剩余的节点删除便可
区别
vue2和vue3的比较:都用了双端指针
vue3和react比较:vue3在判断是否须要移动,使用了react的递增法;react是单端比较,这样移动效率下降,vue3是使用双端比较
几个算法看下来,套路就是找到移动的节点,而后给他移动到正确的位置。把该加的新节点添加好,把该删的旧节点删了,整个算法就结束了。
此文借鉴别人的文章,梳理成本身的笔记,分别分析了react、vue二、vue3的diff算法实现原理和具体实现,同时比较了这3种算法,应对面试确定不会惧怕。固然总结它不只仅为了之后的面试,也为了提高算法思想。
最长递增子序列可使用动态规划方法 juejin.cn/post/696278…