
watch 的一個核心用途是在響應式資料發生改變時,執行 Side Effect。
然而,當 Side Effect 是非同步或需要手動清理時,就會出現一個常見的問題:如果監聽的資料在短時間內多次變更,前一次的 Side Effect可能沒清理乾淨,就會與下一次的 Side Effect 產生衝突或造成資源洩漏。
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Document</title>
  <style>
    #app,#div{
      width: 100px;
      height: 100px;
      background-color: red;
      margin-bottom: 10px;
    }
    #div{
      background-color: blue;
    }
  </style>
</head>
<body>
  <div id="app"></div>
  <div id="div"></div>
  <button id="button">按鈕</button>
  <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 flag = ref(true)
      watch(flag, (newVal, oldVal) => {
        const dom = newVal ? app : div
      
        function handler () {
          console.log(newVal ? '點擊app' : '點擊div')
        }
        dom.addEventListener('click', handler)
    }, 
    { immediate: true }
  )
  button.onclick = () => {
    flag.value = !flag.value
  }
  </script>
</body>
</html>
上述範例是一個常見的資源洩漏問題:現在可以看到有兩個色塊,點了app 會被觸發,點擊 div 沒有反應,點擊按鈕,當 flag 從 true 變為 false 時,此時div 點擊後會被觸發,但你點擊 app 控制台仍然有輸出,這是因為app 元素上註冊的 click 事件監聽器並沒有被移除。
因此,即使邏輯上它不應該再響應點擊,但 click 監聽器依然殘留在記憶體中並繼續觸發。
官方的解決方案是,有一個onCleanup函式:
<body>
  <div id="app"></div>
  <div id="div"></div>
  <button id="button">按鈕</button>
  <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 flag = ref(true)
      watch(flag, (newVal, oldVal, onCleanup) => {
        const dom = newVal ? app : div
      
        function handler () {
          console.log(newVal ? '點擊app' : '點擊div')
        }
        dom.addEventListener('click', handler)
      onCleanup(() => {
        dom.removeEventListener('click', handler)
      })
    }, 
    { immediate: true }
  )
  button.onclick = () => {
    flag.value = !flag.value
  }
  </script>
</body>
我們現在在監聽的時候綁一個監聽事件,onCleanup 接受一個 callback 函式,我們在函式中寫移除事件。
onCleanup 註冊的 callback 函式會在下一次 watch callback 即將執行**之前被呼叫,**這個時機確保了我們在新的副作用出現前,清理掉上一個過期的 Side Effect。

你會發現回去點 app,它不會再被觸發了。
export function watch(source, cb, options) {
...
...
  let cleanup = null
  function onCleanup(cb) {
    cleanup = cb
  }
  function job() {
    if(cleanup) {
      // 確認是不是要清理之前的 sideEffect 函式
      cleanup()
      cleanup = null
    }
    // 執行 effect 的函式,得到新的數值,不能直接執行 getter,因為要收集依賴
    const newValue = effect.run()
    cb(newValue, oldValue, onCleanup)
    oldValue = newValue
  }
...
...
}
我們儲存外部傳入 onCleanup 的 callback 函式,把它儲存到變數之中,接著判斷,如果 onCleanup 傳入函式存在,在每次執行 job 函式之前,先執行一次清理函式。
onCleanup 是 watch API 中一個很重要但容易被忽略的特性。它為開發者提供了一個標準化的機制,來應對 Side Effect 帶來的挑戰:資源洩漏(如未移除的事件監聽器)與非同步競爭條件(如過期的網路請求)。
同步更新《嘿,日安!》技術部落格