_update
之前学过,vue通过_update
将虚拟DOM转为真实的DOM并更新视图,但是只介绍了初始化的时候,并没有讲到更新。当我们修改数据的时候,页面也应该会发生更新,同样会走到_update
中。_update
会判断是首次渲染,还是更新视图。_update
主要调用了_patch__
方法。
patch
这次主要介绍的是更新时候的__patch__
,是这么调用的:
vm.$el = vm.__patch__(prevVnode, vnode)
patch将新老VNode
节点进行比对,然后将根据两者的比较结果进行最小单位地修改视图,而不是将整个视图根据新的VNode
重绘。patch
的核心在于diff
算法,这套算法可以高效地比较viturl dom
的变更,得出变化以修改视图。
diff
算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n),是一种相当高效的算法。
两张图代表旧的VNode
与新VNode
进行patch的过程,他们只是在同层级的VNode
之间进行比较得到变化。
在源码中,patch
会比较新旧VNode
,然后做出不同的处理。
首先学习以下怎么比较两个新旧的VNode
,是使用了sameVnode
方法。
sameVnode
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
的逻辑非常简单,如果两个 vnode
的 key
不相等,则是不同的;否则继续判断对于同步组件,则判断 isComment
、data
、input
类型等是否相同,对于异步组件,则判断 asyncFactory
是否相同。
所以根据新旧 vnode
是否为 sameVnode
,patch
会走到不同的更新逻辑。
新旧节点不同时
如果sameVnode
返回false时,会经历三个步骤:
- 调用
createElm
创建新节点; - 更新父的占位符节点;
- 删除旧节点。
新旧节点相同时
当sameVnode
返回true
是,会调用patchVnode
方法,这个方法作用就是把新的 vnode
patch
到旧的 vnode
上
prepatch
当更新的 vnode
是一个组件 vnode
的时候,会执行prepatch
:
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
prepatch
会拿到新的 vnode
的组件配置以及组件实例,然后调用updateChildComponent
函数对占位符 vm.$vnode
、slot
,listeners
,props
等等进行更新
update
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
在执行完新的 vnode
的 prepatch
钩子函数,会执行所有 module
的 update
钩子函数以及用户自定义 update
钩子函数,对于 module
的钩子函数。
完成patch过程
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(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
这个环节有两大类:
-
vnode
是文本类型,且新旧节点的text
内容不相等时,则直接替换文本节点:else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text) }
-
vnode
不是文本类型时,则判断它们的子节点:-
oldCh
与ch
都存在且不相同时,使用updateChildren
函数来更新子节点(updateChildren
划重点); -
如果只有
ch
存在表示旧节点不需要了,如果旧的节点是文本节点则先将节点的文本清除,然后通过addVnodes
将ch
批量插入到新节点elm
下; -
如果只有
oldCh
,则直接移除; -
如果旧节点是文本节点,清除其节点文本内容。
-
执行 postpatch 钩子函数
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
执行完 patch
过程后,会执行 postpatch
钩子函数,它是组件自定义的钩子函数,有则执行。
这样就完成了DOM的更新了。
我们知道Vue是先渲染VNode
,再将VNode
转为真实的DOM,所以每次更新时,都会先更新VNode
先,所以会执行patch
方法,我们再上面已经大致分析了patch
的过程,但是还没有具体分析,更新是,新旧节点都有子内容时会调用的updateChildren
方法,这个方法就是diff
的核心。
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]
oldStartIdx
指向的是old
节点的第一个节点的下标;newStartIdx
指向的是new
节点的第一个节点的下标;oldEndIdx
指向的是old
节点的第一个节点的下标;oldStartVnode
指向的是old
节点的第一个节点;oldEndVnode
指向的是old
节点的最后一个节点;newEndIdx
指向的是new
节点的第一个节点的下标;newStartVnode
指向的是new
节点的第一个节点;newEndVnode
指向的是new
节点的最后一个节点;
举例,要完成这样的视图更新,从ABCD变为DCBAE:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
}
我们会对新旧子节点进行遍历,当满足上述条件才会跳出循环。接下来分析每一种情况:
-
当
oldStartVnode
是undefine
的情况下,就让oldStartVnode
等于下一个兄弟节点if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left }
-
当
oldEndVnode
是undefine
的情况下,就让oldEndVnode
等于上一个兄弟节点else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] }
-
当
oldStartVnode
和newStartVnode
是sameVnode
时,会继续调用patchVnode
,然后将oldStartVnode
和newStartVnode
移动到下一个兄弟节点else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] }
-
当
oldEndVnode
和newEndVnode
是sameVnode
时,会继续调用patchVnode
,然后将oldEndVnode
和newEndVnode
移动到上一个兄弟节点。else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] }
-
当
oldStartVnode
和newEndVnode
是sameVnode
时,这说明oldStartVnode
跑到了newEndVnode
的后面,进行patchVnode
的同时还需要将真实DOM节点移动到newEndVnode
的后面。else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] }
-
当
oldStartVnode
和newEndVnode
是sameVnode
时,这说明oldEndVnode
跑到了oldStartVnode
的前面,进行patchVnode
的同时真实的DOM节点移动到了oldStartVnode
的前面。else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] }
-
如果以上情况均不符合,
(1) 则通过
createKeyToOldIdx
会得到一个oldKeyToIdx
,里面存放了一个key
为旧的VNode
,value
为对应index
序列的哈希表。if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
(2)从这个哈希表中可以找到是否有与
newStartVnode
一致key
的旧的VNode
节点,idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
如果找到且同时满足
sameVnode
,patchVnode
的同时会将这个真实DOM(elmToMove)移动到oldStartVnode
对应的真实DOM的前面。(比如说newStartIdx
对应的key
和oldCh
中某个key相等,那么就会把这个oldCh
某个key相等的节点,移动到oldChild
中newStartIdx
对应的位置去)
当然也有可能newStartVnode
在旧的VNode
节点找不到一致的key,或者是即便key相同却不是sameVnode
,这个时候会调用createElm
创建一个新的DOM节点。
循环结束后,如果oldStartIdx > oldEndIdx
,这个时候老的VNode
节点已经遍历完了,但是新的节点还没有。说明了新的VNode
节点实际上比老的VNode
节点多,也就是比真实DOM多,需要将剩下的(也就是新增的)VNode
节点插入到真实DOM节点中去,此时调用addVnodes
(批量调用createElm
的接口将这些节点加入到真实DOM中去)。
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
}
同理,当newStartIdx > newEndIdx
时,新的VNode
节点已经遍历完了,但是老的节点还有剩余,说明真实DOM节点多余了,需要从文档中删除,这时候调用removeVnodes
将这些多余的真实DOM删除。
else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
以上步骤只是将虚拟DOM映射成了真实的DOM。最后会依赖于虚拟DOM的生命钩子,给这些DOM加入attr
、class
、style
等DOM属性。
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
总结
组件更新的过程核心就是新旧 vnode diff
,对新旧节点相同以及不同的情况分别做不同的处理。
新旧节点不同的更新流程是:创建新节点->更新父占位符节点->删除旧节点;
而新旧节点相同的更新流程是去获取它们的 children,根据不同情况做不同的更新逻辑。最复杂的情况是新旧节点相同且它们都存在子节点,那么会执行 updateChildren
逻辑。
最后我们还需要调用VNode
的生命钩子给DOM入attr
、class
、style
等DOM属性。