effectScope()
本篇筆記為閱讀完此 RFC 後並參考了 VueUse 中的一些實作後的筆記。
effectScope
是 Vue 3.2 才出現的 API,官方文件對其的說明是創建一個 effect 作用域,可以捕獲其中所建立的響應式副作用 (例如 computed, watcher),這樣捕獲到的副作用可以一起處理,更多此 API 的說明可以參考官方RFC
effectScope()
- 型別
function effectScope(detached?: boolean): EffectScope;
interface EffectScope {
run<T>(fn: () => T): T | undefined; // undefined if scope is inactive
stop(): void;
}
- 範例
const scope = effectScope();
scope.run(() => {
const doubled = computed(() => counter.value * 2);
watch(doubled, () => console.log(doubled.value));
watchEffect(() => console.log('Count: ', doubled.value));
});
// to dispose all effects in the scope
scope.stop();
為什麼會有 effectScope?
根據 RFC 上的說法,在 Vue 的元件 setup()
中,這些副作用 (effect) 會被收集並綁定到目前實例 (instance) 上,當實例被卸載時,副作用將被自動處理。
然而如果我們在元件之外或是做為一個獨立的 package 使用它們時,就需要手動去收集&清除這些副作用,如下:
- 收集:
const disposables = [];
const counter = ref(0);
const doubled = computed(() => counter.value * 2);
disposables.push(() => stop(doubled));
const stopWatch1 = watchEffect(() => {
console.log(`counter: ${counter.value}`);
});
disposables.push(stopWatch1);
const stopWatch2 = watch(doubled, () => {
console.log(doubled.value);
});
disposables.push(stopWatch2);
- 清理:
disposables.forEach((f) => f());
disposables = [];
手動去執行不僅複雜的邏輯收集成本較高之外,如果忘記收集也可能造成記憶體洩漏 (memory leaks),effectScope 就是為了幫開發者做這件事而誕生的。
effectScope 的使用
基本用法
建立一個作用域 (scope):
const scope = effectScope();
這個 scope 可以執行一個函式並將捕獲函式同步執行期間的所有副作用,也包含任何在內部建立副作用的 API (e.g. computed
, watch
and watchEffect
):
scope.run(() => {
const doubled = computed(() => counter.value * 2);
watch(doubled, () => console.log(doubled.value));
watchEffect(() => console.log('Count: ', doubled.value));
});
// the same scope can run multiple times
scope.run(() => {
watch(counter, () => {
/*...*/
});
});
當 scope.stop()
被呼叫時,它將遞迴地停止所有捕獲的副作用和巢狀的作用域。
scope.stop();
巢狀作用域 Nested Scopes
預設情況下巢狀的作用域也由父層作用域收集,當父層作用域被釋放 (dispose) 時,它的所有後代作用域也將停止。
const scope = effectScope();
scope.run(() => {
const doubled = computed(() => counter.value * 2);
// not need to get the stop handler, it will be collected by the outer scope
effectScope().run(() => {
watch(doubled, () => console.log(doubled.value));
});
watchEffect(() => console.log('Count: ', doubled.value));
});
// dispose all effects, including those in the nested scopes
scope.stop();
分離的巢狀作用域 Detached Nested Scopes
detached
表示是否分離和父層的聯繫,如果為 true
則表示與父層分離,執行父層的 stop
方法時不會去停止巢狀作用域的監聽:
let nestedScope;
const parentScope = effectScope();
parentScope.run(() => {
const doubled = computed(() => counter.value * 2);
// with the detected flag,
// the scope will not be collected and disposed by the outer scope
nestedScope = effectScope(true /* detached */);
nestedScope.run(() => {
watch(doubled, () => console.log(doubled.value));
});
watchEffect(() => console.log('Count: ', doubled.value));
});
// disposes all effects, but not `nestedScope`
parentScope.stop();
// stop the nested scope only when appropriate
nestedScope.stop();
onScopeDispose
和 onUnmounted()
很像,區別在 onScopeDispose
作用於目前的作用域而不是元件實例。這可以幫助我們去清理 composable functions 在其作用域中的副作用,由於 setup()
也為元件建立了一個作用域,當沒有顯式建立作用域時,它會等效於 onUnmounted()
:
import { onScopeDispose } from 'vue';
const scope = effectScope();
scope.run(() => {
onScopeDispose(() => {
console.log('cleaned!');
});
});
scope.stop(); // logs 'cleaned!'
使用案例
Shared Composable
有些組合函式會設置全域的副作用,例如下面的 useMouse()
函式:
import { ref, onUnmounted } from 'vue';
export function useMouse() {
const x = ref(0);
const y = ref(0);
function handler(e) {
x.value = e.x;
y.value = e.y;
}
window.addEventListener('mousemove', handler);
onUnmounted(() => {
window.removeEventListener('mousemove', handler);
});
return { x, y };
}
如果在多個元件中呼叫 useMouse()
,則每個元件都會掛上一個 mousemove
的偵聽器並創建自己的 x
和 y
。 在這裡應該能夠透過在多個元件之間共享相同的偵聽器和x
y
來提升效能,但我們無法,因為每個 onUnmounted
的呼叫都耦合到單個元件實例。
我們可以使用 detached scope 和 onScopeDispose
來做到這件事。首先我們需要將 onUnmounted
換成 onScopeDispose
:
import { ref, onUnmounted } from 'vue';
import { ref, onScopeDispose } from 'vue';
export function useMouse() {
onUnmounted(() => {
onScopeDispose(() => {
window.removeEventListener('mousemove', handler);
});
}
這依然能正常執行是因為 Vue 元件現在也還是在一個作用域中執行它的 setup()
,這個作用域將在元件卸載時被釋放。
然後我們可以新建一個用來管理父層作用域訂閱的工具函式:
/* createSharedComposable.js */
import { effectScope, onScopeDispose } from 'vue';
export function createSharedComposable(composable) {
let subscribers = 0;
let state, scope;
const dispose = () => {
if (scope && --subscribers <= 0) {
scope.stop();
state = scope = null;
}
};
return (...arg) => {
subscribers++;
if (!state) {
scope = effectScope(true);
state = scope.run(() => composable(...arg));
}
onScopeDispose(dispose);
return state;
};
}
現在我們就可以透過它來創造一個可以共享版本的 useMouse
:
const useSharedMouse = createSharedComposable(useMouse);
這個新的 useSharedMouse
組合函式只會在全域掛一次事件偵聽器,無論有多少元件去使用它,並且它會在沒有任何元件使用時自己移除掉事件偵聽器。
Ephemeral Scopes
我們還可以動態建立或釋放一些作用域,onScopeDispose
讓 useMouse
能正確進行清理,而在此過程中永遠不會呼叫 onUnmounted
。
<script setup>
import { effectScope, onScopeDispose, ref, watch } from 'vue';
import { useMouse } from '../composable/useMouse';
const enabled = ref(true);
let mouseState, mouseScope;
const dispose = () => {
mouseScope && mouseScope.stop();
mouseState = null;
};
watch(
enabled,
() => {
if (enabled.value) {
mouseScope = effectScope();
mouseState = mouseScope.run(() => useMouse());
} else {
dispose();
}
},
{ immediate: true }
);
onScopeDispose(dispose);
</script>
Global State
我們還可以利用 effectScope
去模擬一個 store
來完成一套比較靈活的狀態管理。
- 創建一個生成全域狀態的函式
import { effectScope } from 'vue';
/**
* 讓狀態存在於全域作用域讓多個 Vue 實例複用
* @param {() => T} stateFactory - 創建狀態的工廠函式
* @returns stateFactory 的回傳值
*/
export function createGlobalState(stateFactory) {
let initialized = false;
let state;
const scope = effectScope(true);
return () => {
if (!initialized) {
// 只在首次呼叫時執行,防止重複觸發 run
state = scope.run(stateFactory);
initialized = true;
}
return state;
};
}
- 撰寫一個
store
import { ref, computed } from 'vue';
import { createGlobalState } from '../utils/createGlobalState';
export default createGlobalState(() => {
// state
const count = ref(0);
// getters
const doubleCount = computed(() => count.value * 2);
// actions
const increment = () => count.value++;
return { count, doubleCount, increment };
});
- 在不同元件裡使用它
/* A.vue */
import useStore from '@/composable/useStore';
const { count, doubleCount, increment } = useStore();
/* B.vue */
import useStore from '@/composable/useStore';
const { count, doubleCount, increment } = useStore();
此時 A B 兩個元件呼叫這個 store 所拿到的 count
、doubleCount
將會是同一份,也就是說 effectScope
同時也提供了新的狀態管理方式,在一些情境下可以替代 vuex
。
上面的實作是參照了 VueUse 中的 createGlobalState 函式的原始碼,VueUse 裡大量使用了 effectScope
去管理響應式副作用,想了解更多使用法也可以自行去參考 VueUse 文件中的範例及原始碼。
Online Demo
案例實作 Demo code:StackBlitz
參考資料:
- effectScope - @Vue doc
- createGlobalState - @VueUse
- How to understand the effectscope in Vue? - @stack overflow