Vue3 響應式系統:Effect Scope 與 stop() 完全攻略
簡介
在 Vue 3 中,響應式系統(Reactivity System) 重新設計為基於 Proxy 的 fine‑grained 追蹤機制,讓開發者可以更精確地控制副作用(effect)的生命週期。
其中,effectScope(或 EffectScope)提供了分組管理多個副作用的能力,配合 stop() 方法,我們可以在需要時一次性停止整個作用域內的所有副作用,避免記憶體洩漏或不必要的計算。
對於大型單頁應用(SPA)或需要在組件外部使用 Vue 响應式 API(例如自訂 hook、工具函式、測試環境)時,正確使用 effectScope 與 stop() 能夠讓程式碼保持乾淨、可預測,且更易於維護。
核心概念
1. 什麼是 Effect Scope?
effectScope 是 Vue 3 提供的 API,用來建立一個作用域(scope),在這個作用域內註冊的所有 effect(即 watchEffect、computed、reactive 產生的副作用)都會被同時管理。
- 建立方式
import { effectScope } from 'vue' const scope = effectScope() // 建立一個獨立的作用域 - 在作用域內註冊副作用
scope.run(() => { // 任何在這裡呼叫的 watchEffect、computed 都會被綁定到此 scope watchEffect(() => { console.log(state.count) }) }) - 停止作用域
scope.stop() // 會一次性停止此 scope 內的全部副作用
重點:
effectScope本身不會自動追蹤或取消追蹤,必須手動呼叫stop()(或在組件卸載時自動清理)。
2. stop() 的兩種使用情境
| 使用情境 | 說明 | 範例 |
|---|---|---|
| 單一 effect | watchEffect、computed 回傳的 stop 函式可直接停止該副作用。 |
const stop = watchEffect(fn); stop(); |
| Effect Scope | 透過 scope.stop() 一次性停止整個作用域內的所有副作用。 |
const scope = effectScope(); scope.run(() => {...}); scope.stop(); |
3. 為什麼要使用 Effect Scope?
- 組件外的副作用:在自訂 composable(如
useFetch)中,若需要在多個watchEffect之間共享生命週期,使用effectScope可以一次性清理。 - 測試與工具函式:測試時常需要在每個測試案例結束後手動停止副作用,以避免測試相互污染。
- 動態掛載/卸載:在動態渲染的子視圖或插件中,透過
effectScope可以在視圖被移除時自動釋放資源。
程式碼範例
以下示範 5 個實用範例,涵蓋從最基本的 watchEffect 到在 composable 中使用 effectScope 的完整流程。
範例 1:最簡單的 watchEffect + stop()
import { reactive, watchEffect } from 'vue'
const state = reactive({ count: 0 })
// 建立一個副作用,會在 state.count 改變時執行
const stop = watchEffect(() => {
console.log('count =', state.count)
})
// 改變值,觸發副作用
state.count++ // -> "count = 1"
state.count++ // -> "count = 2"
// 停止副作用,之後的變更不再印出
stop()
state.count++ // (不會有輸出)
小技巧:
watchEffect回傳的stop函式是 唯一 的,若同時有多個watchEffect,必須分別保存各自的stop。
範例 2:使用 effectScope 包裹多個副作用
import { reactive, watchEffect, effectScope } from 'vue'
const state = reactive({ a: 1, b: 2 })
// 建立一個 scope
const scope = effectScope()
scope.run(() => {
// 下面兩個 watchEffect 都屬於同一個 scope
watchEffect(() => {
console.log('a =', state.a)
})
watchEffect(() => {
console.log('b =', state.b)
})
})
// 觸發
state.a = 10 // -> "a = 10"
state.b = 20 // -> "b = 20"
// 一次性停止兩個副作用
scope.stop()
state.a = 100 // (不會印出)
state.b = 200 // (不會印出)
重點:只要在
scope.run內呼叫的watchEffect、computed都會自動被加入此 scope,不需要手動保存每個stop。
範例 3:在自訂 composable 中使用 effectScope
// useTimer.js
import { ref, effectScope } from 'vue'
export function useTimer(interval = 1000) {
const seconds = ref(0)
const scope = effectScope()
// 在 scope 中建立計時器的副作用
scope.run(() => {
const timer = setInterval(() => {
seconds.value++
}, interval)
// 當 scope 被停止時,清除計時器
// 這裡使用 onScopeDispose 讓 Vue 自動呼叫
scope.onScopeDispose(() => clearInterval(timer))
})
// 回傳的 stop 方法讓使用者自行停止計時
const stop = () => scope.stop()
return { seconds, stop }
}
// component.vue
<script setup>
import { useTimer } from './useTimer'
const { seconds, stop } = useTimer(500)
onMounted(() => {
console.log('計時開始')
})
onBeforeUnmount(() => {
// 組件卸載時自動停止計時
stop()
})
</script>
<template>
<div>已經過 {{ seconds }} 秒</div>
</template>
實務意義:在 自訂 Hook 中使用
effectScope,可以讓開發者在不依賴組件生命週期的情況下,仍然保證副作用的正確清理。
範例 4:測試環境下的批次清理
// timer.test.js
import { useTimer } from './useTimer'
import { nextTick } from 'vue'
test('timer increments correctly', async () => {
const { seconds, stop } = useTimer(10)
// 等待 35ms,預期 seconds 為 3
await new Promise(r => setTimeout(r, 35))
await nextTick()
expect(seconds.value).toBeGreaterThanOrEqual(3)
// 立即停止計時,確保後續不再變動
stop()
const snapshot = seconds.value
await new Promise(r => setTimeout(r, 30))
expect(seconds.value).toBe(snapshot) // 不會再變
})
技巧:在每個測試結束後呼叫
stop(),避免不同測試之間的計時器互相干擾,確保 測試隔離。
範例 5:動態掛載的子組件與 Effect Scope
// Parent.vue
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const showChild = ref(true)
</script>
<template>
<button @click="showChild = !showChild">
{{ showChild ? '卸載' : '掛載' }} Child
</button>
<Child v-if="showChild" />
</template>
// Child.vue
<script setup>
import { ref, effectScope, onMounted, onBeforeUnmount } from 'vue'
const count = ref(0)
const scope = effectScope()
scope.run(() => {
// 每秒自動遞增
const timer = setInterval(() => {
count.value++
}, 1000)
// 當子組件被卸載時自動清理
scope.onScopeDispose(() => clearInterval(timer))
})
onBeforeUnmount(() => {
// 手動停止,保險起見
scope.stop()
})
</script>
<template>
<div>Child count: {{ count }}</div>
</template>
關鍵:即使子組件被
v-if移除,透過effectScope仍能確保 計時器被正確清除,避免「隱形計時器」持續執行導致記憶體洩漏。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
忘記呼叫 stop() |
副作用仍持續追蹤,會導致不必要的重算或記憶體洩漏。 | 在 onBeforeUnmount、測試的 afterEach,或自訂 composable 中提供 stop 方法。 |
在同一作用域內呼叫多次 run |
會產生多層嵌套的 scope,stop() 只會停止最外層,內層仍在執行。 |
建議一次性在 run 內完成所有註冊,或使用 scope = effectScope(true) 產生「可嵌套」的 scope,並在適當時機分別停止。 |
在非 Vue 生命週期內使用 watchEffect |
若直接在普通函式中使用,無法自動清理。 | 必須自行建立 effectScope 包裹,或在外層手動保存 stop。 |
誤把 computed 當作副作用 |
computed 本身不會立即執行,只有在被讀取時才會觸發。 |
需要時可使用 watchEffect(() => computedValue.value) 產生副作用,或直接在 scope.run 中呼叫 computed。 |
忘記在 onScopeDispose 中釋放外部資源 |
如 setInterval、addEventListener 等不會被 Vue 自動清理。 |
在 scope.run 內註冊 scope.onScopeDispose(() => {/* 清理 */})。 |
最佳實踐清單
- 始終返回
stop:在任何自訂 composable 中,將scope.stop或單一watchEffect的stop作為回傳值提供給使用者。 - 使用
onScopeDispose:在effectScope內註冊清理邏輯,確保即使忘記手動呼叫stop(),在作用域被 GC 時仍能釋放資源。 - 避免過度嵌套:除非真的需要分層管理,否則保持每個功能區塊只使用 一個
effectScope。 - 在測試中加入
afterEach(() => scope?.stop?.()):保證測試環境乾淨。 - 記得在組件的
onBeforeUnmount中停止:即使使用onScopeDispose,手動stop()仍是保險的做法。
實際應用場景
| 場景 | 為什麼需要 Effect Scope & stop() |
|---|---|
| 自訂資料抓取 Hook(useFetch) | 多個 watchEffect 用於監控 URL、參數變化;在組件卸載或請求取消時一次性停止所有副作用。 |
| 全局事件總線 | 透過 effectScope 訂閱多個全局事件;在模組卸載時一次性解除所有監聽,避免「幽靈」事件。 |
| 動態表格排序與過濾 | 每個欄位的排序、過濾條件都會產生 computed + watchEffect;切換頁籤時使用 scope.stop() 清除不再需要的計算。 |
| 插件開發 | 插件在安裝時建立多個副作用,提供 uninstall 方法呼叫 scope.stop(),確保插件被卸載後不留痕跡。 |
| SSR(伺服器端渲染) | 在每一次請求的渲染流程中,使用 effectScope 包裹所有副作用,渲染結束後立即 stop(),避免跨請求的狀態污染。 |
總結
- Effect Scope 是 Vue 3 為了讓副作用管理更可組合、可清理而設計的工具。
- 透過
scope.run(() => {...}),我們可以把多個watchEffect、computed、甚至自訂的資源(如setInterval、事件監聽) 一次性納入同一個生命週期。 stop()(單一副作用或整個 scope)是 釋放資源、避免記憶體洩漏 的關鍵。- 在 組件、Composable、測試、插件、SSR 等不同層面,都能透過正確使用
effectScope與stop(),讓程式碼保持乾淨且易於維護。
記得:「建立即管理,使用即清除」——每當你在 Vue 3 中建立一個副作用,就思考它的生命週期,適時使用
effectScope與stop(),讓你的應用永遠保持高效、可靠。