Vue 虚拟 DOM 与 Diff 算法
问题
Vue 的虚拟 DOM 是什么?Diff 算法是如何工作的?key 在其中起什么作用?
答案
什么是虚拟 DOM
虚拟 DOM(Virtual DOM)是用 JavaScript 对象来描述真实 DOM 结构的抽象层。Vue 通过对比新旧虚拟 DOM 树的差异(Diff),最小化真实 DOM 操作。
// 真实 DOM
// <div class="container">
// <span>Hello</span>
// </div>
// 虚拟 DOM(VNode)
interface VNode {
type: string | Component; // 标签名或组件
props: Record<string, any>; // 属性
children: VNode[] | string; // 子节点
key: string | number | null; // 唯一标识
el: Element | null; // 对应的真实 DOM
}
const vnode: VNode = {
type: 'div',
props: { class: 'container' },
children: [
{
type: 'span',
props: null,
children: 'Hello',
key: null,
el: null
}
],
key: null,
el: null
};
虚拟 DOM 的优势
| 优势 | 说明 |
|---|---|
| 跨平台 | 不直接依赖 DOM API,可渲染到不同平台 |
| 批量更新 | 收集多次变更,一次性更新 DOM |
| 最小化 DOM 操作 | 通过 Diff 算法只更新变化的部分 |
| 声明式编程 | 开发者关注数据,框架处理 DOM 更新 |
虚拟 DOM 的优势在于开发效率和可维护性,而非绝对性能。简单场景下,直接操作 DOM 可能更快。
Diff 算法核心策略
Vue 3 的 Diff 算法采用同层比较策略,时间复杂度从 优化到 :
三大假设
- 跨层级移动极少 → 只比较同层节点
- 相同类型才复用 → 类型不同直接替换
- key 标识唯一性 → 通过 key 快速定位节点
patch 函数核心逻辑
function patch(
n1: VNode | null, // 旧节点
n2: VNode, // 新节点
container: Element
) {
// 1. 旧节点不存在 → 挂载新节点
if (!n1) {
mount(n2, container);
return;
}
// 2. 类型不同 → 卸载旧节点,挂载新节点
if (n1.type !== n2.type) {
unmount(n1);
mount(n2, container);
return;
}
// 3. 类型相同 → 更新属性和子节点
const el = (n2.el = n1.el!);
// 更新 props
patchProps(el, n1.props, n2.props);
// 更新 children
patchChildren(n1, n2, el);
}
patchChildren - 子节点 Diff
子节点有三种类型:文本、数组、空,组合成 9 种情况:
function patchChildren(
n1: VNode,
n2: VNode,
container: Element
) {
const c1 = n1.children;
const c2 = n2.children;
// 新子节点是文本
if (typeof c2 === 'string') {
if (Array.isArray(c1)) {
// 卸载旧的子节点数组
c1.forEach(child => unmount(child));
}
container.textContent = c2;
return;
}
// 新子节点是数组
if (Array.isArray(c2)) {
if (Array.isArray(c1)) {
// 核心:双端 Diff 算法
patchKeyedChildren(c1, c2, container);
} else {
container.textContent = '';
c2.forEach(child => mount(child, container));
}
return;
}
// 新子节点为空
if (Array.isArray(c1)) {
c1.forEach(child => unmount(child));
} else if (typeof c1 === 'string') {
container.textContent = '';
}
}
双端 Diff 算法(Vue 2)
Vue 2 使用双端比较算法,同时从新旧节点的两端进行比较:
function patchKeyedChildren(
c1: VNode[],
c2: VNode[],
container: Element
) {
let oldStartIdx = 0;
let oldEndIdx = c1.length - 1;
let newStartIdx = 0;
let newEndIdx = c2.length - 1;
let oldStartVNode = c1[oldStartIdx];
let oldEndVNode = c1[oldEndIdx];
let newStartVNode = c2[newStartIdx];
let newEndVNode = c2[newEndIdx];
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (sameVNode(oldStartVNode, newStartVNode)) {
// 头头相同
patch(oldStartVNode, newStartVNode, container);
oldStartVNode = c1[++oldStartIdx];
newStartVNode = c2[++newStartIdx];
} else if (sameVNode(oldEndVNode, newEndVNode)) {
// 尾尾相同
patch(oldEndVNode, newEndVNode, container);
oldEndVNode = c1[--oldEndIdx];
newEndVNode = c2[--newEndIdx];
} else if (sameVNode(oldStartVNode, newEndVNode)) {
// 头尾相同:旧头移动到旧尾后面
patch(oldStartVNode, newEndVNode, container);
container.insertBefore(oldStartVNode.el!, oldEndVNode.el!.nextSibling);
oldStartVNode = c1[++oldStartIdx];
newEndVNode = c2[--newEndIdx];
} else if (sameVNode(oldEndVNode, newStartVNode)) {
// 尾头相同:旧尾移动到旧头前面
patch(oldEndVNode, newStartVNode, container);
container.insertBefore(oldEndVNode.el!, oldStartVNode.el!);
oldEndVNode = c1[--oldEndIdx];
newStartVNode = c2[++newStartIdx];
} else {
// 四种比较都不匹配,使用 key 查找
const idxInOld = c1.findIndex(
node => node.key === newStartVNode.key
);
if (idxInOld >= 0) {
const vnodeToMove = c1[idxInOld];
patch(vnodeToMove, newStartVNode, container);
container.insertBefore(vnodeToMove.el!, oldStartVNode.el!);
c1[idxInOld] = undefined as any; // 标记已处理
} else {
// 新节点,挂载
mount(newStartVNode, container, oldStartVNode.el!);
}
newStartVNode = c2[++newStartIdx];
}
}
// 处理剩余节点
if (oldStartIdx > oldEndIdx) {
// 旧节点遍历完,新节点有剩余 → 新增
for (let i = newStartIdx; i <= newEndIdx; i++) {
mount(c2[i], container, c2[newEndIdx + 1]?.el);
}
} else if (newStartIdx > newEndIdx) {
// 新节点遍历完,旧节点有剩余 → 删除
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (c1[i]) unmount(c1[i]);
}
}
}
function sameVNode(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key;
}
快速 Diff 算法(Vue 3)
Vue 3 采用更高效的快速 Diff 算法,结合最长递增子序列(LIS):
function patchKeyedChildrenFast(
c1: VNode[],
c2: VNode[],
container: Element
) {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1;
let e2 = l2 - 1;
// 1. 从头部开始同步
while (i <= e1 && i <= e2) {
if (sameVNode(c1[i], c2[i])) {
patch(c1[i], c2[i], container);
i++;
} else {
break;
}
}
// 2. 从尾部开始同步
while (i <= e1 && i <= e2) {
if (sameVNode(c1[e1], c2[e2])) {
patch(c1[e1], c2[e2], container);
e1--;
e2--;
} else {
break;
}
}
// 3. 旧节点遍历完,有新节点需要挂载
if (i > e1 && i <= e2) {
const anchor = c2[e2 + 1]?.el || null;
while (i <= e2) {
mount(c2[i], container, anchor);
i++;
}
}
// 4. 新节点遍历完,有旧节点需要卸载
else if (i > e2 && i <= e1) {
while (i <= e1) {
unmount(c1[i]);
i++;
}
}
// 5. 中间部分乱序,使用最长递增子序列优化
else {
const s1 = i;
const s2 = i;
// 建立新节点 key -> index 的映射
const keyToNewIndexMap = new Map<any, number>();
for (i = s2; i <= e2; i++) {
keyToNewIndexMap.set(c2[i].key, i);
}
// 记录新节点在旧节点中的位置
const toBePatched = e2 - s2 + 1;
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
let moved = false;
let maxNewIndexSoFar = 0;
for (i = s1; i <= e1; i++) {
const oldVNode = c1[i];
const newIndex = keyToNewIndexMap.get(oldVNode.key);
if (newIndex === undefined) {
unmount(oldVNode);
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1;
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
moved = true;
}
patch(oldVNode, c2[newIndex], container);
}
}
// 使用最长递增子序列最小化移动
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: [];
let j = increasingNewIndexSequence.length - 1;
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const anchor = c2[nextIndex + 1]?.el || null;
if (newIndexToOldIndexMap[i] === 0) {
// 新节点,挂载
mount(c2[nextIndex], container, anchor);
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 需要移动
container.insertBefore(c2[nextIndex].el!, anchor);
} else {
// 在递增子序列中,不需要移动
j--;
}
}
}
}
}
// 最长递增子序列算法
function getSequence(arr: number[]): number[] {
const p = arr.slice();
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
key 的作用
// ❌ 不使用 key:节点复用错误,性能差
// 旧: [A, B, C]
// 新: [C, A, B]
// Diff 会认为 ABC 都需要更新内容
// ✅ 使用 key:正确识别节点,只需移动
// 旧: [A:1, B:2, C:3]
// 新: [C:3, A:1, B:2]
// Diff 识别出只需要移动 C 到最前面
<!-- ❌ 错误:index 会随数组变化 -->
<li v-for="(item, index) in list" :key="index">{{ item }}</li>
<!-- ✅ 正确:使用唯一标识 -->
<li v-for="item in list" :key="item.id">{{ item }}</li>
常见面试问题
Q1: Vue 2 和 Vue 3 的 Diff 算法有什么区别?
答案:
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 算法 | 双端 Diff | 快速 Diff |
| 核心优化 | 头尾双指针 | 最长递增子序列 |
| 静态提升 | ❌ | ✅ |
| 静态标记 | ❌ | ✅ PatchFlags |
Vue 3 的快速 Diff 优势:
- 预处理相同前缀/后缀:先处理头尾不变的节点
- 最长递增子序列:最小化 DOM 移动操作
- 静态提升:静态节点跳过 Diff
- PatchFlags:精确标记动态内容类型
Q2: key 为什么不能用 index?
答案:
使用 index 作为 key 会导致:
// 原始数据: [{id: 1, name: 'A'}, {id: 2, name: 'B'}]
// key 使用 index: [A:0, B:1]
// 删除第一项后: [{id: 2, name: 'B'}]
// key 变成: [B:0]
// Diff 认为:key=0 的节点内容从 A 变成 B
// 实际上应该:删除 A 节点,复用 B 节点
问题:
- 错误的节点复用:状态混乱
- 性能下降:本可以复用的节点被更新
- 表单输入丢失:输入框等组件状态错乱
Q3: 虚拟 DOM 一定比直接操作 DOM 快吗?
答案:
不一定。虚拟 DOM 有额外开销:
- 创建 VNode 对象
- Diff 对比
- 最终还是要操作 DOM
// 简单场景:直接操作 DOM 更快
element.textContent = 'new text';
// 复杂场景:虚拟 DOM 优势明显
// - 批量更新
// - 最小化重排重绘
// - 跨平台渲染
虚拟 DOM 的价值在于开发效率而非绝对性能。
Q4: Vue 3 的静态提升是什么?
答案:
静态节点在编译时提升到渲染函数外部,避免重复创建:
<template>
<div>
<span>静态文本</span>
<span>{{ dynamic }}</span>
</div>
</template>
编译后:
// 静态提升到外部
const _hoisted_1 = /*#__PURE__*/ createVNode("span", null, "静态文本");
function render() {
return createVNode("div", null, [
_hoisted_1, // 直接复用
createVNode("span", null, ctx.dynamic, 1 /* TEXT */)
]);
}
Q5: Vue 3 的快速 Diff 算法和 Vue 2 的双端 Diff 有什么区别?
答案:
两者在核心策略和优化手段上有显著差异。Vue 3 的快速 Diff 在保留双端思想的基础上,引入了预处理 + 最长递增子序列(LIS),大幅减少 DOM 移动次数。
算法流程对比
具体差异
| 维度 | Vue 2 双端 Diff | Vue 3 快速 Diff |
|---|---|---|
| 比较方式 | 头头、尾尾、头尾、尾头四次比较 | 先处理相同前缀和后缀 |
| 查找策略 | 遍历查找 key() | 构建 key → index Map( 查找) |
| 移动优化 | 逐个判断是否需要移动 | 最长递增子序列,批量确定不动节点 |
| 最坏复杂度 | (无 key 时遍历查找) | (LIS 的时间复杂度) |
| 编译优化配合 | 无 | PatchFlags + 静态提升 |
示例:节点移动
假设旧列表为 [A, B, C, D, E],新列表为 [A, D, B, C, E]:
// Vue 2 双端 Diff:
// 1. 头头比较:A === A → patch,两端指针内移
// 2. 尾尾比较:E === E → patch,两端指针内移
// 3. 剩余 [B, C, D] vs [D, B, C]
// 4. 头头:B !== D → 头尾:B !== C → 尾头:D === D → 移动 D 到 B 前面
// 5. 头头:B === B → patch
// 6. 头头:C === C → patch
// 总共移动:1 次
// Vue 3 快速 Diff:
// 1. 预处理前缀:A === A → patch
// 2. 预处理后缀:E === E → patch
// 3. 剩余中间部分 [B, C, D] vs [D, B, C]
// 4. 构建 key→index 映射:{ D: 1, B: 2, C: 3 }
// 5. newIndexToOldIndexMap: [3, 1, 2](D 在旧索引 3,B 在 1,C 在 2)
// 6. 最长递增子序列:[1, 2](B, C 不需要移动)
// 7. 只移动 D 到 B 前面
// 总共移动:1 次,但判断过程更高效
Vue 3 快速 Diff 通过最长递增子序列一次性确定哪些节点不需要移动,避免了 Vue 2 双端 Diff 中逐步试探的过程。在大量节点乱序移动的场景下,Vue 3 的移动次数更少,性能更优。
Q6: 什么是静态提升(Static Hoisting)?对性能有什么影响?
答案:
静态提升是 Vue 3 编译器的优化策略,将模板中不会变化的静态节点和属性提升到渲染函数外部,避免每次渲染时重复创建。
优化前后对比
<template>
<div>
<h1 class="title">固定标题</h1>
<p>固定段落</p>
<span>{{ message }}</span>
<ul>
<li class="item">静态项 1</li>
<li class="item">静态项 2</li>
<li class="item">静态项 3</li>
</ul>
</div>
</template>
未优化(每次渲染都重新创建所有 VNode):
function render(ctx: { message: string }) {
return createVNode('div', null, [
createVNode('h1', { class: 'title' }, '固定标题'),
createVNode('p', null, '固定段落'),
createVNode('span', null, ctx.message, 1 /* TEXT */),
createVNode('ul', null, [
createVNode('li', { class: 'item' }, '静态项 1'),
createVNode('li', { class: 'item' }, '静态项 2'),
createVNode('li', { class: 'item' }, '静态项 3'),
]),
]);
}
静态提升后:
// 静态节点提升到模块作用域,只创建一次
const _hoisted_1 = createVNode('h1', { class: 'title' }, '固定标题');
const _hoisted_2 = createVNode('p', null, '固定段落');
const _hoisted_3 = createVNode('ul', null, [
createVNode('li', { class: 'item' }, '静态项 1'),
createVNode('li', { class: 'item' }, '静态项 2'),
createVNode('li', { class: 'item' }, '静态项 3'),
]);
function render(ctx: { message: string }) {
return createVNode('div', null, [
_hoisted_1, // 直接复用
_hoisted_2, // 直接复用
createVNode('span', null, ctx.message, 1 /* TEXT */),
_hoisted_3, // 直接复用(整棵子树)
]);
}
提升层级
| 提升类型 | 说明 | 示例 |
|---|---|---|
| 静态节点提升 | 纯静态节点提升为常量 | <h1>标题</h1> |
| 静态属性提升 | 静态 props 提升为常量 | { class: 'title' } |
| 静态子树提升 | 整棵静态子树提升 | 多个连续静态节点 |
| 字符串化 | 连续静态节点编译为 HTML 字符串 | 超过阈值时触发 |
字符串化优化
当连续静态节点数量超过阈值(默认 20 个)时,Vue 3 会将它们编译为 HTML 字符串,通过 innerHTML 一次性插入:
// 大量静态节点 → 字符串化
const _hoisted_1 = /*#__PURE__*/ createStaticVNode(
'<li class="item">项目 1</li><li class="item">项目 2</li>...(20+个)',
20
);
性能影响
在 Vue 3 Template Explorer 中,可以勾选 hoistStatic 选项实时查看编译器输出的静态提升效果。
Q7: Patch Flags 是什么?如何优化 Diff 过程?
答案:
Patch Flags(补丁标记)是 Vue 3 编译器在编译阶段为动态节点生成的标记,告诉运行时 Diff 算法"这个节点的哪些部分是动态的",从而跳过不必要的比较。
标记类型
// Vue 3 源码中的 PatchFlags 枚举
enum PatchFlags {
TEXT = 1, // 动态文本内容
CLASS = 1 << 1, // 动态 class
STYLE = 1 << 2, // 动态 style
PROPS = 1 << 3, // 动态非 class/style 的 props
FULL_PROPS = 1 << 4, // 有动态 key 的 props(需要全量 Diff)
NEED_HYDRATION = 1 << 5, // 需要 hydration 的事件监听
STABLE_FRAGMENT = 1 << 6, // 子节点顺序稳定的 Fragment
KEYED_FRAGMENT = 1 << 7, // 有 key 的 Fragment
UNKEYED_FRAGMENT = 1 << 8, // 无 key 的 Fragment
NEED_PATCH = 1 << 9, // 非 props 的动态绑定(如 ref、指令)
DYNAMIC_SLOTS = 1 << 10, // 动态插槽
// 以下为特殊标记(负数),不参与位运算优化
HOISTED = -1, // 静态提升的节点
BAIL = -2, // Diff 应退出优化模式
}
编译示例
<template>
<div>
<!-- 纯静态 -->
<h1>标题</h1>
<!-- 动态文本 → PatchFlag: 1 (TEXT) -->
<span>{{ msg }}</span>
<!-- 动态 class → PatchFlag: 2 (CLASS) -->
<div :class="{ active: isActive }">内容</div>
<!-- 动态 style → PatchFlag: 4 (STYLE) -->
<p :style="{ color: textColor }">段落</p>
<!-- 动态文本 + 动态 class → PatchFlag: 3 (TEXT | CLASS) -->
<span :class="cls">{{ text }}</span>
<!-- 动态 props → PatchFlag: 8 (PROPS),附带动态属性名 -->
<img :src="imgUrl" :alt="imgAlt" />
</div>
</template>
编译结果:
const _hoisted_1 = createVNode('h1', null, '标题', -1 /* HOISTED */);
function render(ctx: Record<string, any>) {
return createVNode('div', null, [
_hoisted_1,
createVNode('span', null, ctx.msg, 1 /* TEXT */),
createVNode('div', { class: { active: ctx.isActive } }, '内容', 2 /* CLASS */),
createVNode('p', { style: { color: ctx.textColor } }, '段落', 4 /* STYLE */),
createVNode('span', { class: ctx.cls }, ctx.text, 3 /* TEXT | CLASS */),
createVNode('img', { src: ctx.imgUrl, alt: ctx.imgAlt }, null, 8 /* PROPS */, ['src', 'alt']),
]);
}
Diff 时如何利用 Patch Flags
function patchElement(n1: VNode, n2: VNode) {
const el = (n2.el = n1.el!);
const patchFlag = n2.patchFlag;
// 有 PatchFlag → 精确更新
if (patchFlag > 0) {
if (patchFlag & PatchFlags.TEXT) {
// 只更新文本内容
if (n1.children !== n2.children) {
el.textContent = n2.children as string;
}
}
if (patchFlag & PatchFlags.CLASS) {
// 只更新 class
if (n1.props?.class !== n2.props?.class) {
el.className = n2.props?.class;
}
}
if (patchFlag & PatchFlags.STYLE) {
// 只更新 style
patchStyle(el, n1.props?.style, n2.props?.style);
}
if (patchFlag & PatchFlags.PROPS) {
// 只 Diff 指定的动态属性
const dynamicProps = n2.dynamicProps!;
for (const key of dynamicProps) {
if (n1.props?.[key] !== n2.props?.[key]) {
el.setAttribute(key, n2.props?.[key]);
}
}
}
} else if (patchFlag === PatchFlags.FULL_PROPS) {
// 需要全量 Diff props
patchProps(el, n1.props, n2.props);
}
// PatchFlag 为 -1 (HOISTED) 的节点直接跳过
}
Block Tree 与动态节点收集
Vue 3 还引入了 Block Tree,在编译时将模板中所有动态节点收集到一个扁平数组中,Diff 时只遍历动态节点,跳过整棵静态子树:
// 编译器生成的 Block
function render(ctx: Record<string, any>) {
return (
openBlock(),
createBlock('div', null, [
_hoisted_1, // 静态节点,不进入 dynamicChildren
_hoisted_2,
createVNode('span', null, ctx.msg, 1 /* TEXT */), // 动态节点
createVNode('div', { class: ctx.cls }, '内容', 2 /* CLASS */), // 动态节点
])
);
// Block 的 dynamicChildren = [span, div](只有动态节点)
}
| 优化手段 | 效果 |
|---|---|
| PatchFlags | 精确知道节点的哪些部分是动态的,避免全量比较 |
| Block Tree | 将 Diff 范围从整棵树缩小到动态节点集合 |
| 位运算标记 | patchFlag & PatchFlags.TEXT 高效判断, |
| 静态提升 | 静态节点标记为 -1,Diff 时完全跳过 |
Q8: 虚拟 DOM 一定比直接操作 DOM 快吗?什么场景下不适合用虚拟 DOM?
答案:
虚拟 DOM 不一定比直接操作 DOM 快。虚拟 DOM 的本质是用 JavaScript 运算(创建 VNode、Diff 对比)换取最小化的 DOM 操作,它提供的是一个性能下限有保障的方案,而非最优方案。
性能公式对比
当 DOM 操作本身很简单时,虚拟 DOM 额外的 JavaScript 运算反而成为负担。
虚拟 DOM 不占优的场景
场景一:简单的文本更新
// 直接操作 DOM:1 次操作
element.textContent = 'new text';
// 虚拟 DOM:
// 1. 创建新的 VNode 树
// 2. Diff 对比新旧 VNode
// 3. 发现文本变化
// 4. element.textContent = 'new text'
// 多了步骤 1-3 的开销
场景二:已知精确变更的场景
// 开发者明确知道哪里需要更新
function updatePrice(el: HTMLElement, price: number) {
el.querySelector('.price')!.textContent = `¥${price}`;
el.querySelector('.price')!.classList.toggle('expensive', price > 1000);
}
// 虚拟 DOM 需要 Diff 整棵组件树来发现这两处变更
场景三:高频动画 / Canvas 渲染
// Canvas 动画:直接操作更高效
function animate(ctx: CanvasRenderingContext2D) {
requestAnimationFrame(() => {
ctx.clearRect(0, 0, 800, 600);
// 直接绘制,无需虚拟 DOM
particles.forEach((p) => {
ctx.fillRect(p.x, p.y, 2, 2);
});
animate(ctx);
});
}
场景四:极大规模 DOM 节点
当节点数量达到数万级别时,即使有 Diff 优化,VNode 对象本身的内存分配和垃圾回收也会带来压力。此时应使用虚拟滚动等方案减少节点数量。
虚拟 DOM 的真正优势
| 优势 | 说明 |
|---|---|
| 开发效率 | 声明式编程,开发者不需要手动追踪每个变更 |
| 性能下限 | 自动 Diff 保证不会出现灾难性的 DOM 操作 |
| 跨平台 | 相同逻辑可渲染到 DOM、Canvas、Native、SSR 字符串 |
| 批量更新 | 合并多次数据变更为一次 DOM 更新 |
| 可预测性 | 相同数据总是产生相同的 UI,便于调试和测试 |
性能对比图
面试中回答这个问题时,不要简单说"虚拟 DOM 更快"或"更慢",应强调以下观点:
- 虚拟 DOM 是性能与开发效率的平衡方案
- 它保证了不错的性能下限,而非最优性能上限
- Vue 3 通过编译优化(PatchFlags、静态提升、Block Tree)进一步缩小了虚拟 DOM 与手动操作的性能差距
- 在复杂应用中,虚拟 DOM 的自动 Diff + 最小化更新通常比开发者手动管理 DOM 更可靠
尤雨溪对虚拟 DOM 性能的经典论述
框架的意义在于为你掩盖底层的 DOM 操作,让你用更声明式的方式来描述你的目的,从而让你的代码更容易维护。没有任何框架可以比纯手动的优化 DOM 操作更快,因为框架的 DOM 操作层需要应对任何上层 API 可能产生的操作,它的实现必须是普适的。框架给你的保证是,我在不需要你手动优化的情况下,依然可以给你提供过得去的性能。
重点是"过得去的性能" + "更好的开发体验",而不是"最快"。