iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Vue.js

從零到一打造 Vue3 響應式系統系列 第 24

Day 24 - Watch:Options

  • 分享至 

  • xImage
  •  

banner

Watch Options 我們常用的選項:

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

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

export function watch(source, cb, options) {

  const { immediate, once, deep } = options || {}
...
...
}

immediate

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

export function watch(source, cb, options) {

  const { immediate, once, deep } = options || {}

 ...
 ...

  if(immediate) {
    // 第一次立即執行一次
    job()
  }else{
    // 因為不是第一次執行,才會得到舊的資料,收集依賴
    oldValue = effect.run() // 收集依賴後,得到 run 返回值,取得舊的數值
  }
...
...
}

Once

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

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

export function watch(source, cb, options) {

  const { immediate, once, deep } = options || {}

  if(once) {
    const _cb = cb
    cb = (...args) => {
      _cb(...args)
      stop()
    }
  }
...
...
}

Deep

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

import { isObject } from '@vue/shared'

export function watch(source, cb, options) {

  const { immediate, once, deep } = options || {}
  
 ...
 ...
  if(deep){
    const baseGetter = getter
    getter = () => traverse(baseGetter())
  }
...
}

function traverse(value) {
	// 檢查類型
  if(!isObject(value)) {
    return
  }
  for(const key in value) {
    traverse(value[key])
  }
  return value
}

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

function traverse(value, seen = new Set()) {
  if(!isObject(value)) {
    return value
  }
  // 如果之前訪問過,就回傳原本的值,預防循環引用
  if(seen.has(value)) {
    return value
  }

  seen.add(value)
  
  for(const key in value) {
    traverse(value[key], seen)
  }
  return value
}

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

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

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

<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { ref, watch } from '../dist/reactivity.esm.js'

    const state = ref({
      a: {
        b: 1,
        c: {
          d: 1
        }
      }
    })

    watch(state, (newVal, oldVal) => {
      console.log('newVal, oldVal', newVal, oldVal)
    }, { deep: 2 })

    setTimeout(() => {
      state.value.a.c.d = 2
      console.log('更新了')
    }, 1000)

  </script>
</body>

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

day24-01

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

day24-02

  if(deep){
    const baseGetter = getter
    const depth = deep === true ? Infinity : deep
    getter = () => traverse(baseGetter(), depth)
  }
  
	function traverse(value, depth = Infinity, seen = new Set()) {
	  // 如果不是物件,或是監聽層級到了,就回傳原本的值
	  if(!isObject(value) || depth<=0) {
	    return value
	  }
	  // 如果之前訪問過,就回傳原本的值,預防循環引用
	  if(seen.has(value)) {
	    return value
	  }
	
	  depth--
	
	  seen.add(value)
	  
	  for(const key in value) {
	    traverse(value[key],depth, seen)
	  }
	  return value
	}

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

reactive 與 function 處理

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

day24-03

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

<body>
  <div id="app"></div>
  <script type="module">
    import { reactive, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    // import { reactive, watch } from '../dist/reactivity.esm.js'

    const state = reactive({
      a: {
        b: 1,
        c: {
          d: 1
        }
      }
    })

    watch(state, (newVal, oldVal) => {
      console.log('newVal, oldVal', newVal, oldVal)
    }, 
    // { deep: 2 }
  )

    setTimeout(() => {
      state.a.c.d = 2
      console.log('更新了')
    }, 1000)

  </script>
</body>

day24-04

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

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

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

所以我們接下來要做:

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

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

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

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


同步更新《嘿,日安!》技術部落格


上一篇
Day 23 - Watch:基礎實作
下一篇
Day 25 - Watch :清理 SideEffect
系列文
從零到一打造 Vue3 響應式系統30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Dylan
iT邦研究生 5 級 ‧ 2025-10-03 17:19:52

if(!deep) deep = true

deep: 0 的話會跟 vue 表現不一樣😂

heyrian iT邦新手 5 級 ‧ 2025-10-04 10:51:00 檢舉

可以調整成
if (deep === undefined) deep = true

我要留言

立即登入留言