feat(reactivity): new effectScope API (#2195)

This commit is contained in:
Anthony Fu 2021-07-07 21:07:19 +08:00 committed by Evan You
parent 87f69fd0bb
commit f5617fc3bb
16 changed files with 400 additions and 89 deletions

View File

@ -0,0 +1,238 @@
import { nextTick, watch, watchEffect } from '@vue/runtime-core'
import {
reactive,
effect,
EffectScope,
onScopeDispose,
computed,
ref,
ComputedRef
} from '../src'
describe('reactivity/effect/scope', () => {
it('should run', () => {
const fnSpy = jest.fn(() => {})
new EffectScope().run(fnSpy)
expect(fnSpy).toHaveBeenCalledTimes(1)
})
it('should accept zero argument', () => {
const scope = new EffectScope()
expect(scope.effects.length).toBe(0)
})
it('should return run value', () => {
expect(new EffectScope().run(() => 1)).toBe(1)
})
it('should collect the effects', () => {
const scope = new EffectScope()
scope.run(() => {
let dummy
const counter = reactive({ num: 0 })
effect(() => (dummy = counter.num))
expect(dummy).toBe(0)
counter.num = 7
expect(dummy).toBe(7)
})
expect(scope.effects.length).toBe(1)
})
it('stop', () => {
let dummy, doubled
const counter = reactive({ num: 0 })
const scope = new EffectScope()
scope.run(() => {
effect(() => (dummy = counter.num))
effect(() => (doubled = counter.num * 2))
})
expect(scope.effects.length).toBe(2)
expect(dummy).toBe(0)
counter.num = 7
expect(dummy).toBe(7)
expect(doubled).toBe(14)
scope.stop()
counter.num = 6
expect(dummy).toBe(7)
expect(doubled).toBe(14)
})
it('should collect nested scope', () => {
let dummy, doubled
const counter = reactive({ num: 0 })
const scope = new EffectScope()
scope.run(() => {
effect(() => (dummy = counter.num))
// nested scope
new EffectScope().run(() => {
effect(() => (doubled = counter.num * 2))
})
})
expect(scope.effects.length).toBe(2)
expect(scope.effects[1]).toBeInstanceOf(EffectScope)
expect(dummy).toBe(0)
counter.num = 7
expect(dummy).toBe(7)
expect(doubled).toBe(14)
// stop the nested scope as well
scope.stop()
counter.num = 6
expect(dummy).toBe(7)
expect(doubled).toBe(14)
})
it('nested scope can be escaped', () => {
let dummy, doubled
const counter = reactive({ num: 0 })
const scope = new EffectScope()
scope.run(() => {
effect(() => (dummy = counter.num))
// nested scope
new EffectScope(true).run(() => {
effect(() => (doubled = counter.num * 2))
})
})
expect(scope.effects.length).toBe(1)
expect(dummy).toBe(0)
counter.num = 7
expect(dummy).toBe(7)
expect(doubled).toBe(14)
scope.stop()
counter.num = 6
expect(dummy).toBe(7)
// nested scope should not be stoped
expect(doubled).toBe(12)
})
it('able to run the scope', () => {
let dummy, doubled
const counter = reactive({ num: 0 })
const scope = new EffectScope()
scope.run(() => {
effect(() => (dummy = counter.num))
})
expect(scope.effects.length).toBe(1)
scope.run(() => {
effect(() => (doubled = counter.num * 2))
})
expect(scope.effects.length).toBe(2)
counter.num = 7
expect(dummy).toBe(7)
expect(doubled).toBe(14)
scope.stop()
})
it('can not run an inactive scope', () => {
let dummy, doubled
const counter = reactive({ num: 0 })
const scope = new EffectScope()
scope.run(() => {
effect(() => (dummy = counter.num))
})
expect(scope.effects.length).toBe(1)
scope.stop()
scope.run(() => {
effect(() => (doubled = counter.num * 2))
})
expect('[Vue warn] cannot run an inactive effect scope.').toHaveBeenWarned()
expect(scope.effects.length).toBe(1)
counter.num = 7
expect(dummy).toBe(0)
expect(doubled).toBe(undefined)
})
it('should fire onDispose hook', () => {
let dummy = 0
const scope = new EffectScope()
scope.run(() => {
onScopeDispose(() => (dummy += 1))
onScopeDispose(() => (dummy += 2))
})
scope.run(() => {
onScopeDispose(() => (dummy += 4))
})
expect(dummy).toBe(0)
scope.stop()
expect(dummy).toBe(7)
})
it('test with higher level APIs', async () => {
const r = ref(1)
const computedSpy = jest.fn()
const watchSpy = jest.fn()
const watchEffectSpy = jest.fn()
let c: ComputedRef
const scope = new EffectScope()
scope.run(() => {
c = computed(() => {
computedSpy()
return r.value + 1
})
watch(r, watchSpy)
watchEffect(() => {
watchEffectSpy()
r.value
})
})
c!.value // computed is lazy so trigger collection
expect(computedSpy).toHaveBeenCalledTimes(1)
expect(watchSpy).toHaveBeenCalledTimes(0)
expect(watchEffectSpy).toHaveBeenCalledTimes(1)
r.value++
c!.value
await nextTick()
expect(computedSpy).toHaveBeenCalledTimes(2)
expect(watchSpy).toHaveBeenCalledTimes(1)
expect(watchEffectSpy).toHaveBeenCalledTimes(2)
scope.stop()
r.value++
c!.value
await nextTick()
// should not trigger anymore
expect(computedSpy).toHaveBeenCalledTimes(2)
expect(watchSpy).toHaveBeenCalledTimes(1)
expect(watchEffectSpy).toHaveBeenCalledTimes(2)
})
})

View File

@ -1,5 +1,6 @@
import { TrackOpTypes, TriggerOpTypes } from './operations' import { TrackOpTypes, TriggerOpTypes } from './operations'
import { extend, isArray, isIntegerKey, isMap } from '@vue/shared' import { extend, isArray, isIntegerKey, isMap } from '@vue/shared'
import { EffectScope, recordEffectScope } from './effectScope'
// The main WeakMap that stores {target -> key -> dep} connections. // The main WeakMap that stores {target -> key -> dep} connections.
// Conceptually, it's easier to think of a dependency as a Dep class // Conceptually, it's easier to think of a dependency as a Dep class
@ -43,9 +44,12 @@ export class ReactiveEffect<T = any> {
constructor( constructor(
public fn: () => T, public fn: () => T,
public scheduler: EffectScheduler | null = null, public scheduler: EffectScheduler | null = null,
scope?: EffectScope | null,
// allow recursive self-invocation // allow recursive self-invocation
public allowRecurse = false public allowRecurse = false
) {} ) {
recordEffectScope(this, scope)
}
run() { run() {
if (!this.active) { if (!this.active) {
@ -60,8 +64,7 @@ export class ReactiveEffect<T = any> {
} finally { } finally {
effectStack.pop() effectStack.pop()
resetTracking() resetTracking()
const n = effectStack.length activeEffect = effectStack[effectStack.length - 1]
activeEffect = n > 0 ? effectStack[n - 1] : undefined
} }
} }
} }
@ -90,6 +93,7 @@ export class ReactiveEffect<T = any> {
export interface ReactiveEffectOptions { export interface ReactiveEffectOptions {
lazy?: boolean lazy?: boolean
scheduler?: EffectScheduler scheduler?: EffectScheduler
scope?: EffectScope
allowRecurse?: boolean allowRecurse?: boolean
onStop?: () => void onStop?: () => void
onTrack?: (event: DebuggerEvent) => void onTrack?: (event: DebuggerEvent) => void
@ -112,6 +116,7 @@ export function effect<T = any>(
const _effect = new ReactiveEffect(fn) const _effect = new ReactiveEffect(fn)
if (options) { if (options) {
extend(_effect, options) extend(_effect, options)
if (options.scope) recordEffectScope(_effect, options.scope)
} }
if (!options || !options.lazy) { if (!options || !options.lazy) {
_effect.run() _effect.run()

View File

@ -0,0 +1,81 @@
import { ReactiveEffect } from './effect'
import { warn } from './warning'
let activeEffectScope: EffectScope | undefined
const effectScopeStack: EffectScope[] = []
export class EffectScope {
active = true
effects: (ReactiveEffect | EffectScope)[] = []
cleanups: (() => void)[] = []
constructor(detached = false) {
if (!detached) {
recordEffectScope(this)
}
}
run<T>(fn: () => T): T | undefined {
if (this.active) {
try {
this.on()
return fn()
} finally {
this.off()
}
} else if (__DEV__) {
warn(`cannot run an inactive effect scope.`)
}
}
on() {
if (this.active) {
effectScopeStack.push(this)
activeEffectScope = this
}
}
off() {
if (this.active) {
effectScopeStack.pop()
activeEffectScope = effectScopeStack[effectScopeStack.length - 1]
}
}
stop() {
if (this.active) {
this.effects.forEach(e => e.stop())
this.cleanups.forEach(cleanup => cleanup())
this.active = false
}
}
}
export function effectScope(detached?: boolean) {
return new EffectScope(detached)
}
export function recordEffectScope(
effect: ReactiveEffect | EffectScope,
scope?: EffectScope | null
) {
scope = scope || activeEffectScope
if (scope && scope.active) {
scope.effects.push(effect)
}
}
export function getCurrentScope() {
return activeEffectScope
}
export function onScopeDispose(fn: () => void) {
if (activeEffectScope) {
activeEffectScope.cleanups.push(fn)
} else if (__DEV__) {
warn(
`onDispose() is called when there is no active effect scope ` +
` to be associated with.`
)
}
}

View File

@ -51,4 +51,10 @@ export {
EffectScheduler, EffectScheduler,
DebuggerEvent DebuggerEvent
} from './effect' } from './effect'
export {
effectScope,
EffectScope,
getCurrentScope,
onScopeDispose
} from './effectScope'
export { TrackOpTypes, TriggerOpTypes } from './operations' export { TrackOpTypes, TriggerOpTypes } from './operations'

View File

@ -0,0 +1,3 @@
export function warn(msg: string, ...args: any[]) {
console.warn(`[Vue warn] ${msg}`, ...args)
}

View File

@ -848,15 +848,16 @@ describe('api: watch', () => {
render(h(Comp), nodeOps.createElement('div')) render(h(Comp), nodeOps.createElement('div'))
expect(instance!).toBeDefined() expect(instance!).toBeDefined()
expect(instance!.effects).toBeInstanceOf(Array) expect(instance!.scope.effects).toBeInstanceOf(Array)
expect(instance!.effects!.length).toBe(1) // includes the component's own render effect AND the watcher effect
expect(instance!.scope.effects!.length).toBe(2)
_show!.value = false _show!.value = false
await nextTick() await nextTick()
await nextTick() await nextTick()
expect(instance!.effects![0].active).toBe(false) expect(instance!.scope.effects![0].active).toBe(false)
}) })
test('this.$watch should pass `this.proxy` to watch source as the first argument ', () => { test('this.$watch should pass `this.proxy` to watch source as the first argument ', () => {

View File

@ -1,20 +0,0 @@
import {
computed as _computed,
ComputedRef,
WritableComputedOptions,
WritableComputedRef,
ComputedGetter
} from '@vue/reactivity'
import { recordInstanceBoundEffect } from './component'
export function computed<T>(getter: ComputedGetter<T>): ComputedRef<T>
export function computed<T>(
options: WritableComputedOptions<T>
): WritableComputedRef<T>
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
) {
const c = _computed(getterOrOptions as any)
recordInstanceBoundEffect(c.effect)
return c
}

View File

@ -3,7 +3,8 @@ import {
currentInstance, currentInstance,
isInSSRComponentSetup, isInSSRComponentSetup,
LifecycleHooks, LifecycleHooks,
setCurrentInstance setCurrentInstance,
unsetCurrentInstance
} from './component' } from './component'
import { ComponentPublicInstance } from './componentPublicInstance' import { ComponentPublicInstance } from './componentPublicInstance'
import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling' import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling'
@ -38,7 +39,7 @@ export function injectHook(
// can only be false when the user does something really funky. // can only be false when the user does something really funky.
setCurrentInstance(target) setCurrentInstance(target)
const res = callWithAsyncErrorHandling(hook, target, type, args) const res = callWithAsyncErrorHandling(hook, target, type, args)
setCurrentInstance(null) unsetCurrentInstance()
resetTracking() resetTracking()
return res return res
}) })

View File

@ -3,7 +3,8 @@ import {
getCurrentInstance, getCurrentInstance,
setCurrentInstance, setCurrentInstance,
SetupContext, SetupContext,
createSetupContext createSetupContext,
unsetCurrentInstance
} from './component' } from './component'
import { EmitFn, EmitsOptions } from './componentEmits' import { EmitFn, EmitsOptions } from './componentEmits'
import { import {
@ -248,9 +249,15 @@ export function mergeDefaults(
* @internal * @internal
*/ */
export function withAsyncContext(getAwaitable: () => any) { export function withAsyncContext(getAwaitable: () => any) {
const ctx = getCurrentInstance() const ctx = getCurrentInstance()!
if (__DEV__ && !ctx) {
warn(
`withAsyncContext called without active current instance. ` +
`This is likely a bug.`
)
}
let awaitable = getAwaitable() let awaitable = getAwaitable()
setCurrentInstance(null) unsetCurrentInstance()
if (isPromise(awaitable)) { if (isPromise(awaitable)) {
awaitable = awaitable.catch(e => { awaitable = awaitable.catch(e => {
setCurrentInstance(ctx) setCurrentInstance(ctx)

View File

@ -25,8 +25,7 @@ import {
import { import {
currentInstance, currentInstance,
ComponentInternalInstance, ComponentInternalInstance,
isInSSRComponentSetup, isInSSRComponentSetup
recordInstanceBoundEffect
} from './component' } from './component'
import { import {
ErrorCodes, ErrorCodes,
@ -326,15 +325,14 @@ function doWatch(
} }
} }
const effect = new ReactiveEffect(getter, scheduler) const scope = instance && instance.scope
const effect = new ReactiveEffect(getter, scheduler, scope)
if (__DEV__) { if (__DEV__) {
effect.onTrack = onTrack effect.onTrack = onTrack
effect.onTrigger = onTrigger effect.onTrigger = onTrigger
} }
recordInstanceBoundEffect(effect, instance)
// initial run // initial run
if (cb) { if (cb) {
if (immediate) { if (immediate) {
@ -353,8 +351,8 @@ function doWatch(
return () => { return () => {
effect.stop() effect.stop()
if (instance) { if (scope) {
remove(instance.effects!, effect) remove(scope.effects!, effect)
} }
} }
} }

View File

@ -563,7 +563,7 @@ function installCompatMount(
} }
delete app._container.__vue_app__ delete app._container.__vue_app__
} else { } else {
const { bum, effects, um } = instance const { bum, scope, um } = instance
// beforeDestroy hooks // beforeDestroy hooks
if (bum) { if (bum) {
invokeArrayFns(bum) invokeArrayFns(bum)
@ -572,10 +572,8 @@ function installCompatMount(
instance.emit('hook:beforeDestroy') instance.emit('hook:beforeDestroy')
} }
// stop effects // stop effects
if (effects) { if (scope) {
for (let i = 0; i < effects.length; i++) { scope.stop()
effects[i].stop()
}
} }
// unmounted hook // unmounted hook
if (um) { if (um) {

View File

@ -1,10 +1,10 @@
import { VNode, VNodeChild, isVNode } from './vnode' import { VNode, VNodeChild, isVNode } from './vnode'
import { import {
ReactiveEffect,
pauseTracking, pauseTracking,
resetTracking, resetTracking,
shallowReadonly, shallowReadonly,
proxyRefs, proxyRefs,
EffectScope,
markRaw markRaw
} from '@vue/reactivity' } from '@vue/reactivity'
import { import {
@ -217,11 +217,6 @@ export interface ComponentInternalInstance {
* Root vnode of this component's own vdom tree * Root vnode of this component's own vdom tree
*/ */
subTree: VNode subTree: VNode
/**
* Main update effect
* @internal
*/
effect: ReactiveEffect
/** /**
* Bound effect runner to be passed to schedulers * Bound effect runner to be passed to schedulers
*/ */
@ -246,7 +241,7 @@ export interface ComponentInternalInstance {
* so that they can be automatically stopped on component unmount * so that they can be automatically stopped on component unmount
* @internal * @internal
*/ */
effects: ReactiveEffect[] | null scope: EffectScope
/** /**
* cache for proxy access type to avoid hasOwnProperty calls * cache for proxy access type to avoid hasOwnProperty calls
* @internal * @internal
@ -451,14 +446,13 @@ export function createComponentInstance(
root: null!, // to be immediately set root: null!, // to be immediately set
next: null, next: null,
subTree: null!, // will be set synchronously right after creation subTree: null!, // will be set synchronously right after creation
effect: null!, // will be set synchronously right after creation
update: null!, // will be set synchronously right after creation update: null!, // will be set synchronously right after creation
scope: new EffectScope(),
render: null, render: null,
proxy: null, proxy: null,
exposed: null, exposed: null,
exposeProxy: null, exposeProxy: null,
withProxy: null, withProxy: null,
effects: null,
provides: parent ? parent.provides : Object.create(appContext.provides), provides: parent ? parent.provides : Object.create(appContext.provides),
accessCache: null!, accessCache: null!,
renderCache: [], renderCache: [],
@ -533,10 +527,14 @@ export let currentInstance: ComponentInternalInstance | null = null
export const getCurrentInstance: () => ComponentInternalInstance | null = () => export const getCurrentInstance: () => ComponentInternalInstance | null = () =>
currentInstance || currentRenderingInstance currentInstance || currentRenderingInstance
export const setCurrentInstance = ( export const setCurrentInstance = (instance: ComponentInternalInstance) => {
instance: ComponentInternalInstance | null
) => {
currentInstance = instance currentInstance = instance
instance.scope.on()
}
export const unsetCurrentInstance = () => {
currentInstance && currentInstance.scope.off()
currentInstance = null
} }
const isBuiltInTag = /*#__PURE__*/ makeMap('slot,component') const isBuiltInTag = /*#__PURE__*/ makeMap('slot,component')
@ -618,7 +616,7 @@ function setupStatefulComponent(
const setupContext = (instance.setupContext = const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null) setup.length > 1 ? createSetupContext(instance) : null)
currentInstance = instance setCurrentInstance(instance)
pauseTracking() pauseTracking()
const setupResult = callWithErrorHandling( const setupResult = callWithErrorHandling(
setup, setup,
@ -627,13 +625,10 @@ function setupStatefulComponent(
[__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext] [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
) )
resetTracking() resetTracking()
currentInstance = null unsetCurrentInstance()
if (isPromise(setupResult)) { if (isPromise(setupResult)) {
const unsetInstance = () => { setupResult.then(unsetCurrentInstance, unsetCurrentInstance)
currentInstance = null
}
setupResult.then(unsetInstance, unsetInstance)
if (isSSR) { if (isSSR) {
// return the promise so server-renderer can wait on it // return the promise so server-renderer can wait on it
@ -801,11 +796,11 @@ export function finishComponentSetup(
// support for 2.x options // support for 2.x options
if (__FEATURE_OPTIONS_API__ && !(__COMPAT__ && skipOptions)) { if (__FEATURE_OPTIONS_API__ && !(__COMPAT__ && skipOptions)) {
currentInstance = instance setCurrentInstance(instance)
pauseTracking() pauseTracking()
applyOptions(instance) applyOptions(instance)
resetTracking() resetTracking()
currentInstance = null unsetCurrentInstance()
} }
// warn missing template/render // warn missing template/render
@ -900,17 +895,6 @@ export function getExposeProxy(instance: ComponentInternalInstance) {
} }
} }
// record effects created during a component's setup() so that they can be
// stopped when the component unmounts
export function recordInstanceBoundEffect(
effect: ReactiveEffect,
instance = currentInstance
) {
if (instance) {
;(instance.effects || (instance.effects = [])).push(effect)
}
}
const classifyRE = /(?:^|[-_])(\w)/g const classifyRE = /(?:^|[-_])(\w)/g
const classify = (str: string): string => const classify = (str: string): string =>
str.replace(classifyRE, c => c.toUpperCase()).replace(/[-_]/g, '') str.replace(classifyRE, c => c.toUpperCase()).replace(/[-_]/g, '')

View File

@ -17,7 +17,7 @@ import {
NOOP, NOOP,
isPromise isPromise
} from '@vue/shared' } from '@vue/shared'
import { computed } from './apiComputed' import { computed } from '@vue/reactivity'
import { import {
watch, watch,
WatchOptions, WatchOptions,

View File

@ -29,7 +29,8 @@ import {
ComponentInternalInstance, ComponentInternalInstance,
ComponentOptions, ComponentOptions,
ConcreteComponent, ConcreteComponent,
setCurrentInstance setCurrentInstance,
unsetCurrentInstance
} from './component' } from './component'
import { isEmitListener } from './componentEmits' import { isEmitListener } from './componentEmits'
import { InternalObjectKey } from './vnode' import { InternalObjectKey } from './vnode'
@ -411,7 +412,7 @@ function resolvePropValue(
: null, : null,
props props
) )
setCurrentInstance(null) unsetCurrentInstance()
} }
} else { } else {
value = defaultValue value = defaultValue

View File

@ -3,6 +3,7 @@
export const version = __VERSION__ export const version = __VERSION__
export { export {
// core // core
computed,
reactive, reactive,
ref, ref,
readonly, readonly,
@ -22,9 +23,17 @@ export {
shallowReactive, shallowReactive,
shallowReadonly, shallowReadonly,
markRaw, markRaw,
toRaw toRaw,
// effect
effect,
stop,
ReactiveEffect,
// effect scope
effectScope,
EffectScope,
getCurrentScope,
onScopeDispose
} from '@vue/reactivity' } from '@vue/reactivity'
export { computed } from './apiComputed'
export { watch, watchEffect } from './apiWatch' export { watch, watchEffect } from './apiWatch'
export { export {
onBeforeMount, onBeforeMount,
@ -137,7 +146,6 @@ declare module '@vue/reactivity' {
} }
export { export {
ReactiveEffect,
ReactiveEffectOptions, ReactiveEffectOptions,
DebuggerEvent, DebuggerEvent,
TrackOpTypes, TrackOpTypes,

View File

@ -1622,11 +1622,12 @@ function baseCreateRenderer(
} }
// create reactive effect for rendering // create reactive effect for rendering
const effect = (instance.effect = new ReactiveEffect( const effect = new ReactiveEffect(
componentUpdateFn, componentUpdateFn,
() => queueJob(instance.update), () => queueJob(instance.update),
instance.scope, // track it in component's effect scope
true /* allowRecurse */ true /* allowRecurse */
)) )
const update = (instance.update = effect.run.bind(effect) as SchedulerJob) const update = (instance.update = effect.run.bind(effect) as SchedulerJob)
update.id = instance.uid update.id = instance.uid
@ -2285,12 +2286,13 @@ function baseCreateRenderer(
unregisterHMR(instance) unregisterHMR(instance)
} }
const { bum, effect, effects, update, subTree, um } = instance const { bum, scope, update, subTree, um } = instance
// beforeUnmount hook // beforeUnmount hook
if (bum) { if (bum) {
invokeArrayFns(bum) invokeArrayFns(bum)
} }
if ( if (
__COMPAT__ && __COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance) isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
@ -2298,15 +2300,13 @@ function baseCreateRenderer(
instance.emit('hook:beforeDestroy') instance.emit('hook:beforeDestroy')
} }
if (effects) { if (scope) {
for (let i = 0; i < effects.length; i++) { scope.stop()
effects[i].stop()
}
} }
// update may be null if a component is unmounted before its async // update may be null if a component is unmounted before its async
// setup has resolved. // setup has resolved.
if (effect) { if (update) {
effect.stop()
// so that scheduler will no longer invoke it // so that scheduler will no longer invoke it
update.active = false update.active = false
unmount(subTree, instance, parentSuspense, doRemove) unmount(subTree, instance, parentSuspense, doRemove)