@frank-shaw
2019-11-30T02:16:49.000000Z
字数 10900
阅读 1889
vue 源码 patch算法
在组件实例初始化的过程中,Watcher实例与updateComponent创建了关联。
let updateComponent = () => {//更新 Component的定义,主要做了两个事情:render(生成vdom)、update(转换vdom为dom)vm._update(vm._render(), hydrating)}
重点关注vm._update()。查看/core/instance/lifecycle.js:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {...//省略if (!prevVnode) {//初始化渲染vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)} else {// updatesvm.$el = vm.__patch__(prevVnode, vnode)}...//省略}
此处的vm.__patch__()是在源码入口阶段的platforms/web/runtime/index.js中定义:
import { patch } from './patch'//定义补丁函数,这是将虚拟DOM转换为真实DOM的操作,非常重要Vue.prototype.__patch__ = inBrowser ? patch : noop
进入到platforms/web/runtime/patch.js:
import * as nodeOps from 'web/runtime/node-ops'import { createPatchFunction } from 'core/vdom/patch'import baseModules from 'core/vdom/modules/index'import platformModules from 'web/runtime/modules/index'const modules = platformModules.concat(baseModules)//引入平台特有代码,其中: nodeOps是节点操作,modules是属性操作export const patch: Function = createPatchFunction({ nodeOps, modules })
createPatchFunction({nodeOps, modules})中的两个参数nodeOps与modules都是和特定平台相关的代码。其中:
nodeOps是节点操作,定义各种原生dom基础操作方法。
modules 是属性操作,定义属性更新实现。
createPatchFunction在文件core/vdom/patch.js中。该文件是整个Vue项目中最大的文件,详细记录了patch的过程。下面我们来详细看看patch算法的原理与实现。
patch算法通过新老VNode树的对比,根据比较结果进行最小单位地修改视图(打补丁的由来),而不是将整个视图根据新的VNode重绘。patch的核心在于diff算法。

diff算法使用同层的树节点进行比较(而非对整棵树进行逐层搜索遍历),时间复杂度只有O(n),是一种相当高效的算法。示意如下图:两棵树之间,相同颜色的方块代表互相进行比较的VNode节点。

看看patch算法的源码:
return function patch (oldVnode, vnode, hydrating, removeOnly) {//新节点不存在,老节点存在,直接移除老节点if (isUndef(vnode)) {if (isDef(oldVnode)) invokeDestroyHook(oldVnode)return}let isInitialPatch = falseconst insertedVnodeQueue = []if (isUndef(oldVnode)) {// empty mount (likely as component), create new root elementisInitialPatch = true//新节点存在,老节点不存在,新增该节点createElm(vnode, insertedVnodeQueue)} else {//判断是否是真实DOM,用于区分是否是初始化逻辑const isRealElement = isDef(oldVnode.nodeType)//不是真实DOM,并且是相同的VNode元素if (!isRealElement && sameVnode(oldVnode, vnode)) {//更新操作patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)} else {//真实DOM,即初始化逻辑if (isRealElement) {if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {//当旧的VNode是服务端渲染的元素,hydrating记为trueoldVnode.removeAttribute(SSR_ATTR)hydrating = true}if (isTrue(hydrating)) {//需要合并服务端渲染的页面到真实DOM上if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {invokeInsertHook(vnode, insertedVnodeQueue, true)return oldVnode}}// 不是服务端渲染节点,或者服务端渲染合并到真实DOM失败,返回一个空节点oldVnode = emptyNodeAt(oldVnode)}// 取代现有元素const oldElm = oldVnode.elmconst parentElm = nodeOps.parentNode(oldElm)//生成真实DOMcreateElm(vnode,insertedVnodeQueue,oldElm._leaveCb ? null : parentElm, //指定父节点nodeOps.nextSibling(oldElm) //指定插入位置:现有元素的旁边)...//省略// 移除老节点if (isDef(parentElm)) {removeVnodes([oldVnode], 0, 0)} else if (isDef(oldVnode.tag)) {invokeDestroyHook(oldVnode)}}}invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)return vnode.elm}
上面代码的逻辑不算难,主要进行的是同层的树节点进行比较。其中包含的操作分别是:增加新节点、删除旧节点、更新节点。
查看源码发现,只有当判定新旧节点是同一个节点的时候,才会执行patchVnode()更新操作。怎样才算同一个节点呢?我们来看sameVnode():
/*判断两个VNode节点是否是同一个节点,需要满足以下条件:key相同tag(当前节点的标签名)相同isComment(是否为注释节点)相同是否data(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息)都有定义当标签是<input>的时候,type必须相同*/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))))}/*判断当标签是<input>的时候,type是否相同某些浏览器不支持动态修改<input>类型,所以他们被视为不同类型*/function sameInputType (a, b) {if (a.tag !== 'input') return truelet iconst typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.typeconst typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.typereturn typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)}
当两个VNode的tag、key、isComment都相同,并且同时定义或未定义data的时候,且如果标签为input则type必须相同。这时候这两个VNode则算sameVnode,可以直接进行patchVnode操作。
function patchVnode (oldVnode,vnode,insertedVnodeQueue,ownerArray,index,removeOnly) {//两个VNode节点相同则直接返回if (oldVnode === vnode) {return}const elm = vnode.elm = oldVnode.elm...//省略/*如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换componentInstance即可。*/if (isTrue(vnode.isStatic) &&isTrue(oldVnode.isStatic) &&vnode.key === oldVnode.key &&(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) {vnode.componentInstance = oldVnode.componentInstancereturn}let iconst data = vnode.dataif (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {//i = data.hook.prepatch,如果存在,见"./create-component componentVNodeHooks"i(oldVnode, vnode)}const oldCh = oldVnode.childrenconst ch = vnode.childrenif (isDef(data) && isPatchable(vnode)) {//调用update回调以及update钩子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的text属性与children属性是互斥关系,若没有text属性,必有childrenif (isUndef(vnode.text)) {if (isDef(oldCh) && isDef(ch)) {//老的有子节点,新的也有子节点,对子节点进行diff操作if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)} else if (isDef(ch)) {//新的有子节点,老的没有子节点,先清空老节点的text内容if (process.env.NODE_ENV !== 'production') {checkDuplicateKeys(ch)}if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')//再增加新节点addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)} else if (isDef(oldCh)) {//老的有子节点,新的没有子节点,移除老节点removeVnodes(oldCh, 0, oldCh.length - 1)} else if (isDef(oldVnode.text)) {nodeOps.setTextContent(elm, '')}} else if (oldVnode.text !== vnode.text) {//两个都没有子节点,那么替换文本内容nodeOps.setTextContent(elm, vnode.text)}if (isDef(data)) {//i = data.hook.postpatch,如果存在,见"./create-component componentVNodeHooks"if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)}}
代码不难理解。整个patchVnode的规则是这样的:
1.如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换elm以及componentInstance即可。
2.新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren,这个updateChildren也是diff的核心。
3.如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。
4.当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。
5.当新老节点都无子节点的时候,只是文本的替换。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {let oldStartIdx = 0let newStartIdx = 0let oldEndIdx = oldCh.length - 1let oldStartVnode = oldCh[0]let oldEndVnode = oldCh[oldEndIdx]let newEndIdx = newCh.length - 1let newStartVnode = newCh[0]let newEndVnode = newCh[newEndIdx]let oldKeyToIdx, idxInOld, vnodeToMove, refElm// removeOnly is a special flag used only by <transition-group>// to ensure removed elements stay in correct relative positions// during leaving transitionsconst canMove = !removeOnlyif (process.env.NODE_ENV !== 'production') {checkDuplicateKeys(newCh)}while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (isUndef(oldStartVnode)) {oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left} else if (isUndef(oldEndVnode)) {oldEndVnode = oldCh[--oldEndIdx]/*前四种情况其实是指定key的时候,判定为同一个VNode,则直接patchVnode即可分别比较oldCh以及newCh的两头节点2*2=4种情况*/} 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)) { //头尾相同patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)//挪动oldStartVnode到oldEndVnode前面canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))oldStartVnode = oldCh[++oldStartIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldEndVnode, newStartVnode)) { //尾头相同patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)//挪动oldEndVnode到oldStartVnode前面canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)oldEndVnode = oldCh[--oldEndIdx]newStartVnode = newCh[++newStartIdx]} else {//在OldCh中,创建一张key<==>idx对应的map表,可加快查找newStartVnode是否在OldCh中if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)idxInOld = isDef(newStartVnode.key)? oldKeyToIdx[newStartVnode.key]: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)if (isUndef(idxInOld)) { //如果不存在OldCh中,那么认定newStartVnode是新元素,创建createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)} else {vnodeToMove = oldCh[idxInOld]/*对比OldCh中找到的【相同key】的Vnode与newStartVnode,是否是相同的节点判断是否是相同节点,除了key相同外,还有:tag(当前节点的标签名)相同isComment(是否为注释节点)相同是否data都有定义当标签是<input>的时候,type必须相同*/if (sameVnode(vnodeToMove, newStartVnode)) {patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)oldCh[idxInOld] = undefinedcanMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)} else {// 如果key相同,但是其他不同,那么认为这是新元素,创建createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)}}newStartVnode = newCh[++newStartIdx]}}if (oldStartIdx > oldEndIdx) {//如果oldCh数组已经遍历完,newCh数组还有元素,那么将剩余元素都创建refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elmaddVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)} else if (newStartIdx > newEndIdx) {//如果newCh数组已经遍历完,oldCh数组还有元素,那么将剩余元素都删除removeVnodes(oldCh, oldStartIdx, oldEndIdx)}}
这个过程不算复杂。通过画图的方式会更加清晰一些。

在新老两个VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。当oldStartIdx <= oldEndIdx或者newStartIdx <= newEndIdx时结束循环。
首先,oldStartVnode、oldEndVnode与newStartVnode、newEndVnode两两比较一共有2*2=4种比较方法。
当新老VNode节点的start或者end满足sameVnode()时,直接将对应VNode节点进行patchVnode即可。

如果oldStartVnode与newEndVnode满足sameVnode(),即sameVnode(oldStartVnode, newEndVnode)。这时候说明oldStartVnode已经跑到oldEndVnode后面了。进行patchVnode的同时,还需要将oldStartVnode移动到oldEndVnode的后面。

如果oldEndVnode与newStartVnode满足sameVnode(),即sameVnode(oldEndVnode, newStartVnode)。这说明oldEndVnode跑到了oldStartVnode的前面。进行patchVnode()的同时,还需要将oldEndVnode移动到oldStartVnode的前面。

如果不符合以上4种简单情况,那么在OldCh中,创建一张key<==>idx对应的map表,可加快查找newStartVnode是否在OldCh中。从这个map表中可以找到是否有与newStartVnode一致key的旧的VNode节点。如果满足sameVnode(),patchVnode()的同时会将这个真实DOM(elmToMove)移动到oldStartVnode对应的真实DOM的前面。

当然,满足相同节点的情况是美好的,更多的情况是不满足相同节点的时候:key不相同,或者key相同但依然不满足sameVnode()。这时,需要创建新节点:

当然,还有一些终止条件的判断。
如果oldCh数组已经遍历完,newCh数组还有元素,那么将剩余元素都创建。
如果newCh数组已经遍历完,oldCh数组还有元素,那么将剩余元素都删除

整个patch算法很长。整体一遍理下来,会对其实现过程有一个更加深刻的理解。
接下来想要问一个问题:updateChildren()的时候,为什么会产生头尾交叉的4种比较的方式呢?它有什么好处么?
结合实际日常网页操作的习惯:我们可能会是只拖拖拽拽一个DOM元素,其他元素都不改变;或者点击一个让展示元素逆向排序的按钮;或者清除了一个DOM元素。
这些操作,都只是简单的改变DOM树的同层结构而已。头尾交叉的比较方式,完全能够高效应对这些日常操作。
这也可以作为一个面向工程化的算法优化案例了~
关于DOM的操作更新,patch算法已经向我们展示了全部过程。不过依然还有细节没有处理完:patch的过程只是将虚拟DOM映射成了真实的DOM。那如何给这些DOM加入attr、class、style等DOM属性呢?
实际上,源码中已经有答案,只是还不够明细。它的实现依赖于VDOM的生命周期函数。在createPatchFunction()中,有这么一段:
//VDOM生命周期钩子函数const hooks = ['create', 'activate', 'update', 'remove', 'destroy']//引入DOM属性相关的生命周期钩子函数for (i = 0; i < hooks.length; ++i) {cbs[hooks[i]] = []//modules包含[attrs,klass,events,domProps,style,transition,ref,directives]等DOM属性for (j = 0; j < modules.length; ++j) {if (isDef(modules[j][hooks[i]])) {cbs[hooks[i]].push(modules[j][hooks[i]])}}}
这个cbs数组中的函数在哪里被调用了呢?来看patchVNode()中容易被忽视的一行代码:
if (isDef(data) && isPatchable(vnode)) {//调用各个DOM属性相关的update钩子函数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)}
此处正式更新对应DOM属性的所在。实际上,也就意味着,在patchVNode()过程中,DOM属性对应的更新也已经悄悄做完了。