一、前言
- 大家好,我是小洛,我前几天去面试了,我简历上写着熟悉Vue2 diff算法。然后和面试官经历了几轮对话。
面试官
:看你简历上写着熟悉Vue2 diff 算法是吧。我
:我说是的。此时我心里嘀咕着,不就是一个diff 算法嘛,不是很简单嘛。面试官
:此时面试官边敲键盘边说:我们来做做题吧!我
:心里嘀咕着,哎,怎么不问diff算法了,我可以给你倒背如流的讲下来。此时面试官说:我们来写一个diif 算法吧。当听到这句话的时候,我当场直接破防了,让我说,我会说,我可不会写呀。也不好拒绝,只能硬着头皮开始写了。下面请大家看我的表演吧。手撕diff算法。
1.1 什么是 diff 算法。
在Vue2/3,react,diff算法是对新旧两个虚拟dom树按照一定的规则,进行对比,从而的出那些节点发生变化了,需要去新增节点还是删除节点,还是移动节点。
从而让我们去减少dom操作,因为dom操作比较昂贵嘛,消耗性能,diff算法会在js层面进行对比,然后决定哪些节点是变动了,再对节点进行操作。
1.2 什么是虚拟dom
- 什么是虚拟dom呢?可以用一句话来表述,虚拟dom就是一个js对象,是对真实dom的一种抽象。最终会渲染为宿主环境上的真实dom,如何渲染的请看:将vnode渲染为真实dom
- 虚拟dom有哪些好处呢?使用虚拟dom可以减少对dom的操作,提升性能,由于虚拟dom是对dom的一种抽象,所以他拥有跨平台的能力,只要我们为特定的平台提供特定的实现就可以了。
- 虚拟dom有哪些缺点?由于要增加运行时的diff,所以runtime体积会增大。
下面我们定义几个不同case 情况下的虚拟dom,看看他们有哪些差异。
- 普通div元素
<div key="1"></div>
const vnode = {
tagName:"div",
key:'1',
text:'',
children:[]
}
- 文本节点
text
const vnode = {
tagName:"",
key:'',
text:'text',
children:[]
}
- 带有孩子节点的div,
<div key='1'>我是文本节点<div>
const vnode = {
tagName:"div",
key:'1',
text:'text',
children:[
{
tagName:"",
key:'',
text:'我是文本节点',
children:[]
}
]
}
- 有多个孩子的div,
<div key='1'><span key='2'>text1</span><span key="3">text2</span></div>
const vnode = {
tagName:"div",
key:'1',
text:'',
children:[
{
tagName:"span",
key:'2',
text:'',
children:[
{
tagName:"",
key:'',
text:'text1',
children:[]
}
]
},
{
tagName:"span",
key:'3',
text:'',
children:[
{
tagName:"",
key:'',
text:'text2',
children:[]
}
]
},
]
}
以上就是几个简单的不同情况下的vnode,diff算法要做的就是将这样的两个vnode树进行对比,从而求出最小操作dom的解。
二、diff算法实现篇
2.1 基础篇
- 首先我们肯定需要定义一个函数,这个函数能够传入
oldVnode
和newVnode
,然后再函数内部会进行一系列的判断。
const patch =(oldVnode, newVnode) => {
....
}
我们下面讨论几种情况。
- 如果
oldVnode
为null,是不是对应着我们的组件首次挂载呀。所以这是需要去遍历newVnode
创建节点。 - 如果
newVnode
为null,是不是对应着我们的组件卸载,这时我们只需要卸载旧组件就可以了。 - 如果两个都不为null,那么就要进行其他条件的对比了。此时
patch
函数的代码如下
const patch = (oldNode, newNode) => {
if (!newNode) {
console.warn(`卸载旧节点${oldNode}`)
return
}
if (!oldNode) {
console.warn(`挂载新节点${newNode}`)
} else {
// 两个都不为null, 进行其他条件的对比
}
}
- 如果两个都不为null,我们此时应该判断下这两个节点相同吗,比如
tagName
相同吗?或者节点唯一标识key
相同吗?此时写一个辅助函数sameNode
,如果两个节点相同,我们就可以进行更仔细的对比了,如果两个节点不同,我们需要卸载旧的,挂载新的。此时patch函数代码如下
const sameVnode = (oldNode, newNode) => {
return oldNode.key === newNode.key && oldNode.tagName === newNode.tagName
}
const patch = (oldNode, newNode) => {
if (!newNode) {
console.warn(`卸载旧节点${oldNode}`)
return
}
if (!oldNode) {
console.warn(`挂载新节点${newNode}`)
} else {
// 是不是同一个节点,我们这里简单一下,根据key和 tagName 判断
if(sameVnode(oldNode, newNode)) {
console.warn(`oldNode: ${oldNode.key} 和 ${newNode.key} 复用`)
// 节点可以复用,要进行更仔细的对比了。
patchVnode(oldNode,newNode)
} else{
console.warn(`卸载${oldNode},挂载 ${newNode}`)
}
}
}
- 现在我们讨论更仔细的对比,我们再写一个函数
patchVnode
来对两个节点进行更细致的对比。
const patchVnode = (oldVnode, newVnode) => {
...
}
- 现在我们讨论更细致的对比case。\
- 首先我们判断下,
newVnode
是不是文本节点,如果是文本节点,那么我们不管旧节点是什么,都需要删除oldVnod
e的所有children
,并且将oldVnod
的text
设为newVnode.text
- 如果
newVnode
不是文本节点,那我们判断下oldVnode
和newVnode
是否都有children
,如果都有,那么就要对他们的所有children
进行对比了,这种情况比较复杂我们后面在进行讨论。 - 如果两个都没有
children
,那么说明newVnode
和oldVnode
至多只有一个有children
,有三种情况,如下:oldVnode
有children,此时我们只需要将oldVnode
的children置null就可以啦。newVnode
有children,此时我们只需要删除旧的文本节点,添加newVnode.children
oldVnode
和newVnode
都没有children
,将旧节点的文本置null 此时patchVnode
函数的代码变成如下:
- 首先我们判断下,
const patchVnode = (oldNode, newNode) => {
// 新节点是不是文本节点
if (!newNode.text) {
// 新旧节点同时有 children
if (newNode.children && oldNode.children) {
// 定义updateChildren 函数来处理两个都有children,后面会讨论的
updateChildren(oldNode.children, newNode.children)
}
// 至多有一个有 children
// 旧节点有 children
else if (oldNode.children) {
console.warn("删除所有旧节点")
} else if(newNode.children) {
console.warn('删除所有旧的文本节点,添加新节点')
} else if (oldNode.text) {
console.warn('将旧节点的文本置空')
}
} else {
console.warn(`删除节点 key 为: ${oldNode.key} 的所有children,然后设置为 ${newNode.text}`)
}
- 上面我们完成了几种不同case下的处理,还有一种是
oldVnode
和newVnode
都有children
的情况。我们定义updateChildren
来处理。其简单的函数定义如下:
const updateChildren = (oldCh, newCh) => {
...
}
我们知道Vue2使用的是双端diff,这个diif算法是从snabdom这个库借鉴过来的,我们定义八个指针,分别指向新旧节点开始和结束的索引以及开始和结束索引所在的节点。如下图:
在diff的时候会从开始往后进行对比:
2.1.1 名词解释:
- 新前:newCh前面的节点
- 新后:newCh后面的节点
- 旧前:oldCh前面的节点
- 旧后:oldCh后面的节点
- 新前vs旧前
先从最前面开始,看下两个节点是不是相同的节点,如果是调用
patch
函数递归打补丁 - 新后vs旧后
如果
新前vs旧前
不是相同的节点,那么开始从后面往前处理,看下是不是相同的节点 - 新前vs旧后
如果上面两种情况都不是相同的节点,那么交叉处理,看下
新前vs旧后
- 新后vs旧前
如果上面三种情况都不是相同的节点,那么交叉处理,看下
新后vs旧前
- 我们举一个例子:如上图的节点
- 第一步:判断旧节点的第一个和新节点的第一个,我们发现,
tagName
和key
都相同,那么可以处理,此时只需要继续调用patch
就可以啊啦。等patch(oldStartVnode, newStartVnode)
处理完,我们让oldStartIndex
和newStartIndex
向后移动一位。 - 当处理到新旧节点的第二个节点时,我们发现他们的
key
不同,所以不能处理了。此时我们从后往前处理 - 第二步:开始从后往前处理,对比
oldEndVnode
和newEndVnode
我们发现是相同节点,然后继续调用patch(oldEndVnode,newEndVnode)
递归处理,等patch(oldEndVnode,newEndVnode)
处理完,我们让oldEndIndex``newEndIndex
往前走一步。此时oldEndVnode = {tagName:"div",key:5}
,newEndVnode = {tagName:"div",key:6}
我们发现key不相同,不能处理了。 - 第三步:进行交叉比较
新前vs旧后
,newStartVnode = {tagName:"div", key:3}
,oldEndVnode = {tagName:"div", key:5}
我们发现不是相同的节点,则进行下一步 - 第四步:进行交叉比较
新后vs旧前
,newEndVnode = {tagName:"div", key:2}
,oldStartVnode = {tagName:"div", key:2}
我们发现是相同的节点,调用patch(oldStartVnode, newEndVnode)
进行递归处理,等处理完我们让oldStartIndex++
,newEndIndex--
。 经历完上面的几个步骤,各个指针的指向情况如下。
- 我们发现现在处理不下去了,现在我们需要做的是查找下新节点在旧节点当中有没有,如果有那么进行移动,如果没有那么进行添加。
- 关于查找新节点在旧节点当中有没有,最简单的就是两个
for
循环,当遍历到每个元素的时候查找在另一个数组中有没有,这样事件复杂度为O(N^2)。 - 我们可以选择先遍历
oldStartIndex
到oldEndIndex
的节点,然后将其映射为一个map,map的键为vnode.key
,map的值为索引。然后在遍历newStartIndex
到newEndIndex
查找在map中有没有,如果有,判断是不是同一节点,如果没有,则说明是新节点,需要创建节点。 此部分代码如下:
const updateChildren = (oldCh, newCh) => {
let newStartIndex = 0;
let oldStartIndex = 0
let newEndIndex = newCh.length - 1;
let oldEndIndex = oldCh.length - 1;
let newStartNode = newCh[0]
let oldStartNode = oldCh[0]
let newEndNode = newCh[newEndIndex]
let oldEndNode = oldCh[oldEndIndex]
let oldKeyToIdx,idxInOld;
while (newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
if (oldStartNode === undefined) { // 元素被右移
console.warn( `oldStartNode 被右移了`)
oldStartNode = oldCh[++oldStartIndex]
} else if (oldEndNode === undefined) {
console.warn( `oldEndNode 被左移了`)
oldEndNode = oldCh[--oldEndIndex]
} else if (sameVnode( oldStartNode,newStartNode)) { // 旧前 vs 新前
console.warn( `旧前 vs 新前 ${oldStartNode.key} 和 ${newStartNode.key} 可以复用`)
patch(oldStartNode, newStartNode)
oldStartNode = oldCh[++oldStartIndex]
newStartNode = newCh[++newStartIndex]
} else if(sameVnode(oldEndNode,newEndNode )) { // 旧后 vs 新后
console.warn( `旧后 vs 新后 ${oldEndNode.key} 和 ${newEndNode.key} 可以复用`)
patch(oldEndNode, newEndNode)
oldEndNode = oldCh[-oldEndIndex]
newEndNode = newCh[--newEndIndex]
} else if (sameVnode( oldEndNode,newStartNode,)) { // 旧后 vs 新前
console.warn( `旧后 vs 新前 ${oldEndNode.key} 和 ${newStartNode.key} 可以复用`)
patch(oldEndNode, newStartNode)
oldEndNode = oldCh[--oldEndIndex]
newStartNode = newCh[++newStartIndex]
} else if (sameVnode(oldStartNode,newEndNode, )) { // 旧前 vs 新后
console.warn( `旧前 vs 新后 ${oldStartNode.key} 和 ${newEndNode.key} 可以复用`)
patch(oldStartNode, newEndNode)
oldStartNode = oldCh[++oldStartIndex]
newEndNode = newCh[--newStartIndex]
} else {
if (!oldKeyToIdx) {
oldKeyToIdx = {}
for (let i = oldStartIndex; i <= oldEndIndex;i++ ) {
const node = oldCh[i]
const key = node.key
oldKeyToIdx[key] = i
}
}
// 在映射的 map 中根据 key 查找有没有
idxInOld = oldKeyToIdx[newStartNode.key]
if (!idxInOld) {
console.warn(`${newStartNode.key} 节点在旧节点中没有需要新创建`)
} else {
// 要移动的元素
console.warn('根据key去查找')
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartNode)) {
console.warn(`${newStartNode.key}根据可以在旧节点中查到了,并且可以复用`)
patch(vnodeToMove, newStartNode)
oldCh[idxInOld] = undefined
} else {
console.warn(`${newStartNode.key}根据可以在旧节点中查找了,但是元素类型不同,删除`)
}
}
newStartNode= newCh[++newStartIndex]
}
}
// 如果 newStartIndex > newEndIndex ,新的先处理完了,删除了元素
if (newStartIndex > newEndIndex) {
console.warn(`需要删除${oldStartIndex} 到 ${oldEndIndex} 的元素`)
} else if (oldStartIndex > oldEndIndex) {
console.warn(`需要添加${newStartIndex} 到 ${newEndIndex} 的元素`)
}
}
2.1.2 注意点:
- 根据map去查找的时候,如果查找到了,也需要判断是不是用一个节点,因为
key
相同但tagName
不一定相同。 - 等
oldCh
和newCh
都处理完了,需要对比newStartIndex
和newEndIndex
,以及oldStartIndex
和oldEndIndex
的大小关系- 如果newStartIndex > newEndIndex,说明
newCh
先处理完了,说明有节点被删除了 - 如果oldStartIndex > oldEndIndex,说明
oldCh
先处理完了,说明有新增节点
- 如果newStartIndex > newEndIndex,说明
- 以上就是Vue2 diff算法的大致流程。完整实现代码如下
2.2 完整实现
const sameVnode = (oldNode, newNode) => {
return oldNode.key === newNode.key && oldNode.tagName === newNode.tagName
}
const patch = (oldNode, newNode) => {
if (!newNode) {
console.warn(`卸载旧节点${oldNode}`)
return
}
if (!oldNode) {
console.warn(`挂载新节点${newNode}`)
} else {
// 是不是同一个节点,我们这里简单一下,根据key和 tagName 判断
if(sameVnode(oldNode, newNode)) {
console.warn(`oldNode: ${oldNode.key} 和 ${newNode.key} 复用`)
patchVnode(oldNode, newNode)
} else{
console.warn(`卸载${oldNode},挂载 ${newNode}`)
}
}
}
const patchVnode = (oldNode, newNode) => {
// 新节点是不是文本节点
if (!newNode.text) {
// 新旧节点同时有 children
if (newNode.children && oldNode.children) {
updateChildren(oldNode.children, newNode.children)
}
// 至多有一个有 children
// 旧节点有 children
else if (oldNode.children) {
console.warn("删除所有旧节点")
} else if(newNode.children) {
console.warn('删除所有旧节点,添加新节点')
} else if (oldNode.text) {
console.warn('将旧节点的文本置空')
}
} else {
console.warn(`删除节点 key 为: ${oldNode.key} 的所有children,然后设置为 ${newNode.text}`)
}
}
const updateChildren = (oldCh, newCh) => {
let newStartIndex = 0;
let oldStartIndex = 0
let newEndIndex = newCh.length - 1;
let oldEndIndex = oldCh.length - 1;
let newStartNode = newCh[0]
let oldStartNode = oldCh[0]
let newEndNode = newCh[newEndIndex]
let oldEndNode = oldCh[oldEndIndex]
let oldKeyToIdx,idxInOld;
while (newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
if (oldStartNode === undefined) { // 元素被右移
console.warn( `oldStartNode 被右移了`)
oldStartNode = oldCh[++oldStartIndex]
} else if (oldEndNode === undefined) {
console.warn( `oldEndNode 被左移了`)
oldEndNode = oldCh[--oldEndIndex]
} else if (sameVnode( oldStartNode,newStartNode)) { // 旧前 vs 新前
console.warn( `旧前 vs 新前 ${oldStartNode.key} 和 ${newStartNode.key} 可以复用`)
patch(oldStartNode, newStartNode)
oldStartNode = oldCh[++oldStartIndex]
newStartNode = newCh[++newStartIndex]
} else if(sameVnode(oldEndNode,newEndNode )) { // 旧后 vs 新后
console.warn( `旧后 vs 新后 ${oldEndNode.key} 和 ${newEndNode.key} 可以复用`)
patch(oldEndNode, newEndNode)
oldEndNode = oldCh[-oldEndIndex]
newEndNode = newCh[--newEndIndex]
} else if (sameVnode( oldEndNode,newStartNode,)) { // 旧后 vs 新前
console.warn( `旧后 vs 新前 ${oldEndNode.key} 和 ${newStartNode.key} 可以复用`)
patch(oldEndNode, newStartNode)
oldEndNode = oldCh[--oldEndIndex]
newStartNode = newCh[++newStartIndex]
} else if (sameVnode(oldStartNode,newEndNode, )) { // 旧前 vs 新后
console.warn( `旧前 vs 新后 ${oldStartNode.key} 和 ${newEndNode.key} 可以复用`)
patch(oldStartNode, newEndNode)
oldStartNode = oldCh[++oldStartIndex]
newEndNode = newCh[--newStartIndex]
} else {
if (!oldKeyToIdx) {
oldKeyToIdx = {}
for (let i = oldStartIndex; i <= oldEndIndex;i++ ) {
const node = oldCh[i]
const key = node.key
oldKeyToIdx[key] = i
}
}
// 在映射的 map 中根据 key 查找有没有
idxInOld = oldKeyToIdx[newStartNode.key]
if (!idxInOld) {
console.warn(`${newStartNode.key} 节点在旧节点中没有需要新创建`)
} else {
// 要移动的元素
console.warn('根据key去查找')
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartNode)) {
console.warn(`${newStartNode.key}根据可以在旧节点中查到了,并且可以复用`)
patch(vnodeToMove, newStartNode)
oldCh[idxInOld] = undefined
} else {
console.warn(`${newStartNode.key}根据可以在旧节点中查找了,但是元素类型不同,删除`)
}
}
newStartNode= newCh[++newStartIndex]
}
}
// 如果 newStartIndex > newEndIndex ,新的先处理完了,删除了元素
if (newStartIndex > newEndIndex) {
console.warn(`需要删除${oldStartIndex} 到 ${oldEndIndex} 的元素`)
} else if (oldStartIndex > oldEndIndex) {
console.warn(`需要添加${newStartIndex} 到 ${newEndIndex} 的元素`)
}
}
2.3 测试
- 我们自己写两个简单的vNode树进行测试下。
const oldNode = {
tagName: 'div',
key: 1,
text: '',
children: [
{
tagName: 'div',
key: 2,
text: '',
children: [
{
tagName: '',
key: 6,
text: '旧节点-文本-1',
children: []
},
]
},
{
tagName: 'div',
key: 3,
text: '',
children: [
{
tagName: 'div',
key: 7,
text: '旧节点-文本-2',
children: []
},
]
},
{
tagName: 'div',
key: 4,
text: '',
children: [
{
tagName: 'div',
key: 8,
text: '旧节点-文本-3',
children: []
},
]
},
{
tagName: 'div',
key: 5,
text: '',
children: [
{
tagName: 'div',
key: 9,
text: '旧节点-文本-4',
children: []
},
]
}
]
}
const newNode = {
tagName: 'div',
key: 1,
text: '',
children: [
{
tagName: 'div',
key: 20,
text: '',
children: [
{
tagName: '',
key: 6,
text: '新节点-文本-1',
children: []
},
]
},
{
tagName: 'div',
key: 3,
text: '',
children: [
{
tagName: 'div',
key: 7,
text: '新节点-文本-2',
children: []
},
]
},
{
tagName: 'div',
key: 4,
text: '',
children: [
{
tagName: 'div',
key: 8,
text: '新节点-文本-3',
children: []
},
]
},
{
tagName: 'div',
key: 5,
text: '',
children: [
{
tagName: 'div',
key: 9,
text: '新节点-文本-4',
children: []
},
]
}
]
}
2.3.1 结果
三、面试题
3.1 什么是diff算法。答案参考本文
3.2 diff算法中key的作用是什么?
key
的作用是当节点不能处理的时候,根据key
去映射一个map
,然后根据key
去查找,可以降低时间复杂度
3.3 diff算法中key为什么要唯一,且要有一定的稳定性?
key
唯一是因为要保证在映射map
的时候,要让map
有唯一的键,不要让后面添加的把前面的覆盖掉。例如:如果有两个节点:
const ch = [{tagName:"div", key:1}, {tagName:"div", key:1}]
上面两个节点的key
重复了,最后映射的map
结果为const map = {1:1}
,导致我们的map
中丢掉了一条记录,这种情况有很大的可能会让页面多渲染一条数据。
key
唯一且稳定,如果key每次都不稳定,比如key=Date.now()
,那么diff
的时候,发现key不同会删掉旧的节点,重新创建新节点,会造成性能问题。
3.4 有时我们也会故意的让key
每次发生变化,让组件去重新创建或者被keep-alive
包裹的组件不要使用缓存。
- 今天的分享就到这里,一个
diff
算法引发的惨案,让我面试的时候很难堪。我们接下来会自己实现Vue3
的diff
,以及React
的diif
,以及React
diff
可中断模式,欢迎关注,我是小洛。