虚拟DOM是Vue的核心之一,它是Virtral DOM,也就是我们经常提到的虚拟节点,是通过js的Object对象模拟DOM节点,然后再通过特定的方法将其渲染成真实的DOM节点。虚拟DOM它不会进行重排与重绘,并且它可以一次性修改真实DOM需要更改的部分,能尽量减少对真实DOM的操作,这样可以减少页面的回流与重绘大大提升页面性能。
虚拟DOM(vdom)它是树状结构的,其节点为vnode,只存在于vdom tree中,通过vnode的elm属性可以访问到对应的真实DOM节点。它其实就是用js对象结构表示树的结构,然后用这样树构建一个真正的dom树,插入到文本当中去,当状态进行更时,重新的构造了一棵新的对象树,新旧树进行对比,然后把差异应用到所构建的真正dom树上,这样就实现了视图的更新操作。
从虚拟DOM到真实DOM之间要经过一系列的流程及步骤。当通过Object.defineProperty中的get及set实现对数据的劫持操作后虚拟dom模拟真实dom结构 ,通过render算法将虚拟dom进行解析,渲染成真实的dom。当数据发生更改会触发set,进行执行对应的Dep.notify函数,这时又会再次生成虚拟dom,用diff算法计算出修改的最小单位 ,生成patch补丁,重新渲染真实dom,但这时只渲染变化的部分,所以渲染的速度会快很多。
patch的本质是将新旧vnode进行比较,创建或者是删除。在patch函数里调用了核心函数patchVnode函数,也正是这个函数调用了diff(updateChildren函数)。下面我们来分别进行介绍。
patch大致可以分为以下几步:
1、如果是首次patch,则创建一个新的节点;
2、当老节点存在时:
老节点不是真实dom且和新节点相同,调用patchVnode来修改现有的节点;
当新老节点不同时:
如果老节点是真实的dom,则创建对应的vnode节点;
为新的vnode创建元素,若parentElm存在,则插入到父元素上
如果组件根节点被替换,遍历更新父节点后移除老节点
3、调用钩子函数insert
4、返回vnode.elm真实dom内容
//:vue/src/core/vdom/patch.js
function patch (oldVnode, vnode, hydrating, removeOnly) {
// 当新节点不存在的时候,销毁旧节点
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
// ...
const insertedVnodeQueue = []
// 如果旧节点 是未定义的,直接创建新节点
if (isUndef(oldVnode)) {
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
const isRealElement = isDef(oldVnode.nodeType)
// 当老节点不是真实的dom节点,当两个节点是相同,进入 patchVnode 的过程
if (!isRealElement && sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 当老节点是真实存在的dom节点的时候
if (isRealElement) {
// ...
}
const oldElm = oldVnode.elm
// 获取父亲节点,这样方便 删除或者增加节点
const parentElm = nodeOps.parentNode(oldElm)
// 在 dom 中插入新节点
createElm(
// ...
)
// 递归 更新父占位符元素
if (isDef(vnode.parent)) {
// ...
}
// 销毁老节点
if (isDef(parentElm)) {
// ...
}
}
}
// 执行虚拟dom的insert钩子函数
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
// 返回最新vnode的elm,也就是真实的 dom节点
return vnode.elm
}
patchVnode它是对比两个虚拟dom不同的地方,分为以下几点:
1. 如果两个节点一样,即新旧虚拟dom是同一地址,则什么也不做,直接返回
2. 处理静态节点的情况
3. vnode不存在text文本时
1). 新老节点都有children子节点时,且子节点不同,这时要调用updateChild递归的进行更新
2). 当老节点有子节点而新节点没有,则把子元素节点进行移除;
2). 当老节点没有子节点而新节点有子节点则先清空文本内容为当前节点添加子节点
3). 都没有子节点,则直接称除节点的文本
4. 新老节点文本不一样时,则直接替换节点文本
function patchVnode(oldVnode, vnode, ) {
// 如果新节点和旧节点相等直接返回不进行修改
if (oldVnode === vnode) {
return
}
// ...
// 当前节点 是注释节点或者是一个异步函数节点那不执行
if (isTrue(oldVnode.isAsyncPlaceholder)) {
//...
return
}
//当前节点是静态节点时或者once时
// ...
//调用prepatch钩子
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// ...
//调用update钩子
if (isDef(data) && isPatchable(vnode)) {
// ...
}
//新节点没有text属性时
if (isUndef(vnode.text)) {
// ...
}
if (isDef(data)) {
//执行postpatch钩子
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
在patchVnode中,我们知道如果新老节点都有子节点,且不同,这时我们就会调用upadateChildren函数,这个函数它是通过diff算法尽可能的复用先前的dom节点。
DOM diff算法在比较新旧节点时,只会在同级的虚拟dom做比较,不会跨级,在递归地进行了同级虚拟dom比较后,最终实现对整个dom树的更新操作。
它其实是一个时间复杂度O(N^3)的问题,当同级进行比较时,算法复杂度就可以达到O(n)。在实际的代码中,会对新旧两棵树进行一个深度的优先遍历,这样每一个节点都会有一个唯一的标记,在深度遍历时,每遍历一个节点就进行新旧对比,如果有差异就先把差异记录在一个对象中。
我们可以借用下图来了解新旧节点的对比。其中oldStartIndx,oldEndInx,newStartIndx,newEndInx,它们分别是新老虚拟node的索引 ,oldStartVnode,oldEndVnode,newStartVnode,newEndVnode分别指向索引对应的vnode。其中我们主要是对oldStartIndx,oldEndInx,newStartIndx,newEndInx进行比较,一共有4种方式,如果4种都没有匹配,设置了key,就会用key进行比较,在比较的过程中,需要oldStartIdx小于oldEndIdx,newStartIdx小于newStartInx。
如果oldStartVnode不存在,则oldStartVnode向右移动一位,随之oldStartIndx向右移动一位。同样的道理适用于当oldEndVnode不存在,则oldEndVnode向左移动一位,随之oldEndIdx向左移动一位。
当oldStartVnode和newStartVnode 相似时,oldStartIdx和newStartIdx都向右移动一位。同样,当oldEndVnode和newEndVnode相似,oldStartIdx和newEndInx也发生改变向中间移动一位。当他们是sameVnode时,直接patchNode。
当oldStartVnode和newEndVnode相似,则真实dom的第一个节点会移动到最后,且oldStartIdx向中间移动一位,newEndIdx向中间移动一位。
当oldEndVnode和newStartVnode相似时,把oldEndVnode.elm插入到oldStartVnode.elm前面,oldEndIdx向中间移动一位,newStartInx向中间移动一位。
当以上的情况都不相符时,没有匹配成功时,遍布old vnode,进行匹配,如果匹配到,在真实dom中则进节点移到最前面,如果没有成功,则将new vnode对应的节点插入到真实dom对应的old vnode的位置,指针都是向中间进行移动的。
生成一个key与old vnode对应的哈希表,从下面的代码中可以看到map就是以key为属性递增的数字为属性值的对象。vue中的key可以看成是每个vnode的唯一id,在diff算法中,我们先会进行新旧节点的首尾交叉对比,当无法匹配是会用新节点的key与旧节点进行对比,从而的找到相应旧的节点。
function createKeyToOldIdx (children, beginIdx, endIdx) { let i, key const map = {} for (i = beginIdx; i <= endIdx; ++i) { key = children[i].key if (isDef(key)) map[key] = i } return map } --------------------------------------------------- children = [{ key: 'key1' }, { key: 'key2' }] map = { key1: 0, key2: 1, }
如下图我们可以看到new vnode中为红色的节点,在old vnode中找不到,不存在,说明它是新增的,那么就要创建一个新的节点,插入到dom树,插入到oldStartIndx对应dom的前面。newStartIdx向中间移动一位。需要强调的是old vnode中的索引不用动,因为它们没有节,点,所以标记过程中它不需要进行移动。
如果可以找到且和newStartIdx对应的new vnode相似,将map表中这个位置的赋值为undefined,且将new startIdx真实的dom 插入到最前面,然后newStartIdx向中间移动一位。
当newStartIdx相遇newEndIdx,如果这时,oldStartIdx和oldEndIdx还没有相遇,说明这两个指针之间的节点(包括这两个节点),是要进行删除的。我们上一步对蓝色的old vnode进行了undefined标记,说明这个节点是已经被处理过的,是需要出现在新的dom中的,不用删除,所以在这里我们要删除以下节点。
当然我们也会碰到oldStartIdx和oldEndIdx先相遇但newStartInx和newEndIdx没有相遇的情况,这时就需要对new vnode中的节点进行处理,他们是更新中被加入的节点,这是把它们插入到dom树中。