看不懂来打我!让性能提升56%的Vue3.5响应式重构
前言
在Vue3.5版本中最大的改动就是响应式重构
,重构后性能竟然炸裂的提升了56%
。之所以重构后的响应式性能提升幅度有这么大,主要还是归功于:双向链表
和版本计数
。这篇文章我们来讲讲使用双向链表
后,Vue内部是如何实现依赖收集
和依赖触发
的。搞懂了这个之后你就能掌握Vue3.5重构后的响应式原理,至于版本计数
如果大家感兴趣可以在评论区留言,关注的人多了欧阳后面会再写一篇版本计数
的文章。
关注公众号:【前端欧阳】,给自己一个进阶vue的机会
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订阅者
。
关注公众号:【前端欧阳】,给自己一个进阶vue的机会
另外欧阳写了一本开源电子书vue3编译原理揭秘,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。