写点什么

看不懂来打我!让性能提升 56% 的 Vue3.5 响应式重构

  • 2024-10-15
    福建
  • 本文字数:9027 字

    阅读完需:约 30 分钟

前言


在 Vue3.5 版本中最大的改动就是响应式重构,重构后性能竟然炸裂的提升了56%。之所以重构后的响应式性能提升幅度有这么大,主要还是归功于:双向链表版本计数。这篇文章我们来讲讲使用双向链表后,Vue 内部是如何实现依赖收集依赖触发的。搞懂了这个之后你就能掌握 Vue3.5 重构后的响应式原理,至于版本计数如果大家感兴趣可以在评论区留言,关注的人多了欧阳后面会再写一篇版本计数的文章。


3.5 版本以前的响应式


在 Vue3.5 以前的响应式中主要有两个角色:Sub(订阅者)、Dep(依赖)。其中的订阅者有 watchEffect、watch、render 函数、computed 等。依赖有 ref、reactive 等响应式变量。


举个例子:


<script setup lang="ts">import { ref, watchEffect } from "vue";let dummy1, dummy2;//Dep1const counter1 = ref(1);//Dep2const counter2 = ref(2);//Sub1watchEffect(() => {  dummy1 = counter1.value + counter2.value;  console.log("dummy1", dummy1);});//Sub2watchEffect(() => {  dummy2 = counter1.value + counter2.value + 1;  console.log("dummy2", dummy2);});
counter1.value++;counter2.value++;</script>
复制代码


在上面的两个 watchEffect 中都会去监听 ref 响应式变量:counter1counter2


初始化时会分别执行这两个 watchEffect 中的回调函数,所以就会对里面的响应式变量counter1counter2进行读操作,所以就会走到响应式变量的 get 拦截中。


在 get 拦截中会进行依赖收集(此时的 Dep 依赖分别是变量counter1counter2)。


因为在依赖收集期间是在执行watchEffect中的回调函数,所以依赖对应的Sub订阅者就是 watchEffect。


由于这里有两个 watchEffect,所以这里有两个Sub订阅者,分别对应这两个 watchEffect。


在上面的例子中,watchEffect 监听了多个 ref 变量。也就是说,一个Sub订阅者(也就是一个 watchEffect)可以订阅多个依赖。


ref 响应式变量counter1被多个 watchEffect 给监听。也就是说,一个Dep依赖(也就是counter1变量)可以被多个订阅者给订阅。


Sub订阅者Dep依赖他们两的关系是多对多的关系!!!



上面这个就是以前的响应式模型。


新的响应式模型


在 Vue3.5 版本新的响应式中,Sub 订阅者和 Dep 依赖之间不再有直接的联系,而是新增了一个 Link 作为桥梁。Sub 订阅者通过 Link 访问到 Dep 依赖,同理 Dep 依赖也是通过 Link 访问到 Sub 订阅者。如下图:



把上面这个图看懂了,你就能理解 Vue 新的响应式系统啦。现在你直接看这个图有可能看不懂,没关系,等我讲完后你就能看懂了。


首先从上图中可以看到 Sub 订阅者和 Dep 依赖之间没有任何直接的连接关系了,也就是说 Sub 订阅者不能直接访问到 Dep 依赖,Dep 依赖也不能直接访问 Sub 订阅者。


Dep 依赖我们可以看作是 X 轴,Sub 订阅者可以看作是 Y 轴,这些 Link 就是坐标轴上面的坐标。


Vue 响应式系统的核心还是没有变,只是多了一个 Link,依然还是以前的那一套依赖收集依赖触发的流程。


依赖收集的过程中就会画出上面这个图,这个不要急,我接下来会仔细去讲图是如何画出来的。


那么依赖触发的时候又是如何利用上面这种图从而实现触发依赖的呢?我们来看个例子。


上面的这张图其实对应的是我之前举的例子:


<script setup lang="ts">import { ref, watchEffect } from "vue";let dummy1, dummy2;//Dep1const counter1 = ref(1);//Dep2const counter2 = ref(2);//Sub1watchEffect(() => {  dummy1 = counter1.value + counter2.value;  console.log("dummy1", dummy1);});//Sub2watchEffect(() => {  dummy2 = counter1.value + counter2.value + 1;  console.log("dummy2", dummy2);});
counter1.value++;counter2.value++;</script>
复制代码


图中的Dep1依赖对应的就是变量counter1Dep2依赖对应的就是变量counter2Sub1订阅者对应的就是第一个watchEffect函数,Sub2订阅者对应的就是第二个watchEffect函数。


当执行counter1.value++时,就会被变量counter1(也就是Dep1依赖)的 set 函数拦截。从上图中可以看到Dep1依赖有个箭头(对照表中的sub属性)指向Link3,并且Link3也有一个箭头(对照表中的sub属性)指向Sub2


前面我们讲过了这个Sub2就是对应的第二个watchEffect函数,指向Sub2后我们就可以执行Sub2中的依赖,也就是执行第二个watchEffect函数。这就实现了counter1.value++变量改变后,重新执行第二个watchEffect函数。


执行了第二个watchEffect函数后我们发现Link3在 Y 轴上面还有一个箭头(对照表中的preSub属性)指向了Link1。同理Link1也有一个箭头(对照表中的sub属性)指向了Sub1


前面我们讲过了这个Sub1就是对应的第一个watchEffect函数,指向Sub1后我们就可以执行Sub1中的依赖,也就是执行第一个watchEffect函数。这就实现了counter1.value++变量改变后,重新执行第一个watchEffect函数。


至此我们就实现了counter1.value++变量改变后,重新去执行依赖他的两个watchEffect函数。

我们此时再来回顾一下我们前面画的新的响应式模型图,如下图:



我们从这张图来总结一下依赖触发的的规则:


响应式变量Dep1改变后,首先会指向 Y 轴(Sub订阅者)的队尾的 Link 节点。然后从 Link 节点可以直接访问到 Sub 订阅者,访问到订阅者后就可以触发其依赖,这里就是重新执行对应的watchEffect函数。


接着就是顺着 Y 轴的队尾队头移动,每移动到一个新的 Link 节点都可以指向一个新的 Dep 依赖,在这里触发其依赖就会重新指向对应的watchEffect函数。


看到这里有的同学有疑问如果是Dep2对应的响应式变量改变后指向Link4,那这个Link4又是怎么指向Sub2的呢?他们中间不是还隔了一个Link3吗?


每一个 Link 节点上面都有一个sub属性直接指向 Y 轴上面的 Sub 依赖,所以这里的Link4有个箭头(对照表中的sub属性)可以直接指向Sub2,然后进行依赖触发。


这就是 Vue3.5 版本使用双向链表改进后的依赖触发原理,接下来我们会去讲依赖收集过程中是如何将上面的模型图画出来的。


Dep、Sub 和 Link


在讲 Vue3.5 版本依赖收集之前,我们先来了解一下新的响应式系统中主要的三个角色:Dep依赖Sub订阅者Link节点


这三个角色其实都是 class 类,依赖收集和依赖触发的过程中实际就是在操作这些类 new 出来的的对象。


我们接下来看看这些类中有哪些属性和方法,其实在前面的响应式模型图中我们已经使用箭头标明了这些类上面的属性。


Dep 依赖


简化后的Dep类定义如下:


class Dep {  // 指向Link链表的尾部节点  subs: Link  // 收集依赖  track: Function  // 触发依赖  trigger: Function}
复制代码


Dep 依赖上面的subs属性就是指向队列的尾部,也就是队列中最后一个 Sub 订阅者对应的 Link 节点。



比如这里的Dep1,竖向的Link1Link3就组成了一个队列。其中Link3是队列的队尾,Dep1subs属性就是指向Link3


其次就是track函数,对响应式变量进行读操作时会触发。触发这个函数后会进行依赖收集,后面我会讲。


同样trigger函数用于依赖触发,对响应式变量进行写操作时会触发,后面我也会讲。


Sub 订阅者


简化后的Sub订阅者定义如下:


interface Subscriber {  // 指向Link链表的头部节点  deps: Link  // 指向Link链表的尾部节点  depsTail: Link  // 执行依赖  notify: Function}
复制代码


想必细心的你发现了这里的Subscriber是一个interface接口,而不是一个 class 类。因为实现了这个Subscriber接口的 class 类都是订阅者,比如 watchEffect、watch、render 函数、computed 等。



比如这里的Sub1,横向的Link1Link2就组成一个队列。其中的队尾就是Link2depsTail属性),队头就是Link1deps属性)。


还有就是notify函数,执行这个函数就是在执行依赖。比如对于 watchEffect 来说,执行notify函数后就会执行 watchEffect 的回调函数。


Link 节点


简化后的Link节点类定义如下:


class Link {  // 指向Subscriber订阅者  sub: Subscriber  // 指向Dep依赖  dep: Dep  // 指向Link链表的后一个节点(X轴)  nextDep: Link  // 指向Link链表的前一个节点(X轴)  prevDep: Link  // 指向Link链表的下一个节点(Y轴)  nextSub: Link  // 指向Link链表的上一个节点(Y轴)  prevSub: Link}
复制代码


前面我们讲过了新的响应式模型中Dep依赖Sub订阅者之间不会再有直接的关联,而是通过 Link 作为桥梁。


那么作为桥梁的 Link 节点肯定需要有两个属性能够让他直接访问到Dep依赖Sub订阅者,也就是subdep属性。


其中的sub属性是指向Sub订阅者dep属性是指向Dep依赖



我们知道 Link 是坐标轴的点,那这个点肯定就会有上、下、左、右四个方向。


比如对于Link1可以使用nextDep属性来访问后面这个节点Link2Link2可以使用prevDep属性来访问前面这个节点Link1


请注意,这里名字虽然叫nextDepprevDep,但是他们指向的却是 Link 节点。然后通过这个 Link 节点的dep属性,就可以访问到后一个Dep依赖或者前一个Dep依赖


同理对于Link1可以使用nextSub访问后面这个节点Link3Link3可以使用prevSub访问前面这个节点Link1


同样的这里名字虽然叫nextSubprevSub,但是他们指向的却是 Link 节点。然后通过这个 Link 节点的sub属性,就可以访问到下一个Sub订阅者或者上一个Sub订阅者


如何收集依赖


搞清楚了新的响应式模型中的三个角色:Dep依赖Sub订阅者Link节点,我们现在就可以开始搞清楚新的响应式模型是如何收集依赖的。


接下来我将会带你如何一步步的画出前面讲的那张新的响应式模型图。


还是我们前面的那个例子,代码如下:


<script setup lang="ts">import { ref, watchEffect } from "vue";let dummy1, dummy2;//Dep1const counter1 = ref(1);//Dep2const counter2 = ref(2);//Sub1watchEffect(() => {  dummy1 = counter1.value + counter2.value;  console.log("dummy1", dummy1);});//Sub2watchEffect(() => {  dummy2 = counter1.value + counter2.value + 1;  console.log("dummy2", dummy2);});
counter1.value++;counter2.value++;</script>
复制代码


大家都知道响应式变量有getset拦截,当对变量进行读操作时会走到get拦截中,进行写操作时会走到set拦截中。


上面的例子第一个watchEffect我们叫做Sub1订阅者,第二个watchEffect叫做Sub2订阅者.

初始化时watchEffect中的回调会执行一次,这里有两个watchEffect,会依次去执行。


在 Vue 内部有个全局变量叫activeSub,里面存的是当前 active 的 Sub 订阅者。


执行第一个watchEffect回调时,当前的activeSub就是Sub1


Sub1中使用到了响应式变量counter1counter2,所以会对这两个变量依次进行读操作。


第一个watchEffectcounter1进行读操作


先对counter1进行读操作时,会走到get拦截中。核心代码如下:


class RefImpl {get value() {  this.dep.track();  return this._value;}}
复制代码


从上面可以看到在 get 拦截中直接调用了 dep 依赖的track方法进行依赖收集。


在执行track方法之前我们思考一下当前响应式系统中有哪些角色,分别是Sub1Sub2这两个watchEffect回调函数订阅者,以及counter1counter2这两个 Dep 依赖。此时的响应式模型如下图:



从上图可以看到此时只有 X 坐标轴的 Dep 依赖,以及 Y 坐标轴的 Sub 订阅者,没有一个 Link 节点。


我们接着来看看 dep 依赖的track方法,核心代码如下:


class Dep {// 指向Link链表的尾部节点subs: Link;track() {  let link = new Link(activeSub, this);  if (!activeSub.deps) {    activeSub.deps = activeSub.depsTail = link;  } else {    link.prevDep = activeSub.depsTail;    activeSub.depsTail!.nextDep = link;    activeSub.depsTail = link;  }  addSub(link);}}
复制代码


从上面的代码可以看到,每执行一次track方法,也就是说每次收集依赖,都会执行new Link去生成一个 Link 节点。


并且传入两个参数,activeSub为当前 active 的订阅者,在这里就是Sub1(第一个watchEffect)。第二个参数为this,指向当前的 Dep 依赖对象,也就是Dep1counter1变量)。


先不看track后面的代码,我们来看看Link这个 class 的代码,核心代码如下:


class Link {// 指向Link链表的后一个节点(X轴)nextDep: Link;// 指向Link链表的前一个节点(X轴)prevDep: Link;// 指向Link链表的下一个节点(Y轴)nextSub: Link;// 指向Link链表的上一个节点(Y轴)prevSub: Link;- constructor(public sub: Subscriber, public dep: Dep) {  // ...省略}}
复制代码


细心的小伙伴可能发现了在Link中没有声明subdep属性,那么为什么前面我们会说 Link 节点中的subdep属性分别指向 Sub 订阅者和 Dep 依赖呢?


因为在 constructor 构造函数中使用了public关键字,所以subdep就作为属性暴露出来了。


执行完let link = new Link(activeSub, this)后,在响应式系统模型中初始化出来第一个 Link 节点,如下图:



从上图可以看到Link1sub属性指向Sub1订阅者,dep属性指向Dep1依赖。


我们接着来看track方法中剩下的代码,如下:


class Dep {// 指向Link链表的尾部节点subs: Link;track() {  let link = new Link(activeSub, this);  if (!activeSub.deps) {    activeSub.deps = activeSub.depsTail = link;  } else {    link.prevDep = activeSub.depsTail;    activeSub.depsTail!.nextDep = link;    activeSub.depsTail = link;  }  addSub(link);}}
复制代码


先来看if (!activeSub.deps)activeSub前面讲过了是Sub1activeSub.deps就是Sub1deps属性,也就是Sub1队列上的第一个 Link。


从上图中可以看到此时的Sub1并没有箭头指向Link1,所以if (!activeSub.deps)为 true,代码会执行


activeSub.deps = activeSub.depsTail = link;
复制代码


depsdepsTail属性分别指向Sub1队列的头部和尾部,当前队列中只有Link1这一个节点,那么头部和尾部当然都指向Link1


执行完这行代码后响应式模型图就变成下面这样的了,如下图:



从上图中可以看到Sub1的队列中只有Link1这一个节点,所以队列的头部和尾部都指向Link1


处理完Sub1的队列,但是Dep1的队列还没处理,Dep1的队列是由addSub(link)函数处理的。addSub函数代码如下:


function addSub(link: Link) {const currentTail = link.dep.subs;if (currentTail !== link) {  link.prevSub = currentTail;  if (currentTail) currentTail.nextSub = link;}link.dep.subs = link;}
复制代码


由于Dep1队列中没有 Link 节点,所以此时在addSub函数中主要是执行第三块代码:link.dep.subs = link。`


link.dep是指向Dep1,前面我们讲过了 Dep 依赖的subs属性指向队列的尾部。所以link.dep.subs = link就是将Link1指向Dep1的队列的尾部,执行完这行代码后响应式模型图就变成下面这样的了,如下图:



到这里对第一个响应式变量counter1进行读操作进行的依赖收集就完了。


第一个watchEffectcounter2进行读操作


在第一个 watchEffect 中接着会对counter2变量进行读操作。同样会走到get拦截中,然后执行track函数,代码如下:


class Dep {  // 指向Link链表的尾部节点  subs: Link;  track() {    let link = new Link(activeSub, this);
if (!activeSub.deps) { activeSub.deps = activeSub.depsTail = link; } else { link.prevDep = activeSub.depsTail; activeSub.depsTail!.nextDep = link; activeSub.depsTail = link; }
addSub(link); }}
复制代码


同样的会执行一次new Link(activeSub, this),然后把新生成的Link2subdep属性分别指向Sub1Dep2。执行后的响应式模型图如下图:



从上面的图中可以看到此时Sub1deps属性是指向Link1的,所以这次代码会走进else模块中。else部分代码如下:


link.prevDep = activeSub.depsTail;activeSub.depsTail.nextDep = link;activeSub.depsTail = link;
复制代码


activeSub.depsTail指向Sub1队列尾部的 Link,值是Link1。所以执行link.prevDep = activeSub.depsTail就是将Link2prevDep属性指向Link1


同理activeSub.depsTail.nextDep = link就是将Link1nextDep属性指向Link2,执行完这两行代码后Link1Link2之间就建立关系了。如下图:



从上图中可以看到此时Link1Link2之间就有箭头连接,可以互相访问到对方。


最后就是执行activeSub.depsTail = link,这行代码是将Sub1队列的尾部指向Link2。执行完这行代码后模型图如下:



Sub1订阅者的队列就处理完了,接着就是处理Dep2依赖的队列。Dep2的处理方式和Dep1是一样的,让Dep2队列的队尾指向Link2,处理完了后模型图如下:



到这里第一个 watchEffect(也就是Sub1)对其依赖的两个响应式变量counter1(也就是Dep1)和counter2(也就是Dep2),进行依赖收集的过程就执行完了。


第二个watchEffectcounter1进行读操作


接着我们来看第二个watchEffect,同样的还是会对counter1进行读操作。然后触发其get拦截,接着执行track方法。回忆一下track方法的代码,如下:


class Dep {  // 指向Link链表的尾部节点  subs: Link;  track() {    let link = new Link(activeSub, this);
if (!activeSub.deps) { activeSub.deps = activeSub.depsTail = link; } else { link.prevDep = activeSub.depsTail; activeSub.depsTail!.nextDep = link; activeSub.depsTail = link; }
addSub(link); }}
复制代码


这里还是会使用new Link(activeSub, this)创建一个Link3节点,节点的subdep属性分别指向Sub2Dep1。如下图:



同样的Sub2队列上此时还没任何值,所以if (!activeSub.deps)为 true,和之前一样会去执行activeSub.deps = activeSub.depsTail = link;Sub2队列的头部和尾部都设置为Link3。如下图:



处理完Sub2队列后就应该调用addSub函数来处理Dep1的队列了,回忆一下addSub函数,代码如下:


function addSub(link: Link) {  const currentTail = link.dep.subs;  if (currentTail !== link) {    link.prevSub = currentTail;    if (currentTail) currentTail.nextSub = link;  }
link.dep.subs = link;}
复制代码


link.dep指向Dep1依赖,link.dep.subs指向Dep1依赖队列的尾部。从前面的图可以看到此时队列的尾部是Link1,所以currentTail的值就是Link1


if (currentTail !== link)也就是判断Link1Link3是否相等,很明显不相等,就会走到 if 的里面去。


接着就是执行link.prevSub = currentTail,前面讲过了此时link就是Link3currentTail就是Link1。执行这行代码就是将Link3prevSub属性指向Link1


接着就是执行currentTail.nextSub = link,这行代码是将Link1nextSub指向Link3


执行完上面这两行代码后Link1Link3之间就建立联系了,可以通过prevSubnextSub属性访问到对方。如下图:



接着就是执行link.dep.subs = link,将Dep1队列的尾部指向Link3,如下图:



到这里第一个响应式变量counter1进行依赖收集就完成了。


第二个watchEffectcounter2进行读操作


在第二个 watchEffect 中接着会对counter2变量进行读操作。同样会走到get拦截中,然后执行track函数,代码如下:


class Dep {  // 指向Link链表的尾部节点  subs: Link;  track() {    let link = new Link(activeSub, this);
if (!activeSub.deps) { activeSub.deps = activeSub.depsTail = link; } else { link.prevDep = activeSub.depsTail; activeSub.depsTail!.nextDep = link; activeSub.depsTail = link; }
addSub(link); }}
复制代码


这里还是会使用new Link(activeSub, this)创建一个Link4节点,节点的subdep属性分别指向Sub2Dep2。如下图:



此时的activeSub就是Sub2activeSub.deps就是指向Sub2队列的头部。所以此时头部是指向Link3,代码会走到 else 模块中。


在 else 中首先会执行link.prevDep = activeSub.depsTailactiveSub.depsTail是指向Sub2队列的尾部,也就是Link3。执行完这行代码后会将Link4prevDep指向Link3


接着就是执行activeSub.depsTail!.nextDep = link,前面讲过了activeSub.depsTail是指向Link3。执行完这行代码后会将Link3nextDep属性指向Link4


执行完上面这两行代码后Link3Link4之间就建立联系了,可以通过nextDepprevDep属性访问到对方。如下图:



接着就是执行activeSub.depsTail = link,将Sub2队列的尾部指向Link4。如下图:



接着就是执行addSub函数处理Dep2的队列,代码如下:


function addSub(link: Link) {  const currentTail = link.dep.subs;  if (currentTail !== link) {    link.prevSub = currentTail;    if (currentTail) currentTail.nextSub = link;  }
link.dep.subs = link;}
复制代码


link.dep指向Dep2依赖,link.dep.subs指向Dep2依赖队列的尾部。从前面的图可以看到此时队列的尾部是Link2,所以currentTail的值就是Link2。前面讲过了此时link就是Link4if (currentTail !== link)也就是判断Link2Link4是否相等,很明显不相等,就会走到 if 的里面去。


接着就是执行link.prevSub = currentTailcurrentTail就是Link2。执行这行代码就是将Link4prevSub属性指向Link2


接着就是执行currentTail.nextSub = link,这行代码是将Link2nextSub指向Link4

执行完上面这两行代码后Link2Link4之间就建立联系了,可以通过prevSubnextSub属性访问到对方。如下图:



最后就是执行link.dep.subs = linkDep2队列的尾部指向Link4,如下图:



至此整个依赖收集过程就完成了,最终就画出了 Vue 新的响应式模型。


依赖触发


当执行counter1.value++时,就会被变量counter1(也就是Dep1依赖)的 set 函数拦截。

此时就可以通过Dep1subs属性指向队列的尾部,也就是指向Link3


Link3中可以直接通过sub属性访问到订阅者Sub2,也就是第二个watchEffect,从而执行第二个watchEffect的回调函数。


接着就是使用 Link 的preSub属性从队尾依次移动到队头,从而触发Dep1队列中的所有 Sub 订阅者。

在这里就是使用preSub属性访问到Link1(就到队列的头部啦),Link1中可以直接通过sub属性访问到订阅者Sub1,也就是第一个watchEffect,从而执行第一个watchEffect的回调函数。


总结


这篇文章讲了 Vue 新的响应式模型,里面主要有三个角色:Dep依赖Sub订阅者Link节点

Dep依赖Sub订阅者不再有直接的联系,而是通过Link节点作为桥梁。


依赖收集的过程中会构建Dep依赖的队列,队列是由Link节点组成。以及构建Sub订阅者的队列,队列同样是由Link节点组成。


依赖触发时就可以通过Dep依赖的队列的队尾出发,Link节点可以访问和触发对应的Sub订阅者

然后依次从队尾向队头移动,依次触发队列中每个Link节点Sub订阅者


文章转载自:前端欧阳

原文链接:https://www.cnblogs.com/heavenYJJ/p/18463647

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
看不懂来打我!让性能提升56%的Vue3.5响应式重构_JavaScript_快乐非自愿限量之名_InfoQ写作社区