前端让性能提升56%的Vue3.5响应式重构
在 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; //Dep1 const counter1 = ref(1); //Dep2 const counter2 = ref(2); //Sub1 watchEffect(() => { dummy1 = counter1.value + counter2.value; console.log("dummy1", dummy1); }); //Sub2 watchEffect(() => { dummy2 = counter1.value + counter2.value + 1; console.log("dummy2", dummy2); }); counter1.value++; counter2.value++; </script>
在上面的两个 watchEffect 中都会去监听 ref 响应式变量:counter1
和counter2
。
初始化时会分别执行这两个 watchEffect 中的回调函数,所以就会对里面的响应式变量counter1
和counter2
进行读操作
,所以就会走到响应式变量的 get 拦截中。
在 get 拦截中会进行依赖收集(此时的 Dep 依赖分别是变量counter1
和counter2
)。
因为在依赖收集期间是在执行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; //Dep1 const counter1 = ref(1); //Dep2 const counter2 = ref(2); //Sub1 watchEffect(() => { dummy1 = counter1.value + counter2.value; console.log("dummy1", dummy1); }); //Sub2 watchEffect(() => { dummy2 = counter1.value + counter2.value + 1; console.log("dummy2", dummy2); }); counter1.value++; counter2.value++; </script>
图中的Dep1 依赖
对应的就是变量counter1
,Dep2 依赖
对应的就是变量counter2
。Sub1 订阅者
对应的就是第一个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
,竖向的Link1
和Link3
就组成了一个队列。其中Link3
是队列的队尾,Dep1
的subs
属性就是指向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
,横向的Link1
和Link2
就组成一个队列。其中的队尾就是Link2
(depsTail
属性),队头就是Link1
(deps
属性)。
还有就是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 订阅者,也就是 sub 和 dep 属性。
其中的 sub 属性是指向 Sub 订阅者,dep 属性是指向 Dep 依赖。
我们知道 Link 是坐标轴的点,那这个点肯定就会有上、下、左、右四个方向。
比如对于 Link1 可以使用 nextDep 属性来访问后面这个节点 Link2,Link2 可以使用 prevDep 属性来访问前面这个节点 Link1。
请注意,这里名字虽然叫 nextDep 和 prevDep,但是他们指向的却是 Link 节点。然后通过这个 Link 节点的 dep 属性,就可以访问到后一个 Dep 依赖或者前一个 Dep 依赖。
同理对于 Link1 可以使用 nextSub 访问后面这个节点 Link3,Link3 可以使用 prevSub 访问前面这个节点 Link1。
同样的这里名字虽然叫 nextSub 和 prevSub,但是他们指向的却是 Link 节点。然后通过这个 Link 节点的 sub 属性,就可以访问到下一个 Sub 订阅者或者上一个 Sub 订阅者。
如何收集依赖
搞清楚了新的响应式模型中的三个角色:Dep 依赖、Sub 订阅者、Link 节点,我们现在就可以开始搞清楚新的响应式模型是如何收集依赖的。
接下来我将会带你如何一步步的画出前面讲的那张新的响应式模型图。
还是我们前面的那个例子,代码如下:
<script setup lang="ts"> import { ref, watchEffect } from "vue"; let dummy1, dummy2; //Dep1 const counter1 = ref(1); //Dep2 const counter2 = ref(2); //Sub1 watchEffect(() => { dummy1 = counter1.value + counter2.value; console.log("dummy1", dummy1); }); //Sub2 watchEffect(() => { dummy2 = counter1.value + counter2.value + 1; console.log("dummy2", dummy2); }); counter1.value++; counter2.value++; </script>
大家都知道响应式变量有get
和set
拦截,当对变量进行读操作时会走到get
拦截中,进行写操作时会走到set
拦截中。
上面的例子第一个watchEffect
我们叫做Sub1
订阅者,第二个watchEffect
叫做Sub2
订阅者.
初始化时watchEffect
中的回调会执行一次,这里有两个watchEffect
,会依次去执行。
在 Vue 内部有个全局变量叫activeSub
,里面存的是当前 active 的 Sub 订阅者。
执行第一个watchEffect
回调时,当前的activeSub
就是Sub1
。
在Sub1
中使用到了响应式变量counter1
和counter2
,所以会对这两个变量依次进行读操作。
第一个watchEffect对counter1进行读操作
先对counter1
进行读操作时,会走到get
拦截中。核心代码如下:
class RefImpl { get value() { this.dep.track(); return this._value; } }
从上面可以看到在 get 拦截中直接调用了 dep 依赖的track
方法进行依赖收集。
在执行track
方法之前我们思考一下当前响应式系统中有哪些角色,分别是Sub1
和Sub2
这两个watchEffect
回调函数订阅者,以及counter1
和counter2
这两个 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 依赖对象,也就是Dep1
(counter1
变量)。
先不看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
中没有声明sub
和dep
属性,那么为什么前面我们会说 Link 节点中的sub
和dep
属性分别指向 Sub 订阅者和 Dep 依赖呢?
因为在 constructor 构造函数中使用了public
关键字,所以sub
和dep
就作为属性暴露出来了。
执行完let link = new Link(activeSub, this)
后,在响应式系统模型中初始化出来第一个 Link 节点,如下图:
从上图可以看到Link1
的sub
属性指向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
前面讲过了是Sub1
。activeSub.deps
就是Sub1
的deps
属性,也就是Sub1
队列上的第一个 Link。
从上图中可以看到此时的Sub1
并没有箭头指向Link1
,所以if (!activeSub.deps)
为 true,代码会执行
activeSub.deps = activeSub.depsTail = link;
deps
和depsTail
属性分别指向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
进行读操作进行的依赖收集就完了。
第一个watchEffect对counter2进行读操作
在第一个 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)
,然后把新生成的Link2
的sub
和dep
属性分别指向Sub1
和Dep2
。执行后的响应式模型图如下图:
从上面的图中可以看到此时Sub1
的deps
属性是指向Link1
的,所以这次代码会走进else
模块中。else
部分代码如下:
link.prevDep = activeSub.depsTail; activeSub.depsTail.nextDep = link; activeSub.depsTail = link;
activeSub.depsTail
指向Sub1
队列尾部的 Link,值是Link1
。所以执行link.prevDep = activeSub.depsTail
就是将Link2
的prevDep
属性指向Link1
。
同理activeSub.depsTail.nextDep = link
就是将Link1
的nextDep
属性指向Link2
,执行完这两行代码后Link1
和Link2
之间就建立关系了。如下图:
从上图中可以看到此时Link1
和Link2
之间就有箭头连接,可以互相访问到对方。
最后就是执行activeSub.depsTail = link
,这行代码是将Sub1
队列的尾部指向Link2
。执行完这行代码后模型图如下:
Sub1
订阅者的队列就处理完了,接着就是处理Dep2
依赖的队列。Dep2
的处理方式和Dep1
是一样的,让Dep2
队列的队尾指向Link2
,处理完了后模型图如下:
到这里第一个 watchEffect(也就是Sub1
)对其依赖的两个响应式变量counter1
(也就是Dep1
)和counter2
(也就是Dep2
),进行依赖收集的过程就执行完了。
第二个watchEffect对counter1进行读操作
接着我们来看第二个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
节点,节点的sub
和dep
属性分别指向Sub2
和Dep1
。如下图:
同样的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)
也就是判断Link1
和Link3
是否相等,很明显不相等,就会走到 if 的里面去。
接着就是执行link.prevSub = currentTail
,前面讲过了此时link
就是Link3
,currentTail
就是Link1
。执行这行代码就是将Link3
的prevSub
属性指向Link1
。
接着就是执行currentTail.nextSub = link
,这行代码是将Link1
的nextSub
指向Link3
。
执行完上面这两行代码后Link1
和Link3
之间就建立联系了,可以通过prevSub
和nextSub
属性访问到对方。如下图:
接着就是执行link.dep.subs = link
,将Dep1
队列的尾部指向Link3
,如下图:
到这里第一个响应式变量counter1
进行依赖收集就完成了。
第二个watchEffect对counter2进行读操作
在第二个 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
节点,节点的sub
和dep
属性分别指向Sub2
和Dep2
。如下图:
此时的activeSub
就是Sub2
,activeSub.deps
就是指向Sub2
队列的头部。所以此时头部是指向Link3
,代码会走到 else 模块中。
在 else 中首先会执行link.prevDep = activeSub.depsTail
,activeSub.depsTail
是指向Sub2
队列的尾部,也就是Link3
。执行完这行代码后会将Link4
的prevDep
指向Link3
。
接着就是执行activeSub.depsTail!.nextDep = link
,前面讲过了activeSub.depsTail
是指向Link3
。执行完这行代码后会将Link3
的nextDep
属性指向Link4
。
执行完上面这两行代码后Link3
和Link4
之间就建立联系了,可以通过nextDep
和prevDep
属性访问到对方。如下图:
接着就是执行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
就是Link4
,if (currentTail !== link)
也就是判断Link2
和Link4
是否相等,很明显不相等,就会走到 if 的里面去。
接着就是执行link.prevSub = currentTail
,currentTail
就是Link2
。执行这行代码就是将Link4
的prevSub
属性指向Link2
。
接着就是执行currentTail.nextSub = link
,这行代码是将Link2
的nextSub
指向Link4
。
执行完上面这两行代码后Link2
和Link4
之间就建立联系了,可以通过prevSub
和nextSub
属性访问到对方。如下图:
最后就是执行link.dep.subs = link
将Dep2
队列的尾部指向Link4
,如下图:
至此整个依赖收集过程就完成了,最终就画出了 Vue 新的响应式模型。
依赖触发
当执行counter1.value++
时,就会被变量counter1
(也就是Dep1 依赖
)的 set 函数拦截。
此时就可以通过Dep1
的subs
属性指向队列的尾部,也就是指向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 订阅者
。
文章来源公众号:前端欧阳
码云笔记 » 前端让性能提升56%的Vue3.5响应式重构