文章
Vue 虚拟 DOM 与 Diff 算法完全指南
Vue 虚拟 DOM 与 Diff 算法完全指南 从"为什么需要虚拟 DOM"到"Diff 算法如何工作",深入理解 Vue 渲染机制的核心原理 目录 1. 核心概念 2. 虚拟 DOM 详解 3. Diff 算法原理...
Vue 虚拟 DOM 与 Diff 算法完全指南 #
从"为什么需要虚拟 DOM"到"Diff 算法如何工作",深入理解 Vue 渲染机制的核心原理
目录 #
一、核心概念 #
1.1 什么是虚拟 DOM?(大白话) #
虚拟 DOM(Virtual DOM) 就是用 JavaScript 对象来描述真实的 DOM 结构。
打个比方:
- 真实 DOM = 一栋真实的房子(砖头、水泥、钢筋)
- 虚拟 DOM = 房子的设计图纸(用数据描述房子结构)
// 真实 DOM
<div id="app" class="container">
<h1>标题</h1>
<p>内容</p>
</div>
// 虚拟 DOM(JavaScript 对象)
const vnode = {
tag: 'div',
props: {
id: 'app',
className: 'container'
},
children: [
{
tag: 'h1',
props: {},
children: ['标题']
},
{
tag: 'p',
props: {},
children: ['内容']
}
]
}核心思想: 不直接操作真实 DOM,而是操作 JavaScript 对象(虚拟 DOM),然后让框架帮我们把变化"翻译"成真实 DOM 操作。
1.2 为什么需要虚拟 DOM? #
问题 1:直接操作 DOM 很慢
// 每次修改 DOM,浏览器都要:
// 1. 重新计算样式
// 2. 重新布局(reflow)
// 3. 重新绘制(repaint)
// 这些操作很昂贵!
// 糟糕的写法:频繁操作 DOM
for (let i = 0; i < 1000; i++) {
const div = document.createElement('div');
document.body.appendChild(div); // 每次都触发 reflow
}
// 好的写法:批量更新
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const div = document.createElement('div');
fragment.appendChild(div); // 先加到文档片段,不触发 reflow
}
document.body.appendChild(fragment); // 一次性添加问题 2:手动优化 DOM 很难
// 需求:列表数据从 [A, B, C] 变成 [A, C, B]
// 方案 1:简单粗暴,清空重建
container.innerHTML = '';
[A, C, B].forEach(item => {
container.appendChild(createElement(item));
});
// 问题:全部重建,性能差
// 方案 2:聪明一点,只移动需要的
// 移动 B 到 C 后面
container.insertBefore(container.children[1], container.children[2].nextSibling);
// 问题:代码复杂,容易出错,难维护
// 虚拟 DOM:自动帮你算出最优方案
// 只需要:newVnode = render([A, C, B])
// 框架自动:找到最小操作 = 移动 B虚拟 DOM 解决的问题:
| 问题 | 直接操作 DOM | 虚拟 DOM |
|---|---|---|
| 性能 | 频繁操作,每次都触发 reflow | 批量更新,最小化 DOM 操作 |
| 心智负担 | 手动优化,代码复杂 | 声明式,框架自动优化 |
| 跨平台 | 只能在浏览器 | 可以渲染到原生、Canvas、SVG |
| 开发效率 | 命令式,关注"怎么做" | 声明式,关注"是什么" |
1.3 什么是 Diff 算法? #
Diff 算法 = 比较新旧虚拟 DOM,找出最小变化量的算法。
旧虚拟 DOM 新虚拟 DOM Diff 结果
<div> <div> ┌─────────────────┐
<span>A</span> <span>A</span> │ 保持不变 │
<span>B</span> <span>C</span> │ 修改 B → C │
<span>C</span> <span>D</span> │ 修改 C → D │
</div> </div> └─────────────────┘
Diff 算法目标:用最少的操作,把旧的变成新的核心问题: 如何高效地找到最优解?
- 朴素算法:O(n³) 复杂度(每个节点都和所有节点比较)
- Vue 的算法:O(n) 复杂度(通过策略降低复杂度)
1.4 核心名词解释 #
| 名词 | 大白话解释 |
|---|---|
| VNode | Virtual Node,虚拟节点,一个 JS 对象描述一个 DOM 节点 |
| Patch | 补丁,Diff 算法计算出的差异,用于更新真实 DOM |
| Reconciliation | 协调,React 中称为 Reconciler,即 Diff 过程 |
| Same VNode | 相同节点,tag 和 key 都相同的节点 |
| Unmount | 卸载,移除 DOM 节点,触发 beforeUnmount/unmounted 钩子 |
| Patch Flag | 补丁标记,Vue3 的优化,标记动态内容类型 |
二、虚拟 DOM 详解 #
2.1 虚拟 DOM 的结构 #
Vue2 的 VNode 结构:
// Vue2 源码简化版
function VNode(tag, data, children, text, elm) {
this.tag = tag; // 标签名:'div'、'span'、组件构造函数
this.data = data; // 属性:class、style、props、attrs、on...
this.children = children; // 子节点数组
this.text = text; // 文本内容
this.elm = elm; // 对应的真实 DOM 节点
this.key = data?.key; // key,用于 Diff 优化
// ... 其他属性
}
// 示例
const vnode = new VNode(
'div',
{ class: 'container', key: 'app' },
[
new VNode('h1', {}, [new VNode(undefined, undefined, undefined, '标题')]),
new VNode('p', {}, [new VNode(undefined, undefined, undefined, '内容')])
]
);Vue3 的 VNode 结构:
// Vue3 源码简化版
function createVNode(type, props, children) {
const vnode = {
__v_isVNode: true, // 标识是 VNode
type, // 标签名或组件
props, // 属性
children, // 子节点
key: props?.key ?? null, // key
ref: props?.ref ?? null, // ref
// Vue3 新增:优化相关
shapeFlag: 0, // 形状标记(元素/组件/插槽...)
patchFlag: 0, // 补丁标记(动态内容类型)
dynamicProps: null, // 动态属性
dynamicChildren: null, // 动态子节点(Block 树)
// 真实 DOM 引用
el: null,
anchor: null,
target: null,
targetAnchor: null,
};
// 设置 shapeFlag
if (typeof type === 'string') {
vnode.shapeFlag = ShapeFlags.ELEMENT;
} else if (typeof type === 'function') {
vnode.shapeFlag = ShapeFlags.FUNCTIONAL_COMPONENT;
} else {
vnode.shapeFlag = ShapeFlags.STATEFUL_COMPONENT;
}
// 设置 children
if (children) {
if (Array.isArray(children)) {
vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
} else if (typeof children === 'string') {
vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN;
}
}
return vnode;
}
// 示例
const vnode = createVNode('div', { class: 'container' }, [
createVNode('h1', null, '标题'),
createVNode('p', null, '内容')
]);2.2 虚拟 DOM 的创建过程 #
从模板到虚拟 DOM:
1. 模板编译
↓
2. 生成渲染函数(render function)
↓
3. 执行渲染函数,创建虚拟 DOM
↓
4. 虚拟 DOM 转换为真实 DOM代码示例:
<!-- 模板 -->
<template>
<div class="container">
<h1>{{ title }}</h1>
<p>{{ content }}</p>
</div>
</template>
<script>
export default {
data() {
return {
title: '标题',
content: '内容'
}
}
}
</script>编译后生成的渲染函数:
// Vue2 编译结果(简化版)
function render() {
with(this) {
return _c('div', { staticClass: 'container' }, [
_c('h1', [_v(_s(title))]),
_c('p', [_v(_s(content))])
])
}
}
// Vue3 编译结果(简化版)
function render(_ctx, _cache) {
return _createElementVNode('div', { class: 'container' }, [
_createElementVNode('h1', null, _toDisplayString(_ctx.title), 1 /* TEXT */),
_createElementVNode('p', null, _toDisplayString(_ctx.content), 1 /* TEXT */)
])
}2.3 虚拟 DOM 的优势与代价 #
优势:
| 优势 | 说明 |
|---|---|
| 跨平台 | 虚拟 DOM 是纯 JS 对象,可以渲染到任何平台 |
| 批量更新 | 多次修改合并成一次 DOM 操作 |
| 最小化操作 | Diff 算法找出最小变化 |
| 组件化 | 每个组件对应一棵虚拟 DOM 树 |
代价:
| 代价 | 说明 |
|---|---|
| 内存占用 | 需要维护虚拟 DOM 树 |
| 计算开销 | 每次更新都要创建新的虚拟 DOM 并 Diff |
| 初次渲染 | 比直接 innerHTML 慢 |
什么时候虚拟 DOM 比直接 DOM 操作快?
// 场景 1:大量数据变化
// 直接 DOM 操作:需要遍历每个元素,逐个修改
// 虚拟 DOM:批量 Diff 后一次性更新
// ✅ 虚拟 DOM 更快
// 场景 2:单次简单修改
// 直接 DOM 操作:element.textContent = 'new text'
// 虚拟 DOM:创建 VNode → Diff → Patch
// ❌ 直接 DOM 操作更快
// 结论:
// - 大量更新、复杂 UI → 虚拟 DOM 有优势
// - 少量更新、简单 UI → 直接 DOM 操作可能更快
// - 但虚拟 DOM 的心智模型更好,开发效率更高三、Diff 算法原理 #
3.1 Diff 算法的核心策略 #
Vue 的 Diff 算法采用"同层比较"策略:
只比较同一层级的节点,不跨层级比较
A A
/ \ / \
B C → B C
/ \ / \
D E D' E'
只比较:
- A vs A
- B vs B
- C vs C
- D vs D'
- E vs E'
不会比较:
- D vs C
- B vs E为什么可以这样做?
- 实际情况:跨层级移动节点的场景很少
- 复杂度降低:O(n³) → O(n)
- 实现简单:递归比较即可
3.2 Diff 算法的整体流程 #
┌─────────────────────────────────────────────────────────────┐
│ Vue Diff 算法流程 │
└─────────────────────────────────────────────────────────────┘
1. 比较新旧节点是否相同
├── 相同:patchVnode(更新属性、处理子节点)
└── 不同:替换整个节点
2. 如果节点相同,处理子节点
├── 新旧都有子节点:updateChildren(核心 Diff)
├── 只有新节点有子节点:添加子节点
├── 只有旧节点有子节点:删除子节点
└── 新旧都无子节点:只更新属性
3. updateChildren(核心算法)
├── 双端比较(Vue2)
└── 最长递增子序列(Vue3)3.3 Vue2 的双端 Diff 算法 #
双端比较:同时从两端开始比较
// Vue2 源码简化版
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let newStartIdx = 0;
let newEndIdx = newCh.length - 1;
let oldStartVnode = oldCh[oldStartIdx];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[newStartIdx];
let newEndVnode = newCh[newEndIdx];
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (sameVnode(oldStartVnode, newStartVnode)) {
// 头对头:新旧头部节点相同
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 尾对尾:新旧尾部节点相同
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 头对尾:旧头和新尾相同
patchVnode(oldStartVnode, newEndVnode);
// 移动到末尾
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 尾对头:旧尾和新头相同
patchVnode(oldEndVnode, newStartVnode);
// 移动到开头
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 四种情况都不匹配,用 key 查找
let idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
if (idxInOld) {
// 找到了,移动过来
const vnodeToMove = oldCh[idxInOld];
patchVnode(vnodeToMove, newStartVnode);
oldCh[idxInOld] = undefined;
parentElm.insertBefore(vnodeToMove.elm, oldStartVnode.elm);
} else {
// 没找到,创建新节点
createElm(newStartVnode, parentElm, oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
}
// 处理剩余节点
if (oldStartIdx > oldEndIdx) {
// 旧的遍历完了,新的还有,批量添加
const refElm = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
// 新的遍历完了,旧的还有,批量删除
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
}双端比较的四种情况图解:
旧节点:[A, B, C, D]
新节点:[D, A, B, C]
第 1 步:比较旧头 A 和新头 D
不匹配
第 2 步:比较旧尾 D 和新尾 C
不匹配
第 3 步:比较旧头 A 和新尾 C
不匹配
第 4 步:比较旧尾 D 和新头 D
匹配!移动 D 到开头
旧:[A, B, C, _]
新:[D, A, B, C]
继续比较...
最终:D 移到开头,其他不变3.4 Vue3 的最长递增子序列 Diff #
Vue3 优化了 Diff 算法,使用最长递增子序列(LIS):
// Vue3 源码简化版
function updateChildren(parent, oldChildren, newChildren) {
let i = 0;
const newChildrenLength = newChildren.length;
let oldChildrenLength = oldChildren.length;
// 1. 从头部开始同步
while (i <= oldChildrenLength && i <= newChildrenLength) {
const oldChild = oldChildren[i];
const newChild = newChildren[i];
if (!isSameVNodeType(oldChild, newChild)) {
break;
}
patch(oldChild, newChild);
i++;
}
// 2. 从尾部开始同步
while (i <= oldChildrenLength && i <= newChildrenLength) {
const oldChild = oldChildren[oldChildrenLength];
const newChild = newChildren[newChildrenLength];
if (!isSameVNodeType(oldChild, newChild)) {
break;
}
patch(oldChild, newChild);
oldChildrenLength--;
newChildrenLength--;
}
// 3. 处理剩余的新节点(新增)
if (i > oldChildrenLength) {
if (i <= newChildrenLength) {
// 批量添加新节点
const nextPos = newChildrenLength + 1;
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;
while (i <= newChildrenLength) {
mount(newChildren[i++], parent, anchor);
}
}
}
// 4. 处理剩余的旧节点(删除)
else if (i > newChildrenLength) {
while (i <= oldChildrenLength) {
unmount(oldChildren[i++]);
}
}
// 5. 处理乱序部分(核心:最长递增子序列)
else {
const oldStartIdx = i;
const newStartIdx = i;
const oldEndIdx = oldChildrenLength;
const newEndIdx = newChildrenLength;
// 5.1 建立 key -> index 映射
const keyToNewIndexMap = new Map();
for (i = newStartIdx; i <= newEndIdx; i++) {
keyToNewIndexMap.set(newChildren[i].key, i);
}
// 5.2 遍历旧节点,更新能匹配的,标记要删除的
const toBePatched = newEndIdx - newStartIdx + 1;
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
let moved = false;
let maxNewIndexSoFar = 0;
for (i = oldStartIdx; i <= oldEndIdx; i++) {
const oldChild = oldChildren[i];
const newIndex = keyToNewIndexMap.get(oldChild.key);
if (newIndex === undefined) {
// 旧节点不在新列表中,删除
unmount(oldChild);
} else {
// 更新映射
newIndexToOldIndexMap[newIndex - newStartIdx] = i + 1;
// 判断是否需要移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
moved = true;
}
patch(oldChild, newChildren[newIndex]);
}
}
// 5.3 移动和挂载
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: [];
let j = increasingNewIndexSequence.length - 1;
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = newStartIdx + i;
const newChild = newChildren[nextIndex];
const anchor = nextIndex + 1 < newChildren.length ? newChildren[nextIndex + 1].el : null;
if (newIndexToOldIndexMap[i] === 0) {
// 新节点,挂载
mount(newChild, parent, anchor);
} else if (moved) {
// 需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(newChild, parent, anchor);
} else {
j--;
}
}
}
}
}
// 最长递增子序列算法
function getSequence(arr) {
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;
}最长递增子序列图解:
旧节点:[A, B, C, D, E]
新节点:[A, C, D, B, E]
1. 建立 key -> index 映射(新节点)
{ A: 0, C: 1, D: 2, B: 3, E: 4 }
2. 遍历旧节点,建立 newIndexToOldIndexMap
旧节点索引:0(A), 1(B), 2(C), 3(D), 4(E)
新节点索引:0(A), 3(B), 1(C), 2(D), 4(E)
newIndexToOldIndexMap = [1, 3, 4, 2, 5]
// 含义:新位置 0 的元素在旧位置 1
// 新位置 1 的元素在旧位置 3
// ...
3. 计算最长递增子序列
[1, 3, 4, 2, 5] 的 LIS = [1, 2, 5]
对应新索引:[0, 2, 4] = [A, D, E]
含义:A、D、E 不需要移动
只有 C 和 B 需要移动
4. 移动节点
C 移到 B 前面
B 移到 D 前面
最终结果:[A, C, D, B, E]3.5 Vue2 vs Vue3 Diff 算法对比 #
| 特性 | Vue2 | Vue3 |
|---|---|---|
| 算法 | 双端比较 | 最长递增子序列 |
| 复杂度 | O(n) 平均 | O(n log n) 最坏情况 |
| 优势 | 简单直观 | 移动次数更少 |
| 劣势 | 某些场景移动次数多 | 实现复杂 |
示例对比:
旧:[A, B, C, D, E, F, G]
新:[A, C, D, B, E, F, G]
Vue2 双端比较:
- 头对头:A = A ✓
- 尾对尾:G = G ✓
- 头对尾:B ≠ G
- 尾对头:G ≠ C
- 查找:找到 C,移动
- 继续比较...
- 最终:移动多次
Vue3 LIS:
- 前缀同步:A
- 后缀同步:E, F, G
- 中间:[B, C, D] → [C, D, B]
- LIS:[C, D]
- 只需移动 B
- 最终:只移动 1 次四、Vue2 与 Vue3 的 Diff 差异 #
4.1 架构层面的差异 #
Vue2:
Options API
↓
模板编译
↓
渲染函数(render)
↓
虚拟 DOM(VNode)
↓
Diff(双端比较)
↓
Patch(更新真实 DOM)Vue3:
Composition API
↓
模板编译(带 Patch Flag)
↓
渲染函数(带优化标记)
↓
虚拟 DOM(Block Tree)
↓
Diff(最长递增子序列)
↓
Patch(靶向更新)4.2 Patch Flag 优化 #
Vue3 引入 Patch Flag,实现靶向更新:
// Vue2:全量 Diff
<div class="container">
<h1>{{ title }}</h1>
<p>{{ content }}</p>
</div>
// Vue3:带 Patch Flag
// 编译结果
function render(_ctx, _cache) {
return _createElementVNode('div', { class: 'container' }, [
_createElementVNode('h1', null, _toDisplayString(_ctx.title), 1 /* TEXT */),
_createElementVNode('p', null, _toDisplayString(_ctx.content), 1 /* TEXT */)
])
}
// Patch Flag 含义
const PatchFlags = {
TEXT: 1, // 动态文本
CLASS: 2, // 动态 class
STYLE: 4, // 动态 style
PROPS: 8, // 动态属性
FULL_PROPS: 16, // 有动态 key 的属性
HYDRATE_EVENTS: 32,// 有事件监听器
STABLE_FRAGMENT: 64,
KEYED_FRAGMENT: 128,
UNKEYED_FRAGMENT: 256,
NEED_PATCH: 512,
DYNAMIC_SLOTS: 1024,
HOISTED: -1,
BAIL: -2
};好处:
// 只更新标记为动态的部分
// 静态内容不会参与 Diff
<div>
<span class="static">静态内容</span> <!-- 不会比较 -->
<span class="dynamic">{{ title }}</span> <!-- 只比较这个 -->
</div>4.3 Block Tree 优化 #
Vue3 的 Block Tree:
// Vue2:整棵树参与 Diff
<div>
<div>
<span>静态</span>
<span>{{ title }}</span>
</div>
<div>
<span>静态</span>
<span>{{ content }}</span>
</div>
</div>
// Vue3:收集动态节点到 Block
// 只有动态节点参与 Diff
const block = {
dynamicChildren: [
vnode1, // {{ title }}
vnode2, // {{ content }}
]
};Block 的收集过程:
// 模板
<div>
<span>静态</span>
<span>{{ title }}</span>
</div>
// 编译后
function render() {
return (_openBlock(), _createElementBlock('div', null, [
_createElementVNode('span', null, '静态'), // 静态,不收集
_createElementVNode('span', null, _toDisplayString(title), 1 /* TEXT */) // 动态,收集
]));
}
// Block 收集动态子节点
// 只有带 Patch Flag 的节点会被收集4.4 静态提升 #
Vue3 会提升静态节点:
// Vue2:每次渲染都创建 VNode
function render() {
return _c('div', [
_c('span', { staticClass: 'title' }, '静态标题'), // 每次都创建
_c('span', [_v(_s(title))])
]);
}
// Vue3:静态节点提升到渲染函数外
const _hoisted_1 = _createElementVNode('span', { class: 'title' }, '静态标题');
function render() {
return (_openBlock(), _createElementBlock('div', null, [
_hoisted_1, // 直接复用
_createElementVNode('span', null, _toDisplayString(title), 1)
]));
}好处:
- 静态节点只创建一次
- 减少内存占用
- 减少创建 VNode 的开销
4.5 缓存事件处理函数 #
Vue3 会缓存内联事件:
// Vue2:每次渲染创建新函数
function render() {
return _c('button', {
on: {
click: function($event) { _ctx.count++ } // 每次创建
}
}, '点击');
}
// Vue3:缓存事件处理函数
function render() {
return (_openBlock(), _createElementBlock('button', {
onClick: _cache[0] || (_cache[0] = ($event) => _ctx.count++) // 缓存
}, '点击'));
}好处:
- 避免不必要的更新(子组件认为 props 没变)
- 减少函数创建开销
五、Key 的重要性 #
5.1 Key 的作用 #
Key 是 VNode 的唯一标识,用于 Diff 算法判断节点是否相同。
// 判断两个节点是否相同(sameVnode)
function sameVnode(vnode1, vnode2) {
return (
vnode1.key === vnode2.key && // key 相同
vnode1.tag === vnode2.tag // 标签相同
);
}没有 Key 会怎样?
// 旧列表
[A, B, C]
// 新列表(删除 B)
[A, C]
// 没有 Key 时:
// Vue 认为 A=A,但 B≠C
// 所以会:保留 A,修改 B 为 C,删除 C
// 操作:1 次修改 + 1 次删除
// 有 Key 时:
// Vue 知道 A=A,C=C,B 被删除
// 操作:1 次删除(删除 B)
// 更高效!5.2 为什么不能用 index 作为 Key? #
错误示例:
<template>
<ul>
<!-- ❌ 不要用 index 作为 key -->
<li v-for="(item, index) in list" :key="index">
{{ item.name }}
</li>
</ul>
</template>问题分析:
初始状态:
list = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }]
index = [0, 1]
渲染:<li key="0">A</li><li key="1">B</li>
在开头插入新元素:
list = [{ id: 3, name: 'C' }, { id: 1, name: 'A' }, { id: 2, name: 'B' }]
index = [0, 1, 2]
用 index 作为 key:
<li key="0">C</li> <!-- 之前是 A,现在变成了 C -->
<li key="1">A</li> <!-- 之前是 B,现在变成了 A -->
<li key="2">B</li> <!-- 新增 -->
Diff 结果:
- key="0" 的节点:内容从 A 变成 C(修改)
- key="1" 的节点:内容从 B 变成 A(修改)
- key="2" 的节点:新增 B(创建)
问题:本应只插入 1 个节点,现在修改了 2 个 + 新增了 1 个正确做法:
<template>
<ul>
<!-- ✅ 用唯一 ID 作为 key -->
<li v-for="item in list" :key="item.id">
{{ item.name }}
</li>
</ul>
</template>分析:
初始状态:
list = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }]
渲染:<li key="1">A</li><li key="2">B</li>
在开头插入新元素:
list = [{ id: 3, name: 'C' }, { id: 1, name: 'A' }, { id: 2, name: 'B' }]
用 id 作为 key:
<li key="3">C</li> <!-- 新增 -->
<li key="1">A</li> <!-- 保持不变 -->
<li key="2">B</li> <!-- 保持不变 -->
Diff 结果:
- key="3" 的节点:新增(创建)
- key="1" 的节点:复用(无修改)
- key="2" 的节点:复用(无修改)
完美:只新增了 1 个节点,其他复用5.3 Key 的最佳实践 #
<template>
<!-- ✅ 列表渲染:用唯一标识 -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
<!-- ✅ 简单列表:没有唯一 ID,可以用 item 本身(如果唯一) -->
<span v-for="tag in tags" :key="tag">{{ tag }}</span>
<!-- ✅ 强制重新渲染:改变 key -->
<UserForm :key="formKey" />
<button @click="formKey++">重置表单</button>
<!-- ❌ 不要用 index -->
<li v-for="(item, index) in items" :key="index">...</li>
<!-- ❌ 不要用随机数(每次渲染都变) -->
<li v-for="item in items" :key="Math.random()">...</li>
<!-- ❌ 不要省略 key(等同于用 index) -->
<li v-for="item in items">...</li>
</template>5.4 Key 的特殊情况 #
强制重新渲染:
<template>
<!-- 通过改变 key,强制组件重新渲染 -->
<UserForm :key="formKey" />
<button @click="resetForm">重置表单</button>
</template>
<script>
export default {
data() {
return {
formKey: 0
};
},
methods: {
resetForm() {
// 改变 key,组件会被销毁重建
this.formKey++;
}
}
};
</script>v-if 和 v-for:
<template>
<!-- ❌ 不推荐:v-if 和 v-for 同级 -->
<li v-for="item in items" v-if="item.show" :key="item.id">
{{ item.name }}
</li>
<!-- ✅ 推荐:使用 computed -->
<li v-for="item in visibleItems" :key="item.id">
{{ item.name }}
</li>
<!-- ✅ 或者用 template 包裹 -->
<template v-for="item in items" :key="item.id">
<li v-if="item.show">{{ item.name }}</li>
</template>
</template>
<script>
export default {
computed: {
visibleItems() {
return this.items.filter(item => item.show);
}
}
};
</script>六、性能优化实践 #
6.1 减少 Diff 范围 #
使用 computed 缓存:
<template>
<!-- ❌ 每次渲染都过滤 -->
<li v-for="item in items.filter(i => i.active)" :key="item.id">
{{ item.name }}
</li>
<!-- ✅ 用 computed 缓存 -->
<li v-for="item in activeItems" :key="item.id">
{{ item.name }}
</li>
</template>
<script>
export default {
computed: {
activeItems() {
return this.items.filter(i => i.active);
}
}
};
</script>6.2 使用 v-once 和 v-memo #
v-once:只渲染一次
<template>
<!-- 静态内容,只渲染一次,之后不再更新 -->
<div v-once>
<h1>静态标题</h1>
<p>这段内容永远不会改变</p>
</div>
<!-- 初始渲染后,不再参与 Diff -->
<MyComponent v-once :data="initialData" />
</template>v-memo:条件缓存(Vue3.2+)
<template>
<!-- 只有当 item.id 或 selected 变化时才更新 -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, selected]">
<span :class="{ active: item.id === selected }">{{ item.name }}</span>
</div>
<!-- 性能优化:大列表 -->
<tr v-for="row in hugeList" :key="row.id" v-memo="[row.id, sortKey]">
<td>{{ row.name }}</td>
<td>{{ row.value }}</td>
</tr>
</template>6.3 合理拆分组件 #
<!-- ❌ 大组件:任何变化都会触发整个组件 Diff -->
<template>
<div>
<Header />
<Content :data="contentData" />
<Footer />
</div>
</template>
<!-- ✅ 拆分组件:只有变化的部分 Diff -->
<template>
<div>
<Header /> <!-- 独立组件,不受其他影响 -->
<Content :data="contentData" /> <!-- 只有 contentData 变化时更新 -->
<Footer /> <!-- 独立组件 -->
</div>
</template>6.4 避免不必要的响应式数据 #
// ❌ 不需要响应式的大数据
export default {
data() {
return {
hugeList: [] // 响应式,每个元素都有 getter/setter
};
}
};
// ✅ 非响应式数据
export default {
created() {
this.hugeList = []; // 普通属性,不是响应式
}
};
// ✅ Vue3 shallowRef
import { shallowRef } from 'vue';
const hugeList = shallowRef([]); // 只有 .value 是响应式,内部元素不是6.5 虚拟列表 #
大列表使用虚拟滚动:
<template>
<!-- 使用 vue-virtual-scroller 或类似库 -->
<RecycleScroller
:items="hugeList"
:item-size="50"
key-field="id"
v-slot="{ item }"
>
<div class="item">{{ item.name }}</div>
</RecycleScroller>
</template>
<script>
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
export default {
components: { RecycleScroller },
data() {
return {
hugeList: Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`
}))
};
}
};
</script>6.6 使用 DevTools 分析 #
// Vue DevTools 性能分析
// 1. 安装 Vue DevTools 浏览器扩展
// 2. 打开开发者工具,切换到 Vue 标签
// 3. 点击"Performance"选项卡
// 4. 点击录制,操作页面,停止录制
// 5. 查看每个组件的渲染时间
// 或使用 performance API
performance.mark('start');
// ... 执行代码
performance.mark('end');
performance.measure('MyComponent render', 'start', 'end');
const measures = performance.getEntriesByName('MyComponent render');
console.log('渲染时间:', measures[0].duration, 'ms');七、源码解析 #
7.1 Vue2 patch 函数 #
// Vue2 源码简化版
function patch(oldVnode, vnode) {
// 如果 oldVnode 不存在,创建新元素
if (isUndef(oldVnode)) {
createElm(vnode);
} else {
const isRealElement = isDef(oldVnode.nodeType);
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 相同节点,更新
patchVnode(oldVnode, vnode);
} else {
// 不同节点,替换
const oldElm = oldVnode.elm;
const parentElm = nodeOps.parentNode(oldElm);
// 创建新元素
createElm(vnode, parentElm, nodeOps.nextSibling(oldElm));
// 删除旧元素
if (parentElm) {
removeVnodes([oldVnode]);
}
}
}
return vnode.elm;
}
function patchVnode(oldVnode, vnode) {
const elm = vnode.elm = oldVnode.elm;
const oldCh = oldVnode.children;
const ch = vnode.children;
// 更新属性
if (isDef(vnode.data)) {
updateAttributes(oldVnode.data, vnode.data);
}
// 处理文本节点
if (isUndef(oldCh) && isDef(ch)) {
if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '');
}
addVnodes(elm, null, ch);
} else if (isDef(oldCh) && isUndef(ch)) {
removeVnodes(oldCh);
} else if (isDef(oldCh) && isDef(ch)) {
// 都有子节点,Diff
updateChildren(elm, oldCh, ch);
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '');
}
}7.2 Vue3 patch 函数 #
// Vue3 源码简化版
function patch(
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = false
) {
// 如果新旧节点类型不同,卸载旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1);
unmount(n1, parentComponent, parentSuspense, true);
n1 = null;
}
const { type, shapeFlag } = n2;
switch (type) {
case Text:
processText(n1, n2, container, anchor);
break;
case Comment:
processCommentNode(n1, n2, container, anchor);
break;
case Fragment:
processFragment(n1, n2, container, anchor, parentComponent, parentSuspense, slotScopeIds, optimized);
break;
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 处理原生元素
processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 处理组件
processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
} else if (shapeFlag & ShapeFlags.TELEPORT) {
// 处理 Teleport
type.process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
} else if (shapeFlag & ShapeFlags.SUSPENSE) {
// 处理 Suspense
type.process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
}
}
}
function processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) {
isSVG = isSVG || n2.type === 'svg';
if (n1 == null) {
// 挂载
mountElement(n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
} else {
// 更新
patchElement(n1, n2, parentComponent, parentSuspense, slotScopeIds, optimized);
}
}
function patchElement(n1, n2, parentComponent, parentSuspense, slotScopeIds, optimized) {
const el = (n2.el = n1.el);
const oldProps = n1.props || {};
const newProps = n2.props || {};
// 更新子节点
patchChildren(n1, n2, el, null, parentComponent, parentSuspense, slotScopeIds, optimized);
// 更新属性
patchProps(el, n2, oldProps, newProps, ...);
}7.3 最长递增子序列算法 #
// Vue3 源码:最长递增子序列(LIS)
// 算法:贪心 + 二分查找
// 时间复杂度:O(n log n)
function getSequence(arr) {
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;
}
// 二分查找,找到第一个大于等于 arrI 的位置
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;
}
// 示例
getSequence([2, 3, 1, 5, 6, 8, 7, 9, 4]);
// 返回:[2, 3, 5, 6, 7, 9] 或 [1, 3, 5, 6, 7, 9]
// 表示这些位置的元素组成最长递增子序列八、常见问题 #
Q1:虚拟 DOM 一定比直接操作 DOM 快吗? #
答案:不一定。
| 场景 | 更快的方式 |
|---|---|
| 大量数据批量更新 | 虚拟 DOM |
| 少量简单更新 | 直接 DOM |
| 首次渲染 | innerHTML |
| 频繁小更新 | 虚拟 DOM(批量合并) |
结论: 虚拟 DOM 的优势更多在于开发体验(声明式编程)和跨平台能力,而不是纯粹的性能。
Q2:为什么 Vue 的 Diff 算法是 O(n) 而不是 O(n³)? #
答案:因为采用了"同层比较"策略。
O(n³) 的朴素算法:
- 遍历旧树的所有节点
- 对于每个节点,遍历新树的所有节点
- 找到最匹配的节点
- 复杂度:n * n * n = O(n³)
O(n) 的 Vue 算法:
- 只比较同层级的节点
- 通过 key 快速匹配
- 双端比较或 LIS
- 复杂度:n = O(n)Q3:React 和 Vue 的 Diff 算法有什么区别? #
| 特性 | React | Vue2 | Vue3 |
|---|---|---|---|
| 策略 | 右侧遍历 | 双端比较 | LIS |
| 复杂度 | O(n) | O(n) | O(n log n) |
| 移动 | 可能多次移动 | 较优移动 | 最少移动 |
| Key 要求 | 强烈建议 | 强烈建议 | 强烈建议 |
Q4:什么时候需要手动优化 Diff 性能? #
需要优化的情况:
- 大列表(1000+ 项):使用虚拟滚动
- 频繁更新的数据:使用 v-memo 或拆分组件
- 复杂的嵌套结构:合理使用 key、拆分组件
- 动画性能问题:使用 CSS transform、requestAnimationFrame
不需要优化的情况:
- 小型应用:Vue 的默认优化已经足够
- 数据量小(< 100 项):Diff 性能可以忽略
- 更新频率低:不必过度优化
Q5:如何调试 Diff 性能问题? #
// 1. 使用 Vue DevTools Performance 面板
// 2. 使用 console.time
console.time('render');
this.list = newList;
this.$nextTick(() => {
console.timeEnd('render');
});
// 3. 使用 performance API
performance.mark('update-start');
this.list = newList;
this.$nextTick(() => {
performance.mark('update-end');
performance.measure('update', 'update-start', 'update-end');
console.log(performance.getEntriesByName('update'));
});
// 4. Vue2 全局性能监控
Vue.config.performance = true;
// 5. Vue3 性能追踪
app.config.performance = true;Q6:Vue3 的 Patch Flag 有多少种? #
const PatchFlags = {
TEXT: 1, // 动态文本内容
CLASS: 2, // 动态 class
STYLE: 4, // 动态 style
PROPS: 8, // 动态属性(非 class/style)
FULL_PROPS: 16, // 有动态 key 的属性
HYDRATE_EVENTS: 32,// 有事件监听器
STABLE_FRAGMENT: 64, // 子元素顺序不会变的 fragment
KEYED_FRAGMENT: 128, // 带有 key 的 fragment
UNKEYED_FRAGMENT: 256, // 不带 key 的 fragment
NEED_PATCH: 512, // 需要进行非 props 比较
DYNAMIC_SLOTS: 1024, // 动态插槽
HOISTED: -1, // 静态提升的节点
BAIL: -2, // 退出 Diff 优化
};Q7:如何理解 Vue3 的 Block? #
Block = 收集了动态子节点的特殊 VNode
// 普通 VNode
const vnode = {
type: 'div',
children: [...]
};
// Block
const block = {
type: 'div',
children: [...],
dynamicChildren: [ // 只包含动态节点
{ type: 'span', children: '{{ title }}', patchFlag: 1 }
]
};
// Diff 时,只需要比较 dynamicChildren
// 而不是整个 children 树九、总结速记 #
核心概念速记 #
| 概念 | 定义 |
|---|---|
| 虚拟 DOM | 用 JS 对象描述真实 DOM 结构 |
| Diff 算法 | 比较新旧虚拟 DOM,找出最小变化 |
| Patch | 将 Diff 结果应用到真实 DOM |
| Key | VNode 的唯一标识,用于 Diff 判断相同节点 |
虚拟 DOM 优势 #
- 跨平台 - 可以渲染到任何目标
- 批量更新 - 合并多次修改,最小化 DOM 操作
- 声明式 - 关注"是什么",不关注"怎么做"
Diff 策略速记 #
Vue2:双端比较
- 头对头
- 尾对尾
- 头对尾
- 尾对头
- 查找匹配
Vue3:最长递增子序列(LIS)
- 前缀同步
- 后缀同步
- 建立 key 映射
- 计算 LIS
- 最少移动Key 使用规则 #
✅ 用唯一 ID::key="item.id"
❌ 不用 index::key="index"
❌ 不用随机数::key="Math.random()"
❌ 不省略 key性能优化速记 #
| 技术 | 作用 |
|---|---|
| computed | 缓存计算结果 |
| v-once | 只渲染一次 |
| v-memo | 条件缓存 |
| 拆分组件 | 减小 Diff 范围 |
| 虚拟列表 | 大列表优化 |
| Patch Flag | 靶向更新(Vue3) |
| Block Tree | 收集动态节点(Vue3) |
Vue2 vs Vue3 Diff 对比 #
| 特性 | Vue2 | Vue3 |
|---|---|---|
| 算法 | 双端比较 | LIS |
| 静态提升 | 无 | 有 |
| Patch Flag | 无 | 有 |
| Block | 无 | 有 |
| 事件缓存 | 无 | 有 |
最后更新:2026-03-29