浅入浅出图解 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

需要先澄清一点: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
本文的教学实现分为四步:
- 用 JavaScript 对象描述节点;
- 把 VNode 递归转换为真实 DOM;
- 新旧 VNode 比较,生成简化补丁;
- 遍历旧 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分支判断失效;import和module.exports混用,取决于构建器时可能直接报错;- 所有 children 都假定是对象,文本节点需要单独处理;
class、style、value、checked更接近 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())

在 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))
}
}
上面把 PATCH 从 diff.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)


原文只处理删除,所以 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 变化做了启发式假设:
- 不同类型节点通常替换子树;
- 主要比较同层节点;
- key 帮助列表匹配身份;
- 编译器提供静态/动态节点信息。
这些策略让常见更新接近线性或更低的实际工作量,但不能说任何情况下都严格 O(n),也不能说每次都得到理论最少的 DOM 操作。
九、这个 Demo 与真实框架的差异
| 教学 Demo | Vue/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 还包含编译优化、组件边界、调度和宿主平台处理。
参考资料
- React Render and Commit
- React Rendering Lists:key
- React Preserving and Resetting State
- Vue 3 渲染机制
- Vue 3
keyAPI - Vue 2 渲染函数
原文作者:寒东设计师。原文关于虚拟 DOM、Diff 四步流程、VNode 创建、补丁对象和真实 DOM 更新的主线予以保留;抓取残片、属性/文本更新缺失、tagName 拼写错误、模块系统混用和把极简 Demo 等同于 React 实现的表述已整理或修正。