From eb347c2e0f3bcc1a78ca407d53654dff32134007 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Wed, 7 Jul 2021 21:07:19 +0800 Subject: [PATCH] feat(reactivity): new effectScope API (#2195) --- .../reactivity/__tests__/effectScope.spec.ts | 238 ++++++++++++++++++ packages/reactivity/src/effect.ts | 11 +- packages/reactivity/src/effectScope.ts | 81 ++++++ packages/reactivity/src/index.ts | 6 + packages/reactivity/src/warning.ts | 3 + .../runtime-core/__tests__/apiWatch.spec.ts | 7 +- packages/runtime-core/src/apiComputed.ts | 20 -- packages/runtime-core/src/apiLifecycle.ts | 5 +- packages/runtime-core/src/apiSetupHelpers.ts | 13 +- packages/runtime-core/src/apiWatch.ts | 12 +- packages/runtime-core/src/compat/global.ts | 8 +- packages/runtime-core/src/component.ts | 46 ++-- packages/runtime-core/src/componentOptions.ts | 2 +- packages/runtime-core/src/componentProps.ts | 5 +- packages/runtime-core/src/index.ts | 14 +- packages/runtime-core/src/renderer.ts | 18 +- 16 files changed, 400 insertions(+), 89 deletions(-) create mode 100644 packages/reactivity/__tests__/effectScope.spec.ts create mode 100644 packages/reactivity/src/effectScope.ts create mode 100644 packages/reactivity/src/warning.ts delete mode 100644 packages/runtime-core/src/apiComputed.ts diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts new file mode 100644 index 00000000000..b5bc970e224 --- /dev/null +++ b/packages/reactivity/__tests__/effectScope.spec.ts @@ -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) + }) +}) diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index d2733e93cd6..37c80d84896 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -1,5 +1,6 @@ import { TrackOpTypes, TriggerOpTypes } from './operations' import { extend, isArray, isIntegerKey, isMap } from '@vue/shared' +import { EffectScope, recordEffectScope } from './effectScope' // The main WeakMap that stores {target -> key -> dep} connections. // Conceptually, it's easier to think of a dependency as a Dep class @@ -43,9 +44,12 @@ export class ReactiveEffect { constructor( public fn: () => T, public scheduler: EffectScheduler | null = null, + scope?: EffectScope | null, // allow recursive self-invocation public allowRecurse = false - ) {} + ) { + recordEffectScope(this, scope) + } run() { if (!this.active) { @@ -60,8 +64,7 @@ export class ReactiveEffect { } finally { effectStack.pop() resetTracking() - const n = effectStack.length - activeEffect = n > 0 ? effectStack[n - 1] : undefined + activeEffect = effectStack[effectStack.length - 1] } } } @@ -90,6 +93,7 @@ export class ReactiveEffect { export interface ReactiveEffectOptions { lazy?: boolean scheduler?: EffectScheduler + scope?: EffectScope allowRecurse?: boolean onStop?: () => void onTrack?: (event: DebuggerEvent) => void @@ -112,6 +116,7 @@ export function effect( const _effect = new ReactiveEffect(fn) if (options) { extend(_effect, options) + if (options.scope) recordEffectScope(_effect, options.scope) } if (!options || !options.lazy) { _effect.run() diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts new file mode 100644 index 00000000000..fdacffc541d --- /dev/null +++ b/packages/reactivity/src/effectScope.ts @@ -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(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.` + ) + } +} diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index e392f182439..d86f0bde882 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -51,4 +51,10 @@ export { EffectScheduler, DebuggerEvent } from './effect' +export { + effectScope, + EffectScope, + getCurrentScope, + onScopeDispose +} from './effectScope' export { TrackOpTypes, TriggerOpTypes } from './operations' diff --git a/packages/reactivity/src/warning.ts b/packages/reactivity/src/warning.ts new file mode 100644 index 00000000000..c6cbdfe8c45 --- /dev/null +++ b/packages/reactivity/src/warning.ts @@ -0,0 +1,3 @@ +export function warn(msg: string, ...args: any[]) { + console.warn(`[Vue warn] ${msg}`, ...args) +} diff --git a/packages/runtime-core/__tests__/apiWatch.spec.ts b/packages/runtime-core/__tests__/apiWatch.spec.ts index 28f06057bee..9611b761f03 100644 --- a/packages/runtime-core/__tests__/apiWatch.spec.ts +++ b/packages/runtime-core/__tests__/apiWatch.spec.ts @@ -848,15 +848,16 @@ describe('api: watch', () => { render(h(Comp), nodeOps.createElement('div')) expect(instance!).toBeDefined() - expect(instance!.effects).toBeInstanceOf(Array) - expect(instance!.effects!.length).toBe(1) + expect(instance!.scope.effects).toBeInstanceOf(Array) + // includes the component's own render effect AND the watcher effect + expect(instance!.scope.effects!.length).toBe(2) _show!.value = false 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 ', () => { diff --git a/packages/runtime-core/src/apiComputed.ts b/packages/runtime-core/src/apiComputed.ts deleted file mode 100644 index 02b0ab88aeb..00000000000 --- a/packages/runtime-core/src/apiComputed.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - computed as _computed, - ComputedRef, - WritableComputedOptions, - WritableComputedRef, - ComputedGetter -} from '@vue/reactivity' -import { recordInstanceBoundEffect } from './component' - -export function computed(getter: ComputedGetter): ComputedRef -export function computed( - options: WritableComputedOptions -): WritableComputedRef -export function computed( - getterOrOptions: ComputedGetter | WritableComputedOptions -) { - const c = _computed(getterOrOptions as any) - recordInstanceBoundEffect(c.effect) - return c -} diff --git a/packages/runtime-core/src/apiLifecycle.ts b/packages/runtime-core/src/apiLifecycle.ts index 7decc103a16..61db5e3409f 100644 --- a/packages/runtime-core/src/apiLifecycle.ts +++ b/packages/runtime-core/src/apiLifecycle.ts @@ -3,7 +3,8 @@ import { currentInstance, isInSSRComponentSetup, LifecycleHooks, - setCurrentInstance + setCurrentInstance, + unsetCurrentInstance } from './component' import { ComponentPublicInstance } from './componentPublicInstance' import { callWithAsyncErrorHandling, ErrorTypeStrings } from './errorHandling' @@ -38,7 +39,7 @@ export function injectHook( // can only be false when the user does something really funky. setCurrentInstance(target) const res = callWithAsyncErrorHandling(hook, target, type, args) - setCurrentInstance(null) + unsetCurrentInstance() resetTracking() return res }) diff --git a/packages/runtime-core/src/apiSetupHelpers.ts b/packages/runtime-core/src/apiSetupHelpers.ts index 4dcadbabdde..08a94f7bc31 100644 --- a/packages/runtime-core/src/apiSetupHelpers.ts +++ b/packages/runtime-core/src/apiSetupHelpers.ts @@ -3,7 +3,8 @@ import { getCurrentInstance, setCurrentInstance, SetupContext, - createSetupContext + createSetupContext, + unsetCurrentInstance } from './component' import { EmitFn, EmitsOptions } from './componentEmits' import { @@ -248,9 +249,15 @@ export function mergeDefaults( * @internal */ 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() - setCurrentInstance(null) + unsetCurrentInstance() if (isPromise(awaitable)) { awaitable = awaitable.catch(e => { setCurrentInstance(ctx) diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index f589ab62951..eb3a4503ceb 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -25,8 +25,7 @@ import { import { currentInstance, ComponentInternalInstance, - isInSSRComponentSetup, - recordInstanceBoundEffect + isInSSRComponentSetup } from './component' import { 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__) { effect.onTrack = onTrack effect.onTrigger = onTrigger } - recordInstanceBoundEffect(effect, instance) - // initial run if (cb) { if (immediate) { @@ -353,8 +351,8 @@ function doWatch( return () => { effect.stop() - if (instance) { - remove(instance.effects!, effect) + if (scope) { + remove(scope.effects!, effect) } } } diff --git a/packages/runtime-core/src/compat/global.ts b/packages/runtime-core/src/compat/global.ts index f843b5ead14..a130514726a 100644 --- a/packages/runtime-core/src/compat/global.ts +++ b/packages/runtime-core/src/compat/global.ts @@ -563,7 +563,7 @@ function installCompatMount( } delete app._container.__vue_app__ } else { - const { bum, effects, um } = instance + const { bum, scope, um } = instance // beforeDestroy hooks if (bum) { invokeArrayFns(bum) @@ -572,10 +572,8 @@ function installCompatMount( instance.emit('hook:beforeDestroy') } // stop effects - if (effects) { - for (let i = 0; i < effects.length; i++) { - effects[i].stop() - } + if (scope) { + scope.stop() } // unmounted hook if (um) { diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 4da57d44368..3caf0df5864 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -1,10 +1,10 @@ import { VNode, VNodeChild, isVNode } from './vnode' import { - ReactiveEffect, pauseTracking, resetTracking, shallowReadonly, proxyRefs, + EffectScope, markRaw } from '@vue/reactivity' import { @@ -217,11 +217,6 @@ export interface ComponentInternalInstance { * Root vnode of this component's own vdom tree */ subTree: VNode - /** - * Main update effect - * @internal - */ - effect: ReactiveEffect /** * 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 * @internal */ - effects: ReactiveEffect[] | null + scope: EffectScope /** * cache for proxy access type to avoid hasOwnProperty calls * @internal @@ -451,14 +446,13 @@ export function createComponentInstance( root: null!, // to be immediately set next: null, 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 + scope: new EffectScope(), render: null, proxy: null, exposed: null, exposeProxy: null, withProxy: null, - effects: null, provides: parent ? parent.provides : Object.create(appContext.provides), accessCache: null!, renderCache: [], @@ -533,10 +527,14 @@ export let currentInstance: ComponentInternalInstance | null = null export const getCurrentInstance: () => ComponentInternalInstance | null = () => currentInstance || currentRenderingInstance -export const setCurrentInstance = ( - instance: ComponentInternalInstance | null -) => { +export const setCurrentInstance = (instance: ComponentInternalInstance) => { currentInstance = instance + instance.scope.on() +} + +export const unsetCurrentInstance = () => { + currentInstance && currentInstance.scope.off() + currentInstance = null } const isBuiltInTag = /*#__PURE__*/ makeMap('slot,component') @@ -618,7 +616,7 @@ function setupStatefulComponent( const setupContext = (instance.setupContext = setup.length > 1 ? createSetupContext(instance) : null) - currentInstance = instance + setCurrentInstance(instance) pauseTracking() const setupResult = callWithErrorHandling( setup, @@ -627,13 +625,10 @@ function setupStatefulComponent( [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext] ) resetTracking() - currentInstance = null + unsetCurrentInstance() if (isPromise(setupResult)) { - const unsetInstance = () => { - currentInstance = null - } - setupResult.then(unsetInstance, unsetInstance) + setupResult.then(unsetCurrentInstance, unsetCurrentInstance) if (isSSR) { // return the promise so server-renderer can wait on it @@ -801,11 +796,11 @@ export function finishComponentSetup( // support for 2.x options if (__FEATURE_OPTIONS_API__ && !(__COMPAT__ && skipOptions)) { - currentInstance = instance + setCurrentInstance(instance) pauseTracking() applyOptions(instance) resetTracking() - currentInstance = null + unsetCurrentInstance() } // 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 classify = (str: string): string => str.replace(classifyRE, c => c.toUpperCase()).replace(/[-_]/g, '') diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 5c7960d341c..e8cc5f75d67 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -17,7 +17,7 @@ import { NOOP, isPromise } from '@vue/shared' -import { computed } from './apiComputed' +import { computed } from '@vue/reactivity' import { watch, WatchOptions, diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index cacd72102ba..c4ab05dd021 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -29,7 +29,8 @@ import { ComponentInternalInstance, ComponentOptions, ConcreteComponent, - setCurrentInstance + setCurrentInstance, + unsetCurrentInstance } from './component' import { isEmitListener } from './componentEmits' import { InternalObjectKey } from './vnode' @@ -411,7 +412,7 @@ function resolvePropValue( : null, props ) - setCurrentInstance(null) + unsetCurrentInstance() } } else { value = defaultValue diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 296ac574f70..3b745735542 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -3,6 +3,7 @@ export const version = __VERSION__ export { // core + computed, reactive, ref, readonly, @@ -22,9 +23,17 @@ export { shallowReactive, shallowReadonly, markRaw, - toRaw + toRaw, + // effect + effect, + stop, + ReactiveEffect, + // effect scope + effectScope, + EffectScope, + getCurrentScope, + onScopeDispose } from '@vue/reactivity' -export { computed } from './apiComputed' export { watch, watchEffect } from './apiWatch' export { onBeforeMount, @@ -137,7 +146,6 @@ declare module '@vue/reactivity' { } export { - ReactiveEffect, ReactiveEffectOptions, DebuggerEvent, TrackOpTypes, diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 5ecee3edd4c..9415eabb2a2 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1622,11 +1622,12 @@ function baseCreateRenderer( } // create reactive effect for rendering - const effect = (instance.effect = new ReactiveEffect( + const effect = new ReactiveEffect( componentUpdateFn, () => queueJob(instance.update), + instance.scope, // track it in component's effect scope true /* allowRecurse */ - )) + ) const update = (instance.update = effect.run.bind(effect) as SchedulerJob) update.id = instance.uid @@ -2285,12 +2286,13 @@ function baseCreateRenderer( unregisterHMR(instance) } - const { bum, effect, effects, update, subTree, um } = instance + const { bum, scope, update, subTree, um } = instance // beforeUnmount hook if (bum) { invokeArrayFns(bum) } + if ( __COMPAT__ && isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance) @@ -2298,15 +2300,13 @@ function baseCreateRenderer( instance.emit('hook:beforeDestroy') } - if (effects) { - for (let i = 0; i < effects.length; i++) { - effects[i].stop() - } + if (scope) { + scope.stop() } + // update may be null if a component is unmounted before its async // setup has resolved. - if (effect) { - effect.stop() + if (update) { // so that scheduler will no longer invoke it update.active = false unmount(subTree, instance, parentSuspense, doRemove)