逐步搞懂 Vue 的 patchChildren,把 Diff 算法拆给你看

6 阅读11分钟

Vue 的 patchChildren一文看懂

在啃 Vue3 源码的时候,翻到 patchChildren 这一块直接卡住了。网上搜了一圈,要么上来就丢一堆概念,要么就是贴一整段源码说"自己看"。折腾了一段时间,终于把这块逻辑从头到尾捋顺了,索性写篇文章记录一下,也给正在啃源码的朋友搭把手。

说实话,Diff 算法听起来挺唬人,但拆开来看其实就是一件事——页面更新的时候,怎么用最小的代价把旧页面变成新页面。而 patchChildren 就是干这件事的核心函数。

这篇文章我会从最简单的版本开始,一步一步往上加功能,每一步都能跑通、能理解。跟着看完,你对 Vue 的子节点更新逻辑基本就能了然于胸了。


先搞清楚 patchChildren 是干嘛的

在讲代码之前,先说个前提。

Vue 更新页面的时候,不会直接操作真实 DOM。它会维护一份"虚拟 DOM"(就是用 JS 对象描述页面结构),然后对比新旧虚拟 DOM 的差异,最后只把有变化的部分更新到真实 DOM 上。

patchChildren 就是负责更新某个父元素下面所有子节点的函数。它接收三个参数:

  • n1:旧的虚拟 DOM 节点
  • n2:新的虚拟 DOM 节点
  • container:真实的 DOM 容器(就是页面上的那个父元素)

一句话概括它的职责:对比新旧子节点,该更新的更新,该新增的新增,该删的删。


第一版:最简粗暴的更新

我们先看一个最基础的版本,只考虑"新旧子节点数量一样"的情况:

function patchChildren(n1, n2, container) {
  // 新子节点是纯文本
  if (typeof n2.children === 'string') {
    // 文本更新逻辑,先不管
  }
  // 新子节点是数组(多个标签)
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 按下标一一对比,逐个更新
    for (let i = 0; i < oldChildren.length; i++) {
      patch(oldChildren[i], newChildren[i])
    }
  }
  else {
    // 新无子节点,清空逻辑,先不管
  }
}

逻辑很简单粗暴——旧节点有几个,新节点就有几个,按下标顺序挨个调用 patch 更新。

patch 是 Vue 里负责单个节点更新的函数:标签一样就改内容,标签不一样就销毁旧的创建新的。

这个版本能跑,但问题也很明显:如果新旧子节点数量不一样呢? 多出来的怎么办?少了的怎么办?


第二版:加上新增和删除

接下来我们把逻辑补全,处理子节点数量不一致的情况:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略文本处理
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    const oldLen = oldChildren.length
    const newLen = newChildren.length
    // 取较短的长度,算出能一一对应的部分
    const commonLength = Math.min(oldLen, newLen)

    // 第一步:能对上的,原地更新
    for (let i = 0; i < commonLength; i++) {
      patch(oldChildren[i], newChildren[i], container)
    }

    // 第二步:新节点更多 → 多出来的要挂载
    if (newLen > oldLen) {
      for (let i = commonLength; i < newLen; i++) {
        patch(null, newChildren[i], container)
      }
    }
    // 第三步:旧节点更多 → 多出来的要卸载
    else if (oldLen > newLen) {
      for (let i = commonLength; i < oldLen; i++) {
        unmount(oldChildren[i])
      }
    }
  }
  else {
    // 省略
  }
}

拆开来看这三步:

第一步,先把能一一对应的子节点更新了。比如旧的有 3 个,新的有 5 个,那前 3 个先挨个更新。

第二步,新的比旧的多,多出来的那些调用 patch(null, 新节点)。第一个参数传 null 意味着"没有旧节点",所以会直接创建新的真实 DOM 挂载到页面上。

第三步,旧的多新的少,多出来的旧节点调用 unmount 直接从页面删掉。

举个具体例子感受一下:

原来页面有 div1、div2,更新后要变成 div1、div2、div3、div4

  • 前 2 个原地更新
  • 后 2 个是全新的,新建挂载

反过来:

原来页面有 div1、div2、div3,更新后只要 div1

  • 第 1 个原地更新
  • 后 2 个旧节点直接删除

到这一步,基本的增删改都能处理了。但还有一个大问题——它只按下标顺序比对。如果子节点只是换了顺序(比如列表排序),它不会聪明地移动 DOM,而是全部删掉重建,性能很差。

这就是为什么 Vue 需要引入 key


第三版:引入 key,实现 DOM 复用

用过 Vue 的都知道写 v-for 要加 :key,但很多人可能不太清楚它底层到底干了什么。看这段代码就明白了:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children

    // 遍历每一个新子节点
    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i]

      // 拿着新节点去旧节点里找 key 一样的
      for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        if (newVNode.key === oldVNode.key) {
          // key 相同 → 是同一个元素,复用旧 DOM,只更新内容
          patch(oldVNode, newVNode, container)
          break // 找到了就别找了,处理下一个
        }
      }
    }
  }
}

key 就是每个节点的"身份证号"。身份证一样,就说明是同一个元素,只是内容变了,不需要删掉重建,直接在原来的 DOM 上改就行。

打个比方:

旧页面有 3 个人:甲(key=1)、乙(key=2)、丙(key=3) 新页面要变成:乙(key=2)、甲(key=1)、丁(key=4)

执行过程:

  1. 拿新人"乙"去旧人里找,找到 key=2 的乙 → 不换人,直接给旧乙换身衣服(更新数据)
  2. 拿新人"甲"去旧人里找,找到 key=1 的甲 → 同理原地更新
  3. 拿新人"丁"去旧人里找,找不到 → 这是新来的,需要另外处理(后面会说)

你看,甲和乙只是换了顺序,但因为 key 能对上,DOM 直接复用,不用销毁重建。这就是 key 的核心价值。

不过这个版本还有个问题——它能复用 DOM,但不会移动 DOM 的位置。也就是说,虽然旧乙的 DOM 被复用了,但它在页面上的物理位置没变,视觉上顺序还是错的。

所以我们需要进一步优化。


第四版:lastIndex 判断是否需要移动

这版加了一个关键变量 lastIndex,用来记录上一个被复用的节点在旧数组里的位置。通过比较当前位置和上次位置,就能判断出元素是不是"往前挪了":

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    let lastIndex = 0 // 记录旧节点中最大下标

    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i]

      for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        if (newVNode.key === oldVNode.key) {
          patch(oldVNode, newVNode, container)

          if (j < lastIndex) {
            // 当前旧下标 < 上次最大下标
            // 说明这个元素往前挪了,需要移动 DOM
          } else {
            // 顺序正常,不用移动,更新最大下标
            lastIndex = j
          }
          break
        }
      }
    }
  }
}

这个 j < lastIndex 的判断是整段逻辑的灵魂,我用一个例子帮你理清:

旧 key 顺序:1、2、3 新 key 顺序:3、1、2

执行过程:

  1. 处理新 key=3:在旧数组里找到 j=2,2 >= lastIndex(0),顺序正常,不移动,lastIndex 更新为 2
  2. 处理新 key=1:在旧数组里找到 j=1,1 < lastIndex(2),说明这个元素本来在后面,现在跑到前面了 → 需要移动 DOM
  3. 处理新 key=2:在旧数组里找到 j=2,同样 2 < lastIndex(2) 不成立... 等等,这里 j=2 等于 lastIndex=2,所以不移动,lastIndex 更新为 2

嗯,你可能会问:判断出需要移动之后,具体怎么移?这就是下一版要解决的问题。


第五版:锚点精准插入,移动到正确位置

光知道"要移动"还不够,还得知道"移到哪"。这一版引入了锚点(anchor) 的概念:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    let lastIndex = 0

    for (let i = 0; i < newChildren.length; i++) {
      const newVNode = newChildren[i]
      let find = false // 标记是否找到可复用的旧节点

      for (let j = 0; j < oldChildren.length; j++) {
        const oldVNode = oldChildren[j]

        if (newVNode.key === oldVNode.key) {
          find = true
          patch(oldVNode, newVNode, container)

          if (j < lastIndex) {
            // 需要移动:找到新顺序里的前一个兄弟节点
            const prevVNode = newChildren[i - 1]
            if (prevVNode) {
              // 锚点 = 前一个节点的下一个兄弟元素
              const anchor = prevVNode.el.nextSibling
              // 把当前 DOM 插到锚点前面 = 放到前一个节点的后面
              insert(newVNode.el, container, anchor)
            }
          } else {
            lastIndex = j
          }
          break
        }
      }

      // find 为 false:旧节点里没找到 → 这是新增节点
      if (!find) {
        const prevVNode = newChildren[i - 1]
        let anchor = null

        if (prevVNode) {
          // 有前兄弟节点,插到它后面
          anchor = prevVNode.el.nextSibling
        } else {
          // 没有前兄弟,说明是第一个子元素,插到最前面
          anchor = container.firstChild
        }
        // 创建新 DOM 并挂载到锚点位置
        patch(null, newVNode, container, anchor)
      }
    }
  }
}

这里有两块新逻辑,我分开说。

移动 DOM 的具体操作

当判断出 j < lastIndex 需要移动时:

  1. 先找到当前节点在新顺序里的前一个兄弟节点 prevVNode
  2. 拿到前一个兄弟节点的真实 DOM 的下一个兄弟元素作为锚点 anchor
  3. 调用 insert 把当前 DOM 插到锚点前面

说白了就是:我要站到前一个兄弟的后面。通过"前一个兄弟的下一个元素"作为锚点,就能精确定位。

新增节点的处理

注意这里多了一个 find 变量。内层循环跑完如果 find 还是 false,说明这个新节点在旧节点里完全找不到同 key 的,那就是个全新元素。

新增的时候同样需要锚点来决定插在哪:

  • 有前兄弟节点 → 插到前兄弟后面
  • 没有前兄弟(自己是第一个) → 插到容器最前面

patch(null, newVNode, container, anchor) 里第一个参数传 null,代表没有旧节点,走的是挂载逻辑,会创建新的真实 DOM。

顺便说一下,patch 函数本身也做了对应改造来支持锚点:

function patch(n1, n2, container, anchor) {
  if (typeof n2.type === 'string') {
    if (!n1) {
      // 全新节点,挂载时带上锚点
      mountElement(n2, container, anchor)
    } else {
      // 有旧节点,走更新逻辑
      patchElement(n1, n2)
    }
  }
  // ...其他类型省略
}

mountElement 内部调用 insert(el, container, anchor),不传锚点就默认追加到最后,传了就插到锚点前面。


第六版:补齐最后一块拼图——删除多余旧节点

前面处理了复用、移动、新增,还差一个:旧的节点里有些在新列表里已经不存在了,需要删掉

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略
  }
  else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children
    const newChildren = n2.children
    let lastIndex = 0

    // ...前面复用、移动、新增的逻辑(和上一版一样)
    for (let i = 0; i < newChildren.length; i++) {
      // ...(同上,省略)
    }

    // ========== 新增:遍历旧节点,清理不需要的 ==========
    for (let i = 0; i < oldChildren.length; i++) {
      const oldVNode = oldChildren[i]
      // 拿旧节点的 key 去新列表里找
      const has = newChildren.find(vnode => vnode.key === oldVNode.key)

      if (!has) {
        // 新列表里找不到这个 key → 这个旧节点不需要了,删掉
        unmount(oldVNode)
      }
    }
  }
}

逻辑很直白:遍历所有旧节点,拿着它的 key 去新列表里找,找不到就说明新页面已经不需要它了,直接 unmount 删掉。

再举个完整的例子把所有逻辑串起来:

旧 key:1、2、3 新 key:3、1、4

执行过程:

  1. key=3:旧里找到,复用 DOM,顺序正常不移动
  2. key=1:旧里找到,j < lastIndex,触发移动
  3. key=4:旧里找不到,find=false,判定为新增,创建并插入
  4. 清理阶段:遍历旧节点 1、2、3
    • key=1:新里有 → 保留
    • key=2:新里没有 → unmount 删除
    • key=3:新里有 → 保留

最终结果:key=2 被清理,key=4 被新增,key=1 和 key=3 被复用并移动到正确位置。整个更新过程没有多余的 DOM 创建和销毁。


回顾一下完整流程

到这里,patchChildren 的核心逻辑就完整了。我用一张流程图帮你把所有分支串起来:

patchChildren 被调用
  │
  ├─ 新子节点是文本 → 走文本更新逻辑
  │
  ├─ 新子节点是数组 → 进入核心 Diff
  │   │
  │   ├─ 遍历新节点,用 key 去旧节点里匹配
  │   │   │
  │   │   ├─ 找到了(find=true)
  │   │   │   ├─ 复用旧 DOM,patch 更新内容
  │   │   │   ├─ j < lastIndex → 移动 DOM 到正确位置
  │   │   │   └─ j >= lastIndex → 不移动,更新 lastIndex
  │   │   │
  │   │   └─ 没找到(find=false)→ 新增节点,锚点精准插入
  │   │
  │   └─ 遍历旧节点,清理新列表中不存在的 → unmount 删除
  │
  └─ 新无子节点 → 清空容器

总结成一句话:能复用就复用,该移动就移动,多了就新增,少了就删除。

这就是 Vue 简易版 Diff 子节点更新的全部核心逻辑。当然,Vue3 实际源码里用的是更高效的快速 Diff 算法(基于最长递增子序列),但核心思想是一脉相承的。搞懂了这个简易版,再看源码里的完整实现会轻松很多。


最后说两句

啃源码这件事,说实话一开始挺痛苦的,尤其是 Diff 这块,变量多、嵌套深,很容易看着看着就迷失了。但如果你能像我这样,从最简单的版本开始,一步一步往上加功能,每一步都搞清楚"为什么要这样写",其实也没那么难。

希望这篇文章能帮到正在啃 Vue 源码的你。如果觉得有帮助,欢迎点赞收藏,有问题也欢迎在评论区交流。


参考:Vue.js 设计与实现 —— 霍春阳