- Start Date: 2020-08-20
- Target Major Version: 3.x
- Reference Issues: (fill in existing related issues, if any)
- Implementation PR: #2195
Introducing a new effectScope()
API for @vue/reactivity
. An EffectScope
instance can automatically collect effects run within a synchronous function so that these effects can be disposed together at a later time.
// 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()
In Vue's component setup()
, effects will be collected and bound to the current instance. When the instance get unmounted, effects will be disposed automatically. This is a convenient and intuitive feature.
However, when we are using them outside of components or as a standalone package, it's not that simple. For example, this might be what we need to do for disposing the effects of computed
& watch
const disposables = []
const counter = ref(0)
const doubled = 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)
And to stop the effects:
disposables.forEach((f) => f())
disposables = []
Especially when we have some long and complex composable code, it's laborious to manually collect all the effects. It's also easy to forget collecting them (or you don't have access to effects created in the composable functions) which might result in memory leakage and unexpected behavior.
This RFC is trying to abstract the component's setup()
effect collecting and disposing feature into a more general API that can be reused outside of the component model.
It also provides the functionality to create "detached" effects from the component's setup()
scope or user-defined scope. Resolving vuejs/core#1532.
-
effectScope(detached = false): EffectScope
interface EffectScope { run<T>(fn: () => T): T | undefined // undefined if scope is inactive stop(): void }
-
getCurrentScope(): EffectScope | undefined
-
onScopeDispose(fn: () => void): void
Creating a scope:
const scope = effectScope()
A scope can run a function and will capture all effects created during the function's synchronous execution, including any API that creates effects internally, 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, () => {
/*...*/
})
})
The run
method also forwards the return value of the executed function:
console.log(scope.run(() => 1)) // 1
When scope.stop()
is called, it will stop all the captured effects and nested scopes recursively.
scope.stop()
Nested scopes should also be collected by their parent scope. And when the parent scope gets disposed, all its descendant scopes will also be stopped.
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()
effectScope
accepts an argument to be created in "detached" mode. A detached scope will not be collected by its parent scope.
This also makes usages like "lazy initialization" possible.
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()
The global hook onScopeDispose()
serves a similar functionality to onUnmounted()
, but works for the current scope instead of the component instance. This could benefit composable functions to clean up their side effects along with its scope. Since setup()
also creates a scope for the component, it will be equivalent to onUnmounted()
when there is no explicit effect scope created.
import { onScopeDispose } from 'vue'
const scope = effectScope()
scope.run(() => {
onScopeDispose(() => {
console.log('cleaned!')
})
})
scope.stop() // logs 'cleaned!'
A new API getCurrentScope()
is introduced to get the current scope.
import { getCurrentScope } from 'vue'
getCurrentScope() // EffectScope | undefined
Some composables setup global side effects. For example the following useMouse()
function:
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 }
}
If useMouse()
is called in multiple components, each component will attach a mousemove
listener and create its own copy of x
and y
refs. We should be able to make this more efficient by sharing the same set of listeners and refs across multiple components, but we can't because each onUnmounted
call is coupled to a single component instance.
We can achieve this using detached scope, and onScopeDispose
. First, we need to replace onUnmounted
with onScopeDispose
:
- onUnmounted(() => {
+ onScopeDispose(() => {
window.removeEventListener('mousemove', handler)
})
This still works because a Vue component now also runs its setup()
inside a scope, which will be disposed when the component is unmounted.
Then, we can create a utility function that manages parent scope subscriptions:
function createSharedComposable(composable) {
let subscribers = 0
let state, scope
const dispose = () => {
if (scope && --subscribers <= 0) {
scope.stop()
state = scope = null
}
}
return (...args) => {
subscribers++
if (!state) {
scope = effectScope(true)
state = scope.run(() => composable(...args))
}
onScopeDispose(dispose)
return state
}
}
Now we can create a shared version of useMouse
:
const useSharedMouse = createSharedComposable(useMouse)
The new useSharedMouse
composable will set up the listener only once no matter how many components are using it, and removes the listener when no component is using it anymore. In fact, the useMouse
function should probably be a shared composable in the first place!
export default {
setup() {
const enabled = ref(false)
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)
},
}
In the example above, we would create and dispose some scopes on the fly, onScopeDispose
allow useMouse
to do the cleanup correctly while onUnmounted
would never be called during this process.
Currently in @vue/runtime-dom
, we wrap the computed
to add the instance binding. This makes the following statements NOT equivalent
// not the same
import { computed } from '@vue/reactivity'
import { computed } from 'vue'
This should not be an issue for most of the users, but for some libraries that would like to only rely on @vue/reactivity
(for more flexible usages), this might be a pitfall and cause some unwanted side-effects.
With this RFC, @vue/runtime-dom
can use the effectScope
to collect the effects directly and computed rewrapping will not be necessary anymore.
// with the RFC, `vue` simply redirect `computed` from `@vue/reactivity`
import { computed } from '@vue/reactivity'
import { computed } from 'vue'
- It doesn't work well with async functions
M/A
This is a new API and should not affect the existing code. It's also a low-level API only intended for advanced users and library authors.
None