虚拟DOM与Diff算法
学习目标
完成本章学习后,你将能够:
- 理解为什么需要虚拟DOM,以及它解决了什么问题
- 掌握虚拟DOM的数据结构(VNode)
- 理解Diff算法的核心思想和优化策略
- 理解key属性的作用和原理
- 手写一个简易的虚拟DOM实现
- 分析Vue 3中Diff算法的优化
前置知识
学习本章内容前,你需要掌握:
- JavaScript基础 - 对象、数组、递归
- DOM操作 - 理解真实DOM操作
- Vue基础 - 了解Vue的基本用法
问题引入
实际场景
假设你正在开发一个电商网站的商品列表页面,用户可以通过筛选条件(价格、品牌、评分等)来过滤商品。每次用户修改筛选条件,页面都需要重新渲染商品列表。
传统DOM操作的问题:
javascript
// 传统方式:直接操作DOM
function updateProductList(products) {
const container = document.getElementById('product-list');
// 清空容器
container.innerHTML = '';
// 重新创建所有商品元素
products.forEach(product => {
const div = document.createElement('div');
div.className = 'product-item';
div.innerHTML = `
<img src="${product.image}" />
<h3>${product.name}</h3>
<p>${product.price}</p>
`;
container.appendChild(div);
});
}
这种方式存在严重的性能问题:
- 全量更新:即使只有一个商品的价格变了,也要重新创建所有DOM元素
- 频繁操作DOM:DOM操作是昂贵的,每次appendChild都会触发浏览器的重排和重绘
- 丢失状态:用户的滚动位置、输入框内容等状态会丢失
- 无法优化:不知道哪些元素真正需要更新
为什么需要虚拟DOM
虚拟DOM就是为了解决上述问题而诞生的。它的核心思想是:
用JavaScript对象来描述DOM结构,通过对比新旧虚拟DOM的差异,只更新真正需要变化的部分。
虚拟DOM的优势:
- ✅ 性能优化:通过Diff算法最小化DOM操作
- ✅ 跨平台:虚拟DOM是纯JavaScript对象,可以渲染到不同平台(Web、Native、小程序)
- ✅ 开发体验:声明式编程,不需要手动操作DOM
- ✅ 批量更新:可以将多次更新合并为一次DOM操作
核心概念
概念1:虚拟DOM(VNode)
虚拟DOM本质上是一个JavaScript对象,用来描述真实DOM的结构。
VNode的数据结构
javascript
/**
* ========================================
* 数据结构:VNode(虚拟节点)
* ========================================
*
* 【作用】
* 用JavaScript对象描述DOM节点的结构和属性
*
* 【核心属性】
*/
const vnode = {
// 【type】节点类型
// 类型:string | Component
// 说明:HTML标签名(如'div')或组件对象
type: 'div',
// 【props】节点属性
// 类型:object | null
// 说明:包含class、style、事件监听器等
props: {
class: 'container',
style: { color: 'red' },
onClick: () => console.log('clicked')
},
// 【children】子节点
// 类型:Array<VNode | string> | string | null
// 说明:子节点数组,可以是VNode或文本
children: [
{
type: 'h1',
props: null,
children: 'Hello World' // 文本节点
},
{
type: 'p',
props: { class: 'desc' },
children: 'This is a paragraph'
}
],
// 【key】节点标识
// 类型:string | number | null
// 说明:用于Diff算法优化,标识节点的唯一性
key: null,
// 【el】真实DOM引用
// 类型:Element | null
// 说明:指向对应的真实DOM元素(渲染后才有)
el: null
};
创建VNode的h函数
javascript
/**
* ========================================
* 函数:h (hyperscript)
* ========================================
*
* 【作用】
* 创建虚拟DOM节点(VNode)
*
* 【参数】
* @param {string | Component} type - 节点类型(标签名或组件)
* @param {object | null} props - 节点属性
* @param {Array | string} children - 子节点
*
* 【返回值】
* 类型:VNode对象
*
* 【使用示例】
*/
function h(type, props, children) {
return {
type,
props,
children,
key: props?.key || null,
el: null
};
}
// 示例1:创建简单元素
const vnode1 = h('div', { class: 'container' }, 'Hello');
// 结果:{ type: 'div', props: { class: 'container' }, children: 'Hello', ... }
// 示例2:创建嵌套元素
const vnode2 = h('div', { id: 'app' }, [
h('h1', null, 'Title'),
h('p', { class: 'desc' }, 'Description')
]);
// 示例3:在Vue中使用(Vue的h函数)
import { h } from 'vue';
const vnode3 = h('div', { class: 'box' }, [
h('span', null, 'Text')
]);
虚拟DOM对应的真实DOM
javascript
// 虚拟DOM
const vnode = h('div', { class: 'container', id: 'app' }, [
h('h1', null, 'Hello'),
h('p', { class: 'desc' }, 'World')
]);
// 对应的真实DOM
/*
<div class="container" id="app">
<h1>Hello</h1>
<p class="desc">World</p>
</div>
*/
概念2:渲染(Render)
渲染就是将虚拟DOM转换为真实DOM的过程。
首次渲染(mount)
javascript
/**
* 将虚拟DOM渲染为真实DOM并挂载到容器中
* @param {VNode} vnode - 虚拟DOM节点
* @param {Element} container - 容器元素
*/
function mount(vnode, container) {
// 1. 根据vnode.type创建真实DOM元素
const el = document.createElement(vnode.type);
// 2. 将真实DOM引用保存到vnode.el(后续更新时需要)
vnode.el = el;
// 3. 处理props(属性、样式、事件等)
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
// 处理事件监听器(以on开头的属性)
if (key.startsWith('on')) {
const eventName = key.slice(2).toLowerCase(); // onClick -> click
el.addEventListener(eventName, value);
}
// 处理class属性
else if (key === 'class') {
el.className = value;
}
// 处理style属性
else if (key === 'style') {
for (const styleName in value) {
el.style[styleName] = value[styleName];
}
}
// 处理其他属性
else {
el.setAttribute(key, value);
}
}
}
// 4. 处理children(子节点)
if (vnode.children) {
if (typeof vnode.children === 'string') {
// 文本子节点
el.textContent = vnode.children;
} else {
// 数组子节点,递归渲染
vnode.children.forEach(child => {
mount(child, el); // 递归挂载子节点
});
}
}
// 5. 将元素挂载到容器中
container.appendChild(el);
}
// 使用示例
const vnode = h('div', { class: 'app' }, [
h('h1', null, 'Hello'),
h('p', null, 'World')
]);
const container = document.getElementById('root');
mount(vnode, container);
// 结果:在#root中创建了对应的DOM结构
概念3:Diff算法
Diff算法是虚拟DOM的核心,用于对比新旧虚拟DOM的差异,找出最小的更新操作。
Diff算法的核心思想
javascript
/**
* ========================================
* 算法:Diff(差异对比)
* ========================================
*
* 【作用】
* 对比新旧虚拟DOM,找出需要更新的部分
*
* 【核心原则】
* 1. 同层比较:只比较同一层级的节点,不跨层级比较
* 2. 类型比较:type不同直接替换,不再比较子节点
* 3. key优化:使用key标识节点,优化列表更新
*
* 【参数】
* @param {VNode} oldVNode - 旧的虚拟DOM
* @param {VNode} newVNode - 新的虚拟DOM
*
* 【更新策略】
*/
function patch(oldVNode, newVNode) {
// 策略1:类型不同,直接替换
if (oldVNode.type !== newVNode.type) {
const parent = oldVNode.el.parentNode;
parent.removeChild(oldVNode.el); // 移除旧节点
mount(newVNode, parent); // 挂载新节点
return;
}
// 策略2:类型相同,复用DOM元素
const el = newVNode.el = oldVNode.el; // 复用真实DOM
// 策略3:更新props
const oldProps = oldVNode.props || {};
const newProps = newVNode.props || {};
// 添加或更新新属性
for (const key in newProps) {
const oldValue = oldProps[key];
const newValue = newProps[key];
if (oldValue !== newValue) {
// 更新属性(简化版,实际需要处理事件、style等)
el.setAttribute(key, newValue);
}
}
// 移除旧属性
for (const key in oldProps) {
if (!(key in newProps)) {
el.removeAttribute(key);
}
}
// 策略4:更新children(核心难点)
const oldChildren = oldVNode.children || [];
const newChildren = newVNode.children || [];
// 简化版:逐个对比(实际Vue使用更复杂的算法)
if (typeof newChildren === 'string') {
// 新children是文本
el.textContent = newChildren;
} else {
// 新children是数组,需要Diff子节点
patchChildren(el, oldChildren, newChildren);
}
}
子节点Diff算法(简化版)
javascript
/**
* 对比并更新子节点列表
* @param {Element} parent - 父元素
* @param {Array<VNode>} oldChildren - 旧子节点数组
* @param {Array<VNode>} newChildren - 新子节点数组
*/
function patchChildren(parent, oldChildren, newChildren) {
// 简化版Diff:逐个对比
// 实际Vue 3使用更高效的"最长递增子序列"算法
const commonLength = Math.min(oldChildren.length, newChildren.length);
// 1. 对比公共部分
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i]);
}
// 2. 新节点更多,需要添加
if (newChildren.length > oldChildren.length) {
newChildren.slice(commonLength).forEach(child => {
mount(child, parent);
});
}
// 3. 旧节点更多,需要删除
if (oldChildren.length > newChildren.length) {
oldChildren.slice(commonLength).forEach(child => {
parent.removeChild(child.el);
});
}
}
概念4:key的作用
key是虚拟DOM中非常重要的属性,用于标识节点的唯一性,帮助Diff算法更高效地复用节点。
为什么需要key
javascript
// 场景:列表中插入新元素
// 没有key的情况
const oldVNodes = [
h('li', null, 'A'),
h('li', null, 'B'),
h('li', null, 'C')
];
const newVNodes = [
h('li', null, 'A'),
h('li', null, 'D'), // 新插入的元素
h('li', null, 'B'),
h('li', null, 'C')
];
// 没有key时,Diff算法会认为:
// - 第2个li从'B'变成了'D' → 更新文本
// - 第3个li从'C'变成了'B' → 更新文本
// - 第4个li是新增的'C' → 创建新节点
// 结果:3次DOM操作(2次更新 + 1次创建)
// 有key的情况
const oldVNodesWithKey = [
h('li', { key: 'a' }, 'A'),
h('li', { key: 'b' }, 'B'),
h('li', { key: 'c' }, 'C')
];
const newVNodesWithKey = [
h('li', { key: 'a' }, 'A'),
h('li', { key: 'd' }, 'D'), // 新插入的元素
h('li', { key: 'b' }, 'B'),
h('li', { key: 'c' }, 'C')
];
// 有key时,Diff算法会认为:
// - key='a'的节点没变 → 不更新
// - key='d'是新节点 → 创建并插入
// - key='b'的节点移动了位置 → 移动DOM
// - key='c'的节点移动了位置 → 移动DOM
// 结果:1次DOM操作(1次创建 + 2次移动,移动比更新快)
key的使用规则
javascript
/**
* ========================================
* 属性:key
* ========================================
*
* 【作用】
* 标识节点的唯一性,帮助Diff算法复用节点
*
* 【使用场景】
* - 列表渲染(v-for)
* - 动态组件切换
* - 强制替换元素
*
* 【正确用法】
*/
// ✅ 好:使用唯一且稳定的ID
const products = [
{ id: 1, name: 'iPhone' },
{ id: 2, name: 'iPad' },
{ id: 3, name: 'MacBook' }
];
const vnodes = products.map(product =>
h('div', { key: product.id }, product.name)
);
// ✅ 好:使用业务数据的唯一标识
const users = [
{ userId: 'u001', name: 'Alice' },
{ userId: 'u002', name: 'Bob' }
];
const userVNodes = users.map(user =>
h('div', { key: user.userId }, user.name)
);
/**
* 【错误用法】
*/
// ❌ 坏:使用数组索引作为key(列表顺序会变化时)
const badVNodes1 = products.map((product, index) =>
h('div', { key: index }, product.name)
);
// 问题:插入或删除元素时,索引会变化,导致key对应的元素错乱
// ❌ 坏:使用随机数作为key
const badVNodes2 = products.map(product =>
h('div', { key: Math.random() }, product.name)
);
// 问题:每次渲染key都不同,无法复用节点
// ❌ 坏:不使用key(在动态列表中)
const badVNodes3 = products.map(product =>
h('div', null, product.name)
);
// 问题:无法准确识别节点,可能导致状态错乱
/**
* 【特殊情况:可以使用index作为key】
*/
// ✅ 可以:列表是静态的,不会增删改
const staticList = ['Apple', 'Banana', 'Orange'];
const staticVNodes = staticList.map((item, index) =>
h('li', { key: index }, item)
);
// ✅ 可以:列表只会在末尾添加,不会插入或删除
const appendOnlyList = todos.map((todo, index) =>
h('div', { key: index }, todo.text)
);
key导致的常见问题
javascript
// 问题场景:使用index作为key导致状态错乱
// Vue组件示例
const TodoList = {
data() {
return {
todos: [
{ id: 1, text: 'Learn Vue', done: false },
{ id: 2, text: 'Learn React', done: false },
{ id: 3, text: 'Learn Angular', done: false }
]
};
},
methods: {
removeTodo(index) {
this.todos.splice(index, 1);
}
},
template: `
<div>
<!-- ❌ 错误:使用index作为key -->
<div v-for="(todo, index) in todos" :key="index">
<input type="checkbox" v-model="todo.done" />
<span>{{ todo.text }}</span>
<button @click="removeTodo(index)">删除</button>
</div>
</div>
`
};
// 问题演示:
// 1. 用户勾选第2项(Learn React)的checkbox
// 2. 删除第1项(Learn Vue)
// 3. 结果:第2项的checkbox状态丢失了!
// 原因分析:
// 删除前:
// index=0, key=0, text='Learn Vue', done=false
// index=1, key=1, text='Learn React', done=true ← 用户勾选了
// index=2, key=2, text='Learn Angular', done=false
// 删除后:
// index=0, key=0, text='Learn React', done=false ← key=0复用了原来的DOM
// index=1, key=1, text='Learn Angular', done=false ← key=1复用了原来的DOM
// 结果:checkbox状态对应错了!
// ✅ 正确:使用唯一ID作为key
const CorrectTodoList = {
template: `
<div>
<div v-for="todo in todos" :key="todo.id">
<input type="checkbox" v-model="todo.done" />
<span>{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">删除</button>
</div>
</div>
`
};
// 使用todo.id作为key,删除元素时能正确识别和复用节点
概念5:Vue 3的Diff算法优化
Vue 3在Diff算法上做了重要优化,性能比Vue 2提升了很多。
优化1:静态标记(PatchFlag)
javascript
/**
* Vue 3会在编译时标记动态内容,运行时只对比动态部分
*/
// 模板
const template = `
<div>
<p>Static text</p>
<p>{{ dynamicText }}</p>
<p :class="dynamicClass">Text</p>
</div>
`;
// Vue 3编译后(简化版)
const render = () => {
return h('div', null, [
h('p', null, 'Static text'), // 没有PatchFlag,不会对比
h('p', null, dynamicText, 1), // PatchFlag=1(TEXT),只对比文本
h('p', { class: dynamicClass }, 'Text', 2) // PatchFlag=2(CLASS),只对比class
]);
};
// 更新时,Vue 3只会对比有PatchFlag的节点,跳过静态节点
// 性能提升:减少了大量不必要的对比
优化2:静态提升(hoistStatic)
javascript
/**
* Vue 3会将静态节点提升到render函数外部,避免重复创建
*/
// 模板
const template = `
<div>
<p>Static text 1</p>
<p>Static text 2</p>
<p>{{ dynamicText }}</p>
</div>
`;
// Vue 2编译后:每次render都创建静态节点
const render2 = () => {
return h('div', null, [
h('p', null, 'Static text 1'), // 每次都创建
h('p', null, 'Static text 2'), // 每次都创建
h('p', null, dynamicText)
]);
};
// Vue 3编译后:静态节点提升到外部
const hoisted1 = h('p', null, 'Static text 1'); // 只创建一次
const hoisted2 = h('p', null, 'Static text 2'); // 只创建一次
const render3 = () => {
return h('div', null, [
hoisted1, // 直接复用
hoisted2, // 直接复用
h('p', null, dynamicText)
]);
};
// 性能提升:减少了VNode的创建开销
优化3:最长递增子序列(LIS)
javascript
/**
* Vue 3使用最长递增子序列算法优化列表Diff
* 目标:找出不需要移动的节点,最小化DOM移动次数
*/
// 场景:列表重新排序
const oldList = ['A', 'B', 'C', 'D', 'E'];
const newList = ['A', 'C', 'B', 'E', 'D'];
// Vue 2的Diff:双端比较
// 需要移动:C、B、D(3次移动)
// Vue 3的Diff:最长递增子序列
// 1. 找出新列表中元素在旧列表中的位置索引
// A=0, C=2, B=1, E=4, D=3
// 索引序列:[0, 2, 1, 4, 3]
// 2. 找出最长递增子序列
// [0, 2, 4] 对应 A、C、E
// 这些元素不需要移动!
// 3. 只移动不在递增子序列中的元素
// 需要移动:B、D(2次移动)
// 性能提升:减少了DOM移动次数
/**
* 最长递增子序列算法实现(简化版)
*/
function getSequence(arr) {
const len = arr.length;
const result = [0]; // 存储递增子序列的索引
const p = arr.slice(); // 用于回溯
for (let i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
const j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
// 二分查找
let left = 0;
let right = result.length - 1;
while (left < right) {
const mid = (left + right) >> 1;
if (arr[result[mid]] < arrI) {
left = mid + 1;
} else {
right = mid;
}
}
if (arrI < arr[result[left]]) {
if (left > 0) {
p[i] = result[left - 1];
}
result[left] = i;
}
}
}
// 回溯构建完整序列
let i = result.length;
let u = result[i - 1];
while (i-- > 0) {
result[i] = u;
u = p[u];
}
return result;
}
// 使用示例
const indices = [0, 2, 1, 4, 3];
const lis = getSequence(indices);
console.log(lis); // [0, 1, 3] 对应索引位置的元素不需要移动
最佳实践
企业级应用场景
场景1:大列表渲染优化
javascript
// 问题:渲染10000条数据导致页面卡顿
// ❌ 坏:直接渲染所有数据
const BadList = {
data() {
return {
items: Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`
}))
};
},
template: `
<div>
<div v-for="item in items" :key="item.id">
{{ item.text }}
</div>
</div>
`
};
// 问题:创建10000个VNode和DOM元素,性能差
// ✅ 好:使用虚拟滚动(只渲染可见区域)
const GoodList = {
data() {
return {
items: Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`
})),
itemHeight: 50, // 每项高度
visibleCount: 20, // 可见数量
scrollTop: 0
};
},
computed: {
// 计算可见范围
visibleItems() {
const start = Math.floor(this.scrollTop / this.itemHeight);
const end = start + this.visibleCount;
return this.items.slice(start, end).map((item, index) => ({
...item,
top: (start + index) * this.itemHeight
}));
},
// 容器总高度
totalHeight() {
return this.items.length * this.itemHeight;
}
},
methods: {
handleScroll(e) {
this.scrollTop = e.target.scrollTop;
}
},
template: `
<div class="virtual-list" @scroll="handleScroll" style="height: 500px; overflow: auto;">
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ position: 'absolute', top: item.top + 'px', height: itemHeight + 'px' }"
>
{{ item.text }}
</div>
</div>
</div>
`
};
// 优势:只渲染20个VNode,性能提升500倍
场景2:条件渲染优化
javascript
// 问题:频繁切换显示/隐藏导致性能问题
// ❌ 坏:使用v-if频繁切换
const BadToggle = {
data() {
return {
show: true
};
},
template: `
<div>
<button @click="show = !show">Toggle</button>
<div v-if="show">
<ExpensiveComponent /> <!-- 复杂组件 -->
</div>
</div>
`
};
// 问题:每次切换都会销毁和重建组件,开销大
// ✅ 好:使用v-show保持DOM
const GoodToggle = {
data() {
return {
show: true
};
},
template: `
<div>
<button @click="show = !show">Toggle</button>
<div v-show="show">
<ExpensiveComponent /> <!-- 复杂组件 -->
</div>
</div>
`
};
// 优势:只切换display样式,不销毁DOM,性能好
// 💡 选择建议:
// - 频繁切换:使用v-show(避免重复创建/销毁)
// - 很少切换:使用v-if(避免初始渲染开销)
常见陷阱
陷阱1:在循环中使用index作为key
javascript
// ❌ 错误示例
const BadList = {
data() {
return {
users: [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
{ name: 'Charlie', age: 35 }
]
};
},
methods: {
removeUser(index) {
this.users.splice(index, 1);
}
},
template: `
<div>
<div v-for="(user, index) in users" :key="index">
<input v-model="user.name" />
<button @click="removeUser(index)">删除</button>
</div>
</div>
`
};
// 问题:删除中间元素时,后面元素的key会变化,导致不必要的更新
// ✅ 正确做法
const GoodList = {
data() {
return {
users: [
{ id: 1, name: 'Alice', age: 25 },
{ id: 2, name: 'Bob', age: 30 },
{ id: 3, name: 'Charlie', age: 35 }
]
};
},
methods: {
removeUser(id) {
const index = this.users.findIndex(u => u.id === id);
this.users.splice(index, 1);
}
},
template: `
<div>
<div v-for="user in users" :key="user.id">
<input v-model="user.name" />
<button @click="removeUser(user.id)">删除</button>
</div>
</div>
`
};
// 使用唯一ID作为key,确保节点正确复用
陷阱2:直接修改数组导致无法检测
javascript
// ❌ 错误示例(Vue 2)
const BadUpdate = {
data() {
return {
items: ['A', 'B', 'C']
};
},
methods: {
updateItem() {
this.items[0] = 'X'; // Vue 2无法检测到这种修改
}
}
};
// ✅ 正确做法(Vue 2)
const GoodUpdate2 = {
methods: {
updateItem() {
this.$set(this.items, 0, 'X'); // 使用$set
// 或
this.items.splice(0, 1, 'X'); // 使用splice
}
}
};
// ✅ Vue 3不存在这个问题(Proxy可以检测)
const GoodUpdate3 = {
methods: {
updateItem() {
this.items[0] = 'X'; // Vue 3可以正常检测
}
}
};
陷阱3:过度使用虚拟DOM
javascript
// ❌ 坏:简单场景过度使用框架
const BadSimple = {
data() {
return {
count: 0
};
},
template: `
<div>
<span>{{ count }}</span>
<button @click="count++">+1</button>
</div>
`
};
// 问题:简单的计数器不需要虚拟DOM的开销
// ✅ 好:简单场景直接操作DOM
const goodSimple = () => {
let count = 0;
const span = document.getElementById('count');
const button = document.getElementById('btn');
button.addEventListener('click', () => {
count++;
span.textContent = count; // 直接更新DOM更快
});
};
// 💡 选择建议:
// - 简单场景:直接操作DOM
// - 复杂场景:使用虚拟DOM(状态管理、组件化)
性能优化建议
优化1:使用v-once渲染静态内容
javascript
// 对于不会变化的内容,使用v-once避免重复渲染
const OptimizedStatic = {
template: `
<div>
<header v-once>
<h1>网站标题</h1>
<p>这是静态内容,永远不会变化</p>
</header>
<main>
<p>{{ dynamicContent }}</p>
</main>
</div>
`
};
// v-once标记的内容只渲染一次,后续更新会跳过
优化2:使用v-memo缓存子树(Vue 3.2+)
javascript
/**
* ========================================
* 指令:v-memo
* ========================================
*
* 【作用】
* 缓存模板的子树,只有依赖项变化时才重新渲染
*
* 【使用场景】
* - 大列表中的项(依赖项是item数据)
* - 复杂组件(依赖项是props)
*
* 【语法】
* v-memo="[依赖项1, 依赖项2, ...]"
*/
const OptimizedList = {
data() {
return {
items: [
{ id: 1, name: 'Item 1', selected: false },
{ id: 2, name: 'Item 2', selected: false },
// ... 1000项
]
};
},
template: `
<div>
<div
v-for="item in items"
:key="item.id"
v-memo="[item.selected]"
>
<span>{{ item.name }}</span>
<input type="checkbox" v-model="item.selected" />
</div>
</div>
`
};
// v-memo会缓存每一项,只有item.selected变化时才重新渲染该项
// 其他项的更新不会影响已缓存的项
优化3:合理使用computed缓存
javascript
// ❌ 坏:在模板中使用方法(每次渲染都执行)
const BadComputed = {
data() {
return {
items: [/* 大量数据 */]
};
},
methods: {
filterItems() {
// 复杂的过滤逻辑
return this.items.filter(item => item.active);
}
},
template: `
<div>
<div v-for="item in filterItems()" :key="item.id">
{{ item.name }}
</div>
</div>
`
};
// 问题:每次组件重新渲染都会执行filterItems()
// ✅ 好:使用computed缓存结果
const GoodComputed = {
data() {
return {
items: [/* 大量数据 */]
};
},
computed: {
filteredItems() {
// 只有items变化时才重新计算
return this.items.filter(item => item.active);
}
},
template: `
<div>
<div v-for="item in filteredItems" :key="item.id">
{{ item.name }}
</div>
</div>
`
};
// computed会缓存结果,依赖不变时直接返回缓存值
实践练习
练习1:实现简易虚拟DOM(难度:中等)
需求描述
实现一个简易的虚拟DOM系统,包含以下功能:
- h函数:创建虚拟节点
- mount函数:将虚拟节点渲染为真实DOM
- patch函数:对比并更新虚拟节点
功能点:
- 支持元素节点和文本节点
- 支持props(class、style、事件)
- 支持children(字符串或数组)
- 实现简单的Diff算法
实现提示
- h函数返回VNode对象
- mount函数递归创建DOM元素
- patch函数对比type、props、children
- 处理边界情况(null、undefined)
参考答案
javascript
// 1. 创建虚拟节点
function h(type, props, children) {
return {
type,
props: props || {},
children: children || [],
key: props?.key || null,
el: null
};
}
// 2. 挂载虚拟节点
function mount(vnode, container) {
// 创建元素
const el = document.createElement(vnode.type);
vnode.el = el;
// 处理props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
if (key === 'key') {
continue; // key不是DOM属性
} else if (key.startsWith('on')) {
// 事件监听器
const eventName = key.slice(2).toLowerCase();
el.addEventListener(eventName, value);
} else if (key === 'class') {
el.className = value;
} else if (key === 'style') {
Object.assign(el.style, value);
} else {
el.setAttribute(key, value);
}
}
}
// 处理children
if (typeof vnode.children === 'string') {
el.textContent = vnode.children;
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else {
mount(child, el);
}
});
}
// 挂载到容器
container.appendChild(el);
}
// 3. 更新虚拟节点
function patch(oldVNode, newVNode) {
// 类型不同,直接替换
if (oldVNode.type !== newVNode.type) {
const parent = oldVNode.el.parentNode;
const newEl = document.createElement(newVNode.type);
newVNode.el = newEl;
mount(newVNode, parent);
parent.removeChild(oldVNode.el);
return;
}
// 复用DOM元素
const el = newVNode.el = oldVNode.el;
// 更新props
const oldProps = oldVNode.props || {};
const newProps = newVNode.props || {};
// 添加或更新新属性
for (const key in newProps) {
if (key === 'key') continue;
const oldValue = oldProps[key];
const newValue = newProps[key];
if (oldValue !== newValue) {
if (key.startsWith('on')) {
const eventName = key.slice(2).toLowerCase();
if (oldValue) {
el.removeEventListener(eventName, oldValue);
}
el.addEventListener(eventName, newValue);
} else if (key === 'class') {
el.className = newValue;
} else if (key === 'style') {
Object.assign(el.style, newValue);
} else {
el.setAttribute(key, newValue);
}
}
}
// 移除旧属性
for (const key in oldProps) {
if (key === 'key') continue;
if (!(key in newProps)) {
if (key.startsWith('on')) {
const eventName = key.slice(2).toLowerCase();
el.removeEventListener(eventName, oldProps[key]);
} else {
el.removeAttribute(key);
}
}
}
// 更新children
const oldChildren = oldVNode.children;
const newChildren = newVNode.children;
if (typeof newChildren === 'string') {
if (typeof oldChildren === 'string') {
if (oldChildren !== newChildren) {
el.textContent = newChildren;
}
} else {
el.textContent = newChildren;
}
} else if (Array.isArray(newChildren)) {
if (typeof oldChildren === 'string') {
el.textContent = '';
newChildren.forEach(child => mount(child, el));
} else if (Array.isArray(oldChildren)) {
patchChildren(el, oldChildren, newChildren);
}
}
}
// 4. 更新子节点(简化版)
function patchChildren(parent, oldChildren, newChildren) {
const commonLength = Math.min(oldChildren.length, newChildren.length);
// 对比公共部分
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i]);
}
// 添加新节点
if (newChildren.length > oldChildren.length) {
newChildren.slice(commonLength).forEach(child => {
mount(child, parent);
});
}
// 删除旧节点
if (oldChildren.length > newChildren.length) {
oldChildren.slice(commonLength).forEach(child => {
parent.removeChild(child.el);
});
}
}
// 测试代码
const vnode1 = h('div', { class: 'container' }, [
h('h1', null, 'Hello'),
h('p', null, 'World')
]);
const container = document.getElementById('app');
mount(vnode1, container);
// 2秒后更新
setTimeout(() => {
const vnode2 = h('div', { class: 'container' }, [
h('h1', null, 'Hello'),
h('p', null, 'Vue'), // 文本变化
h('span', null, 'New') // 新增节点
]);
patch(vnode1, vnode2);
}, 2000);
答案解析
关键实现点:
- h函数:创建VNode对象,保存type、props、children
- mount函数:递归创建DOM,处理props和children
- patch函数:对比type,复用DOM,更新props和children
- patchChildren函数:简化版Diff,逐个对比子节点
边界情况处理:
- 文本节点和元素节点的区分
- props的添加、更新、删除
- children的不同类型(字符串、数组)
- 事件监听器的移除和添加
练习2:实现带key的Diff算法(难度:困难)
需求描述
优化上一个练习的Diff算法,支持key属性,实现更高效的列表更新。
功能点:
- 使用key标识节点
- 复用相同key的节点
- 最小化DOM操作(移动而非重建)
- 处理新增、删除、移动节点
实现提示
- 建立key到节点的映射
- 先处理相同key的节点(patch)
- 再处理新增节点(mount)
- 最后删除多余节点(remove)
参考答案
javascript
/**
* 带key的子节点Diff算法
* 使用双端比较 + key映射的方式
*/
function patchChildrenWithKey(parent, oldChildren, newChildren) {
let oldStartIdx = 0;
let oldEndIdx = oldChildren.length - 1;
let newStartIdx = 0;
let newEndIdx = newChildren.length - 1;
let oldStartVNode = oldChildren[0];
let oldEndVNode = oldChildren[oldEndIdx];
let newStartVNode = newChildren[0];
let newEndVNode = newChildren[newEndIdx];
// 建立旧节点的key映射
const oldKeyToIdx = {};
for (let i = 0; i < oldChildren.length; i++) {
const key = oldChildren[i].key;
if (key !== null) {
oldKeyToIdx[key] = i;
}
}
// 双端比较
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode === null) {
// 节点已经被移动,跳过
oldStartVNode = oldChildren[++oldStartIdx];
} else if (oldEndVNode === null) {
oldEndVNode = oldChildren[--oldEndIdx];
}
// 情况1:新旧头节点相同
else if (isSameVNode(oldStartVNode, newStartVNode)) {
patch(oldStartVNode, newStartVNode);
oldStartVNode = oldChildren[++oldStartIdx];
newStartVNode = newChildren[++newStartIdx];
}
// 情况2:新旧尾节点相同
else if (isSameVNode(oldEndVNode, newEndVNode)) {
patch(oldEndVNode, newEndVNode);
oldEndVNode = oldChildren[--oldEndIdx];
newEndVNode = newChildren[--newEndIdx];
}
// 情况3:旧头节点与新尾节点相同(节点右移)
else if (isSameVNode(oldStartVNode, newEndVNode)) {
patch(oldStartVNode, newEndVNode);
// 将节点移动到末尾
parent.insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling);
oldStartVNode = oldChildren[++oldStartIdx];
newEndVNode = newChildren[--newEndIdx];
}
// 情况4:旧尾节点与新头节点相同(节点左移)
else if (isSameVNode(oldEndVNode, newStartVNode)) {
patch(oldEndVNode, newStartVNode);
// 将节点移动到开头
parent.insertBefore(oldEndVNode.el, oldStartVNode.el);
oldEndVNode = oldChildren[--oldEndIdx];
newStartVNode = newChildren[++newStartIdx];
}
// 情况5:使用key查找
else {
const idxInOld = oldKeyToIdx[newStartVNode.key];
if (idxInOld === undefined) {
// 新节点,需要创建
mount(newStartVNode, parent);
parent.insertBefore(newStartVNode.el, oldStartVNode.el);
} else {
// 找到相同key的旧节点
const vnodeToMove = oldChildren[idxInOld];
patch(vnodeToMove, newStartVNode);
// 将节点移动到正确位置
parent.insertBefore(vnodeToMove.el, oldStartVNode.el);
// 标记为已处理
oldChildren[idxInOld] = null;
}
newStartVNode = newChildren[++newStartIdx];
}
}
// 添加剩余的新节点
if (newStartIdx <= newEndIdx) {
const before = newChildren[newEndIdx + 1]?.el || null;
for (let i = newStartIdx; i <= newEndIdx; i++) {
mount(newChildren[i], parent);
parent.insertBefore(newChildren[i].el, before);
}
}
// 删除剩余的旧节点
if (oldStartIdx <= oldEndIdx) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldChildren[i]) {
parent.removeChild(oldChildren[i].el);
}
}
}
}
/**
* 判断是否为相同节点
*/
function isSameVNode(vnode1, vnode2) {
return vnode1.type === vnode2.type && vnode1.key === vnode2.key;
}
// 测试代码
const oldVNodes = [
h('li', { key: 'a' }, 'A'),
h('li', { key: 'b' }, 'B'),
h('li', { key: 'c' }, 'C'),
h('li', { key: 'd' }, 'D')
];
const newVNodes = [
h('li', { key: 'a' }, 'A'),
h('li', { key: 'c' }, 'C'),
h('li', { key: 'b' }, 'B'),
h('li', { key: 'e' }, 'E'), // 新增
h('li', { key: 'd' }, 'D')
];
// 挂载旧节点
const ul = document.createElement('ul');
document.body.appendChild(ul);
oldVNodes.forEach(vnode => mount(vnode, ul));
// 更新为新节点
setTimeout(() => {
patchChildrenWithKey(ul, oldVNodes, newVNodes);
// 结果:B和C交换位置,E插入,只进行了必要的DOM操作
}, 2000);
答案解析
双端比较算法:
-
四种快速匹配:
- 新旧头节点相同:直接patch
- 新旧尾节点相同:直接patch
- 旧头新尾相同:patch + 右移
- 旧尾新头相同:patch + 左移
-
key映射查找:
- 快速匹配失败时,使用key查找旧节点
- 找到则移动,找不到则创建
-
收尾处理:
- 添加剩余的新节点
- 删除剩余的旧节点
性能优化:
- 使用key避免不必要的DOM创建
- 优先移动节点而非重建
- 双端比较减少查找次数
进阶阅读
官方文档
深度文章
源码阅读
- Vue 3 runtime-core源码
- Snabbdom虚拟DOM库(Vue 2的虚拟DOM基于此)
性能分析工具
- Vue DevTools - 查看组件渲染性能
- Chrome Performance - 分析渲染性能
- Vue 3 Template Explorer - 查看编译结果
下一步
恭喜你完成了虚拟DOM与Diff算法的学习!
你现在已经理解了:
- ✅ 虚拟DOM的本质和优势
- ✅ VNode的数据结构
- ✅ Diff算法的核心思想
- ✅ key的作用和重要性
- ✅ Vue 3的性能优化策略
下一章我们将学习Vue组件渲染流程,深入理解从模板到页面的完整过程,包括模板编译、组件初始化、更新和卸载的全流程。
推荐学习路径:
- 先学习Vue响应式系统源码解析(理解数据变化如何触发更新)
- 再学习本章(理解更新如何高效渲染)
- 最后学习Vue组件渲染流程(理解完整的渲染链路)