文章

patch 打补丁流程深度解析

patch 是 Vue3 DOM 更新的总入口,对比新旧 VNode 找最小差异并精确应用到真实 DOM。 核心是同类型节点递归修补、不同类型节点直接替换,配合编译期 PatchFlags、双端 Diff 与 key 快速查找把算法压到 O(n)。

patch 打补丁流程深度解析

一句话概括

patch 是 Vue 3 DOM 更新的总入口——对比新旧 VNode,找到最小差异并精确应用到真实 DOM。核心逻辑一句话:同类型节点递归修补,不同类型节点直接替换。加上编译期 PatchFlags + 双端 Diff + key 快速查找,整个算法被压缩到了 O(n) 量级。

核心知识点

1. patch 的顶层逻辑——四步决策

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function patch(oldVNode, newVNode, container) {
  // ① 类型不同 → 整个换
  if (oldVNode.type !== newVNode.type) {
    const newEl = createElement(newVNode);
    oldVNode.el.parentNode.replaceChild(newEl, oldVNode.el);
    return newEl;
  }

  // ② 类型相同 → 复用 DOM 节点
  const el = (newVNode.el = oldVNode.el);

  // ③ 更新 props(class、style、attrs、events)
  patchProps(el, oldVNode.props, newVNode.props);

  // ④ 更新 children —— 真正复杂的地方
  patchChildren(oldVNode, newVNode, el);
  return el;
}

2. patchChildren 的九种组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function patchChildren(n1, n2, container) {
  const c1 = n1.children, c2 = n2.children;

  // 新子节点是文本
  if (typeof c2 === 'string') {
    if (Array.isArray(c1)) unmountChildren(c1);  // 先卸载旧数组
    if (c2 !== c1) container.textContent = c2;
  }
  // 新子是数组,旧子是文本
  else if (Array.isArray(c2) && typeof c1 === 'string') {
    container.textContent = '';
    c2.forEach(c => mount(c, container));
  }
  // 新旧都是数组 → 核心 Diff 算法
  else if (Array.isArray(c2) && Array.isArray(c1)) {
    patchKeyedChildren(c1, c2, container);  // 面试的绝对重点
  }
  // 新子是 null(清空)
  else if (!c2 && c1) {
    unmountChildren(c1);
  }
}

3. 双端 Diff——四个方向同时推进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function patchKeyedChildren(c1, c2, container) {
  let i = 0;                              // 头指针
  let e1 = c1.length - 1, e2 = c2.length - 1;  // 尾指针

  // 第一阶段:四方向快速匹配
  while (i <= e1 && i <= e2) {
    // ① 头对头 —— 最常见(从前面删/加)
    if (sameVNode(c1[i], c2[i])) { patch(c1[i], c2[i]); i++; }
    // ② 尾对尾 —— 次常见(从后面删/加)
    else if (sameVNode(c1[e1], c2[e2])) { patch(c1[e1], c2[e2]); e1--; e2--; }
    // ③ 旧头 vs 新尾 —— 旧头移到最后
    else if (sameVNode(c1[i], c2[e2])) {
      container.insertBefore(c1[i].el, c1[e1].el.nextSibling);
      patch(c1[i], c2[e2]); i++; e2--;
    }
    // ④ 旧尾 vs 新头 —— 旧尾移到最前
    else if (sameVNode(c1[e1], c2[i])) {
      container.insertBefore(c1[e1].el, c1[i].el);
      patch(c1[e1], c2[i]); e1--; i++;
    }
    // ⑤ 四处不中 → 用 key 建立映射表 → O(1) 查找
    else break;
  }

  // 第二阶段:处理剩余
  if (i > e1 && i <= e2) {
    // 旧的全匹配完了,新的还有剩余 → 批量新增
    for (let j = i; j <= e2; j++) mount(c2[j], container);
  } else if (i > e2 && i <= e1) {
    // 新的全匹配完了,旧的还有剩余 → 批量删除
    for (let j = i; j <= e1; j++) unmount(c1[j]);
  }
  // 第三阶段(else):中间乱序部分 → key 映射表查找 + 最长递增子序列
}

4. PatchFlags——编译期的降维打击

1
2
3
4
5
6
7
8
9
10
11
// 模板编译时 Vue 编译器生成:
// <div :class="cls" :id="id">静态文本</div>
// ↓ 编译结果 ↓
const vnode = h('div', { class: cls, id: id }, '静态文本');
vnode.patchFlag = 1 /* CLASS */ | 4 /* PROPS */ | 64 /* HOISTED */;

// patch 时:只检查 patchFlag 标记过的属性
// 静态文本直接跳过比较——位运算 O(1) 判断
if (vnode.patchFlag & PatchFlags.CLASS)  updateClass(el, newProps.class);
if (vnode.patchFlag & PatchFlags.STYLE)  updateStyle(el, newProps.style);
if (vnode.patchFlag & PatchFlags.PROPS)  updateProps(el, newProps);

这就是 Vue 3 比 Vue 2 diff 快的关键: 编译期静态分析打标 → 运行时只 diff 真正动态的部分 → 大量静态节点零开销跳过。

5. patchProps——属性更新的精细控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function patchProps(el, oldProps = {}, newProps = {}) {
  // 先删旧属性(新 props 里没有的)
  for (const key in oldProps) {
    if (!(key in newProps)) {
      if (key.startsWith('on')) el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key]);
      else el.removeAttribute(key);
    }
  }
  // 再设新属性(值变了的)
  for (const key in newProps) {
    const old = oldProps[key], val = newProps[key];
    if (old === val) continue;
    if (key === 'class') el.className = val;           // class 直接走 className
    else if (key === 'style') patchStyle(el, old, val); // style 单独处理
    else if (key.startsWith('on')) {
      el.removeEventListener(key.slice(2).toLowerCase(), old);
      el.addEventListener(key.slice(2).toLowerCase(), val);
    } else el.setAttribute(key, val);
  }
}

其实你每天都在用

  • v-for 拖拽排序: 只移动一个 DOM 节点——头尾匹配 + key 映射 → O(1) 定位 + O(n) 移动
  • v-if/v-else 切换: tag 不同 → 走替换分支,不递归内容——快速卸载 + 重建
  • :class 动态绑定: patchFlag & CLASS → 只更新 el.className,跳过所有其他 props
  • v-model 输入: 每按键 → set 触发 → patch textContent,只改一个文本节点
  • Transition 动画: VNode 标记 leaving 后 patch 延迟卸载——DOM 保留到动画跑完

常见误解(FAQ)

  • ❌ 误区:「Vue 的 Diff 和 React 的全量比对一个级别」 Vue 3 多了一层编译期优化——PatchFlags + block tree 让大量静态节点被标记跳过,diff 步数远少于同等规模 React 的全量 reconciliation。

  • ❌ 误区:「双端 diff 在任何情况下都是 O(n)」 双端 diff 在最佳情况(头尾变化)确实是 O(n)。但中间乱序部分需要建立 key→index 映射表 + 最长递增子序列,这部分是 O(n log n)。只是整体仍然比没有编译优化的 O(n²) 快得多。

  • ❌ 误区:「没有 key 也能正确 diff」 能跑但能跑错——没有 key 时节点复用判断退化为 tag 浅比较,reactivity 的绑定会被”就地复用”导致 input 内容串位。key 不是性能优化,是正确性保证。

  • ❌ 误区:「patchProps 的 class 属性直接 setAttribute 就行」 Vue 把 class 和 style 单独拎出来处理——el.className 直接赋值比 setAttribute('class', ...) 快,style 的 patch 更是只改变化的 CSS 属性,不动整体。这些都是 60fps 的微观保证。

一句话总结

patch 不复杂——同类型修修补补、不同类型推倒重建。Vue 3 通过编译期 PatchFlags 标记 + 双端 Diff + key 映射,把这句话实现到了 O(n) 量级的极致。

本文由作者按照 CC BY 4.0 进行授权