讓性能提升56%的Vue3.5響應(yīng)式重構(gòu)之“版本計數(shù)”
前言
Vue3.5響應(yīng)式重構(gòu)主要分為兩部分:雙向鏈表和版本計數(shù)。在上一篇文章中我們講了 雙向鏈表 ,這篇文章我們接著來講版本計數(shù)。
版本計數(shù)
在上篇 雙向鏈表 文章中我們知道了新的響應(yīng)式模型中主要分為三個部分:Sub訂閱者、Dep依賴、Link節(jié)點。
- Sub訂閱者:主要有watchEffect、watch、render函數(shù)、computed等。
- Dep依賴:主要有ref、reactive、computed等響應(yīng)式變量。
- Link節(jié)點:連接Sub訂閱者和Dep依賴之間的橋梁,Sub訂閱者想訪問Dep依賴只能通過Link節(jié)點,同樣Dep依賴想訪問Sub訂閱者也只能通過Link節(jié)點。
細(xì)心的小伙伴可能發(fā)現(xiàn)了computed計算屬性不僅是Sub訂閱者還是Dep依賴。 原因是computed可以像watchEffect那樣監(jiān)聽里面的響應(yīng)式變量,當(dāng)響應(yīng)式變量改變后會觸發(fā)computed的回調(diào)。
還可以將computed的返回值當(dāng)做ref那樣的普通響應(yīng)式變量去使用,所以我們才說computed不僅是Sub訂閱者還是Dep依賴。
版本計數(shù)中由4個version實現(xiàn),分別是:全局變量globalVersion、dep.version、link.version和computed.globalVersion。
- globalVersion是一個全局變量,初始值為0,僅有響應(yīng)式變量改變后才會觸發(fā)globalVersion++。
- dep.version是在dep依賴上面的一個屬性,初始值是0。當(dāng)dep依賴是ref這種普通響應(yīng)式變量,僅有響應(yīng)式變量改變后才會觸發(fā)dep.version++。當(dāng)computed計算屬性作為dep依賴時,只有等computed最終計算出來的值改變后才會觸發(fā)dep.version++。
- link.version是Link節(jié)點上面的一個屬性,初始值是0。每次響應(yīng)式更新完了后都會保持和dep.version的值相同。在響應(yīng)式更新前就是通過link.version和dep.version的值是否相同判斷是否需要更新。
- computed.globalVersion:計算屬性上面的版本,如果computed.globalVersion === globalVersion說明沒有響應(yīng)式變量改變,計算屬性的回調(diào)就不需要重新執(zhí)行。
而版本計數(shù)最大的受益者就是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中根據(jù)flag.value的值去決定到底返回count1.value * 2還是count2.value * 2。
那么問題來了,當(dāng)flag的值為true時,點擊count2++按鈕,console.log("computed")會執(zhí)行打印嗎?也就是doubleCount的值會重新計算嗎?
答案是:不會。雖然count2也是computed中使用到的響應(yīng)式變量,但是他不參與返回值的計算,所以改變他不會導(dǎo)致computed重新計算。
有的同學(xué)想問為什么能夠做到這么精細(xì)的控制呢?這就要歸功于版本計數(shù)了,我們接下來會細(xì)講。
依賴觸發(fā)
還是前面那個demo,初始化時flag的值是true,所以在computed中會對count1變量進(jìn)行讀操作,然后觸發(fā)get攔截。count1這個ref響應(yīng)式變量就是由RefImpl類new出來的一個對象,代碼如下:
class RefImpl {
dep: Dep = new Dep();
get value() {
this.dep.track()
}
set value() {
this.dep.trigger();
}
}
在get攔截中會執(zhí)行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
}
}
這里我們只關(guān)注Link中的version屬性,其他的屬性在上一篇雙向鏈表文章中已經(jīng)講過了。
在constructor中使用dep.version給link.version賦值,保證dep.version和link.version的值是相等的,也就是等于0。因為dep.version的初始值是0,接著就會講。
當(dāng)我們點擊count1++按鈕時會讓響應(yīng)式變量count1的值自增。因為count1是一個ref響應(yīng)式變量,所以會觸發(fā)其set攔截。代碼如下:
class RefImpl {
dep: Dep = new Dep();
get value() {
this.dep.track()
}
set value() {
this.dep.trigger();
}
}
在set攔截中執(zhí)行的是this.dep.trigger(),trigger函數(shù)代碼如下:
class Dep {
version = 0;
track() {
let link = new Link(activeSub, this);
// ...省略
}
trigger() {
this.version++;
globalVersion++;
this.notify();
}
}
前面講過了globalVersion是一個全局變量,初始值為0。
dep上面的version屬性初始值也是0。
在trigger中分別執(zhí)行了this.version++和globalVersion++,這里的this就是指向的dep。執(zhí)行完后dep.version和globalVersion的值就是1了。而此時link.version的值依然還是0,這個時候dep.version和link.version的值就已經(jīng)不相等了。
接著就是執(zhí)行notify方法按照新的響應(yīng)式模型進(jìn)行通知訂閱者進(jìn)行更新,我們這個例子此時新的響應(yīng)式模型如下圖:
圖片
如果修改的響應(yīng)式變量會觸發(fā)多個訂閱者,比如count1變量被多個watchEffect使用,修改count1變量的值就需要觸發(fā)多個訂閱者的更新。notify方法中正是將多個更新操作放到一個批次中處理,從而提高性能。由于篇幅有限我們就不去細(xì)講notify方法的內(nèi)容,你只需要知道執(zhí)行notify方法就會觸發(fā)訂閱者的更新。
(這兩段是notify方法內(nèi)的邏輯)按照正常的邏輯如果count1變量的值改變,就可以通過Link2節(jié)點找到Sub1訂閱者,然后執(zhí)行訂閱者的notify方法從而進(jìn)行更新。
如果我們的Sub1訂閱者是render函數(shù),是這個正常的邏輯。但是此時我們的Sub1訂閱者是計算屬性doubleCount,這里會有一個優(yōu)化,如果訂閱者是一個計算屬性,觸發(fā)其更新時不會直接執(zhí)行計算屬性的回調(diào)函數(shù),而是直接去通知計算屬性的訂閱者去更新,在更新前才會去執(zhí)行計算屬性的回調(diào)函數(shù)(這個接下來的文章會講)。代碼如下:
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()的執(zhí)行結(jié)果是true就代表當(dāng)前的訂閱者是計算屬性,然后就會觸發(fā)計算屬性“作為依賴”時對應(yīng)的訂閱者。我們這里的計算屬性doubleCount是在template中使用,所以計算屬性doubleCount的訂閱者就是render函數(shù)。
所以這里就是調(diào)用link.sub.notify()不會觸發(fā)計算屬性doubleCount中的回調(diào)函數(shù)重新執(zhí)行,而是去觸發(fā)計算屬性doubleCount的訂閱者,也就是render函數(shù)。在執(zhí)行render函數(shù)之前會再去通過臟檢查(依靠版本計數(shù)實現(xiàn))去判斷是否需要重新執(zhí)行計算屬性的回調(diào),如果需要執(zhí)行計算屬性的回調(diào)那么就去執(zhí)行render函數(shù)重新渲染。
臟檢查
所有的Sub訂閱者內(nèi)部都是基于ReactiveEffect類去實現(xiàn)的,調(diào)用訂閱者的notify方法通知更新實際底層就是在調(diào)用ReactiveEffect類中的runIfDirty方法。代碼如下:
class ReactiveEffect<T = any> implements Subscriber, ReactiveEffectOptions {
/**
* @internal
*/
runIfDirty(): void {
if (isDirty(this)) {
this.run();
}
}
}
在runIfDirty方法中首先會調(diào)用isDirty方法判斷當(dāng)前是否需要更新,如果返回true,那么就執(zhí)行run方法去執(zhí)行Sub訂閱者的回調(diào)函數(shù)進(jìn)行更新。如果是computed、watch、watchEffect等訂閱者調(diào)用run方法就會執(zhí)行其回調(diào)函數(shù),如果是render函數(shù)這種訂閱者調(diào)用run方法就會再次執(zhí)行render函數(shù)。
調(diào)用isDirty方法時傳入的是this,值得注意的是this是指向ReactiveEffect實例。而ReactiveEffect又是繼承自Subscriber訂閱者,所以這里的this是指向的是訂閱者。
前面我們講過了,修改響應(yīng)式變量count1的值時會通知作為訂閱者的doubleCount計算屬性。當(dāng)通知作為訂閱者的計算屬性更新時不會去像watchEffect這樣的訂閱者一樣去執(zhí)行其回調(diào),而是去通知計算屬性作為Dep依賴時訂閱他的訂閱者進(jìn)行更新。在這里計算屬性doubleCount是在template中使用,所以他的訂閱者是render函數(shù)。
所以修改count1變量執(zhí)行runIfDirty時此時觸發(fā)的訂閱者是作為Sub訂閱者的render函數(shù),也就是說此時的this是render函數(shù)??!
我們來看看isDirty是如何進(jìn)行臟檢查,代碼如下:
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;
}
這里就涉及到我們上一節(jié)講過的雙向鏈表了,回顧一下前面講過的響應(yīng)式模型圖,如下圖:
圖片
此時的sub訂閱者是render函數(shù),也就是圖中的Sub2。sub.deps是指向指向Sub2訂閱者X軸(橫向)上面的Link節(jié)點組成的隊列的頭部,link.nextDep就是指向X軸上面下一個Link節(jié)點,通過Link節(jié)點就可以訪問到對應(yīng)的Dep依賴。
在這里render函數(shù)對應(yīng)的訂閱者Sub2在X軸上面只有一個節(jié)點Link3。
這里的for循環(huán)就是去便利Sub訂閱者在X軸上面的所有Link節(jié)點,然后在for循環(huán)內(nèi)部去通過Link節(jié)點訪問到對應(yīng)的Dep依賴去做版本計數(shù)的判斷。
這里的for循環(huán)內(nèi)部的if語句判斷主要分為兩部分:
if (
link.dep.version !== link.version ||
(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))
) {
return true;
}
這兩部分中只要有一個是true,那么就說明當(dāng)前Sub訂閱者需要更新,也就是執(zhí)行其回調(diào)。
我們來看看第一個判斷:
link.dep.version !== link.version
還記得我們前面講過嗎,初始化時會保持dep.version和link.version的值相同。每次響應(yīng)式變量改變時走到set攔截中,在攔截中會去執(zhí)行dep.version++,執(zhí)行完了后此時dep.version和link.version的值就已經(jīng)不相同了,在這里就能知道此時響應(yīng)式變量改變過了,需要通知Sub訂閱者更新執(zhí)行其回調(diào)。
常規(guī)情況下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類,所以說他是一個訂閱者。同時還有g(shù)et和set攔截,以及初始化一個計算屬性時也會去new一個對應(yīng)的Dep依賴。
還有一點值得注意的是計算屬性上面的computed.globalVersion屬性初始值為globalVersion - 1,默認(rèn)是不等于globalVersion的,這是為了第一次執(zhí)行計算屬性時能夠去觸發(fā)執(zhí)行計算屬性的回調(diào),這個在后面的refreshComputed函數(shù)中會講。
我們是直接修改的count1變量,在count1變量的set攔截中觸發(fā)了dep.version++,但是并沒有修改計算屬性對應(yīng)的dep.version。所以當(dāng)計算屬性作為依賴時單純的使用link.dep.version !== link.version 就不能滿足需求了,需要使用到第二個判斷:
(link.dep.computed &&
(refreshComputed(link.dep.computed) ||
link.dep.version !== link.version))
在第二個判斷中首先判斷當(dāng)前當(dāng)前的Dep依賴是不是計算屬性,如果是就調(diào)用refreshComputed函數(shù)去執(zhí)行計算屬性的回調(diào)。然后判斷計算屬性的結(jié)果是否改變,如果改變了在refreshComputed函數(shù)中就會去執(zhí)行l(wèi)ink.dep.version++,所以執(zhí)行完refreshComputed函數(shù)后link.dep.version和link.version的值就不相同了,表示計算屬性的值更新了,當(dāng)然就需要執(zhí)行依賴計算屬性的render函數(shù)啦。
refreshComputed函數(shù)
我們來看看refreshComputed函數(shù)的代碼,簡化后的代碼如下:
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是否相等,如果相等就說明根本就沒有響應(yīng)式變量改變,那么當(dāng)然就無需去重新執(zhí)行計算屬性回調(diào)。
還記得我們前面講過每當(dāng)響應(yīng)式變量改變后觸發(fā)set攔截是都會執(zhí)行g(shù)lobalVersion++嗎?所以這里就可以通過computed.globalVersion === globalVersion判斷是否有響應(yīng)式變量改變,如果沒有說明計算屬性的值肯定就沒有改變。
接著就是執(zhí)行computed.globalVersion = globalVersion將computed.globalVersion的值同步為globalVersion,為了下次判斷是否需要重新執(zhí)行計算屬性做準(zhǔn)備。
在try中會先去執(zhí)行prepareDeps函數(shù),這個先放放接下來講,先來看看try中其他的代碼。
首先調(diào)用const value = computed.fn(computed._value)去重新執(zhí)行計算屬性的回調(diào)函數(shù)拿到計算屬性新的返回值value。
接著就是執(zhí)行if (dep.version === 0 || hasChanged(value, computed._value))
我們前面講過了dep上面的version默認(rèn)值為0,這里的dep.version === 0說明是第一次渲染計算屬性。接著就是使用hasChanged(value, computed._value)判斷計算屬性新的值和舊的值相比較是否有修改。
上面這兩個條件滿足一個就執(zhí)行if里面的內(nèi)容,將新得到的計算屬性的值更新上去,并且執(zhí)行dep.version++。因為前面講過了在外面會使用link.dep.version !== link.version判斷dep的版本是否和link上面的版本是否相同,如果不相等就執(zhí)行render函數(shù)。
這里由于計算屬性的值確實改變了,所以會執(zhí)行dep.version++,dep的版本和link上面的版本此時就不同了,所以就會被標(biāo)記為dirty,從而執(zhí)行render函數(shù)。
如果執(zhí)行計算屬性的回調(diào)函數(shù)出錯了,同樣也執(zhí)行一次dep.version++。
最后就是剩余執(zhí)行計算屬性回調(diào)函數(shù)之前調(diào)用的prepareDeps和finally調(diào)用的cleanupDeps函數(shù)沒講了。
更新響應(yīng)式模型
回顧一下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>
當(dāng)flag的值為true時,對應(yīng)的響應(yīng)式模型前面我們已經(jīng)講過了,如下圖:
圖片
如果我們將flag的值設(shè)置為false呢?此時的計算屬性doubleCount就不再依賴于響應(yīng)式變量count1,而是依賴于響應(yīng)式變量count2。小伙伴們猜猜此時的響應(yīng)式模型應(yīng)該是什么樣的呢?
圖片
現(xiàn)在多了一個count2變量對應(yīng)的Link4,原本Link1和Link2之間的連接也因為計算屬性不再依賴于count1變量后,他們倆之間的連接也沒有了,轉(zhuǎn)而變成了Link1和Link4之間建立連接。
前面沒有講的prepareDeps和cleanupDeps函數(shù)就是去掉Link1和Link2之間的連接。
prepareDeps函數(shù)代碼如下:
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循環(huán)遍歷計算屬性Sub1在X軸上面的Link節(jié)點,也就是Link1和Link2,并且將這些Link節(jié)點的version屬性設(shè)置為-1。
當(dāng)flag的值設(shè)置為false后,重新執(zhí)行計算屬性doubleCount中的回調(diào)函數(shù)時,就會對回調(diào)函數(shù)中的所有響應(yīng)式變量進(jìn)行讀操作。從而再次觸發(fā)響應(yīng)式變量的get攔截,然后執(zhí)行track方法進(jìn)行依賴收集。注意此時新收集了一個響應(yīng)式變量count2。收集完成后響應(yīng)式模型圖如下圖:
圖片
從上圖中可以看到雖然計算屬性雖然不再依賴count1變量,但是count1變量變量對應(yīng)的Link2節(jié)點還在隊列的連接上。
我們在prepareDeps方法中將計算屬性依賴的所有Link節(jié)點的version屬性都設(shè)置為-1,在track方法收集依賴時會執(zhí)行這樣一行代碼,如下:
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的值。
只有計算屬性最新依賴的響應(yīng)式變量才會觸發(fā)track方法進(jìn)行依賴收集,從而將對應(yīng)的link.version從-1更新為dep.version。
而變量count1現(xiàn)在已經(jīng)不會觸發(fā)track方法了,所以變量count1對應(yīng)的link.version的值還是-1。
最后就是執(zhí)行cleanupDeps函數(shù)將link.version的值還是-1的響應(yīng)式變量(也就是不再使用的count1變量)對應(yīng)的Link節(jié)點,從雙向鏈表中給干掉。代碼如下:
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節(jié)點,當(dāng)link.version === -1時,說明這個Link節(jié)點對應(yīng)的Dep依賴已經(jīng)不被計算屬性所依賴了,所以執(zhí)行removeSub和removeDep將其從雙向鏈表中移除。
執(zhí)行完cleanupDeps函數(shù)后此時的響應(yīng)式模型就是我們前面所提到的樣子,如下圖:
圖片
總結(jié)
版本計數(shù)主要有四個版本:全局變量globalVersion、dep.version、link.version和computed.globalVersion。dep.version和link.version如果不相等就說明當(dāng)前響應(yīng)式變量的值改變了,就需要讓Sub訂閱者進(jìn)行更新。
如果是計算屬性作為Dep依賴時就不能通過dep.version和link.version去判斷了,而是執(zhí)行refreshComputed函數(shù)進(jìn)行判斷。在refreshComputed函數(shù)中首先會判斷globalVersion和computed.globalVersion是否相等,如果相等就說明并沒有響應(yīng)式變量更新。如果不相等那么就會執(zhí)行計算屬性的回調(diào)函數(shù),拿到最新的值后去比較計算屬性的值是否改變。并且還會執(zhí)行prepareDeps和cleanupDeps函數(shù)將那些計算屬性不再依賴的響應(yīng)式變量對應(yīng)的Link節(jié)點從雙向鏈表中移除。
最后說一句,版本計數(shù)最大的贏家應(yīng)該是computed計算屬性,雖然引入版本計數(shù)后代碼更難理解了。但是整體流程更加優(yōu)雅,以及現(xiàn)在只需要通過判斷幾個version是否相等就能知道訂閱者是否需要更新,性能當(dāng)然也更好了。