回忆
组件更新的过程核心就是新旧vnode diff,对新旧节点相同以及不同的情况分别做不同处理。新旧节点不同的更新流程是创建新节点->更新父节点—>删除旧节点。新节点相同的更新流程是去获取它们的children,根据不同情况做不同的更新逻辑。
数据变化触发订阅该数据的Watcher执行watcher.run()进行重新reder/update,实现组件更新。
vue.prototype._update中更新渲染的话prevVnode(旧vnode)是存在的,现在我们拿着旧vnode和新vnode去patch了。
patch过程。会把原vnode和新vnode通过sameVnode方法进行比较,来执行不同的patch流程。
1、如果新旧节点相同,执行patchVnode。
2、如果新旧节点不同,会用新节点替换已存在的旧节点,过程大致分为3步。
1、createElm创建一个新的节点。通过旧节点的dom节点得到其父dom节点,把新vnode通过creatElm产生完整新dom对象。2、更新我们父的占位符节点。3、删除旧的节点。
例子One,简单介绍新旧节点相同和不相同情况下的执行逻辑:
子组件v-if(div)和v-else(ul)不同标签是为了演示新旧节点不同的情况。
接下来我们看看patch情况,我们点击change改变响应式属性flag触发订阅该属性的watcher进行update重新render/patch。第一次执行patch进行更新的是最外层app.vue,sameVnode判断为true即新旧vnode相同,那我们就进入patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)方法了,其会执行updateChildComponent方法。1、那先会执行prepatch方法。2、拿到新旧vnode.children(oldCh、ch,由HelloWord、空文本、button,3个节点组成),如果定义了data且有挂载节点会执行update钩子函数,因为新旧vnode都不是普通文本都有children节点,会执行updateChildren方法进行复杂的diff算法(之后另一个例子带图调试)。
子组件(HelloWord)的patch什么时候执行?change方法对flag作修改,执行app.vue的patch做重新渲染。执行updateChildren,会执行到sameVnode(oldStartVnode, newStartVnode),再次进行patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)。这次patch的是子组件(HelloWord)vnode,在执行prepatch中的updateChildComponent方法时会拿到新的props等属性然后赋值给子组件props,数据改变触发set函数,最后触发子组件更新。
子组件(HelloWord)的更新patch,和app.vue不同,它的新旧vnode不同(div和ul)。先拿到旧真实dom的父节点(这里即是
),便于新dom节点挂载。1、creatElm创建新dom节点并挂载(执行完后,页面上有新旧两个真实dom)。2、更新组件vnode在父组件中的占位符vnode(即例子Two,主要用来介绍相同Vnode的复杂diff Patch。
点击change把数组顺序调转同时push‘E’。数据更新触发重新render/patch渲染。我们看看这种情况下如何patch。
因为新旧vnode相同,我们直接进入分析patchVnode逻辑。拿到新旧vnode.children(div,空文本节点,button,3个节点)。执行到updateChildren逻辑(其功能是根据条件符合情况,递归调用patchVnode,把vnode树全部比对一遍,比对过程中把真实dom修改了)。这里先div#app.children[0]的对比,发现sameVnode(oldStartVnode, newStartVnode)符合,执行ul.children[0]的对比(patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue))。
这里开始对比ul.children的li了,很有意思,我们仔细分析。
他们满足sameVnode(oldEndVnode, newStartVnode),会对ul.children.children(文本节点)再次执行patchVnode(这里因为是文本,直接把新文本替换旧文本即可)。然后把真实dom节做移动(nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)),同时把oleEndVnode和newStartVnode根据index做更新。
更新后,此时oleEndVnode和newStartVnode都是C。第二步判断和第一步一样。
更新后,此时oleEndVnode和newStartVnode都是B,还是同上。
更新后,此时oleEndVnode和newStartVnode都是A,符合sameVnode(oldStartVnode, newStartVnode)。执行patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)。文本节点相同不做任何操作。因为(oldStartVnode = oldCh[++oldStartIdx],newStartVnode = newCh[++newStartIdx]),所以把oldStartVnode和newStartVnode前进一位。
此时DCBA形成了。此时不满足进入while的条件了。因为oldStartVnode > oleEndVnode,所以它会把剩下的节点全部插入。
这样就完成了。通过这样根据不同情况,执行不同逻辑,做不同的diff 对dom完成更新,这样做的好处dom更新性能和效率是高的,通过简单移动比粗暴的把整段dom删掉/添加好很多。
组件更新
在组件化章节,我们介绍了 Vue 的组件化实现过程,不过我们只讲了 Vue 组件的创建过程,并没有涉及到组件数据发生变化,更新组件的过程。而通过我们这一章对数据响应式原理的分析,了解到当数据发生变化的时候,会触发渲染 watcher 的回调函数,进而执行组件的更新过程,接下来我们来详细分析这一过程。
我们再回顾一下这个方法,它的定义在 src/core/instance/lifecycle.js 中:
组件更新的过程,会执行 vm.$el = vm.__patch__(prevVnode, vnode),它仍然会调用 patch 函数,在 src/core/vdom/patch.js 中定义:
这里执行 patch 的逻辑和首次渲染是不一样的,因为 oldVnode 不为空,并且它和 vnode 都是 VNode 类型,接下来会通过 sameVNode(oldVnode, vnode) 判断它们是否是相同的 VNode 来决定走不同的更新逻辑:
sameVnode 的逻辑非常简单,如果两个 vnode 的 key 不相等,则是不同的;否则继续判断对于同步组件,则判断 isComment、data、input 类型等是否相同,对于异步组件,则判断 asyncFactory 是否相同。
所以根据新旧 vnode 是否为 sameVnode,会走到不同的更新逻辑,我们先来说一下不同的情况。
新旧节点不同
如果新旧 vnode 不同,那么更新的逻辑非常简单,它本质上是要替换已存在的节点,大致分为 3 步。
1、createElm创建一个新的节点
2、更新我们父的占位符节点(父站位节点,组件a中引用了组件b,引用是
3、删除旧的节点
把 oldVnode 从当前 DOM 树中删除,如果父节点存在则执行 removeVnodes 方法:
删除节点逻辑很简单,就是遍历待删除的 vnodes 做删除,其中 removeAndInvokeRemoveHook 的作用是从 DOM 中移除节点并执行 module 的 remove 钩子函数,并对它的子节点递归调用 removeAndInvokeRemoveHook 函数;invokeDestroyHook 是执行 module 的 destory 钩子函数以及 vnode 的 destory 钩子函数,并对它的子 vnode 递归调用 invokeDestroyHook 函数;removeNode 就是调用平台的 DOM API 去把真正的 DOM 节点移除。
在之前介绍组件生命周期的时候提到 beforeDestroy & destroyed 这两个生命周期钩子函数,它们就是在执行 invokeDestroyHook 过程中,执行了 vnode 的 destory 钩子函数,它的定义在 src/core/vdom/create-component.js 中:
当组件并不是 keepAlive 的时候,会执行 componentInstance.$destroy() 方法,然后就会执行 beforeDestroy & destroyed 两个钩子函数。
新旧节点相同
对于新旧节点不同的情况,这种创建新节点 -> 更新占位符节点 -> 删除旧节点的逻辑是很容易理解的。还有一种组件 vnode 的更新情况是新旧节点相同,它会调用 patchVNode 方法,它的定义在 src/core/vdom/patch.js 中:
patchVnode 的作用就是把新的 vnode patch 到旧的 vnode 上,这里我们只关注关键的核心逻辑,我把它拆成四步骤:
1、执行 prepatch 钩子函数
当更新的 vnode 是一个组件 vnode 的时候,会执行 prepatch 的方法,它的定义在 src/core/vdom/create-component.js 中:
prepatch 方法就是拿到新的 vnode 的组件配置以及组件实例,去执行 updateChildComponent 方法,它的定义在 src/core/instance/lifecycle.js 中:
updateChildComponent 的逻辑也非常简单,由于更新了 vnode,那么 vnode 对应的实例 vm 的一系列属性也会发生变化,包括占位符 vm.$vnode的更新、slot 的更新,listeners 的更新,props 的更新等等。
2、执行 update 钩子函数
回到 patchVNode 函数,在执行完新的 vnode 的 prepatch 钩子函数,会执行所有 module 的 update 钩子函数以及用户自定义 update 钩子函数,对于 module 的钩子函数,之后我们会有具体的章节针对一些具体的 case 分析。
3、完成 patch 过程
如果 vnode 是个文本节点且新旧文本不相同,则直接替换文本内容。如果不是文本节点,则判断它们的子节点,并分了几种情况处理:
1、oldCh(旧子节点) 与 ch(新子节点) 都存在且不相时,使用 updateChildren 函数来更新子节点,这个后面重点讲。
2、如果只有 ch(新子节点) 存在,表示旧节点不需要了。如果旧的节点是文本节点则先将节点的文本清除,然后通过 addVnodes 将 ch 批量插入到新节点 elm下。
3、如果只有 oldCh(旧子节点) 存在,表示更新的是空节点,则需要将旧的节点通过 removeVnodes 全部清除。
4、当只有旧节点是文本节点的时候,则清除其节点文本内容。
4、执行 postpatch 钩子函数
再执行完 patch 过程后,会执行 postpatch 钩子函数,它是组件自定义的钩子函数,有则执行。
那么在整个 pathVnode 过程中,最复杂的就是 updateChildren 方法了。
总结
组件更新的过程核心就是新旧 vnode diff,对新旧节点相同以及不同的情况分别做不同的处理。新旧节点不同的更新流程是创建新节点->更新父占位符节点->删除旧节点;而新旧节点相同的更新流程是去获取它们的 children,根据不同情况做不同的更新逻辑。最复杂的情况是新旧节点相同且它们都存在子节点,那么会执行 updateChildren 逻辑,这块儿可以借助画图的方式配合理解。