前言
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。