嘿,日安!

Day 21 - Computed:即時更新基礎實作

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

今天我們要在保持既有鏈表架構不變的前提下,來實作 computed 的惰性計算 + 快取(dirty 旗標)與調度邏輯。

範例演示

1
<!DOCTYPE html>
2
<html lang="en">
3
4
<head>
5
<meta charset="UTF-8" />
6
<title>Document</title>
7
<style>
8
body {
9
padding: 150px;
10
}
11
</style>
12
</head>
13
14
<body>
15
<div id="app"></div>
22 collapsed lines
16
<script type="module">
17
import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
18
// import { ref, computed, effect } from '../dist/reactivity.esm.js'
19
20
const count = ref(0)
21
22
const c = computed(() => {
23
return count.value + 1
24
})
25
26
effect(() => {
27
console.log(c.value)
28
})
29
30
setTimeout(() => {
31
console.log(count.value)
32
}, 1000)
33
34
</script>
35
</body>
36
37
</html>

先看官方程式碼的效果:

default

可以看到控制台,它會先輸出1,再輸出2,中間的 computed 只在需要時重算(惰性)。

執行順序是:

初始化

  • 初始化變數:countc
  • 初始化 effect,立刻執行console.log(c.value)
  • 收集 computed 依賴,觸發計算函式 () => count.value + 1
  • 讀取了 count.value,函式回傳 0 + 1,結果是 1,輸出1

一秒之後

  • count.value = 1 被執行。
  • Vue 偵測到 count 的值從 0 變成了 1
  • count.value 被修改時,它會通知所有訂閱它的對象,這邊包含c
  • c 接收到通知後,重新計算自己的值,並接著通知所有訂閱 c 的對象(也就是 effect),最終觸發 effect 的重新執行。
  • effect 收到通知,於是自動重新執行它內部的函式:() => console.log(c.value)
    • effect 它再次讀取 c.value
    • 重新執行計算函式 () => count.value + 1
    • 此時,count.value 的值已經是 1
    • c 計算出的新值為 1 + 1 = 2,輸出2

那我們可以看這個過程中,computed 在這當中扮演的角色如下圖。

default

設計核心

首先computed 具有雙重角色:

  • 訂閱者 (Sub),它會收集其執行函式(getter)中所訪問到的所有響應式依賴。
  • **依賴項 (Dep),**當 effect 訪問 computed.value 時,computed 會將這個 effect 收集起來,建立關聯。
  • computed 接收有可能是函式,也有可能是一個物件。
    • 判斷是否為函式,函式有 getter
    • 判斷是否為物件,物件有傳入的 getter 和 setter

怎麼樣是一個 Sub?怎麼樣是一個 Dep?可以查看之前定義的 interface。

1
/**
2
* 依賴項
3
*/
4
export interface Dependency {
5
// 訂閱者鏈表頭節點
6
subs: Link | undefined
7
// 訂閱者鏈表尾節點
8
subsTail: Link | undefined
9
}
10
/**
11
* 訂閱者
12
*/
13
export interface Sub {
14
// 訂閱者鏈表頭節點
15
deps: Link | undefined
5 collapsed lines
16
// 訂閱者鏈表尾節點
17
depsTail: Link | undefined
18
// 是否正在收集依賴
19
tracking: boolean
20
}

Sub

  • deps 頭節點
  • depsTail 尾節點
  • 有是否正在收集依賴的標記

Dep

  • subs 頭節點
  • subsTail 尾節點
  • 一定是響應式,會是 ref 或是 reactive

實作

我們先在 @vue/shared,新增一個函式判斷式。

1
export function isFunction(value) {
2
return typeof value === 'function'
3
}

由於 computed 接收有可能是函式,也有可能是一個物件,所以我們新增一個 computed.ts,導出一個 computed 函式,來判斷它是物件還是函式。

  • 傳入是函式:
    • 表示只有 getter(computed 唯讀)
  • 傳入是物件:
    • 表示有 getter 跟 setter
1
export function computed(getterOptions) {
2
let getter
3
let setter
4
if (isFunction(getterOptions)) {
5
getter = getterOptions
6
} else {
7
getter = getterOptions.get
8
setter = getterOptions.set
9
}
10
11
// ComputedRefImpl 是 computed 實際的響應式實作類別,再將 getter 跟 setter 傳入
12
return new ComputedRefImpl(getter, setter)
13
}

接著讓我們再來實作 ComputedRefImpl 類別,把 Dep 和 Sub 所需要的屬性加入:

1
class ComputedRefImpl implements Dependency, Sub {
2
// computed 是 ref,所以他會有這個標誌,通過 isRef 也回傳 true
3
[ReactiveFlags.IS_REF] = true
4
5
// 保存 fn 返回值
6
_value
7
8
// 如果是 Dep,要關聯 Subs,觸發更新要通知執行 fn
9
subs: Link
10
subsTail: Link
11
12
// 如果是 Sub,要知道哪些 Dep 被收集
13
deps: Link
14
depsTail: Link
15
tracking = false
22 collapsed lines
16
17
constructor(
18
public fn, //getter,但原始碼是fn,為了保持跟 effect 一致
19
private setter
20
) { }
21
get value() {
22
this.update()
23
return this._value
24
}
25
set value(newValue) {
26
// 如果他有傳入 setter,表示是物件傳入
27
if (this.setter) {
28
this.setter(newValue)
29
} else {
30
console.warn('computed is readonly')
31
}
32
}
33
34
update(){
35
this._value = this.fn()
36
}
37
}

我們執行這段程式碼,表面上看,它似乎能正確計算出結果:

default

但其實目前 get value() 每次讀取都直接 update(),都沒有導入快取/dirty 與,在多次讀值或多個 effect 下會一直重複計算。

我們剛剛提到 computed 他有雙重角色,那麼我們要如何讓 computed 同時做 DepSub 的角色呢?

回顧我們先前的邏輯,就可以知道:

當 Computed 作為 Dep

default

我們先在 get value() 裡建立與當前 activeSub 的關聯(link(this, activeSub)),同時改成只有在 dirty 時才 update,避免每次讀值都重算。

1
class ComputedRefImpl implements Dependency, Sub {
2
...
3
...
4
get value() {
5
this.update()
6
if(activeSub){
7
link(this,activeSub)
8
}
9
console.log('computed',this)
10
return this._value
11
}
12
...
13
...
14
}

接下來 console 看看是不是正常收集到 Fn

default

看來有正確儲存 fn ,表示我們建立好關聯關係。

我們現在已經完成下方紅色區塊連結的地方:

default

當 Computed 作為 Sub

default

我們需要在 fn 執行期間,收集訪問的響應式,因此我們看一下之前寫的 effect 的邏輯。

computed 的 getter 執行時仍需收集依賴。沿用先前的 setActiveSub / startTrack / endTrack 機制,不需要改寫 effect 架構。

我們只在 ComputedRefImpl.update() 內部包一層收集區段就好。

1
export function setActiveSub(sub) {
2
activeSub = sub
3
}
4
5
export class ReactiveEffect {
6
...
7
run() {
8
const prevSub = activeSub
9
setActiveSub(this)
10
startTrack(this)
11
12
try {
13
14
return this.fn()
15
8 collapsed lines
16
} finally {
17
endTrack(this)
18
setActiveSub(prevSub)
19
}
20
}
21
...
22
...
23
}

我們透過 setActiveSub 來重新賦值給 activeSub 變數,再引入 computed.ts

1
import { activeSub, setActiveSub } from './effect'
2
...
3
...
4
update(){
5
6
// 為了在 fn 執行期間,收集訪問的響應式
7
const prevSub = activeSub
8
setActiveSub(this)
9
startTrack(this)
10
11
try {
12
this._value = this.fn()
13
14
} finally {
15
endTrack(this)
6 collapsed lines
16
setActiveSub(prevSub)
17
console.log(this)
18
}
19
}
20
...
21
...

在 console 控制台上,我們可以看到 dep 也被成功儲存。

default

這樣看來,下方紅色圈起來的地方也已經完成。

default

報錯

但你應該還會發現有一個錯誤。

default

原因是 Ref 在 setTimeout 觸發更新會執行 setter

1
...
2
...
3
set value(newValue) {
4
if(hasChanged(newValue, this._value)){
5
this._value = isObject(newValue) ? reactive(newValue) : newValue
6
triggerRef(this)
7
}
8
}
9
...

然而執行到propagate函式

1
export function propagate(subs) {
2
let link = subs
3
let queuedEffect = []
4
5
while (link) {
6
const sub = link.sub
7
8
// 只有不在執行中的才加入隊列
9
if(!sub.tracking){
10
queuedEffect.push(sub)
11
}
12
link = link.nextSub
13
}
14
15
queuedEffect.forEach(effect =>effect.notify())
1 collapsed line
16
}

propagate 函式預期所有 sub 都有一個 run() 方法,但我們的 ComputedRefImpl 類別沒有這個方法。

我們目前已經分別完成兩部份的鏈表,分別是:

  • computed 成為 count 的訂閱者 (Sub)
  • computed 成為 effect 的依賴項目 (Dep)

default

default

現在,我們需要將這兩段依賴鏈路串接起來,形成完整的更新流程。

解決問題

default

執行觸發更新時:

  • ref 觸發更新
  • 通過 Sub 找到 computed
  • computed 執行更新
  • computed 再通過 computed 本身的 sub 鏈表
  • 找到所有的 sub 重新執行

因此我們現在要做的就是:

  1. 處理 computed 更新
  2. 讓 computed 通過 sub 鏈表,通知其他 sub 更新。

還記得我們原本在 computed 怎麼執行更新?

default

之前我們在 ComputedRefImpl 中已經定義了 update 方法,可以用它來更新 computed 的值。

1
export function processComputedUpdate(sub) {
2
// 通知 computed 更新
3
sub.update()
4
// 通知 sub 鏈表的其他 sub 更新
5
propagate(sub.subs)
6
}
7
8
export function propagate(subs) {
9
let link = subs
10
let queuedEffect = []
11
12
while (link) {
13
const sub = link.sub
14
15
if(!sub.tracking){
12 collapsed lines
16
// 如果 link.sub有 update 方法,表是傳入的是 computed
17
if('update' in sub){
18
processComputedUpdate(sub)
19
}else{
20
queuedEffect.push(sub)
21
}
22
}
23
link = link.nextSub
24
}
25
26
queuedEffect.forEach(effect =>effect.notify())
27
}

所以我們可以透過傳入的 sub 是否有 update 方法來判斷他是不是 computed,如果傳入的是 computed,那除了觸發更新函式之外,還需要通知 sub 鏈表上的所有 sub 更新。

我們執行這段程式碼,表面上看,它似乎能正確計算出結果:

但如果 index.html 你這樣寫:

1
const count = ref(0)
2
const c = computed(() => {
3
console.count('computed')
4
return count.value + 1
5
})
6
7
effect(() => {
8
console.log(c.value)
9
})
10
11
setTimeout(() => {
12
count.value = 1
13
}, 1000)

你會發現它其實是觸發三次。

default

如果用官方的範例,發現它其實執行兩次而已。

default

這個問題的根源在於 get value() 的實作:每次訪問 .value 都會直接觸發 update() 方法,因此完全沒有實現緩存。

1
get value() {
2
this.update()
3
...
4
...
5
}

今天我們加上快取與 dirty,並以 notify() 充當調度器:上游變更只標髒、下游讀取才重算。下篇我們再補上更進一步的同一 tick 多次讀值只算一次、以及多層 computed 鏈的範例,確認效能與語意

computed 完整程式碼:

1
import { ReactiveFlags } from './ref'
2
import { Dependency, Sub, Link, link, startTrack, endTrack } from './system'
3
import { isFunction } from '@vue/shared'
4
import { activeSub, setActiveSub } from './effect'
5
6
class ComputedRefImpl implements Dependency, Sub {
7
// computed 是 ref,所以他會有這個標誌,通過 isRef 也回傳 true
8
[ReactiveFlags.IS_REF] = true
9
// 保存 fn 返回值
10
_value
11
// 如果是 Dep,要關聯 Subs,觸發更新要通知執行 fn
12
subs: Link
13
subsTail: Link
14
15
// 如果是 Sub,要知道哪些 Dep 被收集
57 collapsed lines
16
deps: Link
17
depsTail: Link
18
tracking = false
19
constructor(
20
public fn, //getter,源碼是fn,保持跟 effect 一致
21
private setter
22
) { }
23
get value() {
24
this.update()
25
26
if(activeSub){
27
link(this,activeSub)
28
}
29
return this._value
30
}
31
set value(newValue) {
32
if (this.setter) {
33
this.setter(newValue)
34
} else {
35
console.warn('computed is readonly')
36
}
37
}
38
39
update(){
40
/**
41
* 收集依賴
42
* 為了在 fn 執行期間,收集訪問的響應式
43
*/
44
45
const prevSub = activeSub
46
setActiveSub(this)
47
startTrack(this)
48
49
try {
50
51
this._value = this.fn()
52
53
} finally {
54
endTrack(this)
55
setActiveSub(prevSub)
56
}
57
}
58
}
59
60
export function computed(getterOptions) {
61
let getter
62
let setter
63
if (isFunction(getterOptions)) {
64
getter = getterOptions
65
} else {
66
// 傳入是物件,物件有 get 和 set
67
getter = getterOptions.get
68
setter = getterOptions.set
69
}
70
71
return new ComputedRefImpl(getter, setter)
72
}
Article title:Day 21 - Computed:即時更新基礎實作
Article author:日安
Release time:30th September 2025