watch
的一個核心用途是在響應式資料發生改變時,執行 Side Effect。
然而,當 Side Effect 是非同步或需要手動清理時,就會出現一個常見的問題:如果監聽的資料在短時間內多次變更,前一次的 Side Effect可能沒清理乾淨,就會與下一次的 Side Effect 產生衝突或造成資源洩漏。
1<!DOCTYPE html>2<html lang="en">3
4<head>5 <meta charset="UTF-8" />6 <title>Document</title>7 <style>8 #app,#div{9 width: 100px;10 height: 100px;11 background-color: red;12 margin-bottom: 10px;13 }14 #div{15 background-color: blue;34 collapsed lines
16 }17 </style>18</head>19
20<body>21 <div id="app"></div>22 <div id="div"></div>23 <button id="button">按鈕</button>24 <script type="module">25 // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'26 import { ref, watch } from '../dist/reactivity.esm.js'27
28 const flag = ref(true)29
30 watch(flag, (newVal, oldVal) => {31 const dom = newVal ? app : div32
33 function handler () {34 console.log(newVal ? '點擊app' : '點擊div')35 }36
37 dom.addEventListener('click', handler)38 },39 { immediate: true }40 )41
42 button.onclick = () => {43 flag.value = !flag.value44 }45
46 </script>47</body>48
49</html>
上述範例是一個常見的資源洩漏問題:現在可以看到有兩個色塊,點了app
會被觸發,點擊 div
沒有反應,點擊按鈕,當 flag
從 true
變為 false
時,此時div
點擊後會被觸發,但你點擊 app
控制台仍然有輸出,這是因為app
元素上註冊的 click
事件監聽器並沒有被移除。
因此,即使邏輯上它不應該再響應點擊,但 click 監聽器依然殘留在記憶體中並繼續觸發。
官方的解決方案是,有一個onCleanup
函式:
1<body>2 <div id="app"></div>3 <div id="div"></div>4 <button id="button">按鈕</button>5 <script type="module">6 import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'7 // import { ref, watch } from '../dist/reactivity.esm.js'8
9 const flag = ref(true)10
11 watch(flag, (newVal, oldVal, onCleanup) => {12 const dom = newVal ? app : div13
14 function handler () {15 console.log(newVal ? '點擊app' : '點擊div')17 collapsed lines
16 }17
18 dom.addEventListener('click', handler)19
20 onCleanup(() => {21 dom.removeEventListener('click', handler)22 })23 },24 { immediate: true }25 )26
27 button.onclick = () => {28 flag.value = !flag.value29 }30
31 </script>32</body>
我們現在在監聽的時候綁一個監聽事件,onCleanup
接受一個 callback 函式,我們在函式中寫移除事件。
onCleanup
註冊的 callback 函式會在下一次 watch
callback 即將執行**之前被呼叫,**這個時機確保了我們在新的副作用出現前,清理掉上一個過期的 Side Effect。
你會發現回去點 app
,它不會再被觸發了。
1export function watch(source, cb, options) {2...3...4 let cleanup = null5
6 function onCleanup(cb) {7 cleanup = cb8 }9
10 function job() {11
12 if(cleanup) {13 // 確認是不是要清理之前的 sideEffect 函式14 cleanup()15 cleanup = null12 collapsed lines
16 }17
18 // 執行 effect 的函式,得到新的數值,不能直接執行 getter,因為要收集依賴19 const newValue = effect.run()20
21 cb(newValue, oldValue, onCleanup)22
23 oldValue = newValue24 }25...26...27}
我們儲存外部傳入 onCleanup
的 callback 函式,把它儲存到變數之中,接著判斷,如果 onCleanup
傳入函式存在,在每次執行 job
函式之前,先執行一次清理函式。
onCleanup
是 watch
API 中一個很重要但容易被忽略的特性。它為開發者提供了一個標準化的機制,來應對 Side Effect 帶來的挑戰:資源洩漏(如未移除的事件監聽器)與非同步競爭條件(如過期的網路請求)。