A new attribute EffectScope has been added in vue3.2. The official documentation is relatively simple, just a high-level attribute, and there is no concrete example.
Recently I was looking at the vueuse framework source code of ANTfu, which uses EffectScope extensively, so I studied how to use this attribute.
What is a EffectScope?
Here is the official documentation, which feels a bit perfunctory
Effect scope is an advanced API primarily intended for library authors. For details on how to leverage this API, please consult its corresponding RFC(opens new window).
RFC explanation of the EffectScopeApi
A handy feature in Vue setup is that responses are collected at initial initialization and automatically untraced when the instance is unmounted. However, when we use or write a separate package outside of the component, this becomes very cumbersome. How do we stop responsive dependency on computed & Watch when in a separate file?
In fact, EffectScope is the scope in which a side effect takes effect.
Vue3’s responsiveness to listening is achieved by Effect, which is automatically deactivated by VUE when our component is destroyed.
So what if we want to control the effect ourselves? For example, I only want to listen for a ref in certain cases, but I don’t want to listen in other cases. What should I do?
Vue3.2 before
// (JUE-RFC example code) const Disposables = [] const counter = ref(0) const double = computed(() => counter. Value * 2) disposables.push(() => stop(doubled.effect)) const stopWatch1 = watchEffect(() => { console.log(`counter: ${counter.value}`) }) disposables.push(stopWatch1) const stopWatch2 = watch(doubled, () => { console.log(doubled.value) }) disposables.push(stopWatch2)Copy the code
How is EffectScope implemented
// effect, computed, watch, watchEffect created inside the scope will be collected
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()
Copy the code
The sample
const scope = effectScope()
let counter = ref(0)
setInterval(() => {
counter.value++
}, 1000);
scope.run(() => {
watchEffect(() =>console.log(`counter: ${counter.value}`))
})
/*log:
counter: 0
counter: 1
counter: 2
counter: 3
counter: 4
counter: 5
*/
Copy the code
const scope = effectScope()
let counter = ref(0)
setInterval(() => {
counter.value++
}, 1000);
scope.run(() => {
watchEffect(() =>console.log(`counter: ${counter.value}`))
})
scope.stop()
/*log:
counter: 0
*/
Copy the code
The basic use
Create a scope:
const scope = effectScope()
Copy the code
A scope can execute a run function (which takes a function as an argument and returns the function’s return value) and capture all effects created during the execution of the function, including apis that can create effects, For example, computed, watch, 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, () => {
/*...*/
})
})
Copy the code
When scope.stop() is called, all captured effects are cancelled, including Scopes that are cancelled recursively
Nested Scopes
Nested scopes are also collected by their parent scopes. And when the parent scope is destroyed, all descendant scopes will also be destroyed recursively.
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()
Copy the code
Detached Nested Scopes
The effectScope accepts a parameter that can be created in detached mode. Detached Scope will not be used by the parent collect.
This feature also addresses an issue lazy Initialization.
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()Copy the code
onScopeDispose
The global hook function onScopeDispose provides functionality similar to onUnmounted except that it works within a scope instead of the current instance.
This allows Composable Functions to clear their side effects through their scope.
Since setup() creates a scope for the current instance by default, onScopeDispose is the same as onUnmounted when no scope is explicitly declared.
import { onScopeDispose } from 'vue' const scope = effectScope() scope.run(() => { onScopeDispose(() => { console.log('cleaned! ') }) }) scope.stop() // logs 'cleaned! 'Copy the code
Getting the current Scope
Get the current Scope by getCurrentScope()
import { getCurrentScope } from 'vue'
getCurrentScope() // EffectScope | undefined
Copy the code
In actual combat
Example: Shared Composable
Some Composables have global side effects, such as the following useMouse() function:
function useMouse() { const x = ref(0) const y = ref(0) window.addEventListener('mousemove', handler) function handler(e) { x.value = e.x y.value = e.y } onUnmounted(() => { window.removeEventListener('mousemove', handler) }) return {x,y} }Copy the code
If useMouse () is called in multiple components, each component appends a Mouseemove listener and creates its own copy of X and Y refs. We should be able to improve efficiency by sharing the same set of listeners and refs across multiple components, but we can’t because each onUnmounted call is coupled to a component instance.
We can do this using separate scopes and onScopeDispose. First, we need to replace onUnmounted with onScopeDispose
- onUnmounted(() => {
+ onScopeDispose(() => {
window.removeEventListener('mousemove', handler)
})
Copy the code
This is still valid because the Vue component now also runs its setup () in scope, which will be released when the component is unloaded.
We can then create a utility function to manage parent scope subscriptions:
function createSharedComposable(composable) { let subscribers = 0 let state, scope const dispose = () => { if (scope && --subscribers <= 0) { scope.stop() state = scope = null } } // A state return (...) is created for the first time, and all subsequent components do not create new states. args) => { subscribers++ if (! state) { scope = effectScope(true) state = scope.run(() => composable(... args)) } onScopeDispose(dispose) return state } }Copy the code
Now we can use the shared version of useMouse
const useSharedMouse = createSharedComposable(useMouse)
Copy the code
From this example, I can’t help but wonder if this model can simulate vuex’s capabilities. Can shared Composables provide more flexibility for global state management?