虚拟DOM最核心的部分是Patch方法,它通过对比新旧两个Vnode之间有哪些不同,然后根据对比结果找出需要更新的节点进行更新.当oldVnode和Vnode不相同时,以Vnode为准来渲染视图.
而Patch中使用了Diff算法来进行比较.Diff 算法是一种通过同层的树节点进行比较的高效算法,避免了对树进行逐层搜索遍历,所以时间复杂度只有 O(n)
让我们先来看看VUE初始化时最后的工作:vm.$mount将vm实例挂载到DOM元素上.
Vue.prototype._init = function (options?: Object) {
......
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
而vue.$mount()函数最后会调用lifestyle.js中的mountComponent方法:其中定义了一个updateComponent
函数用于更新组件.
然后对这个Vue实例(组件)新建一个Watcher并完成依赖收集工作,当响应式数据发生变化时,它的dep数组便会调用dep.notify()来通知所有的依赖更新视图,此时就会调用updateComponent方法.
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
//el代表DOM节点
vm.$el = el
......
//钩子函数
callHook(vm, 'beforeMount')
let updateComponent
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
...
}
} else {
updateComponent = () => {
// vm._render() 返回一个VNode,在生成VNode的过程中,会动态计算getter,同时推入到dep里
vm._update(vm._render(), hydrating)
}
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
updateComponent(): updateComponent()执行的时候内部会调用,vm.__update()方法,并将_render()函数产生的新Vnode传入其中,与OldVnode做diff比较,最后完成更新工作.而_update()函数的定义如下:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode //由render产生的新的Vnode
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
//如果不存在OldVnode,就使用新Vnode创建一个真实DOM
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
//如果存在OldVnode,就调用patch将OldVnode与Vnode做diff操作,将需要更新的Dom节点进行更新.
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// if parent is an HOC, update its $el as well
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
}
__update:__update()中调用了__patch__函数.
综上,patch不是暴力替换节点,而是修改DOM节点(也可以理解为渲染视图),需要做三件事:
1.创建新增的节点 (发生于OldVnode不存在而Vnode存在时,或Vnode和oldVnode不是同一个节点)
2.删除废弃节点 (发生于节点只存在于OldVnode中)
3.修改需要更新的节点(当两个节点时相同的节点时,做Diff比较,更新不同的节点)
__patch__:那么vm.__patch__函数中究竟发生了什么呢?
function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
// 当oldVnode不存在时
if (isUndef(oldVnode)) {
// 创建新的节点
createElm(vnode, insertedVnodeQueue, parentElm, refElm)
} else {
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 对oldVnode和vnode进行diff,并对oldVnode打patch
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
}
}
//sameNode
function sameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
如果不存在oldVnode,就直接用新Vnode创建一个新节点,否则调用sameVnode函数判断OldVnode和Vnode是不是同一个节点(通过判断基本属性),如果是则调用Diff,否则直接跳过Diff过程,根据Vnode创建新的DOM节点,然后删除OldVnode的DOM节点.
patchVnode:我们着重分析更新节点过程,即Diff过程.对应的方法为patchVnode()(Patch.js)
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
// 如果 oldVnode、vnode 是同一节点的话,则直接 return 即可,不同进行更新操作
if (oldVnode === vnode) {
return
}
// 获取对比 vnode 对应的真实的 DOM 节点
const elm = vnode.elm = oldVnode.elm
// 判断 oldVnode 和 vnode 是不是静态节点,如果是静态节点的话,直接 return
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
// 获取新旧 vnode 的 children
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) {//Vnode是元素节点或者text为空的文本节点
if (isDef(oldCh) && isDef(ch)) {//Vnode和OldVnode都是元素节点,且都有子节点,更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {//Vnode和OldVnode都是元素节节点,但只有Vnode有子节点,添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {//Vnode和OldVnode都是元素节点,但只有OldVnode有子节点,删除子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {//Vnode和OldVnode都是文本节点,但只有oldVnode有text,删除text
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {//Vnode有text属性,替换文本
nodeOps.setTextContent(elm, vnode.text)
}
}
updateChildren():我们继续分析,更新子节点函数updateChildren(),它是diff中最重要的环节.
直观上对子节点的更新方法是:循环 newChildren(新子节点列表),每循环到一个新的子节点,就去 oldChildren(旧的子节点列表)中循环查找与其相同的那个旧节点。如果在 oldChildren 中找不到的话,则说明当前循环的新子节点是新增节点,此时需要进行创建新节点并插入到视图的操作。如果找到了与当前循环节点相同的那个旧节点,此时需要进行更新节点的操作。另外还需要说明的一点是,如果找到了与当前循环节点相同的那个旧节点,但是位置不一样的话,除了做更新操作,还需要对节点的位置进行移动。当newChildren中的所有节点遍历完成,oldChildren中仍有剩余,则删除这些节点.
综上,对子节点的更新包含四个操作:
新增节点:插入未处理节点的前面
删除节点:newChildren遍历完成后,删除oldChildren中剩余的节点.
更新节点(同节点位置相同):与前文所述的更新节点操作一致
移动节点(同节点位置不同):更新,并把节点已知未处理节点最前面
Diff: 但这不是性能最优的解决方案,为此引入Diff算法来提高性能.他有两个特点:
1.比较只会在同层进行,不会跨层级比较
2.循环从两边向中间进行
1.第一步
对oldChildren和newChildren的开始和结束位置进行标记:oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。
let oldStartIdx = 0 // 旧节点开始下标
let newStartIdx = 0 // 新节点开始下标
let oldEndIdx = oldCh.length - 1 // 旧节点结束下标
let oldStartVnode = oldCh[0] // 旧节点开始vnode
let oldEndVnode = oldCh[oldEndIdx] // 旧节点结束vnode
let newEndIdx = newCh.length - 1 // 新节点结束下标
let newStartVnode = newCh[0] // 新节点开始vnode
let newEndVnode = newCh[newEndIdx] // 新节点结束vnode
2.第二步
进入循环处理,分情况讨论并移动对应的VNode节点,退出条件是(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
....//处理逻辑
}
当newStartVnode == oldStartVnode时:
if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
当newEndVnode == oldEndVnode时:
else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
以上两种情况直接调用patchVnode()完成更新节点逻辑,并且移动指针.
当oldStartVnode == newEndedVnode时:将节点移动到未处理节点最后面.
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]
}
当oldEndVnode == newStartVnode时:将节点移动到未处理节点最前面.
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]
}
如此,已更新过的节点无论是内容还是位置都是正确的,更新完后就不需要在进行考虑了,我们只需要在未处理节点([start,end])之间进行移动和更新操作即可.
以上四种情况均不满足时:
else {// 没有找到相同的可以复用的节点,则新建节点处理
/* 生成一个key与旧VNode的key对应的哈希表(只有第一次进来undefined的时候会生成,也为后面检测重复的key值做铺垫) 比如childre是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}] beginIdx = 0 endIdx = 2 结果生成{key0: 0, key1: 1, key2: 2} */
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
/*如果newStartVnode新的VNode节点存在key并且这个key在oldVnode中能找到则返回这个节点的idxInOld(即第几个节点,下标)*/
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
/*newStartVnode没有key或者是该key没有在老节点中找到则创建一个新的节点*/
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
/*获取同key的老节点*/
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
/*如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode*/
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
//因为已经patchVnode进去了,所以将这个老节点赋值undefined
oldCh[idxInOld] = undefined
/*当有标识位canMove实可以直接插入oldStartVnode对应的真实Dom节点前面*/
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
/*当新的VNode与找到的同样key的VNode不是sameVNode的时候(比如说tag不一样或者是有不一样type的input标签),创建一个新的节点*/
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
大部分情况下,前面四种方式就可以找到相同的节点,所以节省了很多次循环操作.
3.第三步
退出While循环时,如果newChildren有剩余,则将剩余的节点都加入真实的DOM,如果oldChildren有剩余,则把剩余的节点都删除.至此,整个diff过程完成.
if (oldStartIdx > oldEndIdx) {
/*全部比较完成以后,发现oldStartIdx > oldEndIdx的话,说明老节点已经遍历完了,新节点比老节点多, 所以这时候多出来的新节点需要一个一个创建出来加入到真实Dom中*/
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) //创建 newStartIdx - newEndIdx 之间的所有节点
} else if (newStartIdx > newEndIdx) {
/*如果全部比较完成以后发现newStartIdx > newEndIdx,则说明新节点已经遍历完了,老节点多于新节点,这个时候需要将多余的老节点从真实Dom中移除*/
removeVnodes(oldCh, oldStartIdx, oldEndIdx) //移除 oldStartIdx - oldEndIdx 之间的所有节点
}
小结:
1.本文探讨了Vue何时执行更新DOM元素(updateComponent)的具体流程.
2.本文探讨了通过如何比较Vnode和oldVnode,以及修改DOM的三种情况的逻辑(patch函数)
3.本文探讨了更新节点的详细逻辑(patchVnode)
4.本文探讨了更新子节点的详细逻辑(diff算法)
本文章仅为本人学习总结,如果有不足还请各位指出!