文章

patch 打补丁流程

patch 打补丁流程

一句话概括

patch 是 Vue 3 的 DOM 更新入口函数——它对比新旧 VNode,找到最小差异集并精准应用到真实 DOM。核心逻辑就一句:”同类型节点递归 patch,不同类型节点直接替换”。

核心知识点

1. patch 函数的整体流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function patch(oldVNode, newVNode) {
  // 1. 类型不同 → 整个替换
  if (oldVNode.tag !== newVNode.tag) {
    return oldVNode.el.replaceWith(createEl(newVNode));
  }

  // 2. 同类型 → 复用 DOM 节点,更新属性
  const el = (newVNode.el = oldVNode.el);

  // 3. 更新 props
  patchProps(el, oldVNode.props, newVNode.props);

  // 4. 更新 children(核心复杂度在此)
  patchChildren(oldVNode, newVNode, 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
23
function patchChildren(oldVNode, newVNode, el) {
  const oldCh = oldVNode.children;
  const newCh = newVNode.children;

  // 情况 1:新节点是文本
  if (typeof newCh === 'string') {
    if (Array.isArray(oldCh)) unmountChildren(oldCh); // 先卸载旧的一组子节点
    if (newCh !== oldCh) el.textContent = newCh;
    return;
  }

  // 情况 2:新节点是数组,旧节点是文本
  if (Array.isArray(newCh) && typeof oldCh === 'string') {
    el.textContent = '';
    newCh.forEach(child => el.appendChild(createEl(child)));
    return;
  }

  // 情况 3:新节点是数组,旧节点也是数组 → 核心 Diff
  if (Array.isArray(newCh) && Array.isArray(oldCh)) {
    diffArray(oldCh, newCh, el);
  }
}

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
35
36
37
38
39
40
41
42
43
44
45
46
function diffArray(oldCh, newCh, el) {
  let oldStart = 0, newStart = 0;
  let oldEnd = oldCh.length - 1, newEnd = newCh.length - 1;

  while (oldStart <= oldEnd && newStart <= newEnd) {
    // 头对头
    if (sameVNode(oldCh[oldStart], newCh[newStart])) {
      patch(oldCh[oldStart++], newCh[newStart++]);
    }
    // 尾对尾
    else if (sameVNode(oldCh[oldEnd], newCh[newEnd])) {
      patch(oldCh[oldEnd--], newCh[newEnd--]);
    }
    // 头对尾(旧的头移到旧的尾)
    else if (sameVNode(oldCh[oldStart], newCh[newEnd])) {
      el.insertBefore(oldCh[oldStart].el, oldCh[oldEnd].el.nextSibling);
      patch(oldCh[oldStart++], newCh[newEnd--]);
    }
    // 尾对头(旧的尾移到旧的头)
    else if (sameVNode(oldCh[oldEnd], newCh[newStart])) {
      el.insertBefore(oldCh[oldEnd].el, oldCh[oldStart].el);
      patch(oldCh[oldEnd--], newCh[newStart++]);
    }
    // 四个位置都对不上 → 用 key 在旧节点中查找
    else {
      const idx = findIdxByKey(oldCh, newCh[newStart].props?.key);
      if (idx >= 0) {
        el.insertBefore(oldCh[idx].el, oldCh[oldStart].el);
        patch(oldCh[idx], newCh[newStart]);
        oldCh[idx] = null; // 标记已处理
      } else {
        el.insertBefore(createEl(newCh[newStart]), oldCh[oldStart].el);
      }
      newStart++;
    }
  }

  // 收尾:新增 / 删除
  if (newStart <= newEnd) {
    // newCh 剩余 → 批量新增
    for (let i = newStart; i <= newEnd; i++) el.appendChild(createEl(newCh[i]));
  } else if (oldStart <= oldEnd) {
    // oldCh 剩余 → 批量删除
    for (let i = oldStart; i <= oldEnd; i++) oldCh[i]?.el.remove();
  }
}

4. patchProps——属性更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function patchProps(el, oldProps = {}, newProps = {}) {
  // 移除旧属性
  for (const key in oldProps) {
    if (!(key in newProps)) el.removeAttribute(key);
  }
  // 更新/新增属性
  for (const key in newProps) {
    if (oldProps[key] !== newProps[key]) {
      if (key.startsWith('on')) {
        el.addEventListener(key.slice(2).toLowerCase(), newProps[key]);
      } else {
        el.setAttribute(key, newProps[key]);
      }
    }
  }
}

5. Vue 3 Diff 的编译期优化——PatchFlags

1
2
3
4
5
6
7
8
9
10
11
12
// 编译时,Vue 模板编译器会给动态内容打上 PatchFlags
// <div :class="cls" :id="id">hello</div>
// 编译结果:
const vnode = h('div', { class: cls, id: id }, 'hello');
vnode.patchFlag = 1 /* CLASS */ | 2 /* STYLE */ | 4 /* PROPS */;

// patch 时:只检查 patchFlag 标记的属性类型
function patchElement(old, new) {
  if (new.patchFlag & 1) patchClass(el, old.class, new.class);
  if (new.patchFlag & 4) patchProps(el, old.props, new.props);
  // 静态内容:跳过比较
}

其实你每天都在用

  • v-for 列表重排:拖拽排序一条,diff 只移动一个 DOM 节点,其余原地不动
  • v-if / v-else 切换:不同 tag 直接 replace,不需要深挖内容
  • :class 动态绑定:只有 class 变了,只 patch class 属性,跳过其他 props
  • v-model 输入:每次按键触发 set → patch textContent,只改一个文本节点
  • Transition 组件:VNode 离开时打上 leaving 标记,patch 延迟执行卸载直到动画完成

常见误解

  • ❌ 误区:「Diff 就是全量比对,React/Vue 都一样」 Vue 3 比 React 多了一层编译期优化——静态标记和 block tree 让大量静态内容直接跳过 diff。React 没有编译期,靠 Fiber 用时间切片分摊 diff 开销。

  • ❌ 误区:「双端 Diff 比简单 Diff 更快」 仅在”列表首尾变化”场景更快(如前面插入一条)。对于随机位置的大规模变化,最终会退化为 O(n²) 的查找。key 的存在可以让查找降到 O(1)。

  • ❌ 误区:「没有 key 时 Vue 用的也是双端 diff」 没有 key 时仍然走双端 diff,但”相同节点判断”退化为 tag + 属性的浅比较。最大的问题是就地复用——input 内容会串位,因为节点被复用而非移动。所以 :key 不是改性能,是改正确性。

  • ❌ 误区:「patchChildren 中三个分支的优先级是固定的」 是的,但 Vue 3 内部实际上用了更复杂的位运算 flags 来区分 16 种 children 形态(文本、数组、空、片段等),不是简单的 if-else,而是编译期生成的 shapeFlag + patchFlag 联合判断。

一句话总结

patch 不大,就是”同类型修修补补、不同类型推倒重建”——Vue 的编译优化 + 双端 diff + key 复用,把这句话做到了 60fps 的极致。

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