浅谈vue的diff算法

举报
yd_244540595 发表于 2024/09/05 17:49:49 2024/09/05
【摘要】 作者:Dolphin_海豚我们都清楚vue的diff算法很强大,面试官也喜欢问你diff算法的原理,本期文章就带大家认识下这个算法的意义所在,以及其过程想要聊清楚这个,我们需要先清楚虚拟dom顺便吆喝一句机会,技术大厂,前后端测试可捞。虚拟dom我们先看一个情景,v-for去循环遍历出一个数组<!DOCTYPE html><html lang="en"><head> <meta cha...

作者: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中的编译函数compilertemplate模板代码编译成虚拟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即可,可不能再任性了

1.png

安利这个画图网站,相当的niceExcalidraw

因此yyx这个时候必须考虑到这个问题,那就不能重新让浏览器从头渲染了,所以这就需要找到两份虚拟dom哪些内容需要修改,不需要修改的就保留

这就是著名的diff算法,找出两份虚拟dom的不同去修改。diff算法之后会产生一个补丁包path,然后再去拿着这个补丁包path,也就是不同点去html身上求修改,这样就能具体改掉某个子容器

2.png

这样就大大降低了浏览器的重绘开销~

diff算法

diff全称就是different,就是找不同,找新老虚拟dom的不同,找到并产生一个补丁包

面试官很喜欢问你diff的查找过程是怎样的

diff算法代码我们不用管,只需要知道原理即可

  1. 同层比较,是不是相同的节点,不相同直接废弃OldVDom
  2. 是相同节点,比较节点上的属性,产生一个补丁包path
  3. 继续比较子节点下一层的子节点,采用双端队列的方式,尽量复用,产生一个补丁包
  4. 同上

前面两点我们可以很好理解,就是一个同层比较,但是第三点的双端队列如何理解,这就考虑到比如我的这里三个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的话,如果两个子元素相同,比如我现在的listhtml和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。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。