iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Vue.js

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

Day 23 - Watch:基礎實作

  • 分享至 

  • xImage
  •  

banner

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() 來取得返回值。

export function effect(fn, options) {

  const e = new ReactiveEffect(fn)

  Object.assign(e, options)

  e.run()

  const runner = e.run.bind(e)

  runner.effect = e

  return runner <= 沒有 fn 返回值

}

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

import { isRef } from './ref'
import { ReactiveEffect } from './effect'

export function watch(source, cb, options) {

  let getter // 做成函式 傳入 effect

  if(isRef(source)) { // source 有可能是 ref 物件,進行函式的包裝
    getter = () => source.value
  }

  /**
   * 使用 effect 類別,而不使用 effect 函式,是因為 effect 沒有返回 effect.run() 返回值
   */
  const effect = new ReactiveEffect(getter) //effect 要接收一個函式
}

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

  1. 取得新值:調用 effect.run(),這會重新執行 getter 並返回最新的值(newValue)。
  2. 執行 callback:調用用戶傳入的 cb(newValue, oldValue)
  3. 更新舊值:將本次的 newValue 賦給 oldValue,為下一次變更做準備。
import { isRef } from './ref'
import { ReactiveEffect } from './effect'

export function watch(source, cb, options) {

  let getter // 做成函式 傳入 effect

  if(isRef(source)) { // source 有可能是 ref 物件,進行函式的包裝
    getter = () => source.value
  }

  let oldValue

  function job() {
    // 執行 effect 的函式,得到新的數值,不能直接執行 getter,因為要收集依賴
    const newValue = effect.run()
    cb(newValue, oldValue)

    // 這次更新的新數值就是下次的舊數值
    oldValue = newValue
  }

  /**
   * 使用 effect 類別,而不使用 effect 函式,是因為 effect 沒有返回 effect.run() 返回值
   */
  const effect = new ReactiveEffect(getter) //effect 要接收一個函式

  effect.scheduler = job

  oldValue = effect.run() // 收集依賴後,得到 run 返回值,取得舊的數值

  return () => {} // 停止監聽
}

我們實作看看 index.html

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

    const count = ref(0)

    watch(count, (newVal, oldVal) => {
      console.log('newVal, oldVal', newVal, oldVal)
    })

    setTimeout(() => {
      count.value = 1
    }, 1000)

  </script>
</body>

day23-01

初始化

  • 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是正確的舊值。

停止監聽函式

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

<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 count = ref(0)

    const stop = watch(count, (newVal, oldVal) => {
      console.log('newVal, oldVal', newVal, oldVal)
    })

    setTimeout(() => {
      count.value = 1
      setTimeout(() => {
        stop()
        count.value = 2
      }, 1000)
    }, 1000)

  </script>
</body>

day23-02

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

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

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

day23-03

effect.ts

export class ReactiveEffect implements Sub {

  active = true // 是否啟動監聽
  
...
...

  }

  run() {

    if(!this.active) {
      return this.fn()
    }

   ...
   ...
  }

...
...

  stop() {
    // 停止監聽
    if(this.active) {
      startTrack(this)
      endTrack(this)
      this.active = false
    }
  }

}

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

watch.ts


export function watch(source, cb, options) {
...
...

  function stop() {
    effect.stop()
  }

  return () => {
    stop()
  }
}

這樣子就會輸出一次。

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

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

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

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


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


上一篇
Day 22 - Computed:深入緩存機制實作
下一篇
Day 24 - Watch:Options
系列文
從零到一打造 Vue3 響應式系統30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言