嘿,日安!

Day 22 - Computed:深入緩存機制實作

1st October 2025
Front-end
Vue3
IT鐵人賽2025
從零到一打造 Vue3 響應式系統
Last updated:1st October 2025
10 Minutes
1902 Words

在上一篇文章中,我們提到將透過「緩存」的機制來解決 computed 在訪問時重複執行的問題。

在 Vue 3 的原始碼裡,computed 是靠一個「髒值標記(dirty flag)」來判斷需不需要重新計算的。

Computed 緩存解決方案

核心邏輯

在 computed 中記錄髒標記:當髒標記是 true,才需要進行更新;當髒標記是 false,則表示需要進行緩存。

1
class ComputedRefImpl implements Dependency, Sub {
2
...
3
...
4
tracking = false
5
6
// 計算屬性是否需要重新計算,如果為 true,則重新計算
7
dirty = true
8
9
...
10
...
11
get value() {
12
if(this.dirty){
13
this.update()
14
}
15
...
17 collapsed lines
16
...
17
}
18
19
update(){
20
...
21
...
22
try {
23
24
this._value = this.fn()
25
// 調用 update 更新後,將 dirty 更改為 false
26
this.dirty = false
27
} finally {
28
endTrack(this)
29
setActiveSub(prevSub)
30
}
31
}
32
}

再回去看,現在已經有進行緩存,只執行兩次,可是我們又發現了另一個問題,如果你把 index.html 設定為以下:

1
<body>
2
<div id="app"></div>
3
<script type="module">
4
// import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
5
import { ref, computed, effect } from '../dist/reactivity.esm.js'
6
7
const count = ref(0)
8
9
const c = computed(() => {
10
console.log('computed')
11
return count.value + 1
12
})
13
14
// effect(() => {
15
// console.log(c.value)
7 collapsed lines
16
// })
17
18
console.log(c.value)
19
count.value = 1
20
21
</script>
22
</body>

你會發現 count.value 數值變更之後,他還是訪問 computed,但是依賴 computed 的數值被變更時,我們當下不一定會訪問 computed

查看一下官方程式碼,count.value 數值變更後,computed 沒有被訪問。

default

但是我們的版本,他又再訪問一次computed

default

遇到這個狀況我們可以怎麼做?我們可以做髒標記,等下次computed被 effect 訪問再執行更新。

system.ts
1
...
2
...
3
export function processComputedUpdate(sub) {
4
// 有 sub.subs(effect 鏈表的頭節點),再進行更新
5
if(sub.subs){
6
sub.update()
7
propagate(sub.subs)
8
}
9
}
10
11
export function propagate(subs) {
12
let link = subs
13
let queuedEffect = []
14
19 collapsed lines
15
while (link) {
16
const sub = link.sub
17
18
if(!sub.tracking){
19
if ('update' in sub) {
20
// 被 effect 進行訪問,計算屬性需要重新計算
21
sub.dirty = true
22
processComputedUpdate(sub)
23
} else {
24
queuedEffect.push(sub)
25
}
26
}
27
link = link.nextSub
28
}
29
30
queuedEffect.forEach(effect => effect.notify())
31
}
32
...
33
...

這樣就可以解決緩存的問題。可是我們又發現了新的問題。

Effect 重複執行問題

1
<body>
2
<div id="app"></div>
3
<script type="module">
4
// import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
5
import { ref, computed, effect } from '../dist/reactivity.esm.js'
6
7
const count = ref(0)
8
9
const c = computed(() => {
10
console.log('computed')
11
return count.value * 0
12
})
13
14
effect(() => {
15
console.log(c.value)
8 collapsed lines
16
})
17
18
setTimeout(() => {
19
count.value = 1
20
}, 1000)
21
22
</script>
23
</body>

default

現在我們發現,computed 執行兩次,這個沒什麼問題,有問題是 effect 的數值沒有改動,但是它也執行兩次,如果數值沒變,只要執行一次就好了。

default

回顧我們在 Ref 實作,當觸發更新時,也是新值和舊值不相同的時候,才會觸發更新,在這邊我們也用相同作法。

computed.ts
1
import { hasChanged } from '@vue/shared'
2
...
3
...
4
class ComputedRefImpl implements Dependency, Sub {
5
...
6
...
7
update(){
8
...
9
...
10
try {
11
// 更新前的值
12
const oldValue = this._value
13
// 更新的值
14
this._value = this.fn()
8 collapsed lines
15
this.dirty = false
16
return hasChanged(oldValue, this._value)
17
} finally {
18
endTrack(this)
19
setActiveSub(prevSub)
20
}
21
}
22
}

先將更新前的值保存起來,用hasChanged判斷數值是否改變,再從 update 函式返回值判斷:

system.ts
1
export function processComputedUpdate(sub) {
2
// update 返回值如果是 true
3
// 表示數值不同,effect 執行
4
if(sub.subs && sub.update()){
5
propagate(sub.subs)
6
}
7
}

得到期望結果,computed 執行兩次、effect 執行一次。

default

感覺我們解決了這個問題,但其實發現這個只是非常片面的解決方案,因為 effect 它在訪問相同依賴的時候,會重複觸發。

Effect 訪問相同依賴重複觸發問題

1
<body>
2
<div id="app"></div>
3
<script type="module">
4
// import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
5
import { ref, computed, effect } from '../dist/reactivity.esm.js'
6
7
const count = ref(0)
8
9
effect(() => {
10
console.count('effect')
11
console.log(count.value)
12
count.value
13
})
14
15
setTimeout(() => {
5 collapsed lines
16
count.value = 1
17
}, 1000)
18
19
</script>
20
</body>

default

可以看到這樣它觸發了三次,你如果查看一下 count:

1
effect(() => {
2
console.count('effect')
3
console.log(count.value)
4
count.value
5
})
6
console.log(count)

default

default

會發現它收集了相同依賴收集兩次,這時候該怎麼解決?

default

原始碼在link函式裡面,每次建立關聯關係前都會去遍歷鏈表,確認是不是有建立過關聯關係。

1
export function link(dep, sub) {
2
/**
3
* 復用節點
4
* sub.depsTail 是 undefined,並且有 sub.deps 頭節點,表示要復用
5
*/
6
const currentDep = sub.depsTail
7
const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
8
// 如果 nextDep.dep 等於我當前要收集的 dep
9
if (nextDep && nextDep.dep === dep) {
10
sub.depsTail = nextDep // 移動指針
11
return
12
}
13
14
/**
15
* 如果 dep 和 sub 建立過關聯關係,就直接返回。
15 collapsed lines
16
*/
17
18
let existingLink = sub.deps;
19
while (existingLink) {
20
// 如果在鏈表中找到了與當前 dep 相同的依賴項
21
if (existingLink.dep === dep) {
22
// 表示這個關聯已經建立過了,直接返回,不做任何事
23
return;
24
}
25
// 移動到下一個依賴項節點
26
existingLink = existingLink.nextDep;
27
}
28
...
29
...
30
}
  • link 函式一開始,就先進行檢查。
  • sub.deps (訂閱者的依賴鏈表頭部) 開始進行遍歷。
    • 沿著 nextDep 指標移動,檢查每一個鏈表節點 (Link)。
    • 在每一個節點上,判斷 link.dep 是否與我們正要連結的 dep 是同一個。
  • 如果是,代表已經建立過關聯關係,我們就可以直接 return
  • 如果遍歷完整個鏈表都沒有找到,那才繼續執行後面新增鏈表節點的邏輯。

這邊注意,需要寫在復用節點的邏輯後面:若檢查建立過依賴關係時提前退出,depsTail 標記會一直保持是 undefined,依賴會被錯誤清理。

方法二:重構髒標記

這邊換另一個比較簡易一點的方法,我們不管他們是不是有建立過關聯關係,重點是我們只要讓 effect 函式執行一次就可以了,這樣我們只要調整髒標記處理。

effect.ts
1
export class ReactiveEffect {
2
3
...
4
...
5
dirty = true // 是否需要重新計算
6
...

我們先在 effect 函式,加一個髒標記。

system.ts
1
export function propagate(subs) {
2
...
3
...
4
5
// 不在執行中的才加入隊列 以及 他是髒標記是 false 才執行
6
if(!sub.tracking && !sub.dirty){
7
// 開始執行,髒標記設定為初始值
8
sub.dirty = true
9
if ('update' in sub) {
10
processComputedUpdate(sub)
11
} else {
12
queuedEffect.push(sub)
13
}
14
}
11 collapsed lines
15
...
16
...
17
}
18
19
export function endTrack(sub) {
20
sub.tracking = false // 執行結束,取消標記
21
const depsTail = sub.depsTail
22
sub.dirty = false // fn 執行結束,追蹤完
23
...
24
...
25
}

並且在觸發更新時,增加髒標記的判斷。

如果有多個依賴同時觸發這個 effect,它也只會被加入佇列一次。因為一旦 dirty 變成 true,下一次的 !sub.dirty 判斷就會是 false跳過 if 區塊。

endTrack 中,sub.dirty 被設為 false。這代表 effect 剛剛成功執行完畢,它的狀態是「乾淨的」,不需要再次執行。

computed.ts
1
..
2
..
3
update(){
4
...
5
...
6
try {
7
// 更新前的值
8
const oldValue = this._value
9
// 更新的值
10
this._value = this.fn()
11
this.dirty = false// 刪除髒標記初始化
12
return hasChanged(oldValue, this._value)
13
} finally {
14
endTrack(this)
5 collapsed lines
15
setActiveSub(prevSub)
16
}
17
}
18
..
19
..

清除在 computed.ts 的髒標記初始化,因為我們已經在 endTrack 函式,統一處理初始化。

default

髒標記的判斷與運作流程

  1. 初始化:一個 effect 在執行完畢後,dirty 標記會被設為 false,表示「這是最新狀態,不需要執行」。
  2. 觸發更新時:當依賴項目變更,propagate 函式會檢查 effect 是否為 dirty: false
  3. 加入佇列前:只有當 dirtyfalse 時,才會將馬上設定為 true,然後再將 effect 加入待執行佇列。
  4. 防止重複:這個「先將設定為 true 再入列」的機制,可以保證在同一個事件迴圈中,縱使有多個依賴項目觸發同一個 effect,它也只會被加入佇列一次,避免了不必要的重複執行。
Article title:Day 22 - Computed:深入緩存機制實作
Article author:日安
Release time:1st October 2025