本文 AI 產出,尚未審核

Vue3 響應式系統:Effect Scopestop() 完全攻略


簡介

在 Vue 3 中,響應式系統(Reactivity System) 重新設計為基於 Proxy 的 fine‑grained 追蹤機制,讓開發者可以更精確地控制副作用(effect)的生命週期。
其中,effectScope(或 EffectScope)提供了分組管理多個副作用的能力,配合 stop() 方法,我們可以在需要時一次性停止整個作用域內的所有副作用,避免記憶體洩漏或不必要的計算。

對於大型單頁應用(SPA)或需要在組件外部使用 Vue 响應式 API(例如自訂 hook、工具函式、測試環境)時,正確使用 effectScopestop() 能夠讓程式碼保持乾淨、可預測,且更易於維護。


核心概念

1. 什麼是 Effect Scope?

effectScope 是 Vue 3 提供的 API,用來建立一個作用域(scope),在這個作用域內註冊的所有 effect(即 watchEffectcomputedreactive 產生的副作用)都會被同時管理。

  • 建立方式
    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 watchEffectcomputed 回傳的 stop 函式可直接停止該副作用。 const stop = watchEffect(fn); stop();
Effect Scope 透過 scope.stop() 一次性停止整個作用域內的所有副作用。 const scope = effectScope(); scope.run(() => {...}); scope.stop();

3. 為什麼要使用 Effect Scope?

  1. 組件外的副作用:在自訂 composable(如 useFetch)中,若需要在多個 watchEffect 之間共享生命週期,使用 effectScope 可以一次性清理。
  2. 測試與工具函式:測試時常需要在每個測試案例結束後手動停止副作用,以避免測試相互污染。
  3. 動態掛載/卸載:在動態渲染的子視圖或插件中,透過 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 內呼叫的 watchEffectcomputed 都會自動被加入此 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 中釋放外部資源 setIntervaladdEventListener 等不會被 Vue 自動清理。 scope.run 內註冊 scope.onScopeDispose(() => {/* 清理 */})

最佳實踐清單

  1. 始終返回 stop:在任何自訂 composable 中,將 scope.stop 或單一 watchEffectstop 作為回傳值提供給使用者。
  2. 使用 onScopeDispose:在 effectScope 內註冊清理邏輯,確保即使忘記手動呼叫 stop(),在作用域被 GC 時仍能釋放資源。
  3. 避免過度嵌套:除非真的需要分層管理,否則保持每個功能區塊只使用 一個 effectScope
  4. 在測試中加入 afterEach(() => scope?.stop?.()):保證測試環境乾淨。
  5. 記得在組件的 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(() => {...}),我們可以把多個 watchEffectcomputed、甚至自訂的資源(如 setInterval、事件監聽) 一次性納入同一個生命週期
  • stop()(單一副作用或整個 scope)是 釋放資源、避免記憶體洩漏 的關鍵。
  • 組件、Composable、測試、插件、SSR 等不同層面,都能透過正確使用 effectScopestop(),讓程式碼保持乾淨且易於維護。

記得「建立即管理,使用即清除」——每當你在 Vue 3 中建立一個副作用,就思考它的生命週期,適時使用 effectScopestop(),讓你的應用永遠保持高效、可靠。