浅谈vue的diff算法
作者:Dolphin_海豚
我们都清楚vue的diff算法很强大,面试官也喜欢问你diff算法的原理,本期文章就带大家认识下这个算法的意义所在,以及其过程
想要聊清楚这个,我们需要先清楚虚拟dom
顺便吆喝一句机会,技术大厂,前后端测试可捞。
虚拟dom
我们先看一个情景,v-for去循环遍历出一个数组
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<ul class="list" id="list">
<li class="item" v-for="item in list">{{item}}</li>
</ul>
</div>
<script>
const { createApp, ref} = Vue
createApp({
setup() {
const list = ref(['html', 'css', 'js'])
return {
list
}
}
}).mount('#app')
</script>
</body>
</html>
里面的li
并不是原始的html
结构,原生html
没有v-for
,这是vue
里面的template
模板,对vue
编译器来说这些东西都是字符串,vue
编译器会将这些代码编译成真实的html
。
vue
的编译器也就是编译函数compiler
会把ul
编译成真实的html
结构,这就需要先解析,通过各种正则匹配,解析成一个对象,我大概用js
对象去模拟下最终的效果,这其实就是虚拟dom
let Dom = { // 虚拟dom
tagName: 'ul',
props: {
class: 'list',
id: 'list'
},
children: [
{
tagName: 'li',
props: {
class: 'item'
},
children: ['html']
},
{
tagName: 'li',
props: {
class: 'item'
},
children: ['css']
},
{
tagName: 'li',
props: {
class: 'item'
},
children: ['js']
}
]
}
虚拟dom
本质上就是一个对象,vue
中的编译函数compiler
将template
模板代码编译成虚拟dom
,再然后将虚拟dom
编译成html
代码
假设我现在将数据源最后一个元素js
更改成vue
,那么虚拟dom最后是会变成最后的一个children
的文本内容变成vue
的
我们现在思考这个过程,对于编译器来说,这个过程一定是会重新编译的,也就是数据源一旦变更,编译器就会重新工作,编译出一份新的虚拟dom结构
对于编译器来说,现在有两种方案去解决这个问题,一是直接废除原来的虚拟dom
,重新从头开始创建一个新的虚拟dom
,二是精准找到哪个属性发生了变更,并去修改它,比如这里仅仅最后一个li
发生了变更,那我就去修改原虚拟dom
的最后一个li即可
很明显,后者方案性能更优,但是yyx选择了前者,只要任意数据源发生了变更,就会让编译器重新从头编译出一份新的虚拟dom,这会加重编译器的负担,编译过程仅仅是靠v8工作的,我们要知道,v8引擎的性能是很高的,其实是不怕这个负担,如果选择后者这个方案,就需要专门搞一个算法去查找哪个地方需要修改,与其耗费大量人力去弄个算法查找数据源的变更以及虚拟dom
特定子元素的修改,不如直接重新编译一份新的虚拟dom
v8:我吃柠檬
虚拟dom
最终是会被生成一份真实的dom
结构的,真实的dom
会被拿到浏览器去渲染,也就是回流重绘,要是谈到回流重绘就要考虑到性能问题,因为重绘是非常占用浏览器性能的
这个时候问题又来了,这样不就是两份虚拟dom
吗,一个是老的,一个是新的,如果还是按照前面那样,用新的虚拟dom
重新渲染到浏览器是很难受的,此时承担负担的可不再是v8,这可是重绘,重绘是浏览器负责的,而实际上我仅仅是修改了ul
中的一个li
,那就重绘最后一个li
即可,可不能再任性了
安利这个画图网站,相当的niceExcalidraw
因此yyx这个时候必须考虑到这个问题,那就不能重新让浏览器从头渲染了,所以这就需要找到两份虚拟dom
哪些内容需要修改,不需要修改的就保留
这就是著名的diff
算法,找出两份虚拟dom
的不同去修改。diff
算法之后会产生一个补丁包path
,然后再去拿着这个补丁包path
,也就是不同点去html
身上求修改,这样就能具体改掉某个子容器
这样就大大降低了浏览器的重绘开销~
diff算法
diff
全称就是different
,就是找不同,找新老虚拟dom
的不同,找到并产生一个补丁包
面试官很喜欢问你diff的查找过程是怎样的
diff算法代码我们不用管,只需要知道原理即可
- 同层比较,是不是相同的节点,不相同直接废弃OldVDom
- 是相同节点,比较节点上的属性,产生一个补丁包path
- 继续比较子节点下一层的子节点,采用双端队列的方式,尽量复用,产生一个补丁包
- 同上
前面两点我们可以很好理解,就是一个同层比较,但是第三点的双端队列如何理解,这就考虑到比如我的这里三个li的顺序是不同的,其实是可以进行复用的,采用双端队列就可以很好应对这种情况
双端队列的比较是头头比较,头尾比较,尾尾比较,尾头比较
补丁包其实是一个对象,记录了哪里修改
所以diff
算法有个小缺陷,比如这里,我仅仅给ul
多套一层div
,那么原来的VDom
就会废除掉,我们站在上帝视角肯定会觉得可以复用,但是这里如果你发现了第一层不同还去继续比较diff算法的代码量就会指数级增加,显得过于复杂了
面试官:v-for为何不建议使用index作为key
刚刚说了,对于diff算法,只要是子元素的顺序发生了变化其实都是可以进行复用的,这就是第三步骤的双端队列比较,还是上面的那个栗子,我如果用index
作为key
,然后我添加一个翻转函数,最终是js,css,html
,模拟新老dom
如下
let OldDom = [
{
tagName: 'li',
key: 0,
value: 'html'
},
{
tagName: 'li',
key: 1,
value: 'css'
},
{
tagName: 'li',
key: 2,
value: 'js'
},
]
let NewDom = [
{
tagName: 'li',
key: 0,
value: 'js'
},
{
tagName: 'li',
key: 1,
value: 'css'
},
{
tagName: 'li',
key: 2,
value: 'html'
},
]
翻转后,OldDom
的最后一个li
应该是和NewDom
的第一个li
相同,但是你会发现,其中的key
不同,因此双端比较头尾的时候就会认定不同,因为你用的下标作key
,无论位置如何颠倒,下标永远认定第一个位置是0
所以如果用了index
作为key
,那么像是顺序调到后的子元素是无法进行复用的,因此diff
算法本身为你考虑好的双端比较就派不上用场了
既然如此,那我不用key
行不行?不用key
的话,如果两个子元素相同,比如我现在的list
为html和js,js
,那么新老dom
如下
let OldDom = [
{
tagName: 'li',
value: 'html'
},
{
tagName: 'li',
value: 'js'
},
{
tagName: 'li',
value: 'js'
},
]
let NewDom = [
{
tagName: 'li',
value: 'js'
},
{
tagName: 'li',
value: 'js'
},
{
tagName: 'li',
value: 'html'
},
]
这样就会产生个问题,OldDom
有两个一样的,不知道留下哪一个,按道理找到了相同的节点是要留下来进行复用的,这里就不清楚保留第二个还是第三个了,就会导致查找不准确的问题,这就会让diff
算法调用额外的手段,占用性能
好了现在你就明白了,原来v-for
存在的key
的意义就是为了让diff
算法比较的时候性能更高,否则可能碰到多个相同的子元素,不清楚保留哪一个
v-for
不用index
作为key
就是因为diff
算法已经针对了相同的子元素无论顺序是可以进行复用的,而index
只要位置变了就会让整个子元素变,因此无法保留,这样就会导致重新生成子元素,浪费性能
这种时候肯定有小朋友在想,能否用随机数作key
,肯定是不行的!Math.random
会在你保存代码时候,vue
编译器重新执行代码,随机数又会重新变化,因此新老dom
永远都不会相等
最后
vue
使用的diff
算法很强大,考虑到可以复用不同位置的子元素而使用双端队列比较,既然如此,我们就不建议使用index
作为key
,这样人家辛苦打造的双端diff
就无用武之地,浪费性能!key
不用也会浪费性能,实际上这个key
一般都是使用后端返回的唯一id。
- 点赞
- 收藏
- 关注作者
评论(0)