先看一段代码:

复制代码// 把一个 <div> 的背景色改成红色
document.getElementById('box').style.backgroundColor = 'red'
你觉得这一行代码的执行成本是多少?
答案远比你想象的复杂:
复制代码1. JS 引擎找到 DOM 节点
2. 修改 DOM 节点的 style 属性
3. 浏览器标记这个节点需要重新计算样式(Recalculate Style)
4. 重新布局(Layout/Reflow)—— 可能影响周围元素的位置
5. 重新绘制(Paint)—— 把新的颜色画到屏幕上
6. 合成(Composite)—— 把各层合成最终画面
修改一个属性可能触发整个渲染流水线。
如果是 1000 个属性修改呢?如果是添加、删除、移动几百个节点呢?
虚拟 DOM 就是用普通的 JavaScript 对象来描述一个 DOM 节点。
真实 DOM:
复制代码<div id="app" class="container">
<p>Hello</p>
</div>
等价的虚拟 DOM:
复制代码{
tag: 'div',
props: { id: 'app', class: 'container' },
children: [
{
tag: 'p',
props: {},
children: [
{ tag: undefined, text: 'Hello' } // 文本节点
]
}
]
}
Vue 中把这个 JS 对象叫做 VNode(Virtual Node)。
用一张表来对比:
| 真实 DOM | 虚拟 DOM (VNode) | |
|---|---|---|
| 本质 | C++ 实现的浏览器对象 | 普通 JS 对象 |
| 创建成本 | 高(创建几百个属性) | 低(就几个字段) |
| 操作成本 | 高(可能触发回流) | 低(只是改 JS 对象) |
| 跨平台 | 只能在浏览器 | 可以渲染到不同平台 |
| 可控性 | 浏览器说了算 | 框架完全控制 |
核心思想:
你要重新装修一个房间,有两种方式:
方式一:直接施工(直接操作 DOM)
复制代码"把左边这面墙砸掉" → 工人开始砸
"等等,右边那面也砸" → 工人换位置砸
"不对,左边还是留着吧" → 工人:???
每次指示都立刻执行,改主意了就返工。工期长、成本高。
方式二:先在图纸上画(虚拟 DOM)
复制代码在图纸上画一遍 → 对比旧图纸 → 标记出所有改动 → 一次施工完成
先在纸上(JS 内存)把所有方案画好,确认无误后,列出最小改动清单,一次性施工。
现有一棵旧的 VNode 树和一棵新的 VNode 树,怎么找出"最少改动"?
把两棵树完全比较的时间复杂度是 O(n³)——对一棵有 1000 个节点的树来说,这是 10 亿次比较,不可接受。
但前端有一个重要的观察:大部分情况下,跨层级的移动非常罕见。
基于这个假设,Vue(和 React)的 diff 算法做了一个简化:
这样算法退化到 O(n),即每个节点只比较一次。
Vue 的 diff 采用的是双端比较策略。以下以子节点数组的 diff 为例。
假设旧子节点是 [A, B, C, D],新子节点是 [B, A, D, E]。
复制代码旧: [A, B, C, D]
↑
新: [B, A, D, E]
↑
A !== B → 不匹配,结束头头比较
复制代码旧: [A, B, C, D]
↑
新: [B, A, D, E]
↑
D !== E → 不匹配,结束尾尾比较
复制代码旧头 vs 新尾: A vs E → 不匹配
旧尾 vs 新头: D vs B → 不匹配
此时四个指针都没匹配上,说明需要更复杂的操作。Vue 会尝试在旧节点中查找新节点是否存在(通过 key)。
复制代码<div v-for="item in list" :key="item.id">
key 的作用就是给每个 VNode 一个唯一的身份标识,让 diff 算法能识别出"这个节点只是位置变了,不是被删除重建了"。
复制代码旧: [{key:'A'}, {key:'B'}, {key:'C'}, {key:'D'}]
新: [{key:'B'}, {key:'A'}, {key:'D'}, {key:'E'}]有 key 时:
B 在旧节点中找到 → 移动位置即可
A 在旧节点中找到 → 移动位置即可
D 在旧节点中找到 → 移动位置即可
E 不在旧节点中 → 新建无 key 时:
可能把 B 当成了 A(因为都是第一个位置)
→ 更新 A 的内容为 B,而不是移动
→ 效率低,还可能导致状态丢失
复制代码function createVNode(tag, props, children) {
return { tag, props, children }
}function h(tag, props, ...children) {
return createVNode(tag, props, children.flat())
}
复制代码function mount(vnode, container) {
// 创建元素
const el = document.createElement(vnode.tag) // 设置属性
if (vnode.props) {
for (const key in vnode.props) {
el.setAttribute(key, vnode.props[key])
}
} // 处理子节点
if (vnode.children) {
vnode.children.forEach(child => {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child))
} else {
mount(child, el) // 递归挂载
}
})
} container.appendChild(el)
vnode.el = el // 保存对真实 DOM 的引用
}
复制代码function patch(oldVNode, newVNode) {
const el = (newVNode.el = oldVNode.el) // 1. 标签不同 → 直接替换
if (oldVNode.tag !== newVNode.tag) {
const newEl = document.createElement(newVNode.tag)
el.parentNode.replaceChild(newEl, el)
mount(newVNode, el.parentNode)
return
} // 2. 更新属性
// 移除旧属性
for (const key in oldVNode.props) {
if (!(key in newVNode.props)) {
el.removeAttribute(key)
}
}
// 设置新属性
for (const key in newVNode.props) {
if (oldVNode.props[key] !== newVNode.props[key]) {
el.setAttribute(key, newVNode.props[key])
}
} // 3. 更新子节点
const oldChildren = oldVNode.children || []
const newChildren = newVNode.children || []
const len = Math.max(oldChildren.length, newChildren.length) for (let i = 0; i < len; i++) {
if (i >= oldChildren.length) {
// 新节点,直接挂载
mount(newChildren[i], el)
} else if (i >= newChildren.length) {
// 旧节点多余,删除
el.removeChild(oldChildren[i].el)
} else {
// 都存在,递归 patch
if (typeof oldChildren[i] === 'string' && typeof newChildren[i] === 'string') {
if (oldChildren[i] !== newChildren[i]) {
el.childNodes[i].textContent = newChildren[i]
}
} else {
patch(oldChildren[i], newChildren[i])
}
}
}
}
上面是最简版本的实现,省略了 key 的匹配逻辑,但已经能说明 Diff 的核心思想:同层比较,最小化 DOM 操作。
这是一个经典争论。答案是:
| 场景 | 直接操作 DOM | 虚拟 DOM |
|---|---|---|
| 单个更新 | 更快 | 有 diff 开销 |
| 批量更新 | 需要手动优化 | 自动合并 |
| 代码可维护性 | 散落各处 | 声明式 |
| 跨平台 | 仅浏览器 | 可渲染到原生 |
有了虚拟 DOM,Vue 就知道"视图应该长什么样"。但视图是由组件构成的——组件是怎么创建、挂载、更新的?