看不懂來打我!讓效能提升56%的Vue3.5響應式重構

前端欧阳發表於2024-10-14

前言

在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響應式變數:counter1counter2

初始化時會分別執行這兩個watchEffect中的回撥函式,所以就會對裡面的響應式變數counter1counter2進行讀操作,所以就會走到響應式變數的get攔截中。

在get攔截中會進行依賴收集(此時的Dep依賴分別是變數counter1counter2)。

因為在依賴收集期間是在執行watchEffect中的回撥函式,所以依賴對應的Sub訂閱者就是watchEffect。

由於這裡有兩個watchEffect,所以這裡有兩個Sub訂閱者,分別對應這兩個watchEffect。

在上面的例子中,watchEffect監聽了多個ref變數。也就是說,一個Sub訂閱者(也就是一個watchEffect)可以訂閱多個依賴。

ref響應式變數counter1被多個watchEffect給監聽。也就是說,一個Dep依賴(也就是counter1變數)可以被多個訂閱者給訂閱。

Sub訂閱者Dep依賴他們兩的關係是多對多的關係!!!
old
上面這個就是以前的響應式模型。

新的響應式模型

在Vue3.5版本新的響應式中,Sub訂閱者和Dep依賴之間不再有直接的聯絡,而是新增了一個Link作為橋樑。Sub訂閱者透過Link訪問到Dep依賴,同理Dep依賴也是透過Link訪問到Sub訂閱者。如下圖:
new

把上面這個圖看懂了,你就能理解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依賴對應的就是變數counter1Dep2依賴對應的就是變數counter2Sub1訂閱者對應的就是第一個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函式。

我們此時再來回顧一下我們前面畫的新的響應式模型圖,如下圖:
new
我們從這張圖來總結一下依賴觸發的的規則:

響應式變數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節點。

new

比如這裡的Dep1,豎向的Link1Link3就組成了一個佇列。其中Link3是佇列的隊尾,Dep1subs屬性就是指向Link3

其次就是track函式,對響應式變數進行讀操作時會觸發。觸發這個函式後會進行依賴收集,後面我會講。

同樣trigger函式用於依賴觸發,對響應式變數進行寫操作時會觸發,後面我也會講。

Sub訂閱者

簡化後的Sub訂閱者定義如下:

interface Subscriber {
  // 指向Link連結串列的頭部節點
  deps: Link
  // 指向Link連結串列的尾部節點
  depsTail: Link
  // 執行依賴
  notify: Function
}

想必細心的你發現了這裡的Subscriber是一個interface介面,而不是一個class類。因為實現了這個Subscriber介面的class類都是訂閱者,比如watchEffect、watch、render函式、computed等。

new

比如這裡的Sub1,橫向的Link1Link2就組成一個佇列。其中的隊尾就是Link2depsTail屬性),隊頭就是Link1deps屬性)。

還有就是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訂閱者,也就是subdep屬性。

其中的sub屬性是指向Sub訂閱者dep屬性是指向Dep依賴

new

我們知道Link是座標軸的點,那這個點肯定就會有上、下、左、右四個方向。

比如對於Link1可以使用nextDep屬性來訪問後面這個節點Link2Link2可以使用prevDep屬性來訪問前面這個節點Link1

請注意,這裡名字雖然叫nextDepprevDep,但是他們指向的卻是Link節點。然後透過這個Link節點的dep屬性,就可以訪問到後一個Dep依賴或者前一個Dep依賴

同理對於Link1可以使用nextSub訪問後面這個節點Link3Link3可以使用prevSub訪問前面這個節點Link1

同樣的這裡名字雖然叫nextSubprevSub,但是他們指向的卻是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>

大家都知道響應式變數有getset攔截,當對變數進行讀操作時會走到get攔截中,進行寫操作時會走到set攔截中。

上面的例子第一個watchEffect我們叫做Sub1訂閱者,第二個watchEffect叫做Sub2訂閱者.

初始化時watchEffect中的回撥會執行一次,這裡有兩個watchEffect,會依次去執行。

在Vue內部有個全域性變數叫activeSub,裡面存的是當前active的Sub訂閱者。

執行第一個watchEffect回撥時,當前的activeSub就是Sub1

Sub1中使用到了響應式變數counter1counter2,所以會對這兩個變數依次進行讀操作。

第一個watchEffectcounter1進行讀操作

先對counter1進行讀操作時,會走到get攔截中。核心程式碼如下:

class RefImpl {
get value() {
  this.dep.track();
  return this._value;
}
}

從上面可以看到在get攔截中直接呼叫了dep依賴的track方法進行依賴收集。

在執行track方法之前我們思考一下當前響應式系統中有哪些角色,分別是Sub1Sub2這兩個watchEffect回撥函式訂閱者,以及counter1counter2這兩個Dep依賴。此時的響應式模型如下圖:
step1

從上圖可以看到此時只有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依賴物件,也就是Dep1counter1變數)。

先不看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中沒有宣告subdep屬性,那麼為什麼前面我們會說Link節點中的subdep屬性分別指向Sub訂閱者和Dep依賴呢?

因為在constructor建構函式中使用了public關鍵字,所以subdep就作為屬性暴露出來了。

執行完let link = new Link(activeSub, this)後,在響應式系統模型中初始化出來第一個Link節點,如下圖:
step2

從上圖可以看到Link1sub屬性指向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前面講過了是Sub1activeSub.deps就是Sub1deps屬性,也就是Sub1佇列上的第一個Link。

從上圖中可以看到此時的Sub1並沒有箭頭指向Link1,所以if (!activeSub.deps)為true,程式碼會執行

activeSub.deps = activeSub.depsTail = link;

depsdepsTail屬性分別指向Sub1佇列的頭部和尾部,當前佇列中只有Link1這一個節點,那麼頭部和尾部當然都指向Link1

執行完這行程式碼後響應式模型圖就變成下面這樣的了,如下圖:
step3

從上圖中可以看到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的佇列的尾部,執行完這行程式碼後響應式模型圖就變成下面這樣的了,如下圖:
step4

到這裡對第一個響應式變數counter1進行讀操作進行的依賴收集就完了。

第一個watchEffectcounter2進行讀操作

在第一個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),然後把新生成的Link2subdep屬性分別指向Sub1Dep2。執行後的響應式模型圖如下圖:
step5

從上面的圖中可以看到此時Sub1deps屬性是指向Link1的,所以這次程式碼會走進else模組中。else部分程式碼如下:

link.prevDep = activeSub.depsTail;
activeSub.depsTail.nextDep = link;
activeSub.depsTail = link;

activeSub.depsTail指向Sub1佇列尾部的Link,值是Link1。所以執行link.prevDep = activeSub.depsTail就是將Link2prevDep屬性指向Link1

同理activeSub.depsTail.nextDep = link就是將Link1nextDep屬性指向Link2,執行完這兩行程式碼後Link1Link2之間就建立關係了。如下圖:
step6

從上圖中可以看到此時Link1Link2之間就有箭頭連線,可以互相訪問到對方。

最後就是執行activeSub.depsTail = link,這行程式碼是將Sub1佇列的尾部指向Link2。執行完這行程式碼後模型圖如下:
step7

Sub1訂閱者的佇列就處理完了,接著就是處理Dep2依賴的佇列。Dep2的處理方式和Dep1是一樣的,讓Dep2佇列的隊尾指向Link2,處理完了後模型圖如下:
step8

到這裡第一個watchEffect(也就是Sub1)對其依賴的兩個響應式變數counter1(也就是Dep1)和counter2(也就是Dep2),進行依賴收集的過程就執行完了。

第二個watchEffectcounter1進行讀操作

接著我們來看第二個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節點,節點的subdep屬性分別指向Sub2Dep1。如下圖:
step9

同樣的Sub2佇列上此時還沒任何值,所以if (!activeSub.deps)為true,和之前一樣會去執行activeSub.deps = activeSub.depsTail = link;Sub2佇列的頭部和尾部都設定為Link3。如下圖:
step10

處理完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)也就是判斷Link1Link3是否相等,很明顯不相等,就會走到if的裡面去。

接著就是執行link.prevSub = currentTail,前面講過了此時link就是Link3currentTail就是Link1。執行這行程式碼就是將Link3prevSub屬性指向Link1

接著就是執行currentTail.nextSub = link,這行程式碼是將Link1nextSub指向Link3

執行完上面這兩行程式碼後Link1Link3之間就建立聯絡了,可以透過prevSubnextSub屬性訪問到對方。如下圖:
step11

接著就是執行link.dep.subs = link,將Dep1佇列的尾部指向Link3,如下圖:
step17

到這裡第一個響應式變數counter1進行依賴收集就完成了。

第二個watchEffectcounter2進行讀操作

在第二個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節點,節點的subdep屬性分別指向Sub2Dep2。如下圖:
step12

此時的activeSub就是Sub2activeSub.deps就是指向Sub2佇列的頭部。所以此時頭部是指向Link3,程式碼會走到else模組中。

在else中首先會執行link.prevDep = activeSub.depsTailactiveSub.depsTail是指向Sub2佇列的尾部,也就是Link3。執行完這行程式碼後會將Link4prevDep指向Link3

接著就是執行activeSub.depsTail!.nextDep = link,前面講過了activeSub.depsTail是指向Link3。執行完這行程式碼後會將Link3nextDep屬性指向Link4

執行完上面這兩行程式碼後Link3Link4之間就建立聯絡了,可以透過nextDepprevDep屬性訪問到對方。如下圖:
step13

接著就是執行activeSub.depsTail = link,將Sub2佇列的尾部指向Link4。如下圖:
step14

接著就是執行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就是Link4if (currentTail !== link)也就是判斷Link2Link4是否相等,很明顯不相等,就會走到if的裡面去。

接著就是執行link.prevSub = currentTailcurrentTail就是Link2。執行這行程式碼就是將Link4prevSub屬性指向Link2

接著就是執行currentTail.nextSub = link,這行程式碼是將Link2nextSub指向Link4

執行完上面這兩行程式碼後Link2Link4之間就建立聯絡了,可以透過prevSubnextSub屬性訪問到對方。如下圖:
step15

最後就是執行link.dep.subs = linkDep2佇列的尾部指向Link4,如下圖:
step16

至此整個依賴收集過程就完成了,最終就畫出了Vue新的響應式模型。

依賴觸發

當執行counter1.value++時,就會被變數counter1(也就是Dep1依賴)的set函式攔截。

此時就可以透過Dep1subs屬性指向佇列的尾部,也就是指向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。

相關文章