嘿,日安!

Day 24 - Watch:Options

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

Watch Options 我們常用的選項:

  • immediate:初始化馬上執行一次
  • deep:深層監聽
  • once:只執行一次,就停止監聽

我們先寫接受三個參數,預設值是空的物件。

1
export function watch(source, cb, options) {
2
3
const { immediate, once, deep } = options || {}
4
...
5
...
6
}

immediate

immediate 選項為 true 時,watch 會在初始化階段立即執行一次 job 函式,此時的 callback 函式中 oldValueundefined。如果 immediatefalse(或未提供),則 watch 在初始化時僅只會執行 effect.run() 來收集依賴並取得初始的 oldValue,但不會觸發 callback。

1
export function watch(source, cb, options) {
2
3
const { immediate, once, deep } = options || {}
4
5
...
6
...
7
8
if(immediate) {
9
// 第一次立即執行一次
10
job()
11
}else{
12
// 因為不是第一次執行,才會得到舊的資料,收集依賴
13
oldValue = effect.run() // 收集依賴後,得到 run 返回值,取得舊的數值
14
}
15
...
2 collapsed lines
16
...
17
}

Once

為了實現 once 功能,我們需要對使用者傳入的 callback 函式進行包裝。我們將原本的 callback 函式暫存起來,然後用一個新的匿名函式覆寫 cb

在這個新的函式中,我們先呼叫原始的 callback 函式,之後立即執行 stop() 函式,從而達到『執行一次後即停止』的效果。

1
export function watch(source, cb, options) {
2
3
const { immediate, once, deep } = options || {}
4
5
if(once) {
6
const _cb = cb
7
cb = (...args) => {
8
_cb(...args)
9
stop()
10
}
11
}
12
...
13
...
14
}

Deep

深層監聽(deep: true)的原理是:在依賴收集階段,遍歷地訪問被監聽對象的所有巢狀屬性。這個過程會觸發每一個屬性的 getter,從而將它們全部作為 watch 內部 effect 的依賴項進行收集。一旦任何深層屬性發生變化,watch 都能收到通知。

1
import { isObject } from '@vue/shared'
2
3
export function watch(source, cb, options) {
4
5
const { immediate, once, deep } = options || {}
6
7
...
8
...
9
if(deep){
10
const baseGetter = getter
11
getter = () => traverse(baseGetter())
12
}
13
...
14
}
15
10 collapsed lines
16
function traverse(value) {
17
// 檢查類型
18
if(!isObject(value)) {
19
return
20
}
21
for(const key in value) {
22
traverse(value[key])
23
}
24
return value
25
}

這樣可以解決,但在使用上面有可能會遇到循環引用的問題,因此需要調整一下:

1
function traverse(value, seen = new Set()) {
2
if(!isObject(value)) {
3
return value
4
}
5
// 如果之前訪問過,就回傳原本的值,預防循環引用
6
if(seen.has(value)) {
7
return value
8
}
9
10
seen.add(value)
11
12
for(const key in value) {
13
traverse(value[key], seen)
14
}
15
return value
1 collapsed line
16
}

我們用 Set 結構來記錄在單次遍歷中所有已訪問過的物件。在遍歷到一個新物件前,先檢查它是不是存在 Set 中。

如果存在,說明遇到了循環引用,要立即停止目前的遞迴,從而避免堆疊溢出。

Deep 在3.5版本有一個新的功能,遇到巢狀物件監聽,可以指定監聽層級,像是下方範例:

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 state = ref({
8
a: {
9
b: 1,
10
c: {
11
d: 1
12
}
13
}
14
})
15
11 collapsed lines
16
watch(state, (newVal, oldVal) => {
17
console.log('newVal, oldVal', newVal, oldVal)
18
}, { deep: 2 })
19
20
setTimeout(() => {
21
state.value.a.c.d = 2
22
console.log('更新了')
23
}, 1000)
24
25
</script>
26
</body>

deep 的值是數字時,它代表了監聽的遞迴層級。例如 deep: 2 指的是監聽應深入到目標物件的第二層屬性。在上述範例中,修改 state.value.a.b(第二層)應該觸發監聽,而修改 state.value.a.c.d(第四層)則不應該觸發。

default

如果你切換到官方程式碼,控制台不會輸出任何結果。

default

1
if(deep){
2
const baseGetter = getter
3
const depth = deep === true ? Infinity : deep
4
getter = () => traverse(baseGetter(), depth)
5
}
6
7
function traverse(value, depth = Infinity, seen = new Set()) {
8
// 如果不是物件,或是監聽層級到了,就回傳原本的值
9
if(!isObject(value) || depth<=0) {
10
return value
11
}
12
// 如果之前訪問過,就回傳原本的值,預防循環引用
13
if(seen.has(value)) {
14
return value
15
}
10 collapsed lines
16
17
depth--
18
19
seen.add(value)
20
21
for(const key in value) {
22
traverse(value[key],depth, seen)
23
}
24
return value
25
}

透過在遞迴函式 traverse 中傳遞並遞減 depth 計數,我們就能精確控制依賴收集的深度。

reactive 與 function 處理

我們把剛剛的程式碼改成 reactive 之後,發現控制台報錯

default

確認一下官方的解決方案:

1
<body>
2
<div id="app"></div>
3
<script type="module">
4
import { reactive, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
5
// import { reactive, watch } from '../dist/reactivity.esm.js'
6
7
const state = reactive({
8
a: {
9
b: 1,
10
c: {
11
d: 1
12
}
13
}
14
})
15
13 collapsed lines
16
watch(state, (newVal, oldVal) => {
17
console.log('newVal, oldVal', newVal, oldVal)
18
},
19
// { deep: 2 }
20
)
21
22
setTimeout(() => {
23
state.a.c.d = 2
24
console.log('更新了')
25
}, 1000)
26
27
</script>
28
</body>

default

查看控制台你會發現,當 watch 的監聽來源是 reactive 物件時,deep 選項會預設為 true

因此,我們需要調整 getter 的初始化邏輯來應對此情況:

  • 首先,當來源是 reactive 物件時,getter 應直接返回該物件
  • 其次,若用戶未提供 deep 選項,則應將 deep 的值預設為 true

所以我們接下來要做:

如果 reactive 傳入,預設 deep:true,如果有傳入層級,以傳入層級為主。

1
if(isRef(source)) { // source 有可能是 ref 物件,進行函式的包裝
2
getter = () => source.value
3
}else if(isReactive(source)){
4
// 如果 source 是 reactive,直接賦值給 getter
5
getter = () => source
6
if(!deep) deep = true
7
// 如果 source 是函式,直接賦值給 getter
8
}else if(isFunction(source)){
9
getter = source
10
}

這樣就不會報錯,而且 reactive 也預設監聽。

我們目前已經完成 watchoptions實作,除了擴充了 immediateoncedeep等常用方法,我們還透過遞迴遍歷與 Set 解決了深度監聽中的循環引用問題,並解決了對 refreactivegetter 函式等多種來源的處理。

Article title:Day 24 - Watch:Options
Article author:日安
Release time:3rd October 2025