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)
执行过程:
- 拿新人"乙"去旧人里找,找到 key=2 的乙 → 不换人,直接给旧乙换身衣服(更新数据)
- 拿新人"甲"去旧人里找,找到 key=1 的甲 → 同理原地更新
- 拿新人"丁"去旧人里找,找不到 → 这是新来的,需要另外处理(后面会说)
你看,甲和乙只是换了顺序,但因为 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
执行过程:
- 处理新 key=3:在旧数组里找到 j=2,
2 >= lastIndex(0),顺序正常,不移动,lastIndex更新为 2 - 处理新 key=1:在旧数组里找到 j=1,
1 < lastIndex(2),说明这个元素本来在后面,现在跑到前面了 → 需要移动 DOM - 处理新 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 需要移动时:
- 先找到当前节点在新顺序里的前一个兄弟节点
prevVNode - 拿到前一个兄弟节点的真实 DOM 的下一个兄弟元素作为锚点
anchor - 调用
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
执行过程:
- key=3:旧里找到,复用 DOM,顺序正常不移动
- key=1:旧里找到,j < lastIndex,触发移动
- key=4:旧里找不到,
find=false,判定为新增,创建并插入 - 清理阶段:遍历旧节点 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 设计与实现 —— 霍春阳