嘿,日安!

Day 23 - Watch:基礎實作

2nd October 2025
Front-end
Vue3
IT鐵人賽2025
從零到一打造 Vue3 響應式系統
Last updated:9th October 2025
9 Minutes
1612 Words

watch 是 Vue 非常重要的一個 API,它允許開發者在響應式資料發生變化時,執行特定的副作用(side effects)。這些副作用可以是異步行為,像是發起請求,也可以是需要基於狀態變化執行的複雜邏輯。

在實作之前,我們先來回憶在實作 effect 的時候,我們有做一個 Scheduler 調度器,然而watch 的核心原理與 effect 的調度器(Scheduler)密切相關。

調度器的設計目標是:當響應式數據變更時,不直接重新執行 effect 的主體函式,而是執行一個指定的調度函式。

細節可以回去看之前寫的文章。

核心概念

watch 本質上是 effect 的一種應用。它利用了調度器機制,來實現『監聽資料變更,並執行指定 callback 函式』的功能。

  • effect:當資料發生變化時,本身會重新執行。
  • watch:當資料發生變化時,執行一個自訂的函式,訂且在這個函式中呼叫使用者提供的 callback 函式。

Watch

接收參數:

  • source:要監聽的來源
  • cb:要執行的 callback 函式
  • options:其他選項,如 deepimmediateonce

返回值:一個函式,主要目的是停止監聽

基礎實作

我們建立一個 watch.ts檔案,並且導出。

在實作 watch 時,我們直接使用 ReactiveEffect 類別,而不是 effect 函式。

主要原因是 effect 函式返回的是 runner,我們無法直接取得內部 fn 的返回值,但如果直接使用 ReactiveEffect 實例,可以通過呼叫 effect.run() 來取得返回值。

1
export function effect(fn, options) {
2
3
const e = new ReactiveEffect(fn)
4
5
Object.assign(e, options)
6
7
e.run()
8
9
const runner = e.run.bind(e)
10
11
runner.effect = e
12
13
return runner <= 沒有 fn 返回值
14
15
}

然而ReactiveEffect類別需要傳入一個函式,但是 source 參數不一定是函式,他有可能是一個 ref 物件,因此一開始我們利用 getter 包裝成一個函式。

1
import { isRef } from './ref'
2
import { ReactiveEffect } from './effect'
3
4
export function watch(source, cb, options) {
5
6
let getter // 做成函式 傳入 effect
7
8
if(isRef(source)) { // source 有可能是 ref 物件,進行函式的包裝
9
getter = () => source.value
10
}
11
12
/**
13
* 使用 effect 類別,而不使用 effect 函式,是因為 effect 沒有返回 effect.run() 返回值
14
*/
15
const effect = new ReactiveEffect(getter) //effect 要接收一個函式
1 collapsed line
16
}

接下來,我們需要定義 job 函式,它將作為 effect 的調度器。當監聽的資料發生改變時,job 函式會被觸發,主要功能如下:

  1. 取得新值:調用 effect.run(),這會重新執行 getter 並返回最新的值(newValue)。
  2. 執行 callback:調用用戶傳入的 cb(newValue, oldValue)
  3. 更新舊值:將本次的 newValue 賦給 oldValue,為下一次變更做準備。
1
import { isRef } from './ref'
2
import { ReactiveEffect } from './effect'
3
4
export function watch(source, cb, options) {
5
6
let getter // 做成函式 傳入 effect
7
8
if(isRef(source)) { // source 有可能是 ref 物件,進行函式的包裝
9
getter = () => source.value
10
}
11
12
let oldValue
13
14
function job() {
15
// 執行 effect 的函式,得到新的數值,不能直接執行 getter,因為要收集依賴
18 collapsed lines
16
const newValue = effect.run()
17
cb(newValue, oldValue)
18
19
// 這次更新的新數值就是下次的舊數值
20
oldValue = newValue
21
}
22
23
/**
24
* 使用 effect 類別,而不使用 effect 函式,是因為 effect 沒有返回 effect.run() 返回值
25
*/
26
const effect = new ReactiveEffect(getter) //effect 要接收一個函式
27
28
effect.scheduler = job
29
30
oldValue = effect.run() // 收集依賴後,得到 run 返回值,取得舊的數值
31
32
return () => {} // 停止監聽
33
}

我們實作看看 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, watch } from '../dist/reactivity.esm.js'
6
7
const count = ref(0)
8
9
watch(count, (newVal, oldVal) => {
10
console.log('newVal, oldVal', newVal, oldVal)
11
})
12
13
setTimeout(() => {
14
count.value = 1
15
}, 1000)
3 collapsed lines
16
17
</script>
18
</body>

default

初始化

  • watch 建立一個內部的 effect 來監聽 count
  • effect 會立即執行一次,主要目的有兩個:
    1. 註冊依賴:存取 count.value,讓 watch 開始追蹤 count 的後續變化。
    2. 取得初始值:讀取 count 的當前值 0,並將其存放在 watch 函式內部的 oldValue 變數中。
  • 重點: console.log 在這個階段不會被執行。

更新時 (1 秒後 setTimeout 執行)

  • count.value 的值被更新為 1
  • 資料變動觸發watch 內部建立的 effect,但它執行的是我們自訂的調度器 (scheduler)。
  • scheduler已經被賦予成 job,直接呼叫 job 函式。
  • job 內部會:
    1. 呼叫 effect.run() 來取得 count 的新值 1
    2. 呼叫您提供的回呼函式,並傳入 (newValue: 1, oldValue: 0)
    3. console.log 因此印出 newVal, oldVal 1 0
    4. 將新值 1 存在 oldValue,確保下次更新oldValue是正確的舊值。

停止監聽函式

停止監聽函式剛剛我們沒有寫,接著完成。

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

default

這個範例會輸出兩次,如果我們希望不要輸出第二次結果,應該怎麼做?

首先我們先做一個 active 的監聽標記:

  • run 方法中,如果沒有監聽標記,我們就返回 fn 的返回值
  • 在類別裡面寫一個 stop 停止監聽方法:
    • stop 方法的核心是清除 effect 實例上收集到的所有依賴。這可以通過組合使用 startTrackendTrack 來實現:
    • 首先,呼叫 startTrack(this),此操作會重置 effect 內部的依賴追蹤指針。
    • 接著,立即呼叫 endTrack(this),此操作會清除從指針當前位置到依賴列表末尾的所有依賴項。
    • 將兩者連續呼叫,就可以清除該 effect 的全部依賴。最後,通過將 active 標記設為 false,可以阻止 effect 後續被意外重新執行。

default

effect.ts

1
export class ReactiveEffect implements Sub {
2
3
active = true // 是否啟動監聽
4
5
...
6
...
7
8
}
9
10
run() {
11
12
if(!this.active) {
13
return this.fn()
14
}
15
17 collapsed lines
16
...
17
...
18
}
19
20
...
21
...
22
23
stop() {
24
// 停止監聽
25
if(this.active) {
26
startTrack(this)
27
endTrack(this)
28
this.active = false
29
}
30
}
31
32
}

接著寫一個 stop 做返回函式。

watch.ts

1
export function watch(source, cb, options) {
2
...
3
...
4
5
function stop() {
6
effect.stop()
7
}
8
9
return () => {
10
stop()
11
}
12
}

這樣子就會輸出一次。

總結來說,我們透過直接利用 ReactiveEffect 類別及其調度器(Scheduler)功能,完成了基礎的 watch 實作。

關鍵在於透過 job 函式攔截更新通知,並在其中執行 effect.run() 以取得新舊值,最終呼叫使用者 callback。

同時,我們也為其增加了 stop 方法,實現了手動停止監聽的功能。

下一篇我們會探討 watchoption 參數的實作。

Article title:Day 23 - Watch:基礎實作
Article author:日安
Release time:2nd October 2025