主页
手机版
扫描查看手机站
所在位置:首页 → 教程资讯 → 让性能提升56%的Vue3.5响应式重构之“版本计数”

让性能提升56%的Vue3.5响应式重构之“版本计数”

发布: 更新时间:2024-11-06 09:51:03

前言

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

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

如果修改的响应式变量会触发多个订阅者,比如

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;
}

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



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

如果我们将

flag

的值设置为false呢?此时的计算属性

doubleCount

就不再依赖于响应式变量

count1

,而是依赖于响应式变量

count2

。小伙伴们猜猜此时的响应式模型应该是什么样的呢?

现在多了一个

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

。收集完成后响应式模型图如下图:

从上图中可以看到虽然计算属性虽然不再依赖

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

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

总结

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

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。

软件上新 查看更多