通过篇文字,你能了解到
通常程序在运行时,状态会不断发生变化。每当状态发生变化时,都需要重新渲染。
在这种情况下,最简单粗暴的方式是,把所有DOM全删了,然后使用状态重新生成一份DOM。但是,访问DOM是非常耗费性能的,这会造成相当多性能的浪费。
通常状态变化的只有几个节点,只需要更新这几个节点就可以了,问题是这么找到它们。这个问题有很多种解决方案。
虚拟DOM是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染。在重新渲染之前,会使用新生成的虚拟节点树和旧的虚拟节点树进行对比,只渲染不同的部分。
虚拟DOM能够通过比对后进行针对性更新,但不是唯一的方案。Vue.js可以观察到状态的变化,并且绑定到视图,根本不需要比对。
事实上,在Vue2.0之前是这样实现的。但是这样做有一定代价,因为粒度太细,每绑定一个都会有一个对应的watcher来观察状态的变化,这样就会有一些内存开销以及一些依赖追踪的开销。对于大型项目来说,这个开销是非常大的。
所以,从Vue2.0开始,Vue引入了虚拟DOM。从一个节点生成一个Watcher实例变为一个组件生成一个Watcher实例。也就是说,即便一个组件内有10个节点使用了某个状态,但其实也只有一个Watcher在观察这个状态的变化。状态变化时,只能通知到组件,然后在组件内部通过虚拟DOM去比对与渲染。
虚拟DOM主要做了两件事情:
简单方便
如果使用手动操作真实DOM来完成页面,繁琐又容易出错,在大规模应用下维护起来也很困难。
性能好
使用虚拟DOM,能够有效避免真实DOM数频繁更新,减少重绘与回流,提高性能。
跨平台(最重要)
Vue和React借助虚拟DOM, 带来了跨平台的能力,一套代码多端运行。
首屏加载时间更长
因为需要先生成虚拟DOM再渲染出真实的节点,多了生成虚拟DOM这一个步骤。在页面节点多的情况下会增加耗时。
极端场景下不是最优解
比如当前页面的节点全部替换,那么生成虚拟DOM再去对比替换,都是无效操作。
在Vue.js中存在一个VNode类,使用它可以实例化不同类型的vnode实例,而不同类型的vnode实例各自表示不同类型的DOM元素。
例如:DOM元素有元素节点、文本节点和注释节点,vnode实例也会对应着有元素节点、文本节点和注释节点等。
vnode是Javascript中一个普通的对象,这个对象的属性上保存了生成DOM节点所需要的一些属性。
哈哈
{
'div',
props:{ id:'app', class:'container' },
children: [
{ tag: 'h1', children:'哈哈' }
]
}
状态侦测策略
前面已经说明,Vue.js目前对状态侦测策略采用了中等粒度。当状态发生变化时,只通知到组件级别。
如果没有虚拟DOM,只要组件使用的众多状态中有一个状态发生了变化,那么整个组件就要重新渲染。这明显浪费性能。
为什么操作DOM慢
线程之间通信
因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信
回流重绘
操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。
总结
生成vnode和进行比对的过程也需要消耗时间,但是DOM操作的速度远不如JS的运算速度。因此把大量的DOM操作搬运到JS中,使用patch算法来计算出真正需要更新的节点,最大限度地减少DOM操作。
本质就是用JS的运算成本来替换DOM操作的执行成本。
同层对比
Diff算法比较只会在同层级进行, 不会跨层级比较
深度优先
比对到相同的节点,会对两个节点的所有子节点都比较完成,才会回到同层的节点继续比对
判断相同(复用)
两个虚拟节点的标签类型和key值均相同,但input元素还要看type属性。如果相同会进行复用,只修改内部的一些属性。
新旧两个虚拟节点经过对比,如果相同,就会直接复用
连接真实DOM
先将旧节点对应的真实dom赋值到新节点(真实dom连线到新子节点)
对比属性并更新
然后循环对比新旧节点的属性,看看有没有不一样的地方,将有变化的更新到真实dom中
对比子节点
比较新旧两个节点的所有子节点
(下面了解一下重新渲染时的对比过程。)
双指针
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]
}
源码
patchKeyedChildren
和`patchUnkeyedChildren``是存在key的时候执行的,是正式的开启diff的流程,
patchUnkeyedChildren`针对没有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的情况下,会直接复用,修改属性就可以了。
VUE文档也说明了 这个默认的模式是高效的,但是只适用于不依赖子组件状态或临时 DOM 状态 (例如:表单输入值) 的列表渲染输出
以 a、b、c、d 四个div为例进行说明
[
'1', // A
'2', // B
'3', // C
'4', // D
'5' // E
]
vm.dataList = [4, 1, 3, 5, 2] // 数据位置替换
没有key的情况, 节点位置不变,但是节点innerText内容更新了
[
'<div>4div>', // A
'<div>1div>', // B
'<div>3div>', // C
'<div>5div>', // D
'<div>2div>' // E
]
有key的情况,dom节点位置进行了交换,但是内容没有更新
// <div v-for="i in dataList" :key='i'>{{ i }}div>
[
'<div>4div>', // D
'<div>1div>', // A
'<div>3div>', // C
'<div>5div>', // E
'<div>2div>' // B
]
用 index 作为 key 时,在对数据进行破坏顺序的操作
的修改时,会产生没必要的真实 DOM更新,从而导致效率低
// 在前面增加一个div,内容为6
// 修改了所有div的内容,同时新增了F
// 如果在末尾新增就不会产生这个问题
[
'<div>6div>', // A
'<div>1div>', // B
'<div>2div>', // C
'<div>3div>', // D
'<div>4div>', // E
'<div>5div>' // F
]
如果结构中包含输入类的 DOM,会产生错误的 DOM 更新
(不添加key也同样有这个问题)
举例
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("button", {
onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
}, "按钮"))
}
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
了吗,这就是静态标记。
patchVnode
阶段如果是普通节点,会通过内置的update钩子全量进行新旧对比,然后更新动态属性
的的名称,dynamicProps
修改了哪些属性
,patchFlag
例子
hello
{{msg}}
// 动态绑定id,对文字进行修改
Vue3生成的vnode会有dynamicProps
patchFlag
属性
dynamicProps
标记出来了动态的属性名称,只有 id 是需要动态对比的
patchFlag
是9
,9
代表的就是文字和属性都有修改
主要是针对乱序情况
最长递增子序列(减少移动)
概念:在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。最长递增子序列中的元素在原序列中不一定是连续的。例如:{ 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
流程大致说明
keyToNewIndexMap
详细过程
// 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)
细致分析,尤雨溪直播中提到 vue3.0 diff 算法优化细节 - 掘金 (juejin.cn)
图解Diff算法——Vue篇-技术圈 (proginn.com)