前言

Vue3.5响应式重构主要分为两部分:双向链表版本计数。在上一篇文章中我们讲了 双向链表 ,这篇文章我们接着来讲版本计数

欧阳年底也要毕业了,加入欧阳的面试交流群(分享内推信息)、高质量vue源码交流群

版本计数

看这篇文章之前最好先看一下欧阳之前写的 双向链表 文章,不然有些部分可能看着比较迷茫。

在上篇 双向链表 文章中我们知道了新的响应式模型中主要分为三个部分:Sub订阅者Dep依赖Link节点

  • Sub订阅者:主要有watchEffect、watch、render函数、computed等。

  • Dep依赖:主要有ref、reactive、computed等响应式变量。

  • Link节点:连接Sub订阅者Dep依赖之间的桥梁,Sub订阅者想访问Dep依赖只能通过Link节点,同样Dep依赖想访问Sub订阅者也只能通过Link节点

细心的小伙伴可能发现了computed计算属性不仅是Sub订阅者还是Dep依赖

原因是computed可以像watchEffect那样监听里面的响应式变量,当响应式变量改变后会触发computed的回调。

还可以将computed的返回值当做ref那样的普通响应式变量去使用,所以我们才说computed不仅是Sub订阅者还是Dep依赖。

版本计数中由4个version实现,分别是:全局变量globalVersiondep.versionlink.versioncomputed.globalVersion

  • globalVersion是一个全局变量,初始值为0,仅有响应式变量改变后才会触发globalVersion++

  • dep.version是在dep依赖上面的一个属性,初始值是0。当dep依赖是ref这种普通响应式变量,仅有响应式变量改变后才会触发dep.version++。当computed计算属性作为dep依赖时,只有等computed最终计算出来的值改变后才会触发dep.version++

  • link.version是Link节点上面的一个属性,初始值是0。每次响应式更新完了后都会保持和dep.version的值相同。在响应式更新前就是通过link.versiondep.version的值是否相同判断是否需要更新。

  • computed.globalVersion:计算属性上面的版本,如果computed.globalVersion === globalVersion说明没有响应式变量改变,计算属性的回调就不需要重新执行。

而版本计数最大的受益者就是computed计算属性,这篇文章接下来我们将以computed举例讲解。

看个例子

我们来看个简单的demo,代码如下:

<template>
<p>{{ doubleCount }}</p>
<button @click="flag = !flag">切换flag</button>
<button @click="count1++">count1++</button>
<button @click="count2++">count2++</button>
</template> <script setup>
import { computed, ref } from "vue";
const count1 = ref(1);
const count2 = ref(10);
const flag = ref(true); const doubleCount = computed(() => {
console.log("computed");
if (flag.value) {
return count1.value * 2;
} else {
return count2.value * 2;
}
});
</script>

在computed中根据flag.value的值去决定到底返回count1.value * 2还是count2.value * 2

那么问题来了,当flag的值为true时,点击count2++按钮,console.log("computed")会执行打印吗?也就是doubleCount的值会重新计算吗?

答案是:不会。虽然count2也是computed中使用到的响应式变量,但是他不参与返回值的计算,所以改变他不会导致computed重新计算。

有的同学想问为什么能够做到这么精细的控制呢?这就要归功于版本计数了,我们接下来会细讲。

依赖触发

还是前面那个demo,初始化时flag的值是true,所以在computed中会对count1变量进行读操作,然后触发get拦截。count1这个ref响应式变量就是由RefImpl类new出来的一个对象,代码如下:

class RefImpl {
dep: Dep = new Dep();
get value() {
this.dep.track()
}
set value() {
this.dep.trigger();
}
}

在get拦截中会执行this.dep.track(),其中dep是由Dep类new出来的对象,代码如下

class Dep {
version = 0;
track() {
let link = new Link(activeSub, this);
// ...省略
}
trigger() {
this.version++;
globalVersion++;
this.notify();
}
}

track方法中使用Link类new出来一个link对象,Link类代码如下:

class Link {
version: number /**
* Pointers for doubly-linked lists
*/
nextDep?: Link
prevDep?: Link
nextSub?: Link
prevSub?: Link
prevActiveLink?: Link constructor(
public sub: Subscriber,
public dep: Dep,
) {
this.version = dep.version
this.nextDep =
this.prevDep =
this.nextSub =
this.prevSub =
this.prevActiveLink =
undefined
}
}

这里我们只关注Link中的version属性,其他的属性在上一篇双向链表文章中已经讲过了。

constructor中使用dep.versionlink.version赋值,保证dep.versionlink.version的值是相等的,也就是等于0。因为dep.version的初始值是0,接着就会讲。

当我们点击count1++按钮时会让响应式变量count1的值自增。因为count1是一个ref响应式变量,所以会触发其set拦截。代码如下:

class RefImpl {
dep: Dep = new Dep();
get value() {
this.dep.track()
}
set value() {
this.dep.trigger();
}
}

在set拦截中执行的是this.dep.trigger()trigger函数代码如下:

class Dep {
version = 0;
track() {
let link = new Link(activeSub, this);
// ...省略
}
trigger() {
this.version++;
globalVersion++;
this.notify();
}
}

前面讲过了globalVersion是一个全局变量,初始值为0。

dep上面的version属性初始值也是0。

trigger中分别执行了this.version++globalVersion++,这里的this就是指向的dep。执行完后dep.versionglobalVersion的值就是1了。而此时link.version的值依然还是0,这个时候dep.versionlink.version的值就已经不相等了。

接着就是执行notify方法按照新的响应式模型进行通知订阅者进行更新,我们这个例子此时新的响应式模型如下图:

如果修改的响应式变量会触发多个订阅者,比如count1变量被多个watchEffect使用,修改count1变量的值就需要触发多个订阅者的更新。notify方法中正是将多个更新操作放到一个批次中处理,从而提高性能。由于篇幅有限我们就不去细讲notify方法的内容,你只需要知道执行notify方法就会触发订阅者的更新。

(这两段是notify方法内的逻辑)按照正常的逻辑如果count1变量的值改变,就可以通过Link2节点找到Sub1订阅者,然后执行订阅者的notify方法从而进行更新。

如果我们的Sub1订阅者是render函数,是这个正常的逻辑。但是此时我们的Sub1订阅者是计算属性doubleCount,这里会有一个优化,如果订阅者是一个计算属性,触发其更新时不会直接执行计算属性的回调函数,而是直接去通知计算属性的订阅者去更新,在更新前才会去执行计算属性的回调函数(这个接下来的文章会讲)。代码如下:

if (link.sub.notify()) {
// if notify() returns `true`, this is a computed. Also call notify
// on its dep - it's called here instead of inside computed's notify
// in order to reduce call stack depth.
link.sub.dep.notify()
}

link.sub.notify()的执行结果是true就代表当前的订阅者是计算属性,然后就会触发计算属性“作为依赖”时对应的订阅者。我们这里的计算属性doubleCount是在template中使用,所以计算属性doubleCount的订阅者就是render函数。

所以这里就是调用link.sub.notify()不会触发计算属性doubleCount中的回调函数重新执行,而是去触发计算属性doubleCount的订阅者,也就是render函数。在执行render函数之前会再去通过脏检查(依靠版本计数实现)去判断是否需要重新执行计算属性的回调,如果需要执行计算属性的回调那么就去执行render函数重新渲染。

脏检查

所有的Sub订阅者内部都是基于ReactiveEffect类去实现的,调用订阅者的notify方法通知更新实际底层就是在调用ReactiveEffect类中的runIfDirty方法。代码如下:

class ReactiveEffect<T = any> implements Subscriber, ReactiveEffectOptions {
/**
* @internal
*/
runIfDirty(): void {
if (isDirty(this)) {
this.run();
}
}
}

runIfDirty方法中首先会调用isDirty方法判断当前是否需要更新,如果返回true,那么就执行run方法去执行Sub订阅者的回调函数进行更新。如果是computedwatchwatchEffect等订阅者调用run方法就会执行其回调函数,如果是render函数这种订阅者调用run方法就会再次执行render函数。

调用isDirty方法时传入的是this,值得注意的是this是指向ReactiveEffect实例。而ReactiveEffect又是继承自Subscriber订阅者,所以这里的this是指向的是订阅者。

前面我们讲过了,修改响应式变量count1的值时会通知作为订阅者doubleCount计算属性。当通知作为订阅者的计算属性更新时不会去像watchEffect这样的订阅者一样去执行其回调,而是去通知计算属性作为Dep依赖时订阅他的订阅者进行更新。在这里计算属性doubleCount是在template中使用,所以他的订阅者是render函数。

所以修改count1变量执行runIfDirty时此时触发的订阅者是作为Sub订阅者的render函数,也就是说此时的this是render函数!!

我们来看看isDirty是如何进行脏检查,代码如下:

function isDirty(sub: Subscriber): boolean {
for (let link = sub.deps; link; link = link.nextDep) {
if (
link.dep.version !== link.version ||
(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))
) {
return true;
}
}
return false;
}

这里就涉及到我们上一节讲过的双向链表了,回顾一下前面讲过的响应式模型图,如下图:



此时的sub订阅者是render函数,也就是图中的Sub2sub.deps是指向指向Sub2订阅者X轴(横向)上面的Link节点组成的队列的头部,link.nextDep就是指向X轴上面下一个Link节点,通过Link节点就可以访问到对应的Dep依赖。

在这里render函数对应的订阅者Sub2在X轴上面只有一个节点Link3

这里的for循环就是去便利Sub订阅者在X轴上面的所有Link节点,然后在for循环内部去通过Link节点访问到对应的Dep依赖去做版本计数的判断。

这里的for循环内部的if语句判断主要分为两部分:

 if (
link.dep.version !== link.version ||
(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))
) {
return true;
}

这两部分中只要有一个是true,那么就说明当前Sub订阅者需要更新,也就是执行其回调。

我们来看看第一个判断:

link.dep.version !== link.version

还记得我们前面讲过吗,初始化时会保持dep.versionlink.version的值相同。每次响应式变量改变时走到set拦截中,在拦截中会去执行dep.version++,执行完了后此时dep.versionlink.version的值就已经不相同了,在这里就能知道此时响应式变量改变过了,需要通知Sub订阅者更新执行其回调。

常规情况下Dep依赖是一个ref变量、Sub订阅者是wachEffect这种确实第一个判断就可以满足了。

但是我们这里的link.dep是计算属性doubleCount,计算属性是由ComputedRefImpl类new出来的对象,简化后代码如下:

class ComputedRefImpl<T = any> implements Subscriber {
_value: any = undefined;
readonly dep: Dep = new Dep(this);
globalVersion: number = globalVersion - 1;
get value(): T {
// ...省略
}
set value(newValue) {
// ...省略
}
}

ComputedRefImpl继承了Subscriber类,所以说他是一个订阅者。同时还有get和set拦截,以及初始化一个计算属性时也会去new一个对应的Dep依赖。

还有一点值得注意的是计算属性上面的computed.globalVersion属性初始值为globalVersion - 1,默认是不等于globalVersion的,这是为了第一次执行计算属性时能够去触发执行计算属性的回调,这个在后面的refreshComputed函数中会讲。

我们是直接修改的count1变量,在count1变量的set拦截中触发了dep.version++,但是并没有修改计算属性对应的dep.version。所以当计算属性作为依赖时单纯的使用link.dep.version !== link.version 就不能满足需求了,需要使用到第二个判断:

(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))

在第二个判断中首先判断当前当前的Dep依赖是不是计算属性,如果是就调用refreshComputed函数去执行计算属性的回调。然后判断计算属性的结果是否改变,如果改变了在refreshComputed函数中就会去执行link.dep.version++,所以执行完refreshComputed函数后link.dep.versionlink.version的值就不相同了,表示计算属性的值更新了,当然就需要执行依赖计算属性的render函数啦。

refreshComputed函数

我们来看看refreshComputed函数的代码,简化后的代码如下:

function refreshComputed(computed: ComputedRefImpl): undefined {
if (computed.globalVersion === globalVersion) {
return;
}
computed.globalVersion = globalVersion; const dep = computed.dep;
try {
prepareDeps(computed);
const value = computed.fn(computed._value);
if (dep.version === 0 || hasChanged(value, computed._value)) {
computed._value = value;
dep.version++;
}
} catch (err) {
dep.version++;
throw err;
} finally {
cleanupDeps(computed);
}
}

首先会去判断computed.globalVersion === globalVersion是否相等,如果相等就说明根本就没有响应式变量改变,那么当然就无需去重新执行计算属性回调。

还记得我们前面讲过每当响应式变量改变后触发set拦截是都会执行globalVersion++吗?所以这里就可以通过computed.globalVersion === globalVersion判断是否有响应式变量改变,如果没有说明计算属性的值肯定就没有改变。

接着就是执行computed.globalVersion = globalVersioncomputed.globalVersion的值同步为globalVersion,为了下次判断是否需要重新执行计算属性做准备。

在try中会先去执行prepareDeps函数,这个先放放接下来讲,先来看看try中其他的代码。

首先调用const value = computed.fn(computed._value)去重新执行计算属性的回调函数拿到计算属性新的返回值value

接着就是执行if (dep.version === 0 || hasChanged(value, computed._value))

我们前面讲过了dep上面的version默认值为0,这里的dep.version === 0说明是第一次渲染计算属性。接着就是使用hasChanged(value, computed._value)判断计算属性新的值和旧的值相比较是否有修改。

上面这两个条件满足一个就执行if里面的内容,将新得到的计算属性的值更新上去,并且执行dep.version++。因为前面讲过了在外面会使用link.dep.version !== link.version判断dep的版本是否和link上面的版本是否相同,如果不相等就执行render函数。

这里由于计算属性的值确实改变了,所以会执行dep.version++,dep的版本和link上面的版本此时就不同了,所以就会被标记为dirty,从而执行render函数。

如果执行计算属性的回调函数出错了,同样也执行一次dep.version++

最后就是剩余执行计算属性回调函数之前调用的prepareDeps和finally调用的cleanupDeps函数没讲了。

更新响应式模型

回顾一下demo的代码:

<template>
<p>{{ doubleCount }}</p>
<button @click="flag = !flag">切换flag</button>
<button @click="count1++">count1++</button>
<button @click="count2++">count2++</button>
</template> <script setup>
import { computed, ref } from "vue";
const count1 = ref(1);
const count2 = ref(10);
const flag = ref(true); const doubleCount = computed(() => {
console.log("computed");
if (flag.value) {
return count1.value * 2;
} else {
return count2.value * 2;
}
});
</script>

flag的值为true时,对应的响应式模型前面我们已经讲过了,如下图:

如果我们将flag的值设置为false呢?此时的计算属性doubleCount就不再依赖于响应式变量count1,而是依赖于响应式变量count2。小伙伴们猜猜此时的响应式模型应该是什么样的呢?

现在多了一个count2变量对应的Link4,原本Link1Link2之间的连接也因为计算属性不再依赖于count1变量后,他们俩之间的连接也没有了,转而变成了Link1Link4之间建立连接。

前面没有讲的prepareDepscleanupDeps函数就是去掉Link1Link2之间的连接。

prepareDeps函数代码如下:

function prepareDeps(sub: Subscriber) {
// Prepare deps for tracking, starting from the head
for (let link = sub.deps; link; link = link.nextDep) {
// set all previous deps' (if any) version to -1 so that we can track
// which ones are unused after the run
link.version = -1
// store previous active sub if link was being used in another context
link.prevActiveLink = link.dep.activeLink
link.dep.activeLink = link
}
}

这里使用for循环遍历计算属性Sub1在X轴上面的Link节点,也就是Link1和Link2,并且将这些Link节点的version属性设置为-1。

flag的值设置为false后,重新执行计算属性doubleCount中的回调函数时,就会对回调函数中的所有响应式变量进行读操作。从而再次触发响应式变量的get拦截,然后执行track方法进行依赖收集。注意此时新收集了一个响应式变量count2。收集完成后响应式模型图如下图:

从上图中可以看到虽然计算属性虽然不再依赖count1变量,但是count1变量变量对应的Link2节点还在队列的连接上。

我们在prepareDeps方法中将计算属性依赖的所有Link节点的version属性都设置为-1,在track方法收集依赖时会执行这样一行代码,如下:

class Dep {
track() {
if (link === undefined || link.sub !== activeSub) {
// ...省略
} else if (link.version === -1) {
link.version = this.version;
// ...省略
}
}
}

如果link.version === -1,那么就将link.version的值同步为dep.version的值。

只有计算属性最新依赖的响应式变量才会触发track方法进行依赖收集,从而将对应的link.version-1更新为dep.version

而变量count1现在已经不会触发track方法了,所以变量count1对应的link.version的值还是-1

最后就是执行cleanupDeps函数将link.version的值还是-1的响应式变量(也就是不再使用的count1变量)对应的Link节点,从双向链表中给干掉。代码如下:

function cleanupDeps(sub: Subscriber) {
// Cleanup unsued deps
let head;
let tail = sub.depsTail;
let link = tail;
while (link) {
const prev = link.prevDep;
if (link.version === -1) {
if (link === tail) tail = prev;
// unused - remove it from the dep's subscribing effect list
removeSub(link);
// also remove it from this effect's dep list
removeDep(link);
} else {
// The new head is the last node seen which wasn't removed
// from the doubly-linked list
head = link;
} // restore previous active link if any
link.dep.activeLink = link.prevActiveLink;
link.prevActiveLink = undefined;
link = prev;
}
// set the new head & tail
sub.deps = head;
sub.depsTail = tail;
}

遍历Sub1计算属性横向队列(X轴)上面的Link节点,当link.version === -1时,说明这个Link节点对应的Dep依赖已经不被计算属性所依赖了,所以执行removeSubremoveDep将其从双向链表中移除。

执行完cleanupDeps函数后此时的响应式模型就是我们前面所提到的样子,如下图:

总结

版本计数主要有四个版本:全局变量globalVersiondep.versionlink.versioncomputed.globalVersiondep.versionlink.version如果不相等就说明当前响应式变量的值改变了,就需要让Sub订阅者进行更新。

如果是计算属性作为Dep依赖时就不能通过dep.versionlink.version去判断了,而是执行refreshComputed函数进行判断。在refreshComputed函数中首先会判断globalVersioncomputed.globalVersion是否相等,如果相等就说明并没有响应式变量更新。如果不相等那么就会执行计算属性的回调函数,拿到最新的值后去比较计算属性的值是否改变。并且还会执行prepareDepscleanupDeps函数将那些计算属性不再依赖的响应式变量对应的Link节点从双向链表中移除。

最后说一句,版本计数最大的赢家应该是computed计算属性,虽然引入版本计数后代码更难理解了。但是整体流程更加优雅,以及现在只需要通过判断几个version是否相等就能知道订阅者是否需要更新,性能当然也更好了。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书vue3编译原理揭秘,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

让性能提升56%的Vue3.5响应式重构之“版本计数”的更多相关文章

  1. 简单对比vue2.x与vue3.x响应式及新功能

    简单对比vue2.x与vue3.x响应式 对响应方式来讲:Vue3.x 将使用Proxy ,取代Vue2.x 版本的 Object.defineProperty. 为何要将Object.defineP ...

  2. Vue3.0工程创建 && setup、ref、reactive函数 && Vue3.0响应式实现原理

    1 # 一.创建Vue3.0工程 2 # 1.使用vue-cli创建 3 # 官方文档: https://cli.vuejs.org/zh/guide/creating-a-project.html# ...

  3. vue2与vue3实现响应式的原理区别和提升

    区别: vue2.x: 实现原理: 对象类型:Object.defineProperty()对属性的读取,修改进行拦截(数据劫持): 数组类型:通过重写更新数组的一系列方法来进行拦截(对数组的变更方法 ...

  4. Vue3.0响应式原理

    Vue3.0的响应式基于Proxy实现.具体代码如下: 1 let targetMap = new WeakMap() 2 let effectStack = [] //存储副作用 3 4 const ...

  5. Vue3.0 响应式数据原理:ES6 Proxy

    Vue3.0 开始用 Proxy 代替 Object.defineProperty了,这篇文章结合实例教你如何使用Proxy 本篇文章同时收录[前端知识点]中,链接直达 阅读本文您将收获 JavaSc ...

  6. Vue3.0响应式实现

    基于Proxy // 弱引用映射表 es6 防止对象不能被回收 let toProxy = new WeakMap(); // 原对象: 代理过得对象 let toRaw = new WeakMap( ...

  7. 『MXNet』第六弹_Gluon性能提升 静态图 动态图 符号式编程 命令式编程

    https://www.cnblogs.com/hellcat/p/9084894.html 目录 一.符号式编程 1.命令式编程和符号式编程 2.MXNet的符号式编程 二.惰性计算 用同步函数实际 ...

  8. vue3响应式模式设计原理

    vue3响应式模式设计原理 为什么要关系vue3的设计原理?了解vue3构建原理,将有助于开发者更快速上手Vue3:同时可以提高Vue调试技能,可以快速定位错误 1.vue3对比vue2 vue2的原 ...

  9. vue3 第二天vue响应式原理以及ref和reactive区别

    前言: 前天我们学了 ref 和 reactive ,提到了响应式数据和 Proxy ,那我们今天就来了解一下,vue3 的响应式 在了解之前,先复习一下之前 vue2 的响应式原理 vue2 的响应 ...

  10. vue3响应式原理以及ref和reactive区别还有vue2/3生命周期的对比,第二天

    前言: 前天我们学了 ref 和 reactive ,提到了响应式数据和 Proxy ,那我们今天就来了解一下,vue3 的响应式 在了解之前,先复习一下之前 vue2 的响应式原理 vue2 的响应 ...

随机推荐

  1. SMU 2024 spring 天梯赛自主训练2

    SMU 2024 spring 天梯赛自主训练2 7-1 I Love GPLT - SMU 2024 spring 天梯赛自主训练2 (pintia.cn) PHP 点击查看代码 I L o v e ...

  2. React挂载dom无效的问题

    话不多说,先上代码. 根据我的猜测,ReactDOM.render()这个函数,也就是挂载的意思是将内容进行替换,所以我的vdom1在调试的时候没有展示出来. 然后我创建了两个div块,分别挂载vdo ...

  3. 使用 Portainer CE 管理 Docker

    此文档参考官方文档 Install Portainer CE with Docker on Linux 编写. 创建容器 docker volume create portainer_data 启动 ...

  4. 禁止 SSH 传递 locale 环境变量

    SSH 在连接远程机器时默认会传递一些环境变量,其中就包括你本机的 locale 变量.这会导致远程机器的 locale 配置变成和你本地主机一样.有时候我们不希望这种行为,我们可以通过修改 SSH ...

  5. OpenFeign深入学习笔记

    OpenFeign 是一个声明式的 Web 服务客户端,它使得编写 Web 服务客户端变得更加容易.OpenFeign 是在 Spring Cloud 生态系统中的一个组件,它整合了 Ribbon(客 ...

  6. sicp每日一题[1.44]

    Exercise 1.44 The idea of smoothing a function is an important concept in signal processing. If f is ...

  7. Redis过期策略以及Redis的内存淘汰机制

    此篇介绍了Redis过期策略以及Redis的内存淘汰机制,从内存淘汰的8种策略,如何开启内存淘汰策略到如何选择合适的淘汰策略,对Redis的内存淘汰机制做了全方位的阐述 如何高效的使用内存对于redi ...

  8. 使用 Helm 在 Kubernetes 上安装 Consul

    Consul Sync 部署 官方文档部署:https://developer.hashicorp.com/consul/docs/k8s/installation/install 部署版本 1.14 ...

  9. typescript 5.1

    http://www.patrickzhong.com/TypeScript/PREFACE.html 前言 TypeScript 官网大约从 2020 年开始要打造新版的官网,其中包括官网的样式,以 ...

  10. PyCharm 的一些基本设置&&常用插件&&快捷键

    PyCharm一些基本设置   1.主题色彩   2.添加设置:Ctrl+鼠标滚轮上下调节字体大小           3. 中文语言包   4.翻译插件 5.快捷键