虚拟 DOM 到底是什么?
原文从 JSX、
h函数、虚拟 DOM 渲染、Diff,到virtual-dom、kivi.js 和 snabbdom 的算法演进,内容跨度很大。本文尽量保留原来的学习顺序和手写示例,同时修复chidren拼写、属性删除、空节点、文本节点和性能结论等问题,并补充 Vue 2 与 Vue 3 当前的 renderer 优化。
一、虚拟 DOM 是什么
虚拟 DOM(Virtual DOM,VDOM)是用 JavaScript 数据结构表达 UI 的一种中间表示。例如,一个教学版 vnode 可以写成:
const vnode = {
type: 'div',
props: {
id: 'app',
},
children: [
{
type: 'p',
props: {
class: 'text',
},
children: ['hello world!'],
},
],
}
它表达了下面的结构:
<div id="app">
<p class="text">hello world!</p>
</div>
type、props、children 只是教学模型。不同框架的 vnode 内部字段不同:
- Vue 2 常见字段是
tag、data、children、text、elm、key; - Vue 3 常见字段是
type、props、children、key、patchFlag、shapeFlag; - React element 通常包含
type、key、ref、props等字段; - 这些对象还可能携带组件、事件、Fragment、注释、指令和 renderer 所需的内部信息。
因此,虚拟 DOM 不是“固定格式的三个属性对象”,也不是浏览器原生 DOM 的完整复制品,而是框架用来描述“应该渲染什么”的数据结构。
二、虚拟 DOM、JSX 和模板的关系
虚拟 DOM 和 JSX 经常一起出现,但二者不是同一个概念:
- JSX 是一种 JavaScript 语法扩展;
- 模板是 Vue 常用的声明式语法;
- 编译器可以把 JSX 或模板转换成 render function;
- render function 执行后才产生 vnode;
- vnode 再由 renderer 挂载或更新真实目标。
React 的 JSX 通常通过 Babel 转换为 createElement;Vue 的模板通过 vue/compiler-dom 等编译器转换为 render function。Vue 也支持手写 h/createVNode 或使用 JSX,但模板并不要求 JSX。
一个教学版 JSX 配置可以这样写:
{
"plugins": [
[
"@babel/plugin-transform-react-jsx",
{
"pragma": "h"
}
]
]
}
输入:
function getVNode() {
return (
<div id="app">
<p className="text">hello world!</p>
</div>
)
}
概念上会变成:
function getVNode() {
return h(
'div',
{ id: 'app' },
h('p', { className: 'text' }, 'hello world!'),
)
}
真实 Babel 输出可能包含 jsx runtime、开发环境 source 信息和更多辅助函数,不能把某一次编译结果当成所有项目的固定输出。
三、从 h 函数说起
h 函数的作用是创建 vnode。下面是一个可以运行的简化版本:
function h(type, props, ...children) {
return {
type,
props: props || {},
children: children
.flat(Infinity)
.filter(child => child !== null && child !== undefined && child !== false),
}
}
const vnode = h(
'div',
{ id: 'app' },
h('p', { class: 'text' }, 'hello world!'),
)
真实的 Vue 3 h 支持组件、数组 children、插槽、key、事件和多种参数重载:
import { h } from 'vue'
const vnode = h('button', {
class: 'primary',
onClick: () => console.log('clicked'),
}, '提交')
这个简化 h 没有处理组件、事件、Fragment、v-if、指令和响应式,只用于理解“描述 UI”的过程。
四、把 vnode 渲染为真实 DOM
下面实现一个浏览器端教学 renderer。真实 Vue renderer 还要处理组件、注释、Fragment、事件、属性分类、SVG、指令、transition、hydration 和卸载。
function render(vnode) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
return document.createTextNode(String(vnode))
}
const element = document.createElement(vnode.type)
setProps(element, vnode.props)
for (const child of vnode.children) {
element.appendChild(render(child))
}
// 仅用于教学:记录 vnode 对应的真实节点。
vnode.el = element
return element
}
function setProps(element, props = {}) {
for (const [key, value] of Object.entries(props)) {
setProp(element, key, value)
}
}
function setProp(element, key, value) {
if (value == null || value === false) {
element.removeAttribute(key === 'className' ? 'class' : key)
return
}
if (key === 'className' || key === 'class') {
element.setAttribute('class', value)
return
}
if (key === 'value' && 'value' in element) {
element.value = value
return
}
if (key.startsWith('on') && typeof value === 'function') {
element.addEventListener(key.slice(2).toLowerCase(), value)
return
}
element.setAttribute(key, String(value))
}
const app = document.querySelector('#app')
app.appendChild(render(vnode))
原文中的 vlaue 拼写错误,以及用 if (!newVal) 判断属性删除,都会误伤 0、false 和空字符串。实际 renderer 需要区分属性、DOM property、事件和布尔属性,不能一概使用 setAttribute。

五、组件状态改变后为什么需要 Diff
首次渲染时,可以直接把 vnode 转成真实 DOM:
const oldVNode = h('div', {}, 'before text')
const oldElement = render(oldVNode)
document.querySelector('#app').appendChild(oldElement)
状态变化后重新得到新 vnode:
const newVNode = h('div', {}, 'after text')
如果每次都把整个旧 DOM 删除,再把新 vnode 全部渲染一遍,逻辑简单但会丢失 DOM 状态,并产生不必要的创建、删除和属性设置。于是 renderer 会比较 oldVNode 和 newVNode,再把必要更新提交到真实 DOM。
教学版 Diff 可以表示为补丁:
const patches = diff(oldVNode, newVNode)
patch(oldElement, patches)
现代 Vue/React 的内部实现不一定真的生成一个公开的 patches 数组;它们可能在遍历 vnode 时直接调用 host operations。这里的补丁对象是为了帮助理解“比较”和“提交”两个阶段。

一个最小的 Diff 模型
function diff(oldVNode, newVNode) {
if (oldVNode == null) return { type: 'INSERT', vnode: newVNode }
if (newVNode == null) return { type: 'REMOVE' }
const oldText = typeof oldVNode === 'string' || typeof oldVNode === 'number'
const newText = typeof newVNode === 'string' || typeof newVNode === 'number'
if (oldText || newText) {
if (oldText !== newText) {
return { type: 'REPLACE', vnode: newVNode }
}
return oldVNode === newVNode
? null
: { type: 'TEXT', text: String(newVNode) }
}
if (oldVNode.type !== newVNode.type || oldVNode.key !== newVNode.key) {
return { type: 'REPLACE', vnode: newVNode }
}
return {
type: 'UPDATE',
props: diffProps(oldVNode.props, newVNode.props),
children: diffChildren(oldVNode.children, newVNode.children),
}
}
function diffProps(oldProps = {}, newProps = {}) {
const patches = {}
const keys = new Set([
...Object.keys(oldProps),
...Object.keys(newProps),
])
for (const key of keys) {
if (oldProps[key] !== newProps[key]) {
patches[key] = newProps[key]
}
}
return patches
}
function diffChildren(oldChildren, newChildren) {
const length = Math.max(oldChildren.length, newChildren.length)
return Array.from({ length }, (_, index) => {
return diff(oldChildren[index], newChildren[index])
})
}
这个版本按位置比较子节点,没有处理 keyed 移动,也没有处理事件监听器解绑;它只用于展示基本规则:
- 旧节点不存在,插入;
- 新节点不存在,删除;
- 文本变化,更新文本;
- 类型或 key 不同,替换;
- 类型和 key 相同,继续比较 props 与 children。
六、Diff 算法的演进
原文提到的 virtual-dom、kivi.js、snabbdom 和 Vue 2,展示了列表 Diff 从“按树索引比较”逐步加入 key、双端比较和移动优化的过程。
1. 深度优先遍历
最简单的树 Diff 使用先序深度优先遍历,为节点分配索引:
旧树和新树的根节点 → 索引 0
根的第一个子节点 → 索引 1
该子树的后续节点 → 继续编号
function isText(node) {
return typeof node === 'string' || typeof node === 'number'
}
function walk(oldNode, newNode, patches, index) {
if (oldNode == null) {
patches[index] = { type: 'INSERT', vnode: newNode }
return
}
if (newNode == null) {
patches[index] = { type: 'REMOVE' }
return
}
if (isText(oldNode) || isText(newNode)) {
if (isText(oldNode) !== isText(newNode)) {
patches[index] = { type: 'REPLACE', vnode: newNode }
} else if (String(oldNode) !== String(newNode)) {
patches[index] = { type: 'TEXT', text: String(newNode) }
}
return
}
if (oldNode.type !== newNode.type || oldNode.key !== newNode.key) {
patches[index] = { type: 'REPLACE', vnode: newNode }
return
}
const props = diffProps(oldNode.props, newNode.props)
const children = diffChildrenByIndex(oldNode, newNode, patches, index)
if (Object.keys(props).length || children.some(Boolean)) {
patches[index] = { type: 'UPDATE', props, children }
}
}
function isText(node) {
return typeof node === 'string' || typeof node === 'number'
}
按树索引记录补丁容易实现,但节点插入、删除和移动会改变真实 DOM 的索引;工程 renderer 必须小心维护节点定位,不能把这段代码直接用于生产。
2. key 让列表可以识别身份
旧列表:
[A, B, C]
新列表:
[C, A, B]
如果只按下标比较,可能把 A 的内容改成 C、B 改成 A、C 改成 B;如果列表项是输入框或有内部状态,这种按位置复用会造成状态错位。
稳定 key 可以表达节点身份:
<li v-for="item in items" :key="item.id">
<input v-model="item.name" />
</li>
key 不是“让节点永远不更新”,而是帮助 renderer 判断哪些节点是同一个实例、哪些节点应该移动、创建或删除。使用 index 作为 key 在会插入、删除、排序的列表中风险较高;纯追加且不保留行状态的简单列表例外较多,但业务列表仍应优先使用稳定 id。


3. virtual-dom 的排序思路
virtual-dom 早期实现会把旧子节点和新子节点按 key 建立索引,并生成插入、删除和移动操作。原文中的 sortChildren 展示了这个思路:
function keyIndex(children) {
const keys = new Map()
const free = []
children.forEach((child, index) => {
if (child.key != null) keys.set(child.key, index)
else free.push(index)
})
return { keys, free }
}
如果新旧子节点都有 key,可以先匹配相同 key 的 vnode,再计算它们的相对顺序;没有 key 的节点只能按位置或剩余顺序处理。混用 keyed 和 unkeyed children 会增加歧义,Vue 官方也建议一个列表采用稳定一致的 key 策略。
4. snabbdom 与 Vue 2 的双端比较
snabbdom 的经典算法维护四个指针:
oldStart ↔ oldEnd
newStart ↔ newEnd
优先尝试:
- 旧头和新头;
- 旧尾和新尾;
- 旧头和新尾,节点向右移动;
- 旧尾和新头,节点向左移动。
四种情况都不命中时,再建立旧 key 到索引的映射,在旧列表中查找新头对应的 vnode。Vue 2 的 updateChildren 沿用了类似的双端策略,并加入组件、transition、异步组件和模块钩子等框架分支。
七、Vue 3 的 Diff 和编译器优化
Vue 3 并不只是把 virtual-dom 的 Diff 换了一个名称。模板编译器会把结构信息传给运行时:
- 静态 vnode 可以提升到 render 函数外;
- 动态文本、class、style 和 props 会携带 patch flag;
- block tree 会记录动态后代;
- keyed children 会进行 key 映射,并在必要时使用最长递增子序列(LIS)减少移动;
- 组件的响应式依赖可以让更新范围停留在相关组件。
概念上的编译结果可能类似:
const staticNode = createElementVNode('div', null, '静态内容', -1)
function render(_ctx) {
return createElementBlock('section', null, [
staticNode,
createElementVNode(
'p',
null,
toDisplayString(_ctx.message),
1, // TEXT patch flag
),
])
}
实际生成代码会因编译模式、版本和模板结构不同而变化。patch flag 是内部优化提示,不能把所有 render function 都假设成编译模板的快速路径。
八、虚拟 DOM 真的一定更快吗
不能简单说“操作真实 DOM 很慢,所以虚拟 DOM 一定更快”。更准确地说:
- DOM 操作、布局、样式计算和绘制有真实成本;
- vnode 创建、Diff、组件调度也有 JavaScript 成本;
- 对一个已知的局部变化,手写精确 DOM 更新可能比完整 VDOM 流程更快;
- 对复杂声明式 UI,VDOM 统一了渲染、组件、事件、跨平台 renderer 和更新调度;
- Vue 3 的性能优势还来自编译器、patch flags、静态提升、block tree 和响应式更新范围;
- 十万级 DOM 通常首先是界面设计和虚拟列表问题,不能用固定“React 30 秒、Vue 2 秒”之类的旧测试结论代表所有浏览器和版本。
“跨平台”也不是 vnode 自动变成 iOS、Android 或小程序。需要对应的 renderer 或编译/运行时适配层,把 vnode 映射到目标平台的节点和属性。

九、手写一个状态更新示例
下面不使用 JSX,避免把“组件状态”与“JSX 编译”混在一起:
class Component {
constructor() {
this.state = { text: 'Initialize the Component' }
this.vnode = null
this.el = null
}
render() {
return h('div', {}, this.state.text)
}
mount(container) {
this.vnode = this.render()
this.el = render(this.vnode)
container.appendChild(this.el)
}
setState(partialState) {
this.state = { ...this.state, ...partialState }
const nextVNode = this.render()
// applyPatches 代表 renderer 的提交阶段,完整实现略去。
const patches = diff(this.vnode, nextVNode)
applyPatches(this.el, patches)
this.vnode = nextVNode
}
}
如果使用 Vue,响应式状态变化会触发组件渲染 effect,Vue scheduler 会批量安排更新,renderer 再执行 patch;业务代码通常不需要手动调用 diff 和 patch。
总结
- 虚拟 DOM 是描述 UI 的 JavaScript 中间表示,不是固定的三字段对象;
- JSX、Vue 模板和 render function 都可以生成 vnode,但 JSX 不是虚拟 DOM 的必要条件;
h负责创建 vnode,renderer 负责 mount、patch 和 unmount;- 基础 Diff 会比较类型、文本、属性和子节点,key 用于表达列表项身份;
- virtual-dom 展示了树遍历和 key 排序,snabbdom/Vue 2 使用双端比较,Vue 3 结合编译优化和 keyed Diff/LIS;
- VDOM 没有“天然更快”的保证,性能来自框架对更新、编译、调度和 DOM 提交的整体优化;
- 手写 Diff 适合学习,不能替代 Vue/React 对组件、事件、Fragment、指令、hydration 和边界情况的完整处理。
参考资料
- Vue 3:Rendering Mechanism
- Vue 3:Render Functions & JSX
- Vue 3:内置特殊属性 key
- Vue 2:列表渲染 key
- Vue 2 patch 源码
- Vue 3 renderer 源码
- snabbdom
原文作者:若川。原文关于 JSX 与 vnode、h 函数、渲染真实 DOM、Diff 补丁、virtual-dom、kivi.js、snabbdom、key 和跨平台的主线予以保留;错误的 chidren、属性删除判断、缺失节点处理、固定性能结论、LIS 复杂度和抓取图片引用已修正或本地化。