Vue进入2.0以来在其内部加入了虚拟dom的实现,减少了dom的操作,极大提高了性能,同时其diff算法的时间复杂度为O(n),性能很高。
虚拟dom
首先我们来看下什么是虚拟DOM(virtual DOM ),虚拟DOM就是提通过js生成一个dom对象,之后通过diff算法比较之后生成patch,即补丁,之后虚拟dom通过补丁更新,再渲染成真实DOM显示出来,可以看到只涉及到了一次DOM操作,效率、性能无疑很高。
具体实现步骤:
- 初始化时创建虚拟dom树
- 将虚拟dom render成实体dom,显示出来
- 当前dom节点发生改变时,会生成新的虚拟dom(修改了旧dom)
- 新旧虚拟dom比对之后生成patch对象
- 根据patch对象更正旧虚拟dom,之后render
diff实现
明白了虚拟节点之后我们就以下面代码为例来详细说明下diff的具体操作:
<ul>
<li>1</li>
<li>2</li>
</ul>
在vue中就会生成虚拟dom结构,大概结构如下:
{
tag: 'ul',
children: [
{ tag: 'li', children: [ { vnode: { text: '1' }}] },
{ tag: 'li', children: [ { vnode: { text: '2' }}] },
{ tag: 'li', children: [ { vnode: { text: '3' }}] },
]
}
假如此时我们对当前dom结构进行reverse操作的话,就会变成以下节点树:
{
tag: 'ul',
children: [
{ tag: 'li', children: [ { vnode: { text: '3' }}] },
{ tag: 'li', children: [ { vnode: { text: '2' }}] },
{ tag: 'li', children: [ { vnode: { text: '1' }}] },
]
}
此时就进入了本节的重点,新旧虚拟节点树的diff,我们可以分析下源码实现:
- 首先初始化获取新旧节点树的起始、结束位置以及其对应的节点
src/core/vdom/patch.js
updateChildren方法
let oldStartIdx = 0 // 旧节点起始位置
let newStartIdx = 0 // 新节点起始位置
let oldEndIdx = oldCh.length - 1 // 旧节点结束位置
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1 // 新节点结束位置
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
- 接着开始遍历新旧节点树,直到旧节点或新节点起始位置大于结束位置时结束,即相当于遍历完至少其中一个节点树
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
...
}
- 判断旧节点首尾节点不为空
// 当前旧开始节点是否存在,没有的话初始节点的指向往后移动
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
}
- 当新旧节点首节点比较、尾节点比较时满足sameVnode,此时直接开始patchVNode(递归遍历),遍历之后再移动指针,开始循环下面的节点:
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]
}
- 当老节点开始位置节点与新节点结束位置节点比较时,说明当前oldStartVnode已经在oldEndVnode节点的后面了,此时需要移动dom节点的位置
else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
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)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
- 如果都不满足,则说明没有可以复用的节点,此时会通过查找事先建立好的以旧的 VNode 为 key 值,对应 index 序列为 value 值的哈希表。从这个哈希表中找到与 newStartVnode 一致 key 的旧的 VNode 节点,如果两者满足 sameVnode 的条件,在进行 patchVnode 的同时会将这个真实 dom 移动到 oldStartVnode 对应的真实 dom 的前面;如果没有找到,则说明当前索引下的新的 VNode 节点在旧的 VNode 队列中不存在,无法进行节点的复用,那么就只能调用 createElm 创建一个新的 dom 节点放到当前 newStartIdx 的位置。
else {
// 建立映射表
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 存在key值时用key来比对,不存在时调用方法遍历去比对
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 {
// key相同但是内部有不同的元素、属性等则依旧创建新节点
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
- 当 while 循环结束后,根据新老节点的数目不同,做相应的节点添加或者删除。若新节点数目大于老节点则需要把多出来的节点创建出来加入到真实 dom 中,反之若老节点数目大于新节点则需要把多出来的老节点从真实 dom 中删除。至此整个 diff 过程就已经全部完成了。
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)
}
- 附:sameVnode的实现,首先是比对key值,故设置一个合理的key值可以提升vue性能
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
)
)
)
}
总结
通过上述详细解析我们会发现,之前的例子中dom的reverse操作基本不会涉及到到dom元素的创建,全部复用之前那的dom,因此性能较好。