技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

详解 Vue 的 Diff 算法

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/100-详解vue的diff算法
本文目录10 个章节
  1. 一、数据变化后,Vue 如何更新节点
  2. 二、Virtual DOM 和真实 DOM 的区别
  3. 三、Diff 的比较范围
  4. 四、Vue 2 的 patch
  5. 五、Vue 2 updateChildren:双端 Diff
  6. 六、key 的作用和使用建议
  7. 七、Vue 3 的 Diff 有哪些不同
  8. 八、常见误解
  9. 总结
  10. 参考资料

详解 Vue 的 Diff 算法

原文重点讲解 Vue 2 的 patchsameVnodepatchVnodeupdateChildren 双端 Diff。本文保留原来的源码阅读路线和四种头尾比较场景,修复“从真实 DOM 生成 VNode”“参数顺序”“idxInOld 为 0 时判断错误”等问题,并补充 Vue 3 的 keyed children、patch flags、Block Tree 和 LIS。

一、数据变化后,Vue 如何更新节点

渲染真实 DOM 的成本包括节点创建、属性更新、布局、样式计算和绘制。Vue 并不会因为某个响应式数据变化就把整棵 DOM 树无条件重建,而是大致经过下面的流程:

响应式数据变化
  ↓
组件的渲染 watcher/effect 被调度
  ↓
重新执行 render,得到 newVNode
  ↓
newVNode 与旧的 oldVNode 比较
  ↓
patch/renderer 提交必要的 DOM 操作

需要修正原文中的一个容易误解的说法:Vue 通常不是“先根据真实 DOM 生成一颗 virtual DOM,再修改它”。首次渲染是由模板编译出的 render function 或手写 render function 生成 vnode;后续更新会保留旧 vnode,并生成新 vnode 进行比较。

Diff 是 renderer 的更新过程,结果最终仍然要作用到真实 DOM。它不是把真实 DOM 操作变成零成本,也不保证任何场景都只产生最少的 DOM 操作。

二、Virtual DOM 和真实 DOM 的区别

真实 DOM 是浏览器提供的节点对象:

<div>
  <p>123</p>
</div>

简化的 vnode 可以表示为:

const vnode = {
  tag: 'div',
  data: {},
  children: [
    {
      tag: 'p',
      data: {},
      children: [],
      text: '123',
    },
  ],
}

这是教学模型。Vue 2 的 VNode 还可能包含 elmkeycomponentOptionscomponentInstanceisComment 等字段;Vue 3 的 vnode 字段则包括 typepropschildrenshapeFlagpatchFlagdynamicChildren 等。不同版本的 vnode 不能直接混用。

真实 DOM 与 vnode 树的对应关系(原文图片已本地化)

VNode 的主要价值是:

  • 用 JavaScript 数据结构表达应该渲染的 UI;
  • 允许框架统一处理元素、组件、Fragment、文本和注释节点;
  • 可以在比较后只提交必要变化;
  • 配合不同 renderer 映射到 DOM、SSR 字符串或其他平台。

“虚拟 DOM”并不是浏览器 DOM 的完整复制品,也不是 Vue 固定不变的公开数据结构。

三、Diff 的比较范围

在常见的树 Diff 中,比较主要发生在同一层级:

<!-- 旧树 -->
<div>
  <p>123</p>
</div>

<!-- 新树 -->
<div>
  <span>456</span>
</div>

renderer 会比较同一父节点下的 pspan,不会为了寻找理论上的全局最优解,把旧树的 div 与新树的 span 做任意跨层级匹配。跨层级移动通常会被当作删除旧节点并创建/插入新节点。

Vue Diff 同层级比较示意图(原文图片已本地化)

这是一种工程上的启发式选择:通用树编辑的最优匹配成本很高,而 UI 更新通常更关注同层级的节点复用、列表移动和组件身份。

四、Vue 2 的 patch

Vue 2 的响应式系统中,数据变化会通知依赖它的 watcher;组件渲染 watcher 重新执行 render,最后调用 patch 更新视图。可以把流程简化为:

Dep.notify()
  ↓
渲染 Watcher 更新
  ↓
生成 newVNode
  ↓
patch(oldVNode, newVNode)

Vue 2 响应式更新到 patch 的流程图(原文图片已本地化)

4.1 patch 的核心思想

下面是删减后的 Vue 2 风格伪代码。真实实现还要处理初次挂载、组件、注释、异步组件、hydration、transition 和模块钩子:

function patch(oldVnode, vnode) {
  if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode)
  } else {
    const oldElm = oldVnode.elm
    const parentElm = api.parentNode(oldElm)

    createElm(vnode)

    if (parentElm !== null) {
      api.insertBefore(parentElm, vnode.elm, api.nextSibling(oldElm))
      removeVnodes(parentElm, [oldVnode], 0, 0)
    }
  }

  return vnode
}

这里的两个参数都表示 vnode。首次挂载时,Vue 2 会把真实 DOM 元素转换为一个旧节点或走初始挂载分支;不能把上面的简化代码当作完整 API。

如果新旧节点不值得比较,直接替换旧节点;如果值得比较,则复用旧节点对应的真实元素,继续比较属性、文本和 children。

4.2 sameVnode

Vue 2 的 sameVnode 不是只比较标签名,至少要关注 key、标签、注释状态、data 是否存在和 input 类型:

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)
  )
}

function sameInputType(a, b) {
  if (a.tag !== 'input') return true

  const typeA = a.data && a.data.attrs && a.data.attrs.type
  const typeB = b.data && b.data.attrs && b.data.attrs.type
  return typeA === typeB
}

真实源码还要考虑异步占位符等特殊分支。key 是节点身份的一部分:同一父节点下,key 和类型匹配时才有机会复用组件实例和 DOM 节点。

Vue 2 sameVnode 判断条件示意图(原文图片已本地化)

4.3 patchVnode

sameVnode(oldVnode, vnode) 成立时,Vue 2 会复用旧节点的真实元素:

function patchVnode(oldVnode, vnode) {
  const elm = vnode.elm = oldVnode.elm

  if (oldVnode === vnode) return

  if (
    oldVnode.text != null &&
    vnode.text != null &&
    oldVnode.text !== vnode.text
  ) {
    api.setTextContent(elm, vnode.text)
    return
  }

  updateAttrsClassStyleEvents(elm, oldVnode, vnode)

  const oldCh = oldVnode.children || []
  const ch = vnode.children || []

  if (oldCh.length && ch.length) {
    updateChildren(elm, oldCh, ch)
  } else if (ch.length) {
    addVnodes(elm, null, ch, 0, ch.length - 1)
  } else if (oldCh.length) {
    removeVnodes(elm, oldCh, 0, oldCh.length - 1)
  } else if (oldVnode.text !== vnode.text) {
    api.setTextContent(elm, vnode.text || '')
  }
}

它主要完成:

  1. 复用旧 vnode 对应的真实元素;
  2. 文本节点变化时更新文本;
  3. 更新 attrs、class、style、事件和指令等模块;
  4. 旧子节点和新子节点都存在时,调用 updateChildren
  5. 只有新子节点时创建并插入;
  6. 只有旧子节点时移除;
  7. 文本和子节点之间发生类型变化时,清理旧内容并设置新内容。

patchVnode 更新文本和子节点的流程(原文图片已本地化)

五、Vue 2 updateChildren:双端 Diff

Vue 2 的核心列表比较会同时从旧、新 children 的两端开始:

oldStart ↔ oldEnd
newStart ↔ newEnd

每轮优先尝试以下四种情况:

  1. oldStartVnodenewStartVnode 相同:头部复用;
  2. oldEndVnodenewEndVnode 相同:尾部复用;
  3. oldStartVnodenewEndVnode 相同:旧头节点移动到尾部;
  4. oldEndVnodenewStartVnode 相同:旧尾节点移动到头部。

双端 Diff 的四个指针(原文图片已本地化)

5.1 核心伪代码

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let newEndIdx = newCh.length - 1

  let oldStartVnode = oldCh[oldStartIdx]
  let oldEndVnode = oldCh[oldEndIdx]
  let newStartVnode = newCh[newStartIdx]
  let newEndVnode = newCh[newEndIdx]
  let oldKeyToIdx

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode)
      api.insertBefore(
        parentElm,
        oldStartVnode.elm,
        api.nextSibling(oldEndVnode.elm),
      )
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode)
      api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(
          oldCh,
          oldStartIdx,
          oldEndIdx,
        )
      }

      const idxInOld = oldKeyToIdx[newStartVnode.key]

      // 关键修正:索引 0 是有效位置,不能写成 if (!idxInOld)。
      if (idxInOld == null) {
        api.insertBefore(
          parentElm,
          createElm(newStartVnode),
          oldStartVnode.elm,
        )
      } else {
        const vnodeToMove = oldCh[idxInOld]
        patchVnode(vnodeToMove, newStartVnode)
        oldCh[idxInOld] = undefined
        api.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
      }

      newStartVnode = newCh[++newStartIdx]
    }
  }

  if (oldStartIdx > oldEndIdx) {
    const before = newCh[newEndIdx + 1]
      ? newCh[newEndIdx + 1].elm
      : null
    addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
  } else if (newStartIdx > newEndIdx) {
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

这里的 apicreateElmaddVnodesremoveVnodes 代表 Vue 2 内部的 DOM 操作和辅助函数。代码用于理解算法,真实源码还会调用模块钩子并处理组件节点。

原文中写成 if (!idxInOld) 是一个常见的讲解代码错误:当匹配节点正好位于旧数组下标 0 时,会被错误地当成“没有找到”。正确判断应是 idxInOld == nullidxInOld === undefined

5.2 四种情况的示例

假设旧列表和新列表都使用自身作为 key:

oldCh = [a, b, d]
newCh = [a, c, d, b]

第一轮:

oldStart = a, oldEnd = d
newStart = a, newEnd = b

旧头和新头匹配,复用 a

第二轮:

oldStart = b, oldEnd = d
newStart = c, newEnd = b

旧头 b 与新尾 b 匹配,把真实 DOM 中的 b 移到尾部。

第三轮可能匹配旧尾、新尾的 d,最后剩余的 c 根据新列表位置插入:

a, c, d, b

双端 Diff 列表移动示例(原文图片已本地化)

5.3 循环结束后的两种情况

当某一侧指针越过边界时:

  • oldStartIdx > oldEndIdx:旧节点先遍历完,新列表还有节点,需要插入剩余的新节点;
  • newStartIdx > newEndIdx:新节点先遍历完,旧列表剩余节点需要删除。

双端 Diff 处理新增和删除节点(原文图片已本地化)

六、key 的作用和使用建议

key 用来表达同一父节点下子节点的身份。它影响:

  • DOM 节点是否复用或移动;
  • 子组件实例是否复用;
  • 输入框焦点和本地状态是否跟随正确的列表项;
  • transition、KeepAlive 等功能的节点识别。
<li v-for="item in items" :key="item.id">
  <input v-model="item.text" />
</li>

没有 key 时,Vue 可能按照位置复用节点。纯文本、只追加、不会排序删除且没有内部状态的简单列表,按位置更新通常足够;可插入、删除、排序或包含表单状态的列表,应使用稳定且唯一的业务 id。

不要把“使用 key”理解成“节点永远不会更新”:key 相同只表示有机会匹配到同一节点,节点的 props、文本和子树仍然会继续 patch。

七、Vue 3 的 Diff 有哪些不同

Vue 3 不只是把 Vue 2 的函数改名:

  • patchKeyedChildren 处理有 key 的子节点;
  • patchUnkeyedChildren 处理无 key 的子节点;
  • 头尾同步后建立 key 到新索引的映射;
  • 对需要移动的节点使用最长递增子序列(LIS)减少移动;
  • 模板编译器通过 patch flags、静态提升和 Block Tree 提供动态节点信息;
  • 组件渲染 effect 和 scheduler 共同决定更新时机。

概念上的 Vue 3 keyed children 流程是:

同步相同的头部
  ↓
同步相同的尾部
  ↓
处理一侧剩余的新增/删除
  ↓
为中间区间建立 key 映射
  ↓
patch 可复用节点,创建新节点
  ↓
按 LIS 结果尽量少移动真实节点

LIS 优化减少的是移动操作,并不意味着任何列表更新都只产生一次 DOM 操作。编译器优化、响应式依赖范围和列表 key 共同影响最终性能。

八、常见误解

误解 1:Diff 会比较所有节点的全局最优结果

通常不会。Vue 使用针对 UI 场景的启发式同层比较和 keyed children 策略。

误解 2:Diff 可以避免所有重排和重绘

不能。真实 DOM 的插入、删除、移动、样式变化仍可能触发布局和绘制;Diff 只是减少不必要的更新。

误解 3:没有 key 就一定不能复用节点

没有 key 时仍可能按位置复用;只是无法可靠表达业务项身份,在重排和有状态列表中容易发生错位。

误解 4:Vue 3 仍然完全照搬 Vue 2 双端 Diff

Vue 3 保留了一些头尾比较思想,但增加了 keyed/unkeyed 分支、编译器提供的动态信息和移动优化,源码结构与 Vue 2 不同。

总结

  1. Vue 更新大致是响应式变化、重新 render、生成新 vnode、patch 到真实 DOM;
  2. Diff 主要进行同层级比较,不会为任意跨层级节点寻找全局最优匹配;
  3. Vue 2 的 patch 先判断 sameVnode,再由 patchVnode 更新文本、属性和 children;
  4. updateChildren 通过四个指针进行双端比较,未命中时借助 key 映射查找可移动节点;
  5. idxInOld 为 0 时仍然是有效索引,不能使用 if (!idxInOld)
  6. 稳定 key 表达节点身份,影响 DOM、组件实例和表单状态的复用;
  7. Vue 3 结合 keyed/unkeyed children、patch flags、Block Tree 和 LIS 优化更新;
  8. VDOM/Diff 没有天然的绝对性能保证,应该结合真实场景测量。

参考资料

原文作者:windlany。原文关于 patchsameVnodepatchVnodeupdateChildren、四种头尾匹配、key 表和新增/删除节点的主线予以保留;错误的 VNode 来源表述、源码拼接噪声、idxInOld 下标 0 判断、Vue 2/3 混淆及绝对性能结论已修正或补充说明。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS