Code前端首页关于Code前端联系我们

Vue3的diff算法是怎么工作的?和Vue2有什么本质区别?

terry 42分钟前 阅读数 18 #Vue

很多人接触Vue,最初可能只是用着顺手,但慢慢会好奇:为什么列表更新的时候,不是整个销毁重建,而是只改需要改的地方?这就是虚拟DOM和diff算法的功劳,今天咱们就用接地气的方式,把Vue3的diff算法拆解明白,再聊聊它比Vue2强在哪,看完你就能懂为什么Vue3的列表渲染性能会提升那么多。

先聊透虚拟DOM:为什么要用它做“中介”?

聊diff算法之前,得先铺垫虚拟DOM,不然就像说发动机原理没讲汽车结构一样,有点懵。

直接操作真实DOM有啥问题?

大家都知道,真实DOM操作起来慢,有多慢?举个小例子:你用原生JS写个循环,每次都往div里加个p标签,加1000次试试?或者更直观的,对比一下:

  • 操作真实DOM:浏览器要重新计算CSS样式、重新生成渲染树、重新布局(Reflow)、重新绘制(Repaint),这四步走下来,特别是当DOM结构复杂的时候,特别费性能,就像你每次改房子里的一个花盆,都要把整个房间的家具拆了重新摆一遍。
  • 用虚拟DOM做中介:先在JS里生成一个和真实DOM对应的“轻量版”JSON对象,这个对象里只存了标签名、属性、子节点这些核心信息,没有真实DOM那些乱七八糟的冗余属性,修改的时候,先改这个JSON对象,然后通过diff算法对比新旧两个虚拟DOM,只找出不一样的地方,最后只更新这些不一样的真实DOM节点——相当于你只把旧花盆搬走,把新花盆放在原来的位置,不用折腾整个房间。

那虚拟DOM的核心优势到底是什么?不是“比原生JS快”,很多时候优秀的原生JS操作可能比虚拟DOM快;它的优势是“开发体验好+性能下限足够高”:开发的时候你不用手动去优化DOM操作,Vue3会通过diff算法帮你做好大部分优化,不管你的代码写得粗不粗,都不会太卡。

Vue3的虚拟DOM比Vue2优化了啥?

先别急着跳diff,Vue3的虚拟DOM本身就做了两个对diff算法影响很大的改动:

  1. 静态标记(Patch Flags):这个是核心改动之一,Vue2的diff算法不管节点是不是静态的(比如一个永远不会变的

    标题),都会拿新旧虚拟DOM完整对比一遍,Vue3会在编译模板的时候,给每个动态节点(比如绑定了v-bind:class、v-model、{{}}插值的节点)打上一个数字标记,这个数字是二进制的,每一位代表一种动态类型:比如00001是动态文本,00010是动态class,00100是动态style,01000是动态key,10000是有动态子节点……这样diff算法在对比的时候,看到静态节点(没有标记)就直接跳过,看到动态节点就只对比它对应的那几位标记的属性,效率直接提升了一大截。

  2. 静态提升(Static Hoisting):Vue2里的静态节点,每次组件重新渲染,都会重新生成一遍虚拟DOM;Vue3会把不会变的静态节点提升到组件的作用域外,下次渲染直接复用,连生成虚拟DOM的时间都省了,还有更狠的,如果连续有多个静态节点,Vue3会把它们合并成一个静态字符串节点,进一步减少虚拟DOM的数量。

有了这两个前置优化,Vue3的diff算法就算和Vue2一模一样,性能都会好很多,但Vue3的diff算法本身也做了天翻地覆的改动。

终于到核心:Vue3的diff算法具体怎么比?

假设我们现在有两个新旧虚拟DOM列表,我们要对比的是同层级的节点,这一点Vue3和Vue2是一样的,都是“同层对比,跨层不处理”——因为跨层DOM操作太复杂,性能提升的空间还不如带来的风险大,所以直接整个子树销毁重建,反而更划算。

接下来进入具体的对比流程,我们用一个例子来说明:旧列表的key是[1,2,3,4,5,6],新列表的key是[1,3,4,6,7,2],先记下来这个例子,后面每一步都会用到。

第一步:从左往右逐个对比,直到遇到不一样的节点

旧列表的头指针叫oldStart,初始指向1;尾指针叫oldEnd,初始指向6,新列表的头指针叫newStart,初始指向1;尾指针叫newEnd,初始指向2。 首先对比oldStart和newStart的key,都是1,匹配上了!那就只更新1这个节点的属性(如果有Patch Flags的话),然后oldStart右移一位指向2,newStart右移一位指向3。 接下来对比oldStart(2)和newStart(3),key不一样,左到右的顺序断了,第一步结束。

第二步:从右往左逐个对比,直到遇到不一样的节点

那咱们换个方向,从右往左比:对比oldEnd(6)和newEnd(2),key不一样;不对,等下,再仔细看例子,哦刚才例子举得不太顺?没关系换个更直观的例子片段:旧列表是[A,B,C,D,E],新列表是[A,B,C,F,E],那从右往左比的话,oldEnd(E)和newEnd(E)匹配,oldEnd左移指向D,newEnd左移指向F,这时候右到左也断了,第二步结束。 哦刚才的核心例子还是用吧,换个对比思路:旧[1,2,3,4,5,6],新[1,3,4,6,7,2],第一步左到右到oldStart=2,newStart=3断;第二步右到左,oldEnd=6,newEnd=2,对比不一样;那再试试交叉对比?不对,Vue3交叉对比是第三步之后的?哦不对,Vue2的双端对比才是先四个指针来回比,Vue3简化了双端对比,先做前两步,快速处理首尾相同的,然后进入更高效的“最长递增子序列”(LIS)环节。

哦刚才的例子片段[A,B,C,D,E]→[A,B,C,F,E],前两步下来,oldStart和newStart都指向D和F,oldEnd和newEnd都指向E和E(哦刚才F在new列表的4位,E在5位,那oldEnd是E(5),newEnd是E(5),匹配,所以oldEnd左移到4(D),newEnd左移到4(F),现在四个指针的位置是:oldS=D(4),oldE=D(4),newS=F(4),newE=F(4)——首尾都处理完了,剩下的就是这两个指针之间的节点。

第三步:处理剩下的情况

首尾都处理完之后,会出现四种情况:

情况1:旧列表的首尾指针先相遇(oldS > oldE)

这说明新列表剩下的都是要新增的节点,直接把newS到newE之间的节点插入到oldE后面的位置(或者newStart的前一个位置的后面,逻辑是一样的),比如旧列表是[A,B],新列表是[A,C,D,B],前两步下来,oldS指向C和A?不对,重新举:旧[1,2],新[1,3,4,2],左到右oldS=1→2,newS=1→3;右到左oldE=2,newE=2→4;现在oldS=2 > oldE=1?不,oldS是2,oldE也是2?调整一下:旧[1],新[1,2,3],前两步下来,oldS=1,oldE=1,匹配后都超出范围(假设超出就是索引-1或者大于列表长度),这时候oldS > oldE,所以把newS=2到newE=3的节点插入到旧列表的后面(也就是原oldE=1的后面)。

情况2:新列表的首尾指针先相遇(newS > newE)

这说明旧列表剩下的都是要删除的节点,直接把oldS到oldE之间的节点从真实DOM里删掉,比如旧[1,2,3],新[1,3],前两步下来,oldS=1→2,oldE=3→2;newS=1→3,newE=3→3;现在newS=3 > newE=2?不,newS是3,oldE是2?调整:旧[1,2,3,4],新[1,4],左到右oldS=1→2,newS=1→4;右到左oldE=4→3,newE=4→4;现在newS=4(索引1)> newE=4?不,索引得明确:旧索引是0→1→2→3,新索引是0→1,左到右:旧0(1)=新0(1),旧0+1=1,新0+1=1;右到左:旧3(4)=新1(4),旧3-1=2,新1-1=0;现在newS=1 > newE=0,触发情况2,把旧1(2)到旧2(3)的节点删掉。

情况3:新旧列表都有剩下的节点(oldS <= oldE && newS <= newE)

这是最复杂的情况,也是Vue3和Vue2本质区别最大的地方,我们再回到一开始的核心例子,明确一下索引: 旧列表索引:0→1→2→3→4→5 → 内容key:[1,2,3,4,5,6] 前两步处理:左到右到旧1(2),新1(3);右到左呢?哦刚才我们举的核心例子,右到左的话,旧5(6)和新5(2)不匹配,旧4(5)和新5(2)也不匹配……那刚才是不是漏了?哦不对,Vue3虽然简化了双端对比,但会不会做一个简单的newStart在旧剩余里的查找?或者会不会我应该把核心例子调整成更能体现LIS的? 没关系,先按步骤来:当触发情况3的时候,Vue3会先做一个旧剩余节点的key→索引的映射表:比如旧剩余节点是索引1→5(key[2,3,4,5,6]),那映射表就是{2:1,3:2,4:3,5:4,6:5}。 遍历新剩余节点(索引1→5,key[3,4,6,7,2]),在映射表里找对应的旧索引:

  • 新1(3)→ 旧2 → 记下来2
  • 新2(4)→ 旧3 → 记下来3
  • 新3(6)→ 旧5 → 记下来5
  • 新4(7)→ 找不到 → 记下来0(或者null,代表要新增)
  • 新5(2)→ 旧1 → 记下来1 现在我们得到了一个数组,叫newIndexToOldIndex,就是刚才记下来的:[2,3,5,0,1](注意这里的索引是相对于新剩余节点的起始位置,也就是新剩余节点的索引0对应新原列表的1,对应这个数组的0)。

就是Vue3最亮眼的最长递增子序列(LIS)优化了,首先我们得知道什么是最长递增子序列:就是在一个数组里,找到一个子序列,这个子序列的元素是严格递增的,而且是最长的,比如刚才的数组[2,3,5,0,1],最长递增子序列是[2,3,5],长度是3;或者另一种情况,如果数组是[2,1,3,5,4],最长递增子序列是[2,3,5]或者[1,3,5]或者[1,3,4],长度都是3。

那这个最长递增子序列有什么用呢?它代表了不需要移动的旧节点的索引(相对于旧剩余起始位置的偏移量,或者原旧列表的索引),因为这些节点在新剩余节点里的相对顺序和在旧剩余节点里的相对顺序是一样的,我们只需要把剩下的节点要么移动,要么新增,要么删除。 还是用刚才的核心例子:

  1. 先找到newIndexToOldIndex的LIS:[2,3,5],对应的新剩余节点的索引是0→1→2(新原列表的1→2→3)。
  2. 然后我们从后往前遍历新剩余节点(索引从4→0,新原列表的5→1),同时维护一个LIS的指针(初始指向LIS的最后一个元素,也就是2→3→5的5,位置是2)。
  3. 处理新剩余节点索引4(新原列表5,key2):对应的newIndexToOldIndex是1,不是LIS的当前值5,那怎么办?
    • 首先看这个值是不是0?不是,说明是旧节点,需要移动。
    • 移动到哪里?移动到当前遍历的新剩余节点的下一个节点的前面(因为是从后往前),这里下一个节点是新剩余节点索引5之后?不,新剩余节点是0→4,遍历到4的时候,下一个“锚点”是已经处理好的节点或者newEnd+1的位置,更准确的锚点是:如果当前遍历的新剩余节点不是最后一个,那锚点就是下一个新剩余节点对应的真实DOM节点;如果是最后一个,锚点就是原oldEnd+1的位置,这里新剩余节点4的下一个位置是新原列表6之后?不对,原新列表的newEnd是2(新原列表索引5),处理完旧End之后(原旧列表索引5是6,已经处理过,插入到了哪里?哦原旧列表的oldE在第一步和第二步之后是5,新End是5,那原oldE的真实DOM节点后面?不对,真实DOM的锚点可以用一个变量nextSibling来存,初始是oldEnd+1对应的真实DOM节点(如果oldEnd+1超出,就是null,代表插入到最后)。
    • 那继续:遍历新剩余节点4,nextSibling初始是原oldEnd+1(也就是旧列表索引6,不存在,null),newIndexToOldIndex是1,不是LIS的当前值5,所以找到旧剩余节点索引1对应的真实DOM节点(原旧列表索引1的2),把它移动到nextSibling的前面,然后nextSibling更新为这个刚移动的2的真实DOM节点。
  4. 处理新剩余节点3(新原列表4,key7):对应的newIndexToOldIndex是0,代表要新增,那就创建一个真实DOM节点,插入到nextSibling的前面,然后nextSibling更新为这个7的节点。
  5. 处理新剩余节点2(新原列表3,key6):对应的newIndexToOldIndex是5,正好是LIS的当前值5!那不用移动,直接更新属性,然后LIS指针左移一位指向3,nextSibling更新为这个6的真实DOM节点。
  6. 处理新剩余节点1(新原列表2,key4):对应的newIndexToOldIndex是3,正好是LIS的当前值3!不用移动,更新属性,LIS指针左移一位指向2,nextSibling更新为这个4的节点。
  7. 处理新剩余节点0(新原列表1,key3):对应的newIndexToOldIndex是2,正好是LIS的当前值2!不用移动,更新属性,LIS指针左移一位超出范围,nextSibling更新为这个3的节点。
  8. 别忘了旧剩余节点里还有没用到的!旧剩余节点的索引是1→5,对应的newIndexToOldIndex里用到的是2,3,5,1,还有旧剩余索引4(原旧列表索引4的5)没用到,所以把它删掉。

这样整个对比就完成了!是不是比Vue2的双端对比来回跳指针要清晰很多?而且性能提升很大,因为我们只移动了必要的节点,不需要移动的最长递增子序列的节点都留在了原地。

Vue3的diff算法和Vue2到底有什么本质区别?

刚才铺垫了虚拟DOM的优化,也讲了具体的流程,现在总结一下本质区别:

前置优化的不同

Vue2的虚拟DOM没有静态标记和静态提升,不管节点是不是静态的,都会完整对比;Vue3有了这两个,直接跳过静态节点,只对比动态节点的动态属性,这是“量级”上的优化。

同层剩余节点对比策略的不同(核心本质区别)

Vue2用的是双端对比算法:四个指针(oldS, oldE, newS, newE)来回交叉对比,也就是先比oldS和newS,oldS和newE,oldE和newS,oldE和newE,匹配上了就移动指针或者移动节点,四个都匹配不上就拿newS在旧剩余里找key对应的节点,找到就移动到oldS前面,找不到就新增,最后处理删除和新增。 双端对比的优点是能处理一些特殊情况(比如完全逆序的列表),但缺点是来回跳指针逻辑复杂,而且很多时候还是会移动很多节点; Vue3用的是“快速首尾对比 + key→索引映射 + 最长递增子序列”的算法:先快速处理首尾相同的节点,然后用映射表快速定位旧节点,最后用最长递增子序列找到不需要移动的节点,只移动其他节点,这个算法逻辑更清晰,而且移动节点的次数最少——因为最长递增子序列就是不需要移动的最长序列,剩下的移动次数就是新剩余节点数减去LIS的长度,这是理论上的最优解。

Key的作用更突出

虽然Vue2也强调要给列表加唯一的key,但Vue3的映射表和LIS优化完全依赖key——如果没有key,Vue3的diff算法会退化成和Vue2差不多的“就地复用”算法,甚至更差(因为没有双端对比的交叉处理);如果有唯一的key,Vue3的性能优势才能完全发挥出来。

日常开发中,我们怎么配合Vue3的diff算法?

说了这么多原理,最终还是要落到开发上,有几个小技巧可以让Vue3的diff算法更高效:

一定要给v-for的列表加唯一的、稳定的key

不要用数组的索引当key!因为如果数组发生插入、删除、逆序操作,索引会变,key也就变了,Vue3的映射表和LIS优化就失效了,甚至会出现bug(比如表单输入框的内容错位),最好用数据里的唯一ID当key,比如用户的id、商品的sku。

尽量避免把所有数据放在一个大对象里

虽然Vue3用了Proxy代替了Object.defineProperty,但如果把所有数据放在一个大对象里,每次修改大对象的一个属性,可能会触发很多不必要的依赖更新,进而触发diff算法,尽量把数据拆分成小的响应式对象,或者用ref包裹简单类型的数据。

合理使用v-show和v-if

v-show只是切换CSS的display属性,不会触发diff算法;v-if会销毁和重建真实DOM节点,也会触发diff算法,如果需要频繁切换显示隐藏,用v-show;如果切换频率很低,或者节点结构很复杂,用v-if。

可以使用v-memo优化静态或半静态的列表

Vue3.2新增了v-memo指令,可以缓存列表的渲染结果,如果v-memo的依赖数组里的元素都没变化,就不会重新渲染这个列表项,连虚拟DOM的生成和diff算法的对比都省了,比如一个商品列表,只有商品的价格会变,那v-memo的依赖数组就可以只放价格,这样当库存变的时候,这个商品项就不会重新渲染。

总结一下

Vue3的diff算法之所以比Vue2快,不是因为某一个改动,而是一系列改动的组合:虚拟DOM的静态标记和静态提升减少了对比的节点数量和内容,同层剩余节点的“快速首尾对比 + key→索引映射 + 最长递增子序列”算法减少了移动节点的次数,日常开发中,只要我们配合好这些优化,比如加唯一稳定的key、合理拆分数据、使用v-memo,就能让Vue3的性能优势完全发挥出来。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门