虚拟Dom以及diff算法的由来
我们都知道,Vue1.0中是没有虚拟Dom和diff算法的,具体可以查看我之前的文章Vue1.0原理刨析及实现,那么Vue2.0为什么要引入虚拟Dom和diff算法呢,因为Vue1.0有太多的闭包,小项目还可以,大项目就不适合,就会造成内存泄漏,所以引入虚拟Dom和diff算法成了必须要迈过的一道坎。
虚拟Dom(VNode)
假设我们的真实dom是:
<ul id="container">
<li class="box" :key="user1">张三</li>
<li class="box" :key="user2">李四</li>
</ul>
那么他对应的VNode就是:
<script>
let oldVNode = {
tag: "ul",
data: {
staticClass: "container",
},
text: undefined,
children: [
{
tag: "li",
data: { staticClass: "box", key: "user1" },
text: undefined,
children: [
{ tag: undefined, data: undefined, text: "张三", children: undefined },
],
},
{
tag: "li",
data: { staticClass: "box", key: "user2" },
text: undefined,
children: [
{ tag: undefined, data: undefined, text: "李四", children: undefined },
],
},
],
};
</script>
这时候修改一个li标签的内容
<ul id="container">
<li class="box" :key="user1">张三123123123</li>
<li class="box" :key="user2">李四</li>
</ul>
对应的虚拟dom就变成
let oldVNode = {
tag: "ul",
data: {
staticClass: "container",
},
text: undefined,
children: [
{
tag: "li",
data: { staticClass: "box", key: "user1" },
text: undefined,
children: [
{ tag: undefined, data: undefined, text: "张三123123123", children: undefined },
],
},
{
tag: "li",
data: { staticClass: "box", key: "user2" },
text: undefined,
children: [
{ tag: undefined, data: undefined, text: "李四", children: undefined },
],
},
],
};
diff
简单介绍
用一句话来概括就是:同层比较、深度优先
-
同层比较?
如果比较的过程中不是同层比较,那么时间复杂度会上升,不再是
On
-
深度优先?
在你比较俩颗节点树的时候,就是一个递归的过程
执行过程
当我们this.key = xxx
时,触发当前key
的setter
,并通过内部dep.notify()
通知所有watcher
进行更新,更新的时候就会调用patch
方法。
patch
这个函数的作用就是:通过sameVnode()
判断oldVnode
、newVnode
是否为同一种节点类型。
- 是:调用
patchVnode()
进行diff算法 - 否:直接替换
什么时候会走else?
举例:比如组件初始化的时候,没有oldVnode,那么Vue会传入一个真实dom(isRealElement
就是为处理初始化定义的),显然sameVnode(a,b)
结果为false
,他们并不是同一种类型节点。
patch的核心代码:
function patch(oldVnode, newVnode) {
const isRealElement = isDef(oldVnode.nodeType) //判断oldVnode是否是真实节点
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 更新周期走这里,diff发生的地方
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 如果是真实dom,就转换为Vnode,赋值给oldVnode
if (isRealElement) {
oldVnode = emptyNodeAt(oldVnode)
}
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm) // 得到真实dom的父节点
// 将oldVnode转换为真实dom,并插入
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0) // 删除老的节点
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
sameVnode
这个方法主要是用来比较传入的俩个vnode
是否是相同节点。判断条件见如下代码:
function sameVnode (a, b) {
return (
a.key === b.key && // 比较key
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag && // 比较标签
a.isComment === b.isComment && // 比较注释
isDef(a.data) === isDef(b.data) && // 比较data
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
patchVnode
主要作用:比较俩个Vnode,包括三种类型操作:属性更新 、文本更新、子节点更新
具体规则如下:
- 新老节点均有children子节点,则对子节点进行diff操作,调用
updateChildren
。 - 如果新节点有子节点,而老节点没有子节点,先清空老节点的文本内容,然后为其新增子节点。
- 如果新节点没有子节点,而老节点有子节点,则移除该节点所有的子节点。
- 当新老节点都没有子节点的时候,只是文本的替换。
patchVnode核心代码:
function patchVnode (oldVnode, vnode,) {
if (oldVnode === vnode) {
return
} // 如果新节点等于老节点直接返回
const elm = vnode.elm = oldVnode.elm // 将oldVnode的真实dom节点赋值给Vnode
// 获取新旧节点的子节点数组
const oldCh = oldVnode.children
const ch = vnode.children
// 如果新节点没文本,大概率有子元素
if (isUndef(vnode.text)) {
// 如果双方都有子元素
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
// 如果新节点有子元素
else if (isDef(ch)) {
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
}
// 如果老节点有子元素(走到这一步说明新节点没有子元素)
else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
}
// 如果老节点有文本(走到这一步说明新节点没有文本)
else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
}
// 如果老节点的文本 != 新节点的文本
else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
}
上面的代码验证了我们上面说的四点规则,其中最主要的还是新旧节点都有子元素的时候的对比,也就是updateChildren
。
updateChildren
这个方式是patchVnode中的一个重要方法,也叫重排操作。主要进行新旧虚拟节点的子节点的对比,等通过sameVnode()
找到相同节点时,再递归调用patchVnode
。
对比过程
- 旧首
=>
新首 - 旧尾
=>
新尾 - 旧首
=>
新尾 - 旧尾
=>
新首 - 如果以上都匹配不到,再以新
vnode
为准,依次遍历老节点,直到找到相同的节点之后,再调用patchVnode
备注:过程1~4你可以理解为Vue优化的一种手段,想想你平时使用Vue场景,要么在开头或结尾插入,要么只是单纯的修改某个值(
this.key = xxx
),Vue考虑到了这种场景可能出现的频率很高,索性就做了这个优化,避免每次重复遍历,这样对性能提升很大。
接下来用一个实际例子,来看一下diff
过程
描述:真实DOM和oldVnode是内容分别为a、b、c
的div
,新的虚拟dom只是改变了原来节点的内容以及新增了一个内容为新d
的div
,别的没有任何变化。需要注意的是每次比较都遵循上面的规则。
初始值:
-
oSIdx(oldVnode开头下标) = 0
-
oEIdx(oldVnode结尾下标) = 2
-
nSIdx(newVnode开头下标) = 0
-
nEIdx(newVnode结尾下标) = 3
- 第一步
oldVnode[oSIdx] === newVnode[nSIdx]
描述:按照规则,先 旧首 => 新首,sameVnode(a,b)
结果为true,说明是相同节点。需要做的就是调用patchVnode(oldVnode,vNode)
更新节点的内容,之后oSIdx++
、nSIdx++
。
- 第二步
oldVnode[oSIdx] === newVnode[nSIdx]//注意:此时oSIdx为1,nSIdx为1
描述:此时分别比较oldVnode
、newVnode
对应的第二个节点,因为是循环,所以依然是重新执行规则 旧首 => 新首(下面的源码里面可以看到对应逻辑),sameVnode(a,b)
结果为true
,所以依然是调用patchVnode(oldVnode,vNode)
更新节点内容。之后oSIdx++
、nSIdx++
。
- 第三步
oldVnode[oSIdx] === newVnode[nSIdx]//注意:此时oSIdx为2,nSIdx为2
描述:这一步跟前两步一样,这里不做过多描述。之后oSIdx++
、nSIdx++
。
- 第四步
oSIdx = 3 oEIdx = 2
nSIdx = 3 nEIdx = 3
描述:因为此时oSIdx
>oEIdx
、nSIdx
===nEIdx
(按照源码的逻辑,结束while循环),说明oldCh
先遍历完,所以newCh
比oldCh
多,说明是新增操作,执行addVnodes()
,将新节点插入到dom中。
附录: updateChildren核心源码,以及注释
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // oldVnode开始下标
let oldEndIdx = oldCh.length - 1 // oldVnode结尾下标
let oldStartVnode = oldCh[0] // oldVnode第一个节点
let oldEndVnode = oldCh[oldEndIdx] // oldVnode最后一个节点
let newStartIdx = 0 // newVnode开始下标
let newEndIdx = newCh.length - 1 // newVnode结尾下标
let newStartVnode = newCh[0] // newVnode第一个节点
let newEndVnode = newCh[newEndIdx] // newVnode最后一个节点
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
// 注意循环条件,只有oldVnode和newVnode的开始节点小于等于的时候才会循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // 这一步就是额外操作,如果oldStartVnode取不到元素,就向后移
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx] // 这一步就是额外操作,如果oldEndVnode取不到元素,就向后移
}
// 真正开始执行diff
else if (sameVnode(oldStartVnode, newStartVnode)) {
// 旧首新首比较
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 旧尾新尾比较
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 旧首新尾比较
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 旧尾新首比较
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 以上都不匹配执行下面的逻辑
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
// 循环结束后,判断是oldCh多,还是newCh多
// 如果oldCh多 newCh少 就是删除
// 如果oldCh少 newCh多 就是创建
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
为什么不能用index做key,有哪些危害。
平时for循环的时候,我们为什么一般不用index来做key?
下面我来举两个例子,什么情况会影响,什么情况不会影响,正确的key应该如何设置。
// 旧 // 新
<ul> <ul>
<li :key="0"> user3 </li>
<li :key="0"> user1 </li> <li :key="1"> user1 </li>
<li :key="1"> user2 </li> <li :key="2"> user2 </li>
</ul> </ul>
在上面的例子中,我们只在列表开头插入了一条,别的都不变。按照正常思维,user1、user2可以复用,只创建user3即可。但现在我们的key是index
,当我们点击button执行插入操作的时候,我们看浏览器会如何解析?
index做key
<ul class="container">
<li v-for="(item, index) in list" :key="index" class="box">{{ item }}</li>
</ul>
<button @click="addUser">添加</button>
<script>
export default {
data() {
return {
list: ["user1", "user2", "user3"],
};
},
methods: {
addUser() {
this.list.unshift("user5");
},
},
};
</script>
他直接会将li所有对应的节点全部更新!为什么会这样呢?这时候你就要回头看sameVnode(a,b)
方法。要比较的节点key是相同的,tag是相同的,所以sameVnode()
结果为true,就会直接调用patchVnode()将俩个相同节点进行文本更新
用我们的例子来说的话,进行了4次dom操作(3次更新,1次创建),本应该user4
被创建,而现在创建的变成了user3
根本没有复用这个概念!!!
item做key
如果我们用item
来做key的话,看下浏览器会怎么更新?(正常业务的列表中都有id,一般都用id来做key)
<ul class="container">
<li v-for="item in list" :key="item" class="box">{{ item }}</li>
</ul>
<button @click="addUser">添加</button>
是不是只会进行1次dom操作(1次创建),如此便实现了复用这个概念,更小的代价实现了更新。
点睛之笔
如果我button的方法是push
而不是unshift
,那么我们不管用index
做key也好,item
做key也好,都是一样的,都只会创建新节点,进行一次dom操作。
addUser() {
this.list.push("user5");
},
这是因为diff
的规则就是:
- 旧首
=>
新首 - 旧尾
=>
新尾 - 旧首
=>
新尾 - 旧尾
=>
新首 - 如果以上都匹配不到,再以新
vnode
为准,依次遍历老节点,直到找到相同的节点之后,再调用patchVnode
看到这里,有没有对diff的规则又加深了理解呢。