Vue源码解析-patch&diff算法

23,422 阅读8分钟

在vue中,patch过程,是以新的虚拟dom为基准,改造旧的虚拟dom。

宏观上讲,patch过程就做了3件事:

  • 创建节点
  • 更新节点
  • 删除节点

接下来,我们逐个击破。

一. update

在执行render函数,返回虚拟dom之后,vue会执行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
   
   if (!prevVnode) {
     vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
   } else {
     vm.$el = vm.__patch__(prevVnode, vnode)
   }
   restoreActiveInstance()
   
   // ...
 }
 

setActiveInstance

 export let activeInstance: any = null

 export function setActiveInstance(vm: Component) {
   const prevActiveInstance = activeInstance
   activeInstance = vm
   return () => {
     activeInstance = prevActiveInstance
   }
 }

前面章节,我们分析了组件化实践。setActiveInstance方法是 设置 当前是 哪个组件被激活。 因为 同一时间,只会有一个组件实例化。

activeInstance变量是 当前正在实例化的组件对象。 prevActiveInstance实际上是父的实例化对象。在每次子组件实例化并且patch之后,就会执行restoreActiveInstance方法,就会将当前的 activeInstance 重置为 当前的父组件,以此类推,直到最上层的Vue。

需要指出的是, 这里设置了 activeInstance, 会在 组件实例化的 时候 会使用到, 不清楚的小伙伴可以看我的上一篇 《Vue源码解析-组件化&虚拟DOM》

下面,我们继续看__patch__

二. patch

__patch__方法的定义,实际就是执行的 createPatchFunction 方法。此方法比较庞大,我们先看主入口patch方法定义:

return function patch(oldVnode, vnode, hydrating, removeOnly) {
  // ...
  
  const isRealElement = isDef(oldVnode.nodeType)
  if (!isRealElement && sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
  }else {
    if(isRealElement) {
      // ssr 属性... 暂时忽略
      
      oldVnode = emptyNodeAt(oldVnode)
    }
    
    const oldElm = oldVnode.elm
    const parentElm = nodeOps.parentNode(oldElm)
    createElm(
      vnode, 
      insertedVnodeQueue, 
      oldElm._leaveCb ? null : parentElm, 
      nodeOps.nextSibling(oldElm)
    )
    
    if (isDef(vnode.parent)) {
      // ...
    }
  }
  
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

我们先来看个🌰:

<html>
  <head>
    <meta charset="utf-8"/>
  </head>

  <body>
    <div id='root'></div>

    <script src="../vue/dist/vue.js"></script>
    <script>

      let vm = new Vue({
        el: '#root',
        data() {
          return {
            a: "这是根节点"
          }
        },
        template: `<div data-test='这是测试属性' @click="handleClick"> {{ a }} </div>`,

        methods: {
          handleClick() {
            this.a = '变了'
          }
        },

      })
    </script>
  </body>
</html>

页面渲染时,会执行一次update patch。

oldVnode

此时oldVnode是div#root,是实际的DOM节点。

vnode值是执行render函数得到,其结构大致如下:

vnode

{
  tag: "div",
  text: undefined,
  key: undefined,
  isStatic: false,
  isRootInsert: true,
  isComment: false,
  elm: undefined,
  componentInstance: undefined,
  componentOptions: undefined,
  children: [
    // vnode  纯文本节点
    {
      // ...
    }
  ],
  context: Vue,
  data: {
    attrs: {...},
    on: {
      click: function () {...}
    }
  }
}

nodeType

nodeType实际上是html的原生属性,这里第一次渲染时,nodeType为节点, 值为 1。

不清楚nodeType的小伙伴,可以移步: www.w3school.com.cn/jsref/prop_…

回归到我们的demo中,isRealElement = 1, 显示是true。这个时候会调用 emptyNodeAt

emptyNodeAt

function emptyNodeAt (elm) {
  return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}

问题来了,第一次页面渲染时,oldVnode是id = "root"的真实dom节点。为什么需要调用emptyNodeAt方法,重新设置为虚拟dom节点?

image.png

其实有几点原因:

    1. removeVnodes是基于虚拟dom操作
    1. invokeDestroyHook也是基于虚拟dom操作
    1. 新旧节点diff对比,都是基于虚拟dom操作

此时,根节点root转化为虚拟dom之后(即oldVnode),其数据结构如下:

{
  tag: "div",
  text: undefined,
  key: undefined,
  isStatic: false,
  isRootInsert: true,
  isComment: false,
  elm: undefined,
  componentInstance: undefined,
  componentOptions: undefined,
  children: [],
  context: Vue,
  data: {},
  // 注意此变化
  elm: div#root
}

到这里,页面还没渲染时,只有一个空div,id = 'root', vue将其转化为vnode,其上面的oldVnode空节点 和 new Vue之后的vnode做对比。

image.png

需要指出的是: parentElm 在第一次update时,指的是body

三. createElm

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  // ...
  
  // 嵌套组件处理
  if(createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }
  
  // ...
  
  const tag = vnode.tag
  if(isDef(tag)) {
      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode);
        
      setScope(vnode)
  
      if(__WEEX__) {
        // ... weex相关处理
      }else {
        createChildren(vnode, children, insertedVnodeQueue)
        if (isDef(data)) {
          invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        insert(parentElm, vnode.elm, refElm)
      }
  }else if(isTrue(vnode.isComment)){
     // 注释节点
     vnode.elm = nodeOps.createComment(vnode.text)
     insert(parentElm, vnode.elm, refElm)
  }else {
    // 纯文本节点
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
} 

createComponent实现多层嵌套组件,这里不再赘述。不清楚的小伙伴,可以看我之前的《vue源码解析-组件化&虚拟DOM》。

第一次渲染时,触发createElm方法,传入的vnode即新的vnode。显然,在我们的demo中,tag = "div",ns = undefined。 将会执行nodeOps.createElement方法。

nodeOps对象,实际上就是封装了对原生DOM的操作。 这里createElement方法,实际上就是调用:document.createElement方法,返回原生dom对象。

此时新的虚拟dom上elm属性,指向就是刚创建的div。此时 vnode数据结构如下:

{
  tag: "div",
  text: undefined,
  key: undefined,
  isStatic: false,
  isRootInsert: true,
  isComment: false,
  // elm属性值变更为 刚创建的 div dom对象
  elm: div,
  componentInstance: undefined,
  componentOptions: undefined,
  children: [
    // vnode  纯文本节点
    {
      // ...
    }
  ],
  context: Vue,
  data: {
    attrs: {...},
    on: {
      click: function () {...}
    }
  }
}

需要注意的是:setScope(vnode),实际上是对于elm真实dom的style对象,添加scopeId。

到这里,demo中的外层div已创建,但是这个时候还是没有文字显示。因为children中的文字,也是个虚拟dom,只不过他是普通纯文本节点而已。

下面流程,将调用 createChildren 方法。

四. createChildren

其主干代码如下:

function createChildren (vnode, children, insertedVnodeQueue) {
  // ...
  
  for (let i = 0; i < children.length; ++i) {
    createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
  }
  // ...
}

这里,我们可以看到,实际上是个递归循环操作。 无论我们的组件嵌套多少层,都将对每层vnode的childrens进行循环。然后一个个createElm,遇到childrens,继续调用createChildren。如此反复,递归一个个创建子组件。

在我们的demo中,子的childrens是一行文本,属于纯文本节点。那么createElm时,将进入最后一个else操作,创建文本节点。即原生的dom调用:

document.createTextNode(text)

同理,子的vnode对象上的elm属性,指向了刚刚创建的文本节点的真实dom 对象。

最后调用update restoreActiveInstance方法,激活当前的父组件为 当前的activeInstance实例。

嗯,这只是个最简单的patch过程,还未涉及多层嵌套和对比。

因为这是第一次渲染过程,而diff是发生已渲染页面的情况下,再次发生页面需要变更。

下面,我们将进入数据变化,视图需要变化的patch过程

五. patchVnode

reactiveSetter

前面的章节介绍了依赖收集,我们知道,当数据改变时,会触发reactiveSetter。

首先reactiveSetter 会判断,前后的value是否相同,如果相同直接return。 否则进入下面的环节。

依赖收集时,Dep类的实例对象dep下有个subs数组,里面存放了依赖这些数据的watcher对象。

所以当触发reactiveSetter时,实际上是调用了每个watcher的update方法。

watcher的update方法,并不是直接去更新。而是将watcher放入一个更新队列里。

注意: 这个更新队列的大小,最大是100个

最后调用nextTick函数,设置promise更新队列,在callback中执行Scheduler job,即每个watcher的run方法。最终将进入第二轮patch。

需要注意的是:为什么要有队列?这其实是两方面考虑:

  • 性能考虑,因为同一个nextTick里,可能同一个组件,依赖了多个数据对象,而多个数据对象都变化了,没必要update多次,在队列中,vue会判断是否属于同一个watcher id。
  • 多个组件,分别依赖了多个数据对象。每个组件,实际上都会有自己的nextTick。

这里实际上远不止如此,后面我将单独开一个章节,分享更新队列和nextTick。

此时,oldVnode数据结构如下:

oldVnode

{
  tag: "div",
  text: undefined,
  key: undefined,
  isStatic: false,
  isRootInsert: true,
  isComment: false,
  elm: div,
  componentInstance: undefined,
  componentOptions: undefined,
  children: [
    {
      tag: undefined,
      text: "这是根节点",
      key: undefined,
      isStatic: false,
      isRootInsert: false,
      isComment: false,
      elm: test,
      componentInstance: undefined,
      componentOptions: undefined,
      children: undefined,
      // ...
    }
  ],
  context: Vue,
  data: {
    attrs: {...},
    on: {
      click: function () {...}
    }
  },
  // ...
}

vnode (新的vnode)

{
  tag: "div",
  text: undefined,
  key: undefined,
  isStatic: false,
  isRootInsert: true,
  isComment: false,
  elm: div,
  componentInstance: undefined,
  componentOptions: undefined,
  children: [
    {
      tag: undefined,
      // 注意,这里变了
      text: "变了",
      key: undefined,
      isStatic: false,
      isRootInsert: false,
      isComment: false,
      elm: test,
      componentInstance: undefined,
      componentOptions: undefined,
      children: undefined,
      // ...
    }
  ],
  context: Vue,
  data: {
    attrs: {...},
    on: {
      click: function () {...}
    }
  },
  // ...
}

不难看出,此次isRealElement = false,将先执行sameVnode判断。

我们先看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)
      )
    )
  )
}

这里第一层判断就是key的判断,有没有很熟悉? 这也就是我们写数组循环时,需要加key的原因。

sameInputType方法,其实很简单:

    1. 如果不是input节点,直接返回true
    1. 如果是,那么判断虚拟dom上的data, attrs, type是否相等

下面终于进入了 patchVnode 方法:

主干代码如下:

function patchVnode (
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  if (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
  }
  
  // ...
  // 组件节点,需要先调用组件prepatch钩子,data,props,slot,listener等可能都需要更新
  // ...此处省略
  
  if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        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)
    }
}

可以看到,这里是虚拟dom前后对比更新的情况。大致分以下几种:

    1. 先判断oldVnode和vnode,是否相等,如果相等,return
    1. 如果oldVnode是静态节点,并且vnode也是静态节点。并且,oldVnode.key 和 vnode.key相等。并且,vnode节点是克隆的或者是isOnce,那么直接返回,不需要对比了。
    1. 如果新的vnode,不是文本节点,那么:
    • 3.1 如果oldVnode和vnode都存在children,那么:

      • 3.1.1 如果2个children不相等,那么updateChildren (这里比较复杂,需要单独分析)
    • 3.2 如果新的vnode存在children, 而老的oldVnode不存在children,那么:

      • 3.2.1 如果老的oldVnode是文本节点,那么先清空真实dom中的内容,再把新的vnode的children添加到真实dom中。

      • 3.2.2 如果老的oldVnode不是文本节点,那么直接添加到DOM中

    • 3.3 如果新的vnode不存在children,而老的oldVnode中存在children,那么:直接把dom中的子节点清空

    • 3.4 如果新的vnode,老的oldVnode都不存在children,但是老的oldVnode是文本节点,那么直接清空DOM内容

    1. 如果新的vnode是文本节点,老的oldVnode也是文本节点,那么:如果内容不相等,用新的内容覆盖老的内容

updateChildren

上面,3.1.1情况,如果新老vnode,都存在children,但是他们不相等,那么将调用updateChildren方法。这里单独说明。

其实,都有children的情况下,也不外乎四种处理方式,分别是:

    1. 创建子节点
    1. 删除子节点
    1. 移动子节点
    1. 更新子节点

image.png

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

    if (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]
      } 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 {
        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 {
            
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        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)
    }
  }

image.png

可以看到,updateChildren阶段,实际上是将新的vnode和老的oldVnode,进行双重循环。

如下:

开始

image.png

第二步

image.png

第三步

image.png

第四步

image.png

第五步

image.png

第六步

image.png

diff的比较,实际上,都是以新的vnode为基准,不断的调整老的vnode位置。

可以看到diff的比较策略, 左左,右右,左右,右左

六. 总结

patch第一阶段:activeInstance

    1. 同一时刻,只会有一个组件正在实例化和patch
    1. 设置当前被实例化的activeInstace对象,并且保留preActiveInstace。
    1. 当前保留的activeInstace,在patch过程中,遇到嵌套组件,需要做为其parent,进行组件的实例化。
    1. 当前子组件patch完成后,将切换当前的activeInstance为preActiveInstance,如果还有多层嵌套的话,再次重复上面的过程。

patch第二阶段:root dom patch

    1. dom根即 div#root容器,页面首页渲染时,oldVnode不是一个虚拟dom,而是一个真实dom。此时oldVnode.nodeType = 1,
    1. 将div#root本身,转化为空的虚拟dom。将其作为oldVnode与新的vnode进行对比。
    1. 进入createElm,那么:

    • 3.1 如果存在组件,那么进行组件化patch (组件化不了解的小伙伴可以看我的上一篇)
    1. 完成insert,注意此时并没有进入patchVnode diff。
    1. 设置dom style scope id

patch第三阶段:reactiveSetter

    1. 页面完成了首次渲染,如果页面上有数据变化了,将触发reactiveSetter。(依赖收集不清楚的小伙伴,可以看我之前的分享:《vue源码解析-响应式原理》)
    1. 对比新老数据是否相等,如果相等,直接return
    1. 数据不相等,将根dep下的subs,循环调用watcher的update方法。
    1. watcher的update,并不是直接去通知更新。 而是放在一个队列中。更新通知将进入queueWatcher
    1. queueWatcher中优化不必要的多次渲染,比如:多个值的变化,都指向同一个watcher,没必要触发多次patch
    1. Scheduler job中,将调用watcher的run方法
    1. 执行render函数,获取新的vnode,执行update,重复前1个阶段。
    1. isRealElement 为undefined,进入 patchVnode阶段

patch第四阶段:patchVnode

    1. 比较新老节点,是否相等。即oldVnode == vnode。如果相等,直接return
    1. 是否是静态节点,是否前后key相等,或者 是否是克隆节点/isOnce。是直接return。(备注:静态标记是compiler第二阶段生成的)
    1. 如果是比较的是组件节点,那么根据vnode更新oldVnode组件props, listener, slots, parent等属性
    1. 新的vnode是文本节点,那么:
    • 4.1 如果oldVnode和vnode 都存在childrens,
      • 4.1.1 如果2个children相等,那么直接return
      • 4.1.2 如果2个children不相等,那么只需第5阶段-updateChildren
    • 4.2 如果新的节点存在children, 而老的节点不存在children,那么:
      • 4.2.1 如果老的节点是文本节点,那么先清空老的子节点内容
      • 4.2.1 将新的vnode的多个children,插入到老的dom流中
    • 4.3 如果新的节点不存在children,而老的节点存在children,那么:
      • 4.3.1 将老的childrens全部删除
    • 4.4 如果老节点,新节点都不存在children,并且老的节点是文本节点,那么清空老的节点内容
    1. 新老节点都是文本节点,但是文件节点内容不同,那么直接用新的文本内容 更新 老的文本内容

patch第五阶段:updateChildren

    1. 同层比较,不同层的节点是不能复用的
    1. oldStartVnode指的是未处理的开始节点,newStartVnode新的未处理的开始节点
    1. oldEndVnode指的是未处理的最后节点,newEndVnode新的未处理的最后节点
    1. 比较策略:oldStartVnode 和 newStartVnode 先比较,那么:
    • 4.1 如果相等,那么将 oldStartVnode,newStartVnode 都往后挪一个
    • 4.2 如果不相等,那么进入 oldEndVnode, newEndVnode 比较
    1. oldEndVnode 和 newEndVnode 比较,那么:
    • 5.1 如果相等,那么将 oldEndVnode 和 newEndVnode 都往前挪一个
    • 5.2 如果不相等,那么进入 oldStartVnode 和 newEndVnode
    1. oldStartVnode 和 newEndVnode 比较,那么:
    • 6.1 如果相等,那么将 oldStartVnode 向后挪一个,将 newEndVnode向前挪一个。
    • 6.2 如果不相等,将进入 oldEndVnode 和 newStartVnode 比较
    1. oldEndVnode 和 newStartVnode 比较,那么:
    • 7.1 如果相等,将 oldEndVnode 往前挪一个,newStartVnode 往后挪一个
    • 7.2 如果不相等,那么将进入 查找节点
    1. 根据新的vnode位置,去同层的老节点中查找。
    • 8.1 如果存在,那么移动到对应的位置(注意,是未处理节做参照物,而不是已处理节点)
    • 8.2 如果不存在,那么根据新的节点children,创建节点,放入老的节点之中
    • 8.3 如果老的节点,在新的节点中不存在,那么将老的对应的节点删除
    1. 这就是双指针算法,如此循环,就能将所有节点对比完成。总的概括,不外乎三点:
    • 9.1 同层不存在,直接更新移动
    • 9.2 同层不存在,那么创建
    • 9.3 新的节点,同层 在 老节点中 不存在,那么删除

以上,就是patch阶段的总体流程。

码字不易,多多关注😽