Vue源码解析_虚拟DOM和Diff算法

虚拟DOM基础概念

什么是虚拟DOM?

虚拟DOM(Virtual DOM)是用JavaScript对象来描述真实DOM结构的技术。简单来说,就是把HTML结构用JavaScript对象表示出来。

为什么需要虚拟DOM?

直接操作DOM非常昂贵,每次DOM操作都会触发浏览器的重绘和重排,影响性能。虚拟DOM通过以下方式解决这个问题:

  • 批量更新:将多次DOM操作合并为一次
  • 差异比较:只更新真正变化的部分
  • 内存操作:JavaScript对象操作比DOM操作快得多

虚拟DOM示例

让我们看一个简单的例子:

1
2
3
4
5
<!-- 真实DOM -->
<div class="container" id="app">
<h1>标题</h1>
<p>内容</p>
</div>

对应的虚拟DOM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
tag: 'div', // 标签名
data: { // 节点数据
attrs: {
class: 'container',
id: 'app'
}
},
children: [ // 子节点数组
{
tag: 'h1',
children: [{
text: '标题', // 文本内容
type: 3 // 节点类型:3表示文本节点
}]
},
{
tag: 'p',
children: [{
text: '内容',
type: 3
}]
}
]
}

虚拟DOM vs 真实DOM

特性 虚拟DOM 真实DOM
创建成本 低(JavaScript对象) 高(浏览器解析)
更新成本 低(内存操作) 高(重绘重排)
内存占用
操作速度
平台支持 跨平台 浏览器特定

Vue中的VNode

VNode是什么?

在Vue中,虚拟DOM由VNode类表示。每个VNode实例对应一个真实DOM节点的抽象表示。

VNode的核心作用

  • 描述DOM节点的所有信息
  • 作为diff算法的比较单位
  • 存储组件的相关数据

VNode的基本结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Vue中的VNode类
class VNode {
constructor(tag, data, children, text, elm, context) {
// 基础属性
this.tag = tag // 标签名,如 'div', 'span'
this.data = data // 节点数据(属性、样式、事件等)
this.children = children // 子节点数组
this.text = text // 文本内容(文本节点)
this.elm = elm // 对应的真实DOM节点

// 重要属性
this.key = data && data.key // 节点的唯一标识
this.isStatic = false // 是否为静态节点
this.componentInstance = undefined // 组件实例
}
}

VNode的重要属性

属性 说明 示例
tag 标签名 ‘div’, ‘span’, ‘vue-component-1-MyComponent’
data 节点数据 { attrs: { class: ‘container’ }, on: { click: handler } }
children 子节点数组 [VNode1, VNode2, …]
text 文本内容 ‘Hello World’
key 唯一标识 ‘item-1’, ‘user-123’
isStatic 是否静态 true/false

VNode类型

Vue中的VNode有多种类型,每种类型都有特定的用途:

1. 元素节点

1
2
3
4
5
6
// 普通HTML元素
{
tag: 'div',
data: { attrs: { class: 'container' } },
children: [...]
}

2. 文本节点

1
2
3
4
5
// 文本节点
{
text: 'Hello World',
type: 3
}

3. 组件节点

1
2
3
4
5
6
// 组件节点
{
tag: 'vue-component-1-MyComponent',
componentInstance: componentInstance,
componentOptions: { ... }
}

4. 注释节点

1
2
3
4
5
// 注释节点
{
text: '注释内容',
isComment: true
}

Diff算法原理

什么是Diff算法?

Diff算法是Vue虚拟DOM的核心,用于比较新旧虚拟DOM树的差异,并最小化地更新真实DOM。

算法的核心思想

  1. 同层比较:只比较同一层级的节点,不跨层级比较

    • 如果节点在不同层级,直接替换整个子树
    • 这大大简化了算法复杂度
  2. 唯一标识:通过key属性来识别节点

    • 有key的节点可以通过key快速定位
    • 没有key的节点按位置比较
  3. 最小化更新:只更新发生变化的节点

    • 相同节点复用DOM元素
    • 只更新变化的属性

算法复杂度

  • 传统diff算法:O(n³)
  • Vue的diff算法:O(n)
  • 通过同层比较和key优化,大大降低了复杂度

Diff算法的基本流程

Diff算法的核心是patch函数,它负责比较新旧VNode并更新DOM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function patch(oldVnode, vnode) {
// 1. 如果新节点不存在,销毁旧节点
if (!vnode) {
if (oldVnode) destroyOldVnode(oldVnode)
return
}

// 2. 如果旧节点不存在,创建新节点
if (!oldVnode) {
createNewVnode(vnode)
return
}

// 3. 比较是否为相同节点
if (sameVnode(oldVnode, vnode)) {
// 相同节点,进行详细比较
patchVnode(oldVnode, vnode)
} else {
// 不同节点,替换整个节点
replaceVnode(oldVnode, vnode)
}
}

patch函数的主要步骤

  1. 边界情况处理

    • 新节点不存在:销毁旧节点
    • 旧节点不存在:创建新节点
  2. 节点比较

    • 使用sameVnode判断是否为相同节点
    • 相同节点:调用patchVnode进行详细比较
    • 不同节点:替换整个节点

sameVnode函数

sameVnode函数用于判断两个VNode是否为相同节点:

1
2
3
4
5
6
7
8
9
10
function sameVnode(a, b) {
return (
a.key === b.key && ( // key必须相同
a.tag === b.tag && // 标签名相同
a.isComment === b.isComment && // 注释状态相同
isDef(a.data) === isDef(b.data) && // 数据存在性相同
sameInputType(a, b) // input类型相同
)
)
}

判断条件

  1. key相同:这是最重要的条件
  2. 标签名相同:tag必须一致
  3. 注释状态相同:isComment必须一致
  4. 数据存在性相同:data的undefined状态必须一致
  5. input类型相同:对于input元素,type必须相同

为什么需要这些条件?

  • key:确保节点的唯一性,是diff算法的核心
  • tag:不同类型的元素不能复用
  • isComment:注释节点和普通节点不能复用
  • data:有数据和没数据的节点处理方式不同
  • input type:不同类型的input元素不能复用

Patch过程详解

什么是Patch过程?

在Vue中,把DOM-Diff过程叫做patch过程。patch,意为”补丁”,即指对旧的VNode修补,打补丁从而得到新的VNode。

patch的核心思想

  • 旧的VNode(oldVNode):数据变化之前视图所对应的虚拟DOM节点
  • 新的VNode:数据变化之后将要渲染的新的视图所对应的虚拟DOM节点
  • 以新的VNode为基准:对比旧的oldVNode,让旧的oldVNode变成和新的VNode一样

patch的三种操作

整个patch过程无非就是干三件事:

1. 创建节点

条件:新的VNode中有而旧的oldVNode中没有
操作:在旧的oldVNode中创建对应的节点

1
2
3
4
// 示例:新VNode中有新的子节点
// 旧VNode: [A, B, C]
// 新VNode: [A, B, C, D]
// 操作:创建节点D

创建节点流程图:

2. 删除节点

条件:新的VNode中没有而旧的oldVNode中有
操作:从旧的oldVNode中删除对应的节点

1
2
3
4
// 示例:新VNode中删除了某个子节点
// 旧VNode: [A, B, C]
// 新VNode: [A, C]
// 操作:删除节点B

3. 更新节点

条件:新的VNode和旧的oldVNode中都有
操作:以新的VNode为准,更新旧的oldVNode

1
2
3
4
// 示例:新VNode中某个节点的内容发生了变化
// 旧VNode: { tag: 'div', text: 'Hello' }
// 新VNode: { tag: 'div', text: 'World' }
// 操作:更新文本内容

流程图:

patchVnode函数

patchVnode函数是diff算法的核心,负责比较和更新相同节点的内容。它实现了我们前面提到的三种操作:创建、删除、更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function patchVnode(oldVnode, vnode) {
// 1. 如果节点完全相同,直接返回
if (oldVnode === vnode) {
return
}

// 2. 复用DOM元素
const elm = vnode.elm = oldVnode.elm

// 3. 处理静态节点(性能优化)
if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
vnode.componentInstance = oldVnode.componentInstance
return // 静态节点直接复用,跳过所有更新
}

// 4. 更新节点数据(属性、样式、事件等)
updateNodeData(oldVnode, vnode)

// 5. 处理子节点
const oldCh = oldVnode.children
const ch = vnode.children

if (isUndef(vnode.text)) {
// 新节点不是文本节点
if (isDef(oldCh) && isDef(ch)) {
// 都有子节点 → 调用updateChildren进行详细比较
if (oldCh !== ch) updateChildren(elm, oldCh, ch)
} else if (isDef(ch)) {
// 只有新节点有子节点 → 创建节点
addVnodes(elm, null, ch, 0, ch.length - 1)
} else if (isDef(oldCh)) {
// 只有旧节点有子节点 → 删除节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
} else if (oldVnode.text !== vnode.text) {
// 都是文本节点但内容不同 → 更新文本
setTextContent(elm, vnode.text)
}
}

patchVnode的执行流程

  1. 快速返回:如果节点完全相同,直接返回
  2. DOM复用:复用现有的DOM元素,避免重新创建
  3. 静态节点优化:静态节点直接复用,不进行diff
  4. 数据更新:更新节点的属性、样式、事件等
  5. 子节点处理:根据子节点情况决定创建、删除或更新

更新子节点

当新的VNode与旧的oldVNode都是元素节点并且都包含子节点时,那么这两个节点的VNode实例上的children属性就是所包含的子节点数组。我们把新的VNode上的子节点数组记为newChildren,把旧的oldVNode上的子节点数组记为oldChildren,我们把newChildren里面的元素与oldChildren里的元素一一进行对比,对比两个子节点数组肯定是要通过循环,外层循环newChildren数组,内层循环oldChildren数组,每循环外层newChildren数组里的一个子节点,就去内层oldChildren数组里找看有没有与之相同的子节点

子节点更新算法

双端比较策略

Vue的子节点更新算法采用了双端比较的策略,这是Vue diff算法的核心优化:

传统方法:逐个比较每个节点

  • 时间复杂度:O(n²)
  • 需要多次DOM操作
  • 性能较差

双端比较:从两端开始比较

  • 时间复杂度:O(n)
  • 减少DOM操作次数
  • 性能更好

双端比较的四种策略

1. 新前与旧前比较

1
2
3
4
5
6
// 比较新旧列表的第一个节点
if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, ...)
oldStartIdx++
newStartIdx++
}
  • 适用场景:列表头部插入或删除
  • 性能:O(1)时间复杂度

2. 新后与旧后比较

1
2
3
4
5
6
// 比较新旧列表的最后一个节点
if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, ...)
oldEndIdx--
newEndIdx--
}
  • 适用场景:列表尾部插入或删除
  • 性能:O(1)时间复杂度

3. 新后与旧前比较

1
2
3
4
5
6
// 比较旧列表的第一个节点和新列表的最后一个节点
if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode, ...)
// 移动DOM节点到正确位置
insertBefore(parentElm, oldStartVnode.elm, nextSibling(oldEndVnode.elm))
}
  • 适用场景:列表反转
  • 性能:O(1)时间复杂度

4. 新前与旧后比较

1
2
3
4
5
6
// 比较旧列表的最后一个节点和新列表的第一个节点
if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode, ...)
// 移动DOM节点到正确位置
insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
}
  • 适用场景:元素从尾部移动到头部
  • 性能:O(1)时间复杂度

5. key查找

1
2
3
4
5
// 通过key在旧列表中查找
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
  • 适用场景:复杂的位置变化
  • 性能:O(1)时间复杂度(有key时)

实际应用示例

假如我们现有一份新的newChildren数组和旧的oldChildren数组

1
2
newChildren = ['新子节点1','新子节点2','新子节点3','新子节点4']
oldChildren = ['旧子节点1','旧子节点2','旧子节点3','旧子节点4']
  1. 先把newChildren数组里的所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那就直接进入更新节点的操作;
  2. 如果不同,再把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作;
  3. 如果不同,再把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;
  4. 如果不同,再把newChildren数组里所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;
  5. 最后四种情况都试完如果还不同,那就按照之前循环的方式来查找节点。

在上图中,我们把:
• newChildren数组里的所有未处理子节点的第一个子节点称为:新前;
• newChildren数组里的所有未处理子节点的最后一个子节点称为:新后;
• oldChildren数组里的所有未处理子节点的第一个子节点称为:旧前;
• oldChildren数组里的所有未处理子节点的最后一个子节点称为:旧后;
OK,有了以上概念以后,下面我们就来看看其具体是如何实施的。

新前与旧前

把newChildren数组里的所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那好极了,直接进入之前文章中说的更新节点的操作并且由于新前与旧前两个节点的位置也相同,无需进行节点移动操作;如果不同,没关系,再尝试后面三种情况。

新后与旧后

把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作并且由于新后与旧后两个节点的位置也相同,无需进行节点移动操作;如果不同,继续往后尝试。

新后与旧前

把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作并且由于新后与旧后两个节点的位置也相同,无需进行节点移动操作;如果不同,继续往后尝试。

此时,出现了移动节点的操作,移动节点最关键的地方在于找准要移动的位置。我们一再强调,更新节点要以新VNode为基准,然后操作旧的oldVNode,使之最后旧的oldVNode与新的VNode相同。那么现在的情况是:newChildren数组里的最后一个子节点与oldChildren数组里的第一个子节点相同,那么我们就应该在oldChildren数组里把第一个子节点移动到最后一个子节点的位置,如下图:

从图中不难看出,我们要把oldChildren数组里把第一个子节点移动到数组中所有未处理节点之后。
如果对比之后发现这两个节点仍不是同一个节点,那就继续尝试最后一种情况。

新前与旧后

把newChildren数组里所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;

同样,这种情况的节点移动位置逻辑与“新后与旧前”的逻辑类似,那就是newChildren数组里的第一个子节点与oldChildren数组里的最后一个子节点相同,那么我们就应该在oldChildren数组里把最后一个子节点移动到第一个子节点的位置,如下图:

从图中不难看出,我们要把oldChildren数组里把最后一个子节点移动到数组中所有未处理节点之前。
OK,以上就是子节点对比更新优化策略种的4种情况,如果以上4种情况逐个试遍之后要是还没找到相同的节点,那就再通过之前的循环方式查找。

让我们再通过一个具体的例子来理解双端比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 旧列表:[A, B, C, D]
// 新列表:[A, C, B, D]

// 第1轮:新前与旧前比较 A === A ✓
// 结果:A保持不变,指针移动

// 第2轮:新前与旧前比较 B !== C ✗
// 第3轮:新后与旧后比较 D === D ✓
// 结果:D保持不变,指针移动

// 第4轮:新后与旧前比较 B === B ✓
// 结果:将B移动到新位置
// DOM操作:insertBefore(B, D)

// 第5轮:新前与旧前比较 C === C ✓
// 结果:C保持不变

// 最终结果:只进行了1次DOM移动操作

性能对比

  • 传统方法:需要重新创建B和C两个节点
  • 双端比较:只需要移动B节点一次
  • 性能提升:减少了50%的DOM操作

性能优化技巧

1. key的重要性

key是Vue diff算法的核心,正确使用key可以显著提升性能:

1
2
3
4
5
6
7
8
9
// 好的key使用
<ul>
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
</ul>

// 避免使用index作为key
<ul>
<li v-for="(item, index) in list" :key="index">{{ item.name }}</li>
</ul>

为什么key很重要?

  1. 快速定位:通过key可以快速找到对应的旧节点
  2. 避免重复渲染:相同key的节点可以复用
  3. 减少DOM操作:只更新真正变化的部分

key的最佳实践

  • 使用唯一且稳定的值作为key
  • 避免使用数组索引作为key
  • 避免使用随机值作为key

2. 静态节点优化

静态节点在diff时会被跳过,提升性能:

1
2
3
4
5
6
7
8
9
10
11
// 静态节点会被标记,避免重复diff
function isStatic(node) {
return !!(node.pre || (
!node.hasBindings && // 没有动态绑定
!node.if && !node.for && // 没有v-if或v-for
!isBuiltInTag(node.tag) && // 不是内置标签
isPlatformReservedTag(node.tag) && // 是平台保留标签
!isDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isStaticKey)
))
}

静态节点的特点

  • 没有动态绑定(v-bind、v-on等)
  • 没有条件渲染(v-if、v-for等)
  • 内容不会改变
  • diff时直接跳过

3. 批量更新

Vue会将多次数据变更合并,批量更新DOM:

1
2
3
4
5
6
// Vue会将多次数据变更合并,批量更新DOM
this.message = 'Hello'
this.count = 1
this.name = 'Vue'

// 这些变更会被合并为一次更新

批量更新的原理

  1. 异步更新:数据变更不会立即更新DOM
  2. 队列机制:将更新任务放入队列
  3. 批量执行:在下一个事件循环中批量执行
  4. 去重优化:相同的更新任务会被去重

4. 其他优化技巧

使用v-show代替v-if

1
2
3
4
5
// 频繁切换时使用v-show
<div v-show="visible">内容</div>

// 条件很少改变时使用v-if
<div v-if="condition">内容</div>

避免在模板中使用复杂表达式

1
2
3
4
5
6
7
8
9
// 不好的做法
<div>{{ expensiveComputation() }}</div>

// 好的做法
computed: {
computedValue() {
return this.expensiveComputation()
}
}

合理使用组件

1
2
3
4
5
6
7
8
// 将复杂模板拆分为组件
<template>
<div>
<user-header :user="user" />
<user-content :user="user" />
<user-footer :user="user" />
</div>
</template>

总结

Vue的虚拟DOM和Diff算法是现代前端框架的核心技术之一。通过虚拟DOM,Vue实现了:

  1. 高效的DOM更新:通过Diff算法最小化DOM操作
  2. 跨平台支持:虚拟DOM可以在不同平台上渲染
  3. 开发体验优化:开发者可以专注于业务逻辑

关键要点

  1. 虚拟DOM是真实DOM的JavaScript表示

    • 轻量级的JavaScript对象
    • 包含创建DOM所需的所有信息
    • 可以跨平台使用
  2. Diff算法通过同层比较和key标识来优化性能

    • 同层比较降低算法复杂度
    • key标识提供快速定位
    • 双端比较减少DOM操作
  3. patch过程负责将虚拟DOM转换为真实DOM

    • createElm创建DOM元素
    • patchVnode比较和更新节点
    • updateChildren处理子节点
  4. 合理使用key可以显著提升列表渲染性能

    • 使用唯一且稳定的值
    • 避免使用数组索引
    • 正确使用key可以减少DOM操作