写在前面
在前端中,主要涉及的基本上就是 DOM的相关操作 和 JS,我们都知道 DOM 操作是比较耗时的,那么在我们写前端相关代码的时候,如何减少不必要的 DOM 操作便成了前端优化的重要内容。
虚拟DOM(virtual DOM)
在 jQuery 时代,基本上所有的 DOM 相关的操作都是由我们自己编写(当然博主是没有写过 jQuery 滴,可能因为博主太年轻了吧,错过了 jQuery 大法的时代),如何操作 DOM, 操作 DOM 的时机应该如何安排成了决定性能的关键,而到了 Vue、React 这些框架盛行的时代,框架采用数据驱动视图,封装了大量的 DOM 操作细节,使得更多的 DOM 操作细节的优化从开发者自己抉择、控制转移到了框架内部,那么在学会使用框架后,如果想要更加深入学习框架,那就需要搞懂框架封装的底层原理,其中非常核心的一部分就是虚拟DOM(virtual DOM)
什么是虚拟 DOM
简而言之,就是通过 JS 来模拟 DOM 结构,关于纠结以什么 JS 数据结构来模拟 DOM 并没有一套标准,只要能完全覆盖 DOM 的所有结构即可,下面以较为通用的方式演示一下。
通过对 DOM 结构的分析,我们可以用 tag 表示 DOM 节点的类型,props 表示 DOM 节点的所有属性,包括 style、class 等,children 表示子节点(没有子节点则表示内容),这样子我们就把整个 DOM 通过 JS 模拟出来了,然后呢? 然后看看下一章~~~
// DOM
<div class="container">
<h1 style="color: black;" class="title">HeiHei~~</h1>
<div class="inner-box">
<span class="myname">I am Yimwu</span>
</div>
</div>
// VDOM
let vdom = {
tag: 'div',
props: {
classname: 'container',
},
children: [
{
tag: 'h1',
props: {
classname: 'title',
style: {
color: 'black'
}
},
children: 'HeiHei~~'
},
{
tag: 'div',
props: {
classname: 'inner-box',
},
children: [
{
tag: 'span',
props: {
classname: 'myname'
},
children: 'I am Yimwu'
}
]
}
]
}
虚拟 DOM 的作用
当我们能够在 JS 中模拟出 DOM 结构后,我们就可以通过 JS 来对 DOM 操作进行优化了,怎么优化呢,这个时候 diff 算法就该登场了。当我们通过 JS 对 DOM 进行修改后,并不会直接触发 DOM 更新,而是会先生成一个新的虚拟 DOM,然后利用 diff 算法与修改前生成的虚拟 DOM 进行比较,找出需要修改的点,最后进行真正的 DOM 更新操作
Vue 源码中的 diff 算法
patch.js 路径
Vue 中的 diff 算法相关代码主要在 patch.js 文件中,路径如下图
参考 前端进阶面试题详细解答
patch 函数
1、如果新节点不存在(vnode is undefined),直接执行 destroyhook 并返回
2、如果旧节点不存在(oldVnode is undefined),直接创建新节点
3、如果新节点与旧节点都存在则进入下一层判断,对节点进行比对
4、使用 sameVnode 函数判断新节点与旧节点是否为相同的节点,如果相同则递进对比其子节点,如果不同则直接重新创建新节点
patchVnode 函数
1、如果新节点为文本节点(isUndef(vnode.text) === false) 且 新旧节点文本不同(oldVnode.text !== vnode.text),则直接设置(setTextContent)元素(ele)的文本
2、如果新节点不是文本节点,则又分为以下几种情况
2.1、如果新节点和旧节点都有 child,则调用 updateChildren 更新子节点
2.2、如果只有新节点有 child,则直接添加子节点(addVnode)
2.3、如果只有旧节点有 child,则直接删除子节点(removeVnodes)
2.4、如果旧节点有 text,则删除 text(setTextContext)
updateChildren
updateChildren 函数采用的是双端 diff,所谓双端,也就是从新旧节点的两端同时向中间比较,比较的步骤如下:
1、新开始节点 vs 旧开始节点,如果相同则直接遍历其 children,调用 patchVnode 比较子元素差异,指针往前走一步
2、新结束节点 vs 旧结束节点,如果相同则直接遍历其 children,调用 patchVnode 比较子元素差异,指针往前走一步
3、旧开始节点 vs 新结束节点,如果相同则先把新结束节点移动到旧开始节点的前一个位置,然后遍历其 children,调用 patchVnode 比较子元素差异,指针往前走一步
4、旧结束节点 vs 新开始节点,如果相同则先把新开始节点移动到旧结束节点的后一个位置,然后遍历其 children,调用 patchVnode 比较子元素差异,指针往前走一步
5、若前面4种情况都没有命中,则将遍历新节点,将子节点组个与旧节点的子节点进行一一比较,逐个遍历对比,没有匹配到的则直接重建元素
diff 算法中的 Key 值
从 diff 算法的 updateChildren 函数中我们知道,采用双端 diff 算法会进行新的开始、结束节点和旧的开始、结束节点做对比,当都没有匹配上的时候会采用完全遍历的方式进行一一比较,那么这个时候 key 就发挥出作用了,当我们从新的节点中遍历节点,拿去和旧节点匹配时,如果 key 匹配上的话,那么就表明该元素只是位置发生了移动,直接调整位置后对其子节点进行(sameVnode)检查即可,而不需要完全重建元素,大大节省了性能。
v-for 中 key 值是否可以为 index
答案当然是不可以,举个例子,我们来看下面两个 vdom,从 num 值我们可以发现,新、旧两个 vdom 是两个顺序相反的数组生成的 vdom,安装正常的方式,应该是简单调换一下顺序,直接复用3个元素即可,而当我们以 index 作为 key 时,情况就不同了,由于 index 永远都是从 0 开始,所以这两个 vdom 的 key 值从开始到结束,看起来都是相同的,这就导致了当我们去对比 key 值的时候会发现他们每个都是匹配的,然后对其子节点进行 patchVnode,这个时候由于 props 不同,即 num 不同,因此会触发对应的响应式值的更新机制,而且在这个过程中还会调用多个更新相关的钩子函数,如果定义的属性非常多的话,触发更新将会导致非常大的性能损耗,因此,在使用 v-for 的时候,建议使用类似 id 这种唯一标识的字段替代 index,避免不必要的性能损耗!
const oldVdom = {
tag: "div",
children: [
{
tag: "div",
key: 0,
num: 1
},
{
tag: "div",
key: 1,
num: 2
},
{
tag: "div",
key: 2,
num: 3
},
]
}
const newVdom = {
tag: "div",
children: [
{
tag: "div",
key: 2,
num: 3
},
{
tag: "div",
key: 0,
num: 1
},
{
tag: "div",
key: 1,
num: 2
},
]
}
总结
对于 VDOM 以及 diff 算法的学习,体会到了前端对于性能的极致追求,通过通读 vdom 源码,基本能够从更加深刻的角度去理解采用 VDOM 的目的,以及 key 值在 diff 算法中的真正作用,也能够从更加底层的角度理解为什么不推荐使用 index 作为 key 这个 Best Practices!