watch 是 Vue 非常重要的一個 API,它允許開發者在響應式資料發生變化時,執行特定的副作用(side effects)。這些副作用可以是異步行為,像是發起請求,也可以是需要基於狀態變化執行的複雜邏輯。
在實作之前,我們先來回憶在實作 effect
的時候,我們有做一個 Scheduler 調度器,然而watch
的核心原理與 effect
的調度器(Scheduler)密切相關。
調度器的設計目標是:當響應式數據變更時,不直接重新執行 effect
的主體函式,而是執行一個指定的調度函式。
細節可以回去看之前寫的文章。
核心概念
watch
本質上是 effect
的一種應用。它利用了調度器機制,來實現『監聽資料變更,並執行指定 callback 函式』的功能。
- effect:當資料發生變化時,本身會重新執行。
- watch:當資料發生變化時,執行一個自訂的函式,訂且在這個函式中呼叫使用者提供的 callback 函式。
Watch
接收參數:
- source:要監聽的來源
- cb:要執行的 callback 函式
- options:其他選項,如
deep
、immediate
、once
返回值:一個函式,主要目的是停止監聽
基礎實作
我們建立一個 watch.ts
檔案,並且導出。
在實作 watch
時,我們直接使用 ReactiveEffect
類別,而不是 effect
函式。
主要原因是 effect
函式返回的是 runner
,我們無法直接取得內部 fn
的返回值,但如果直接使用 ReactiveEffect
實例,可以通過呼叫 effect.run()
來取得返回值。
1export 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 = e12
13 return runner <= 沒有 fn 返回值14
15}
然而ReactiveEffect
類別需要傳入一個函式,但是 source 參數不一定是函式,他有可能是一個 ref 物件,因此一開始我們利用 getter 包裝成一個函式。
1import { isRef } from './ref'2import { ReactiveEffect } from './effect'3
4export function watch(source, cb, options) {5
6 let getter // 做成函式 傳入 effect7
8 if(isRef(source)) { // source 有可能是 ref 物件,進行函式的包裝9 getter = () => source.value10 }11
12 /**13 * 使用 effect 類別,而不使用 effect 函式,是因為 effect 沒有返回 effect.run() 返回值14 */15 const effect = new ReactiveEffect(getter) //effect 要接收一個函式1 collapsed line
16}
接下來,我們需要定義 job
函式,它將作為 effect
的調度器。當監聽的資料發生改變時,job
函式會被觸發,主要功能如下:
- 取得新值:調用
effect.run()
,這會重新執行getter
並返回最新的值(newValue
)。 - 執行 callback:調用用戶傳入的
cb(newValue, oldValue)
。 - 更新舊值:將本次的
newValue
賦給oldValue
,為下一次變更做準備。
1import { isRef } from './ref'2import { ReactiveEffect } from './effect'3
4export function watch(source, cb, options) {5
6 let getter // 做成函式 傳入 effect7
8 if(isRef(source)) { // source 有可能是 ref 物件,進行函式的包裝9 getter = () => source.value10 }11
12 let oldValue13
14 function job() {15 // 執行 effect 的函式,得到新的數值,不能直接執行 getter,因為要收集依賴18 collapsed lines
16 const newValue = effect.run()17 cb(newValue, oldValue)18
19 // 這次更新的新數值就是下次的舊數值20 oldValue = newValue21 }22
23 /**24 * 使用 effect 類別,而不使用 effect 函式,是因為 effect 沒有返回 effect.run() 返回值25 */26 const effect = new ReactiveEffect(getter) //effect 要接收一個函式27
28 effect.scheduler = job29
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 = 115 }, 1000)3 collapsed lines
16
17 </script>18</body>
初始化
watch
建立一個內部的effect
來監聽count
effect
會立即執行一次,主要目的有兩個:- 註冊依賴:存取
count.value
,讓watch
開始追蹤count
的後續變化。 - 取得初始值:讀取
count
的當前值0
,並將其存放在watch
函式內部的oldValue
變數中。
- 註冊依賴:存取
- 重點:
console.log
在這個階段不會被執行。
更新時 (1 秒後 setTimeout
執行)
count.value
的值被更新為1
。- 資料變動觸發
watch
內部建立的effect
,但它執行的是我們自訂的調度器 (scheduler
)。 scheduler
已經被賦予成job
,直接呼叫job
函式。job
內部會:- 呼叫
effect.run()
來取得count
的新值1
。 - 呼叫您提供的回呼函式,並傳入
(newValue: 1, oldValue: 0)
。 console.log
因此印出newVal, oldVal 1 0
。- 將新值
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 = 115 setTimeout(() => {7 collapsed lines
16 stop()17 count.value = 218 }, 1000)19 }, 1000)20
21 </script>22</body>
這個範例會輸出兩次,如果我們希望不要輸出第二次結果,應該怎麼做?
首先我們先做一個 active
的監聽標記:
- 在
run
方法中,如果沒有監聽標記,我們就返回 fn 的返回值 - 在類別裡面寫一個
stop
停止監聽方法:stop
方法的核心是清除effect
實例上收集到的所有依賴。這可以通過組合使用startTrack
和endTrack
來實現:- 首先,呼叫
startTrack(this)
,此操作會重置effect
內部的依賴追蹤指針。 - 接著,立即呼叫
endTrack(this)
,此操作會清除從指針當前位置到依賴列表末尾的所有依賴項。 - 將兩者連續呼叫,就可以清除該
effect
的全部依賴。最後,通過將active
標記設為false
,可以阻止effect
後續被意外重新執行。
effect.ts
1export 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 = false29 }30 }31
32}
接著寫一個 stop
做返回函式。
watch.ts
1export 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
方法,實現了手動停止監聽的功能。
下一篇我們會探討 watch
的 option
參數的實作。