当数据变化时,vue是如何更新节点的?
渲染真实DOM的开销是很大的,对DOM的操作会引起整个dom树的重绘和重排。因此,vue采用虚拟dom来对节点进行更新。比如当某个div的属性发生变化时,这时候只需要比较变化前的oldVnode和变换后的Vnode,删除多余属性,更新属性或添加新属性,而不需要删除整个dom元素。
diff过程
diff的过程就是调用名为patch的函数,比较新旧节点,一边比较一边给真实的DOM打补丁
虚拟dom
virtual DOM 是将真实的DOM的数据抽取出来,以对象的形式模拟树形结构。
真实DOM | virual DOM |
---|---|
<div> <p>123</p></div> | var Vnode = {tag:'div', data:{} children: [{tag: 'p', text: '123'}]} |
patch
diff比较新旧节点的时候,只会在同层级进行,不会跨层级比较。
开始
如图所示,即是数据变化前后,新旧节点(虚拟dom)的比较流程。
function patch(oldVnode,vnode)
..... // some code
if(sameVnode(oldVnode,vnode)){
patchVnode(oldVnode,vnode)
}else{
// 新旧节点不一样时
const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
let parentEle = api.parentNode(oEL) //父元素
createEle(vnode) //根据Vnode生成新元素
if(parentEle !== null){
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) //插入新节点
api.removeChild(parentEle, oldVnode.el) //移除旧节点
}
.... // some code
return vnode
}
整个patch过程,先检查新旧节点是否相似,
- 如果不相似,则根据vnode生成新dom节点插入到父元素里,并删除旧的节点
- 如果相似,则先对data比较,包括class、style、event、props、attrs等,有不同就调用对应的update函数,然后对子节点children进行比较,children的比较用到了
diff算法
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)) ||
(isTrue(a.isAsyncPlaceholder) && a.asyncFactory === b.asyncFactory && isUndef(b.asyncFactory.error))
)
}
简单来说,sameVnode就是比较是否有相同的key和tag。
简化后的vnode大致包含以下属性:
{
tag:'div',
data:{}, //属性数据, 包括class style event props attrs等
children: [], //子节点数组
text: undefined, //文本
elem: undefined, // 真实dom
key: undefined //节点标识
}
patch过程(包括diff算法)
patchVnode(oldVnode, vnode){
// 新节点引用旧节点的dom,因此对于新节点来说,vnode.elm 还是指向旧节点的真实dom
const elm = vnode.elm = oldVnode.elm // vnode中的elm存放的是(或者说指向的是)真实的dom结构
let i, oldCh = oldVnode.children, ch = vnode.children
//调用update钩子; 先对data比较,包括class、style、event、props、attrs等
if(vnode.data){
updateAttrs(oldVnode, vnode);
updateClass(oldVnode, vnode);
updateEventListeners(oldVnode, vnode);
updateProps(oldVnode, vnode);
updateStyle(oldVnode, vnode);
}
//判断是否为文本节点
if(vnode.text == undefined){
//vnode 不是文本节点
if(isDef(oldCh) && isDef(ch)){
// oldCh 和 ch都存在
if(oldCh !== ch) updateChidren(elm, oldCh, ch, insertedVnodeQueue) //diff算法
}else if (isDef(ch)){
// 只有ch存在,oldVnode没有children
if(isDef(oldVnode.text)) api.setTextContent(elm, '') // oldVnode如果是文本节点,直接至空
addVnodes(elm, null, ch, 0, ch.length -1, insertedVnodeQueue)
} else if (isDef(oldCh)){
//只有oldCh存在,vnode没有children
removeVnodes(elm, oldCh, 0, oldCh.length -1)
} else if (isDef(oldVnode.text)){
// oldVnode和vNode都没有children,并且,oldVnode是文本节点
api.setTextContent(elm,'')
}
}else if(oldVnode.text !== vnode.text){
//vnode 是文本节点,并且文本内容已经发生了改变
api.setTextContent(elm, vnode.text)
}
}
先对data比较,包括class、style、event、props、attrs等
以updateAttrs为例
function updateAttrs(oldVnode,vnode){
let key,cur, old
const elm = vnode.elm
const oldAttrs = oldVnode.data.attrs ||{}
const attrs = vnode.data.attrs || {}
for(key in attrs){
cur = attrs[key] //vnode中的attrs
old = oldAttrs[key]
if(old !== cur) {
old这个属性与cur这个属性不一致了,则更新/添加
elm.setAttribute(key,cur)
}
}
//删除节点中不存在的属性
for(key in oldAttrs){
if(!(key in attrs)){
//旧属性中存在,而新属性中不存在
elm.removeAttribute(key)
}
}
}
然后,当oldCh 和 ch(newCh)都存在时,比较子节点chidren,此过程就是diff
diff算法
在patch过程中,当oldVnode和vnode均有children子节点时,这时候就会用到diff算法来进行比较。
以下图为例,
比较的方法流程:
- 第一步 头头比较,如果相似,旧头心头均后移,真实dom不变,进入下一次循环,如不相似,进入第二步
- 第二步 尾尾比较,如果相似,旧尾新尾均前移,真是dom不变,进入下一次循环,如不相似,进入第三步
- 第三步 头尾比较, 如果相似,将旧头插入到旧尾最后的位置。旧头后移,新尾前移。如不相似,进入下一次循环
- 第四步 尾头比较,如果相似,将旧尾插入到旧头前面的位置。旧尾前移,新头后移。如不相似进入第五步
- 第五步 若节点有key且再旧子节点数组中找到sameVnode(tag和key都一致),则将其dom移动到当前真实dom序列的头部,新头指针后移; 否则,vnode对应的dom(vnode[newStartIdx],elm)插入到当前真实dom序列的头部,新头指针后移
以上这个循环结束的标志是
- 新的节点数组(newCh)被遍历完了(newStartIdx > newEndIdx)。则把多余的旧dom都删掉
- 旧的节点数组(oldCh)被遍历完了(oldStartIdx > oldEndIdx)。则把多余的新dom都添加
//diff算法源码
function updateChildren(parentElm, oldCh, newCh,insertedVnodeQueue){
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length -1;
let newEndIdx = newCh.length -1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
if(isUndef(oldStartVnode)){
oldStartVnode = oldCh[++oldStartIdx] //这个节点被移走了
}else if(isUndef(oldEndVnode)){
oldEndVnode = oldCh[--oldEndIdx]
} else if(sameVnode(oldStartVnode, newStartVnode)){
//头头相似
patchVnode(oldStartVnode,newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if(sameVnode(oldEndVnode,newEndVnode)){
// 尾尾相似
pathVnode(oldEndVnode,newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if(sameVnode(oldStartVnode,newEndVnode)){
//头尾相似 Vnode moved right
patchVnode(oldStartVnode,newEndVnode)
api.insertBefore(parentElm,oldStartVnode.elm, api.nextSibling(oldEndVnode.elm)) //把oldStartVnode插入到oldEndVnode后面
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if(sameVnode(oldEndVnode, newStartVnode)){
//尾头相等 Vnode moved left
patchVnode(oldEndVnode,newStartVnode)
api.inserBefore(parentElm,oldEndVnode.elm,api.nextSibling(oldStartVnode.elm)) // 把oldEndVnode插入到oldStartVnode的前面
} else {
// 根据旧子节点的key,生成map映射
if(isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
//在旧子节点数组中,找到和newStartVnode相似节点的下标
idxInOld = oldKeyToIdx[newStartVnode.key]
if(isUndef(idxInOld)){
api.insertBefore(parentElm,createElm(newStartVnode),oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
}else {
elmToMove = oldCh[idxInOld]
patchVnode(elmToMove,newStartVnode)
oldCh[idxInOld] = undefined
api.insertBefore(parentElm,elmToMove.elm, OldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
}
}
}
}
注: isUndef函数
都说添加了 :key可以优化v-for的性能,到底是怎么回事呢?
因为v-for大部分情况下生成的都是相同的tag标签,如果没有key标识,那么相当于每次头头比较都能成功。如果你往v-for绑定的数组头部push数据,那么整个dom将全部刷新一遍。
有了key, 其实就是多了一项匹配查找。