Vue 源码浅析:模板渲染- patch 中虚拟 Dom 的 diff 策略



找到 patch 出处

首先在 Vue.prototype._update 中会调用 vm._ patch_ 方法:

<code>//source-code\\vue\\src\\core\\instance\\lifecycle.js

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
//...
\tconst prevVnode = vm._vnode
//...
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
//...
}/<code>

vm._ patch_ 对应就是 Vue.prototype._ patch_ 方法:

<code>Vue.prototype.__patch__ = inBrowser ? patch : noop

export const patch: Function = createPatchFunction({ nodeOps, modules })/<code>

最后在 createPatchFunction 方法中找到 patch 方法:

<code>//source-code\\vue\\src\\core\\vdom\\patch.js
export function createPatchFunction (backend) {
const { modules, nodeOps } = backend


//...
return function patch (oldVnode, vnode, hydrating, removeOnly) {
//...
return vnode.elm
}
}/<code>


oldVnode 和 vnode 在哪里定义?

oldVnode 的定义

在最初的 init 方法中,会向 $mount 方法传入 $options.el 属性:

<code>Vue.prototype._init = function (options?: Object) {
\t//...
vm.$mount(vm.$options.el)
}/<code>

通过 query (querySelector) 选择器方法,获取对应的 dom 内容:

<code>Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
//...
return mount.call(this, el, hydrating)
}/<code>

el 赋值给 vm.$el

<code>export function mountComponent (


vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
//...
}/<code>


再回到最初的 update 方法,看到 oldVnode 就是 vm.$el

<code>if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}/<code>

当然首次 prevVnode 变量的值为 null,则会走到第一个 _ patch_ 方法进行首次渲染。


vnode 的定义

再回到 _update 方法,我们能看到它的入参列表中就有

vnode,再把 vnode 传给 _ patch_ 方法:

<code>//source-code\\vue\\src\\core\\instance\\lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
//...
const prevVnode = vm._vnode
//...
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
//...
}/<code>


这个 vnode 对象 就是由 vm._render() 方法执行后得到:

<code>Vue.prototype._render = function (): VNode {
//...
\tvnode = render.call(vm._renderProxy, vm.$createElement)
//...
return vnode
}/<code>

<code>updateComponent = () => {
vm._update(vm._render(), hydrating)
}/<code>

初始 elm 的挂载

emptyNodeAt 生成 oldVnode 节点

现在我们正式进入

patch 方法,看它最后 vnode.elm 的 dom 元素如何产生?

<code>return function patch (oldVnode, vnode, hydrating, removeOnly) {
\t//...
return vnode.elm
}/<code>

我们的 vnode 目前是有定义的,之前已经看过了 render 方法的解析过程,并且 oldVnode 也是存在的(通过 query 选择器取得),所以将进入 else 判断:

<code>return function patch (oldVnode, vnode, hydrating, removeOnly) {
\tif (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
//...
if (isUndef(oldVnode)) {
}else{
\t// patchNode
}
return vnode.elm
}/<code>


这里是 else 中的逻辑:

根据 oldVnode.nodeType 来判断是否是个真实元素 isRealElement,因为我们 AST 解析出来的虚拟节点是不包括 nodeType 属性的,所以

isRealElement=false

另外,hydrating 为 undefined,所以将直接通过 emptyNodeAt 添加一个空节点,vnode tag 属性为当前 dom 的 tagName

<code>const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
//...
} else {
if (isRealElement) {
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
//...
}
if (isTrue(hydrating)) {
//...
}
// either not server-rendered, or hydration failed.
// create an empty node and replace it
oldVnode = emptyNodeAt(oldVnode)
}
//...
}/<code>

createElm 创建节点

之后会将原来 oldVnode 的 dom 数据保存到 oldVnode.elm 中,并获取父级元素:

<code>const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)/<code>

之后将使用 vnode

,通过 createElm 创建新的虚拟节点,并挂载到 parentElm 上:

<code>// create new node
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)/<code>

当然 createElm 也略复杂,但我们能看到其中的 createChildren insert 方法,也能猜到在做子节点的循环遍历,以及子节点的插入操作:

<code>function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
\t//...
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
//...
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
insert(parentElm, vnode.elm, refElm)
//...
}/<code>

最后将 oldVnode 上的一些旧的事件监听、各种 hook 钩子一并移除掉,再销毁当前元素:

<code>// destroy old node
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}/<code>

如此,将新生成的 vnode.elm 树渲染到 html 页面上了。

新 vnode 的更新

patch 中的 updateChildren

_update 方法里,第一个 if 判断中 patch 已经在首次渲染的时候走过了,对 vm._vnode 初始化了值(即,prevVnode 非 false),所以当后续数据触发更新,再次执行 _update,则会进入 else 中的 patch 方法:

<code>Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
//...
\tconst prevVnode = vm._vnode
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)


} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
//...
}/<code>

顺着之前的 patch 逻辑,会发现 isRealElement 不再是 false 了,因为此时的 oldVnode 是虚拟节点,就不包含标准的 nodeType 属性,则会跳过 emptyNodeAt 方法,从而进入对应的 if 逻辑条件,进入到 patchVnode 方法:

<code>if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}/<code>

patchVnode 方法中,我们跳过多数目前不太关心的条件,直接进到解析子节点 updateChildren 的判断中,它会解析我们 oldVnode 和 vnode 中子节点,并逐层解析

<code>function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
//...


const elm = vnode.elm = oldVnode.elm
//...
const oldCh = oldVnode.children
const ch = vnode.children
//...
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
//...
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
//...
}/<code>

updateChildren 的逻辑相当复杂,可以说是整个 vnode oldVnode 之间 diff 对比的核心:

<code>function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
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

const canMove = !removeOnly
//...

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} 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]
} 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]
} else {
//...
newStartVnode = newCh[++newStartIdx]
}
}
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)
}
}/<code>

所以下面我会根据一个简单的 demo,来示例新老节点更新的 diff 对比过程。

详解 updateChildren 中的 diff 过程

准备新老 node

通过 v-if 来切换的两个不同标签模板内容,来模拟新老节点(oldVnode 和 vnode):


通过 updateChildren 剥离出子节点

注意 text 第二级的子元素本篇不做判断。


4种解析条件判断过程

updateChildren

方法中,一般都会通过如下 4种核心判断,对不同子元素进行比较:

<code>while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
\t//...
}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]
} 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]
}else {
//...
}
}/<code>

首先,会用 oldStartIdxoldEndIdx newStartIdxoldEndIdx 来分别表示 oldCh newCh 子节点队列的起始和结束位置。

然后,依次对比

oldStartVnode newStartVnodeoldEndVnode newEndVnodeoldStartVnode newEndVnodeoldEndVnode newStartVnode 四种位置的对比方式来判断比较节点是否是"相同的"虚拟节点?


当然 sameVnode 方法是有特殊条件的,必须节点上 key 属性一致并且 tag、isComment、data、inputType 一致,或者和其他一些属性一致才算相同元素

<code>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)
)
)
)
}/<code>

下面展示就目前的 demo 节点是如何在这几种判断中"游走的":


首先 oldCh ch 的 start 坐标都在第一个元素。第一次:先对比新老节点的首个元素(1 vs 3):

目前,我们这两个元素分别是 span 标签和注释标签,肯定不符合 sameVnode 的判断。


然后第二次:判断两个末尾元素是否相同(2 vs 5):

很不凑巧,这两者也是不一致。


第三次,判断老节点的首个元素新节点的末元素(1 vs 5):

这两个 tag 都是 span 标签,虽然他们的内容 text 不一致,当然这会交给内部调用的 patchVnode 继续往下一级比较(这里第二级的 children 元素不做展开,最终他们是替换 text 内容):

<code>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]
}/<code>

这里运气比较好,第三次就匹配到了,如果这次没有匹配中,将拿老节点的末个元素新节点的首个元素对比(2 vs 3,这里不做展开)


该判断内会对 oldStartIdx+1,newEndIdx-1 做偏移操作,并且挪到新位置:


这一轮 while 结束了,注意 oldStatrIdx 和 newEndIdx 已经指向定位置(如果以上被其他条件匹配中,是会有不同偏移方式的)。

之后,将通过 while 再一次进入这四种条件的判断,还是从首个元素开始判断,因为 1 和 5 已在上次判断中匹配过了,对应索引已经变更,这里将从 2 和 3 两个新的首个元素开始


由于这两个元素都是注释元素,所以 sameVnode 匹配一致。


之后,再次偏移 oldStartIdx newStartIdx 位置,最后 oldStartIdx 溢出到 oldCh 之外,之后进入 while 循环将不再符合条件


但是我们 节点4 还未被处理过,这将走到 while 外的那段逻辑:

<code>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)
}/<code>

目前会进入 addVnodes 方法,将 节点4 作为 refElm 挂载到 parentElm 中(oldCh 解析完毕);

相反,如果 newStartIdx > newEndIdx ,则会通过 removeVnodes 方法,移除 oldCh 中的部分元素。(ch 解析完毕。不全面的理解:四种判断中,如果多数匹配到 oldEndVnode vs newStartVnode 条件,将使得 newStartIdx 增大,导致大于 newEndIdx,或者 oldCh 数量大于 endCh 等),


如此,整个 oldVnode.elm 将更新至最新结果,return 给 vm.$el 渲染到页面上。


有关 key 属性的判断

有注意到,上面将 4种条件判断时,没有讲 while 中 else 的逻辑,这块内容将涉及 key 属性的逻辑:

<code>while(){
\telse {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
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 {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}/<code>

平时我们编写业务代码时,一定会遇到不同 input 标签在做 v-if/else 之类的切换时,导致 value 还停留在上面,vue 官方也给了示例:使用 key 来解决;同时 key 也建议在 v-for 中使用,以便提升性能。


现在我们逐步看下 vnode 上设置 key 属性是如何工作的?

在 oldCh 中解析 key,组成老节点的 key 集合

oldCh 子节点集合中,从 oldStartIdx 开始,到 oldEndIdx 结束,取存在 key 的元素,对应 value 为索引值:

<code>oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)/<code>

<code>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
}/<code>


判断老节点 key 集合中是否存在新节点 key

如果当前新节点 newStartVnode 存在 key 属性,则会判断该 key 是否是老节点 key 集合之一?如果不是,则会通过 sameVnode

依次比较 oldCh 中所有元素,哪个和 newStartVnode 为"相同元素":

<code>idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)/<code>

<code>function findIdxInOld (node, oldCh, start, end) {
for (let i = start; i < end; i++) {
const c = oldCh[i]
if (isDef(c) && sameVnode(node, c)) return i
}
}/<code>


idxInOld 不存在

如果 idxInOld 不存在,则会把 newStartVnode 节点作为新节点,通过 createElm 加入到 parentElm 中:

<code>createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)/<code>


idxInOld 存在

通过 oldCh[idxInOld] 取得将要移动的元素节点 vnodeToMove:

<code>vnodeToMove = oldCh[idxInOld]/<code>

vnodeToMove newStartVnode sameVnode 比较:

<code>if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}/<code>

如果是"相同元素",则会通过 patchVnode 来做节点间的交换操作。之后为 oldCh[idxInOld] 做置空,以免后续 findIdxInOld 方法还会匹配到结果。

接着把 vnodeToMove.elm 节点加入到 oldStartVnode.elm 之前,结束。

相反 else,通过 createElm newStartVnode 加入到 parentElm 中。


demo 示意

对此过程也做了一个 demo 用于示意:


首先是我们模板中,新老节点树的说明:


然后在 while 判断中,命中于:oldEndVnode, 和 newStartVnode 的判断:


并且偏移 oldStartIdx 和 newEndIdx 的索引位置,再次进入 while 循环,这次将直接进入 else 的逻辑:


分别提取出每个 oldCh 子节点集合的 key 属性,整合到 oldKeyToIdx 中:


然后判断当前 newStartIdx.key 是否属于 oldKeyToIdx

之中:


这里的 节点5节点2 的 key 相同(key=a)。


最后比较这两个节点,是否是 sameVnode,是的话:通过

patchNode 做节点更新操作,将 oldCh[idxInOld] 新节点插入至 parentElm,清空老节点 oldCh[idxInOld]

反之,根据 newStartIVnode 创建新节点,挂载到 parentElm 中。

可能会问 节点1 怎么办?因为每次 else 执行后,会对 newStartIdx 做+1操作,所以以上步骤结束后,newStartIdx 将溢出于 ch 队列;之后会匹配到 newStartIdx > newEndIdx 的判断,从而通过 removeVnodes 溢出 oldCh 中相关元素。


补充:有关 document 的 node 操作

insertBefore 和 nextSibling

基于如下 vue 涉及的源码:

<code>nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))/<code>

通过一个 demo 来熟悉下 insertBefore 和 nextSibling 有什么用?(一段简单的 html,ul 下依次显示三个 li 标签)

<code>
1


2
3
/<code>

接下来我在 id=app 父节点下,提取出 id=one 的 dom 节点,并把它插入到最后个 li 标签之前:

<code>let parentElm = document.getElementById('app');
let startNode = document.getElementById('one');
let restNode = document.getElementById('two').nextSibling;
parentElm.insertBefore(startNode, restNode);/<code>

一顿操作后,页面就会如下显示: