虚拟 DOM 和 DIFF 算法

本文禁止任何形式的搬运、抄袭及洗稿。

虚拟 DOM

虚拟 DOM 本质上是用 JavaScript 对象来描述真实 DOM 结构的数据结构。

  • 真实 DOM:浏览器提供的庞大、复杂的对象树,直接操作成本高(会触发重排和重绘)。
  • 虚拟 DOM:轻量级的 JS 对象,操作成本低。

真实 HTML

xml 复制代码
<div id="app" class="container">
  <h1>Hello</h1>
</div>

对应的虚拟 DOM

css 复制代码
const vnode = {
  tag: 'div',
  props: { id: 'app', class: 'container' },
  children: [
    { tag: 'h1', props: {}, children: ['Hello'] }
  ]
}

虚拟 DOM 是 Diff 算法的载体。当数据变化时,我们不直接操作真实 DOM,而是生成一棵新的虚拟 DOM 树,再通过 Diff 算法对比新旧虚拟树,找出最小差异,最终以最小的代价更新真实 DOM。

因此,"虚拟 DOM 比真实 DOM 快"这句话并不严谨。虚拟 DOM 本身只是一个便于对比差异的 JavaScript 对象,真正带来性能提升的,是 Diff 算法计算出的最小化更新策略。更准确的公式是:

高效的视图更新 = 虚拟 DOM + Diff 算法 + 最小化 DOM 操作

Diff 算法

Diff 算法就是一种对比算法,用于找到新旧虚拟 DOM 之间的最小差异,然后只对变化的部分执行真实 DOM 更新,跳过无需更新的节点,从而提升更新效率。

对比流程

Diff 算法整体采用深度优先遍历 ,并对子节点进行同层比较。从根节点开始,先比较当前节点,若可复用则递归进入子节点数组进行对比,子节点处理完毕后再回到当前层级处理下一个兄弟节点。

当组件内的响应式数据变化时,会触发 setter,通过 Dep.notify 通知所有订阅者(Watcher),最终调用 patch 方法进行新旧对比。

patch

patch 的核心作用是通过 sameVnode 判断新旧虚拟节点是否属于同一类型:

  • :调用 patchVnode 进行深层比较与复用。
  • :直接替换------移除旧节点对应的真实 DOM,创建并插入新节点。
scss 复制代码
function patch(oldVnode, newVnode) {
  if (sameVnode(oldVnode, newVnode)) {
    // 同一类型,深入比较
    patchVnode(oldVnode, newVnode)
  } else {
    // 类型不同,整体替换
    const parent = oldVnode.el.parentNode
    const el = createElm(newVnode)  // 根据新虚拟节点创建真实 DOM
    if (parent) {
      parent.insertBefore(el, oldVnode.el)  // 插入新节点
      parent.removeChild(oldVnode.el)       // 移除旧节点
    }
  }
}

function sameVnode(a, b) {
  return (
    a.key === b.key && // key 相同
    a.tag === b.tag    // 标签名相同
    sameInputType(a, b) // 当标签为input时,type相同
    // 其它条件 ......
  )
}

patchVnode

patchVnode 用于对比两个相同类型节点的自身属性、文本以及子节点。大致流程如下:

  1. 如果新旧节点引用完全一致(oldVnode === newVnode),直接返回。

  2. 如果新节点是文本节点(即 newVnode.text 存在),且与旧文本不同,则更新真实 DOM 的文本内容。

  3. 否则,新旧节点可能包含子节点:

    • 新旧都有子节点 :调用 updateChildren 进行首尾指针对比。
    • 只有新节点有子节点:创建新子节点并插入真实 DOM。
    • 只有旧节点有子节点:从真实 DOM 中移除旧子节点。
    • 两者均无子节点:不做额外处理。
scss 复制代码
function patchVnode(oldVnode, newVnode) {
  const el = (newVnode.el = oldVnode.el) // 复用真实 DOM 引用
  const oldCh = oldVnode.children
  const newCh = newVnode.children

  // 同一对象无需处理
  if (oldVnode === newVnode) return

  // 新节点是文本节点
  if (newVnode.text != null) {
    if (oldVnode.text !== newVnode.text) {
      el.textContent = newVnode.text
    }
  } else {
    // 新节点有子节点
    if (oldCh && newCh) {
      // 新旧都有子节点,进入核心 diff
      if (oldCh !== newCh) {
        updateChildren(el, oldCh, newCh)
      }
    } else if (newCh) {
      // 只有新子节点:创建并添加
      addVnodes(el, null, newCh, 0, newCh.length - 1)
    } else if (oldCh) {
      // 只有旧子节点:删除旧子节点
      removeVnodes(el, oldCh, 0, oldCh.length - 1)
    }
    // 如果都没有子节点,什么也不做
  }
}

updateChildren

updateChildren 是双端 Diff(首尾指针法) 的核心,用于对同层的新旧子节点数组 进行比较。维护四个指针:oldStartIdxoldEndIdxnewStartIdxnewEndIdx,在循环中执行以下逻辑:

  1. 首尾快速尝试(按顺序):

    • 旧头 vs 新头:相同则 patch 并向右移动两个头指针(无需移动 DOM)。
    • 旧尾 vs 新尾:相同则 patch 并向左移动两个尾指针(无需移动 DOM)。
    • 旧头 vs 新尾 :相同则 patch,并将旧头对应的真实 DOM 移动到旧尾之后,然后旧头右移、新尾左移。
    • 旧尾 vs 新头 :相同则 patch,并将旧尾对应的真实 DOM 移动到旧头之前,然后旧尾左移、新头右移。
  2. 乱序查找 :若上述四种比较均失败,则在旧子节点范围(oldStartIdx..oldEndIdx)中查找新头节点:

    • 找到了 :patch 后将该旧节点的真实 DOM 移动到旧头之前(未处理节点前) ,并将旧数组中该位置标记为 undefined(避免重复处理),只将 newStartIdx 右移。
    • 未找到 :视为新增节点,创建真实 DOM 并插入到旧头之前newStartIdx 右移。
  3. 边界跳过 :每次循环开始,若 oldStartIdxoldEndIdx 指向 undefined,则直接向内移动该指针并跳过。

  4. 收尾处理

    • oldStartIdx > oldEndIdx(旧节点先耗尽),剩余新节点均为新增,批量创建并插入。
    • newStartIdx > newEndIdx(新节点先耗尽),剩余旧节点(非 undefined)均需卸载删除。

示例推演 :以下面新旧节点为例,真实 DOM 初始为 a, b, c, d, e

xml 复制代码
<!-- 旧节点 -->
<ul>
  <li>a</li><li>b</li><li>c</li><li>d</li><li>e</li>
</ul>
<!-- 新节点 -->
<ul>
  <li>a</li><li>d</li><li>e</li><li>f</li>
</ul>

初始状态:

ini 复制代码
DOM: a b c d e
old: [a] b c d [e]    (os=0, oe=4)
new: [a] d e [f]      (ns=0, ne=3)

第一步 :旧头 a vs 新头 a ,匹配。

无 DOM 移动,os++ns++

ini 复制代码
DOM: a b c d e
old: a [b] c d [e]   (os=1, oe=4)
new: a [d] e [f]     (ns=1, ne=3)

第二步 :四种比较均不命中,查找新头 d 在旧范围 [b,c,d,e] 中,找到索引 3。

移动 d 对应的真实 DOM 到旧头 b 之前,将 old[3] 置为 undefinedns++

ini 复制代码
DOM: a d b c e
old: a [b] c undefined [e]   (os=1, oe=4)
new: a d [e] [f]             (ns=2, ne=3)

第三步 :旧尾 e vs 新头 e ,匹配。

移动旧尾 e 的 DOM 到旧头 b 之前(即 d 之后),oe--ns++

ini 复制代码
DOM: a d e b c
old: a [b] c undefined e   (os=1, oe=3,oe 收缩后指向索引 3 是 undefined)
new: a d e [f]             (ns=3, ne=3)

注:下一轮循环开始前,oe 因指向 undefined 会继续左移至索引 2(c),范围变为 os=1, oe=2

第四步 :当前有效范围 [b, c],新头为 f。四种比较全不匹配,且 f 未找到。

创建 f 的 DOM 并插入到旧头 b 之前,ns++ns=4 > ne,循环结束。

ini 复制代码
DOM: a d e f b c
old: a [b] [c] undefined e   (os=1, oe=2)
new: a d e [f] []          (ns>ne)

收尾ns > ne,删除旧范围 os=1oe=2 内的 bc

最终真实 DOM:a, d, e, f,与新虚拟 DOM 结构一致。

相关推荐
bkspiderx1 小时前
HTTP协议:Web通信的“通用语言”解析
前端·网络协议·http
云水一下1 小时前
模块系统与 npm——万物皆模块
前端·npm·node.js
ZC跨境爬虫1 小时前
跟着 MDN 学CSS day_47:(移动优先实战——从手机到宽屏的响应式进化)
前端·css·html·tensorflow·媒体
小新1101 小时前
vue实战项目 计算器
前端·javascript·vue.js
秋田君1 小时前
2026 前端新出路:掌握 C++ 核心语法,无缝衔接 QT 桌面开发
前端·c++·qt
老毛肚2 小时前
jeecgboot vue 路由 拆分01
前端·javascript·typescript
ZC跨境爬虫2 小时前
跟着 MDN 学CSS day_46:(响应式实战——用媒体查询打造双列布局)
前端·css·ui·html·tensorflow·媒体
狗凯之家源码网2 小时前
多语言企鹅养殖投资返利系统 自定义产品配置 一键部署源码
前端·架构·php
每天吃饭的羊2 小时前
LeetCode 链表
前端