前言

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实现,分别是:全局变量
globalVersion

dep.version

link.version

computed.globalVersion

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

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

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

    dep.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.version

link.version
赋值,保证
dep.version

link.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.version

globalVersion
的值就是1了。而此时
link.version
的值依然还是0,这个时候
dep.version

link.version
的值就已经不相等了。

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

如果修改的响应式变量会触发多个订阅者,比如
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订阅者的回调函数进行更新。如果是
computed

watch

watchEffect
等订阅者调用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;
}

这里就涉及到我们上一节讲过的双向链表了,回顾一下前面讲过的响应式模型图,如下图:
reactive
此时的sub订阅者是render函数,也就是图中的
Sub2

sub.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.version

link.version
的值相同。每次响应式变量改变时走到set拦截中,在拦截中会去执行
dep.version++
,执行完了后此时
dep.version

link.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.version

link.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 = globalVersion

computed.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时,对应的响应式模型前面我们已经讲过了,如下图:
reactive

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

现在多了一个
count2
变量对应的
Link4
,原本
Link1

Link2
之间的连接也因为计算属性不再依赖于
count1
变量后,他们俩之间的连接也没有了,转而变成了
Link1

Link4
之间建立连接。

前面没有讲的
prepareDeps

cleanupDeps
函数就是去掉
Link1

Link2
之间的连接。

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
。收集完成后响应式模型图如下图:
reactive3

从上图中可以看到虽然计算属性虽然不再依赖
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依赖已经不被计算属性所依赖了,所以执行
removeSub

removeDep
将其从双向链表中移除。

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

总结

版本计数主要有四个版本:全局变量
globalVersion

dep.version

link.version

computed.globalVersion

dep.version

link.version
如果不相等就说明当前响应式变量的值改变了,就需要让Sub订阅者进行更新。

如果是计算属性作为Dep依赖时就不能通过
dep.version

link.version
去判断了,而是执行
refreshComputed
函数进行判断。在
refreshComputed
函数中首先会判断
globalVersion

computed.globalVersion
是否相等,如果相等就说明并没有响应式变量更新。如果不相等那么就会执行计算属性的回调函数,拿到最新的值后去比较计算属性的值是否改变。并且还会执行
prepareDeps

cleanupDeps
函数将那些计算属性不再依赖的响应式变量对应的Link节点从双向链表中移除。

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

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

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

标签: none

添加新评论