通过篇文字,你能了解到
- 为什么引入虚拟DOM?
- 为什么操作DOM慢?
- Vue的怎么对比节点?怎么复用节点?
- v-for中key 的作用是什么?没有key为什么反而快了?
- Vue3在diff算法方面做了哪些优化?
一、为什么会出现虚拟DOM
1. 解决代码维护问题
- Web早期,页面交互比较简单,没有很复杂的状态需要管理,也不太需要频繁地操作DOM,用jQuery来开发就可以满足需求。
- 随着页面复杂度的提高,程序需要维护的状态越来越多,DOM操作也越来约频繁。当状态变得越来越多,DOM操作越来越频繁时,使用jQuery来开发页面,代码会有相当多的代码实在操作DOM,程序状态也很难管理。
- 这就是命令式操作DOM的问题,虽然简单易用,但是不好维护。
2. 虚拟DOM高效更新视图
-
通常程序在运行时,状态会不断发生变化。每当状态发生变化时,都需要重新渲染。
-
在这种情况下,最简单粗暴的方式是,把所有DOM全删了,然后使用状态重新生成一份DOM。但是,访问DOM是非常耗费性能的,这会造成相当多性能的浪费。
-
通常状态变化的只有几个节点,只需要更新这几个节点就可以了,问题是这么找到它们。这个问题有很多种解决方案。
-
虚拟DOM是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染。在重新渲染之前,会使用新生成的虚拟节点树和旧的虚拟节点树进行对比,只渲染不同的部分。
二、为什么引入虚拟DOM
1. Vue状态更新视图的方案变更
-
虚拟DOM能够通过比对后进行针对性更新,但不是唯一的方案。Vue.js可以观察到状态的变化,并且绑定到视图,根本不需要比对。
-
事实上,在Vue2.0之前是这样实现的。但是这样做有一定代价,因为粒度太细,每绑定一个都会有一个对应的watcher来观察状态的变化,这样就会有一些内存开销以及一些依赖追踪的开销。对于大型项目来说,这个开销是非常大的。
-
所以,从Vue2.0开始,Vue引入了虚拟DOM。从一个节点生成一个Watcher实例变为一个组件生成一个Watcher实例。也就是说,即便一个组件内有10个节点使用了某个状态,但其实也只有一个Watcher在观察这个状态的变化。状态变化时,只能通知到组件,然后在组件内部通过虚拟DOM去比对与渲染。
2. Vue中的虚拟DOM
虚拟DOM主要做了两件事情:
- 提供与真实DOM节点对应的虚拟节点vnode,就是用对象去描述DOM。
- 每次生成虚拟节点vnode都会缓存下来,将本次生成的虚拟节点vnode和旧虚拟节点oldVnode进行比对,判断出哪些节点发生了变化,从而只对发生了变化的节点进行更新操作。
3. 虚拟DOM的优点
-
简单方便
如果使用手动操作真实DOM来完成页面,繁琐又容易出错,在大规模应用下维护起来也很困难。
-
性能好
使用虚拟DOM,能够有效避免真实DOM数频繁更新,减少重绘与回流,提高性能。
-
跨平台(最重要)
Vue和React借助虚拟DOM, 带来了跨平台的能力,一套代码多端运行。
4. 虚拟DOM的缺点
-
首屏加载时间更长
因为需要先生成虚拟DOM再渲染出真实的节点,多了生成虚拟DOM这一个步骤。在页面节点多的情况下会增加耗时。
-
极端场景下不是最优解
比如当前页面的节点全部替换,那么生成虚拟DOM再去对比替换,都是无效操作。
三、vnode
-
在Vue.js中存在一个VNode类,使用它可以实例化不同类型的vnode实例,而不同类型的vnode实例各自表示不同类型的DOM元素。
例如:DOM元素有元素节点、文本节点和注释节点,vnode实例也会对应着有元素节点、文本节点和注释节点等。 -
vnode是Javascript中一个普通的对象,这个对象的属性上保存了生成DOM节点所需要的一些属性。
<div id="app">
<h1>哈哈</h1>
</div>
{
'div',
props:{ id:'app', class:'container' },
children: [
{ tag: 'h1', children:'哈哈' }
]
}
四、虚拟DOM比对(diff算法)
1. 为什么要对比
-
状态侦测策略
前面已经说明,Vue.js目前对状态侦测策略采用了中等粒度。当状态发生变化时,只通知到组件级别。
如果没有虚拟DOM,只要组件使用的众多状态中有一个状态发生了变化,那么整个组件就要重新渲染。这明显浪费性能。
-
为什么操作DOM慢
-
线程之间通信
因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信
-
回流重绘
操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。
-
-
总结
生成vnode和进行比对的过程也需要消耗时间,但是DOM操作的速度远不如JS的运算速度。因此把大量的DOM操作搬运到JS中,使用patch算法来计算出真正需要更新的节点,最大限度地减少DOM操作。
本质就是用JS的运算成本来替换DOM操作的执行成本。
2. 基本思路
- 把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点
3. 对比策略
-
同层对比
Diff算法比较只会在同层级进行, 不会跨层级比较
-
深度优先
比对到相同的节点,会对两个节点的所有子节点都比较完成,才会回到同层的节点继续比对
-
判断相同(复用)
两个虚拟节点的标签类型和key值均相同,但input元素还要看type属性。如果相同会进行复用,只修改内部的一些属性。
4. 复用节点
新旧两个虚拟节点经过对比,如果相同,就会直接复用
-
连接真实DOM
先将旧节点对应的真实dom赋值到新节点(真实dom连线到新子节点)
-
对比属性并更新
然后循环对比新旧节点的属性,看看有没有不一样的地方,将有变化的更新到真实dom中
-
对比子节点
比较新旧两个节点的所有子节点
(下面了解一下重新渲染时的对比过程。)
4. 对比根节点
- 相同(复用节点)
- 对比新节点和旧节点的属性,有变化的更新到真实dom中
- 当前新旧两个节点处理完成,开始 「对比子节点」
- 不同(新建)
- 新节点递归, 「新建元素」
- 旧节点 「销毁元素」
5. 对比子节点
-
双指针
vue使用两个指针分别指向新旧子节点树的头和尾
流程如图所示
-
比较流程
注意:
每次对比完一个节点,头指针或尾指针会向中间移动到下一个节点,继续进行比对。
当(新/旧)头指针超过尾指针的时候,循环结束,旧虚拟节点上剩余的所有节点对应的真实DOM会被移除。新虚拟节点上剩余的所有节点会创建真实DOM。
具体流程
-
首先比较新旧的头,直到第一个不相同的,往下
-
然后比较新旧的尾,直到第一个不相同的,往下
-
然后会做头尾,尾头的比较,只要比较结果相同,就移动指针,就重复前面第1、2步的比对
-
如果前面的比较发现都不相同,无法复用,那就需要:
-
把所有oldVnode的
key
做一个映射到oldVnode的索引key -> index
表(遍历oldVnode) -
然后用新
vnode
的key
去找出在旧节点中可以复用的位置(遍历vnode),如果存在,就复用,不存在就重建。
该对比需要遍历两次,时间复杂度为 O(n^2),在Vue3中做了优化
-
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 在旧列表中找到 和新列表头节点key 相同的节点 let newtKey = newStartNode.key, oldIndex = prevChildren.findIndex(child => child.key === newKey); if (oldIndex > -1) { let oldNode = prevChildren[oldIndex]; patch(oldNode, newStartNode, parent) parent.insertBefore(oldNode.el, oldStartNode.el) // 复用后,设置为 undefined prevChildren[oldIndex] = undefined } newStartNode = nextChildren[++newStartIndex] }
-
6. 没有key的子节点对比
源码
- 在比较子节点的过程中,存在
patchKeyedChildren
和`patchUnkeyedChildren`` - ``patchKeyedChildren
是存在key的时候执行的,是正式的开启diff的流程,
patchUnkeyedChildren`针对没有key的情况。 - 也就是说,子节点有没有key值,进行的对比算法是不一样的,前面讲的是有key的情况。
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
/* 对于存在key的情况用于diff算法 */
patchKeyedChildren()
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
/* 对于不存在key的情况,直接patch */
patchUnkeyedChildren()
}
}
没有key的流程如下:
依次遍历
新老vnode进行对比- 如果老节点数量大于新的节点数量 ,
移除
多出来的节点。 - 如果新的节点数量大于老节点的数量,
新增
节点。
特点
-
准确性低
如果两个相同类型的子节点只需要调换位置就能直接复用,不需要修改的条件下:
- 没有key:可以复用,但是需要对属性进行修改。
- 拥有key:能快速找到vnode对应的oldVnode,然后直接复用,只需要移动位置。
-
为什么可能更快
因为准确度降低了,在遍历模板
简单
的情况下,会导致虚拟新旧节点对比更快,节点也会复用。比如:同时增加两个和删除两个,没有key的情况下,会进行增删操作;而有key的情况下,会直接复用,修改属性就可以了。
VUE文档也说明了
这个默认的模式是高效的,但是只适用于不依赖子组件状态或临时 DOM 状态 (例如:表单输入值) 的列表渲染输出
五、key的作用
以 a、b、c、d 四个div为例进行说明
[
'<div>1</div>', // A
'<div>2</div>', // B
'<div>3</div>', // C
'<div>4</div>', // D
'<div>5</div>' // E
]
vm.dataList = [4, 1, 3, 5, 2] // 数据位置替换
1. 没有设置key
没有key的情况, 节点位置不变,但是节点innerText内容更新了
[
'<div>4</div>', // A
'<div>1</div>', // B
'<div>3</div>', // C
'<div>5</div>', // D
'<div>2</div>' // E
]
2. 设置了key
有key的情况,dom节点位置进行了交换,但是内容没有更新
// <div v-for="i in dataList" :key='i'>{{ i }}</div>
[
'<div>4</div>', // D
'<div>1</div>', // A
'<div>3</div>', // C
'<div>5</div>', // E
'<div>2</div>' // B
]
3. 为什么不能设置为index
-
用 index 作为 key 时,在对数据进行
破坏顺序的操作
的修改时,会产生没必要的真实 DOM更新,从而导致效率低// 在前面增加一个div,内容为6 // 修改了所有div的内容,同时新增了F // 如果在末尾新增就不会产生这个问题 [ '<div>6</div>', // A '<div>1</div>', // B '<div>2</div>', // C '<div>3</div>', // D '<div>4</div>', // E '<div>5</div>' // F ]
-
如果结构中包含输入类的 DOM,会
产生错误的 DOM 更新
(不添加key也同样有这个问题)
4. 正确用法
- 用唯一值id做key(我们可以用前后端交互的数据源的id为key)。
5. 总结
- Vue是通过标签名和key来判断两个新旧节点是否相同。缺少key会缺少准确判断并复用节点的依据。在Vue内部会执行与设置key不同的操作。
- key值的正确做法是设置为唯一的id。
六、Vue3 diff算法优化
1. 事件缓存
- 在vue2 中,其实每次更新,render函数跑完之后vnode绑定的事件都是一个全新生成的function,就算它们内部的代码是一样的。
- Vue3缓存我们的事件,事件的变化不会引起重新渲染
举例
<button @click="handleClick">按钮</button>
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("button", {
onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
}, "按钮"))
}
2. 静态标记
Vue3在 patch 过程中就会判断静态标记来跳过一些静态节点对比。
举例
<div id="app">
<div>哈哈</div>
<p>{{ age }}</p>
</div>
Vue2编译的结果是:
with(this){
return _c(
'div',
{attrs:{"id":"app"}},
[
_c('div',[_v("哈哈")]),
_c('p',[_v(_s(age))])
]
)
}
在 Vue3 中编译的结果是这样的:
const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "哈哈", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_hoisted_2,
_createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
]))
}
看到上面编译结果中的 -1
和 1
了吗,这就是静态标记。
3. 静态提升
- 在 Vue2 里每当触发更新的时候,不管元素是否参与更新,每次都会全部重新创建vnode
- Vue3 中会把这个不参与更新的元素保存起来,只创建一次,之后在每次渲染的时候不停地复用。
4. 节点变更类型细分
- vue2 中
patchVnode
阶段如果是普通节点,会通过内置的update钩子全量进行新旧对比,然后更新 - Vue3 增加了动态属性标记和变更属性标记,所以只需要对比动态属性和变更的属性
- 标记出
动态属性
的的名称,dynamicProps
- 对变更的vnode进行标记,表示
修改了哪些属性
,patchFlag
- 标记出
例子
<div>
<span>hello</span>
<span :id="hello" attr="test">{{msg}}</span>
</div>
// 动态绑定id,对文字进行修改
Vue3生成的vnode会有dynamicProps
patchFlag
属性
dynamicProps
标记出来了动态的属性名称,只有 id 是需要动态对比的
patchFlag
是9
,9
代表的就是文字和属性都有修改
5. 子节点对比过程优化
主要是针对乱序情况
-
最长递增子序列(减少移动)
概念:在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。最长递增子序列中的元素在原序列中不一定是连续的。例如:{ 3,5,7,1,2,8 } 的 LIS 是 { 3,5,7,8 },长度为 4
对于移动、删除、添加、更新这些操作,其中最复杂的就是移动操作,Vue中采用最长
递增子序列
来求解不需要移动的元素有哪些,所以这个算法的目的就是最大限度的减少移动
。var prev = [1, 2, 3, 4, 5, 6]// 移动前 var next = [1, 3, 2, 6, 4, 5]// 修改移动后 // 这个时候,可以找到[1,2,4,5],那么需要移动3,6 // 如果是[1,3,6],那么就需要移动2,4,5
-
流程大致说明
- 头和头,尾和尾比较,两种比较都不同后,如果数据还没有比较完成。
- 建一个还未比较的新vnode的key和新索引的映射
keyToNewIndexMap
- 然后用key去获取oldVnode的旧索引,按新索引的顺序放到Map数据中,得到新旧索引的对应关系,从而知道怎么移动节点位置。
-
详细过程
// old arr ["a", "b", "c", "d", "e", "f", "g", "h"] // new arr ["a", "b", "d", "f", "c", "e", "x", "y", "g", "h"]
第1步:从头到尾开始比较,[a,b]是sameVnode,进入patch,到 [c] 停止;
第2步:从尾到头开始比较,[h,g]是sameVnode,进入patch,到 [f] 停止;
第3步:判断新旧数据是否已经比较完毕,如果旧数据比较完毕,新数据多余的说明是新增的。如果新数据比较完毕,旧数据多余的说明是删除的。
第4步:如果新旧vnode都没有比较完毕,
-
建一个还未比较的新vnode的key和index的映射
keyToNewIndexMap
新节点: a b e c d i g h 得到了一个值为 {e:2,c:3,d:4,i:5} key与index的映射
-
循环一遍
oldVnode
剩余数据,通过keyToNewIndexMap
中的key获取到在oldVnode中的旧索引,将获取到的旧索引保存到Map数据中。注意:该旧索引Map和
keyToNewIndexMap
长度一样,不足的用-1填充。而且旧索引Map中的排序是按照keyToNewIndexMap
中key值的顺序排列。也就是旧索引按照新索引的顺序排列。通过key,得到新旧索引的对应关系,也就知道怎么去移动。 -
从尾到头循环一下旧索引Map,不是-1 的,说明vnode在oldVnode中存在,是-1的说明是新增节点,接下来进行进行移动/新增/删除就可以了。
-
使用最长递增子序列可以最大程度的减少 DOM 的移动,达到最少的 DOM 操作。
-
七、参考
《深入浅出Vue.js》——刘博文
深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别 - 掘金 (juejin.cn)