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

显示模式

登录
ARCHIVE DOCUMENTVUE

浅入浅出图解 DOM Diff

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/85-浅入浅出图解domDIff
本文目录11 个章节
  1. 一、虚拟 DOM 和 DOM Diff
  2. 二、Virtual DOM 不一定更快
  3. 三、用四步理解 DOM Diff
  4. 四、通过 VNode 创建真实 DOM
  5. 五、一个可运行方向的简化 Diff
  6. 六、完整示例:删除节点和修改属性
  7. 七、为什么不能只用索引
  8. 八、复杂度应该怎样理解
  9. 九、这个 Demo 与真实框架的差异
  10. 总结
  11. 参考资料

浅入浅出图解 DOM Diff

原文用一个极简的 JavaScript 项目演示“虚拟 DOM → 真实 DOM → Diff → patch”,适合入门。本文尽量保留这个学习路径,并把抓取后的代码恢复成可以阅读的 ES Module 示例,同时说明它与 React/Vue 实际 renderer 的区别。

一、虚拟 DOM 和 DOM Diff

虚拟 DOM 是用 JavaScript 数据结构描述期望的 UI;DOM Diff 是比较前后两份描述,并计算出合理更新操作的过程:

JSX / render function
        ↓
      VNode
        ↓
  mount 创建真实 DOM
        ↓
状态或 props 改变
        ↓
生成新的 VNode
        ↓
reconciliation / patch
        ↓
更新真实 DOM

旧 UI、新 UI 与补丁操作的关系(已本地化)

需要先澄清一点:React 并不会把一个名为 patches 的对象作为公开 API 交给业务代码。上面的 patches 是教学实现;React 内部使用 Fiber、flags 和 commit 阶段,Vue 使用 VNode renderer、Patch Flags、Block Tree 等机制。它们的高层目标类似,但内部代码不能互相复制。

二、Virtual DOM 不一定更快

“DOM 操作一定是性能杀手,Virtual DOM 一定更快”是过于绝对的说法。

  • 一个只有按钮和数字的页面,手写一次 textContent 往往比创建 VNode、执行 Diff 更直接;
  • 浏览器会合并一部分样式和布局工作,但不代表所有 DOM 操作都免费;
  • 复杂 UI 中,框架可以集中处理更新边界、列表身份、批量调度和组件生命周期;
  • Virtual DOM 也有对象创建、组件执行、比较和内存成本;
  • 真正的性能还取决于布局、绘制、脚本、图片、网络和设备,应该用 Performance 面板和真实场景测量。

更准确的表述是:Virtual DOM 提供声明式 UI 和统一协调层,结合响应式系统、编译器优化和批量调度,可以让复杂应用更容易维护,并在很多场景中减少不必要的宿主更新,但不保证所有微型场景都更快。

三、用四步理解 DOM Diff

本文的教学实现分为四步:

  1. 用 JavaScript 对象描述节点;
  2. 把 VNode 递归转换为真实 DOM;
  3. 新旧 VNode 比较,生成简化补丁;
  4. 遍历旧 DOM,把补丁提交到真实节点。

为了保持示例简单,下面只处理:

  • 同层元素;
  • 文本变化;
  • 属性增加、修改、删除;
  • 子节点新增和删除;
  • 标签替换。

它不处理 key 移动、组件、slot、事件委托、Transition、SVG namespace、Portal/Teleport 和异步渲染。

四、通过 VNode 创建真实 DOM

1. VNode 和 createElement

// createElement.js
export class VNode {
  constructor(tagName, attrs = {}, children = []) {
    this.tagName = tagName
    this.attrs = attrs
    this.children = normalizeChildren(children)
  }

  render() {
    return createDom(this)
  }
}

export function createElement(tagName, attrs = {}, children = []) {
  return new VNode(tagName, attrs, children)
}

function normalizeChildren(children) {
  const list = Array.isArray(children) ? children : [children]

  return list
    .flat(Infinity)
    .filter(child => child !== null && child !== undefined && child !== false)
    .map(child => child instanceof VNode ? child : String(child))
}

export function createDom(node) {
  if (node instanceof VNode) {
    const element = document.createElement(node.tagName)

    for (const [name, value] of Object.entries(node.attrs)) {
      setAttr(element, name, value)
    }

    for (const child of node.children) {
      element.appendChild(createDom(child))
    }

    return element
  }

  return document.createTextNode(String(node))
}

function setAttr(element, name, value) {
  if (name === 'className' || name === 'class') {
    element.className = value ?? ''
    return
  }

  if (name === 'style' && value && typeof value === 'object') {
    Object.assign(element.style, value)
    return
  }

  if (name === 'style') {
    element.style.cssText = value ?? ''
    return
  }

  if (name === 'value' && ('value' in element)) {
    element.value = value ?? ''
    return
  }

  if (name === 'checked' && ('checked' in element)) {
    element.checked = Boolean(value)
    return
  }

  if (value === false || value === null || value === undefined) {
    return
  }

  element.setAttribute(name, String(value))
}

原文的示例有几个容易踩坑的地方:

  • setAttr 中把 tagName 错写成了 tageName,会导致 value 分支判断失效;
  • importmodule.exports 混用,取决于构建器时可能直接报错;
  • 所有 children 都假定是对象,文本节点需要单独处理;
  • classstylevaluechecked 更接近 DOM property 的属性应区别处理。

上面的代码使用浏览器 ES Module 语法,保存为 createElement.js 后由支持 module 的入口导入即可。

2. 首次创建

// main.js
import { createElement } from './createElement.js'

const oldTree = createElement('div', { class: 'father' }, [
  createElement('h1', { style: 'color: red' }, ['son1']),
  createElement('h2', { style: 'color: blue' }, ['son2']),
  createElement('h3', { style: 'color: red' }, ['son3']),
])

document.body.appendChild(oldTree.render())

第一次渲染后的 DOM 示例(已本地化)

在 React 中,JSX 通常由编译器转换成 createElement 或现代 JSX runtime 调用;在 Vue 中,template 经过 parse、transform、codegen 生成 render function。它们最终会得到框架自己的 VNode/元素描述,不是浏览器直接理解的 JSX。

五、一个可运行方向的简化 Diff

1. 补丁类型

export const PATCH = {
  TEXT: 'TEXT',
  PROPS: 'PROPS',
  REPLACE: 'REPLACE',
  REMOVE: 'REMOVE',
  INSERT: 'INSERT',
}

2. 属性比较

function diffProps(oldAttrs = {}, newAttrs = {}) {
  const patches = {}
  const names = new Set([
    ...Object.keys(oldAttrs),
    ...Object.keys(newAttrs),
  ])

  for (const name of names) {
    if (!(name in newAttrs)) {
      patches[name] = undefined
    } else if (!Object.is(oldAttrs[name], newAttrs[name])) {
      patches[name] = newAttrs[name]
    }
  }

  return patches
}

undefined 在这里表示删除属性。真实框架不会简单地对所有属性调用 setAttribute:事件监听器、DOM property、class/style、指令和组件 props 都由不同模块处理。

3. 按先序索引比较节点

import { VNode } from './createElement.js'

let currentIndex = 0

export function diff(oldNode, newNode) {
  const patches = {}
  currentIndex = 0
  walk(patches, 0, oldNode, newNode)
  return patches
}

function walk(patches, index, oldNode, newNode) {
  const currentPatches = []

  if (newNode === undefined || newNode === null) {
    currentPatches.push({ type: PATCH.REMOVE })
  } else if (oldNode === undefined || oldNode === null) {
    // 子节点新增由父节点的 INSERT 补丁处理;根节点新增则替换为空容器
    currentPatches.push({ type: PATCH.REPLACE, node: newNode })
  } else if (isText(oldNode) && isText(newNode)) {
    if (String(oldNode) !== String(newNode)) {
      currentPatches.push({ type: PATCH.TEXT, value: String(newNode) })
    }
  } else if (isText(oldNode) || isText(newNode)) {
    currentPatches.push({ type: PATCH.REPLACE, node: newNode })
  } else if (oldNode.tagName !== newNode.tagName) {
    currentPatches.push({ type: PATCH.REPLACE, node: newNode })
  } else {
    const propPatches = diffProps(oldNode.attrs, newNode.attrs)
    if (Object.keys(propPatches).length > 0) {
      currentPatches.push({ type: PATCH.PROPS, props: propPatches })
    }

    diffChildren(
      patches,
      currentPatches,
      oldNode.children,
      newNode.children,
    )
  }

  if (currentPatches.length > 0) {
    patches[index] = currentPatches
  }
}

function diffChildren(patches, parentPatches, oldChildren, newChildren) {
  const length = Math.max(oldChildren.length, newChildren.length)

  for (let i = 0; i < length; i++) {
    const oldChild = oldChildren[i]
    const newChild = newChildren[i]

    if (oldChild === undefined && newChild !== undefined) {
      parentPatches.push({ type: PATCH.INSERT, node: newChild })
      continue
    }

    currentIndex++
    walk(patches, currentIndex, oldChild, newChild)
  }
}

function isText(node) {
  return !(node instanceof VNode)
}

这个版本使用先序索引,只是为了复现原文的教学思路。它仍然有明显限制:列表插入或删除会让后续位置发生变化,重排时不能识别“同一个业务节点”。生产框架通常使用 key 和更复杂的 children reconciliation,而不是把数字索引当作节点身份。

4. 用同样的遍历顺序 patch 真实 DOM

import { createDom } from './createElement.js'
import { PATCH } from './diff.js'

export function patch(root, patches) {
  const state = { index: 0 }
  walk(root, patches, state)
}

function walk(node, patches, state) {
  const currentPatches = patches[state.index] || []
  state.index++

  // 当前节点被移除或替换时,Diff 没有为它的旧后代分配索引,
  // 因此不能继续遍历这些后代;否则会让后续索引错位。
  const stopsAtNode = currentPatches.some(patch => {
    return patch.type === PATCH.REMOVE || patch.type === PATCH.REPLACE
  })

  if (!stopsAtNode) {
    // 先复制旧 children。补丁要在遍历后提交,避免删除节点改变本轮索引。
    const children = [...node.childNodes]
    for (const child of children) {
      walk(child, patches, state)
    }
  }

  applyPatches(node, currentPatches)
}

function applyPatches(node, patches) {
  for (const patch of patches) {
    switch (patch.type) {
      case PATCH.TEXT:
        node.textContent = patch.value
        break
      case PATCH.PROPS:
        for (const [name, value] of Object.entries(patch.props)) {
          applyProp(node, name, value)
        }
        break
      case PATCH.REPLACE:
        node.replaceWith(createDom(patch.node))
        break
      case PATCH.REMOVE:
        node.remove()
        break
      case PATCH.INSERT:
        node.appendChild(createDom(patch.node))
        break
    }
  }
}

function applyProp(node, name, value) {
  if (value === undefined || value === null || value === false) {
    node.removeAttribute(name)
    if (name in node && typeof node[name] !== 'function') {
      try { node[name] = name === 'checked' ? false : '' } catch {}
    }
    return
  }

  if (name === 'class' || name === 'className') {
    node.className = value
  } else if (name === 'style' && typeof value === 'object') {
    Object.assign(node.style, value)
  } else if (name in node && name !== 'id') {
    node[name] = value
  } else {
    node.setAttribute(name, String(value))
  }
}

上面把 PATCHdiff.js 导出,patch.js 再导入使用。这个 Demo 的根节点类型应保持不变;如果根节点需要替换,生产 renderer 通常会返回新的根引用,而不是继续使用已经脱离文档的旧引用。

实际项目不建议照搬这个索引 patch:replaceWith、事件监听器清理、表单焦点、selection、组件生命周期和异步渲染都需要更严谨的处理。

六、完整示例:删除节点和修改属性

import { createElement } from './createElement.js'
import { diff } from './diff.js'
import { patch } from './patch.js'

const oldTree = createElement('div', { class: 'father' }, [
  createElement('h1', { style: 'color: red' }, ['son1']),
  createElement('h2', { style: 'color: blue' }, ['son2']),
  createElement('h3', { style: 'color: green' }, ['son3']),
])

const newTree = createElement('div', { class: 'father updated' }, [
  createElement('h1', { style: 'color: red' }, ['son1']),
  createElement('h2', { style: 'color: green' }, []),
])

const root = oldTree.render()
document.body.appendChild(root)

const patches = diff(oldTree, newTree)
console.log(patches)
patch(root, patches)

Diff 生成的补丁对象示例(已本地化)

patch 后的真实 DOM 示例(已本地化)

原文只处理删除,所以 h2 的颜色和文本变化不会被更新;上面的版本增加了属性、文本、替换和插入分支,但仍然是教学代码。

七、为什么不能只用索引

假设旧列表为:

[A, B, C]

新列表在开头插入 X

[X, A, B, C]

只按索引比较会把 A 当作 X、B 当作 A,导致节点内部状态和 DOM 被错误复用。key 能表达业务身份:

<li v-for="item in list" :key="item.id">
  {{ item.name }}
</li>

key 必须稳定、唯一且可预测。数组 index 只适合不会插入、删除、排序,且列表项没有内部状态的受限场景。

Vue 2 常见双端比较会尝试头头、尾尾、头尾、尾头,再通过 key map 查找;Vue 3 在此基础上结合编译器 Block Tree、Patch Flags 和 keyed children 的最长递增子序列;React 根据元素类型、位置和 key 进行 reconciliation。它们都不是简单的“遍历索引补丁”。

八、复杂度应该怎样理解

通用树编辑距离问题可能达到 O(n³) 级别;React/Vue 等 UI 框架基于常见 UI 变化做了启发式假设:

  1. 不同类型节点通常替换子树;
  2. 主要比较同层节点;
  3. key 帮助列表匹配身份;
  4. 编译器提供静态/动态节点信息。

这些策略让常见更新接近线性或更低的实际工作量,但不能说任何情况下都严格 O(n),也不能说每次都得到理论最少的 DOM 操作。

九、这个 Demo 与真实框架的差异

教学 DemoVue/React 实际实现
用数字索引定位节点使用 VNode/Fiber、key 和内部节点关系
公开生成 patches 对象patch/commit 多为内部实现,不是业务 API
只处理元素、文本和属性还要处理组件、指令、slot、事件、异步和 SSR
同步递归遍历可能批量调度、暂停、恢复或跳过工作
不考虑 DOM 状态需要尽量保留焦点、selection、输入值和组件实例

理解 Demo 的目的是掌握“描述 → 比较 → 提交”的主线,而不是手写一个可以替代 Vue 或 React 的 renderer。

总结

  • JSX/template 需要先变成框架可以处理的 VNode/元素描述;
  • 首次渲染负责创建真实 DOM,更新时比较旧/新描述;
  • Diff 不只是删除节点,还要处理文本、属性、插入、替换和列表身份;
  • 数字索引适合入门演示,真实列表应使用稳定 key;
  • Virtual DOM 不是所有场景都更快,实际性能要结合测量;
  • Vue 和 React 的 patch/reconciliation 还包含编译优化、组件边界、调度和宿主平台处理。

参考资料

原文作者:寒东设计师。原文关于虚拟 DOM、Diff 四步流程、VNode 创建、补丁对象和真实 DOM 更新的主线予以保留;抓取残片、属性/文本更新缺失、tagName 拼写错误、模块系统混用和把极简 Demo 等同于 React 实现的表述已整理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS