From fcadec2a365c0d08609534bb0b8c06afa2f401fd Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Sat, 17 Jul 2021 03:45:39 +0800 Subject: [PATCH] feat: implement `effectScope` api (#762) --- src/apis/computed.ts | 5 +- src/apis/effectScope.ts | 105 ++++++++++ src/apis/index.ts | 19 +- src/apis/watch.ts | 5 +- src/runtimeContext.ts | 16 ++ test/v3/reactivity/effectScope.spec.ts | 254 +++++++++++++++++++++++++ 6 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 src/apis/effectScope.ts create mode 100644 test/v3/reactivity/effectScope.spec.ts diff --git a/src/apis/computed.ts b/src/apis/computed.ts index 9ac0b28b..e386cc4f 100644 --- a/src/apis/computed.ts +++ b/src/apis/computed.ts @@ -1,4 +1,4 @@ -import { getVueConstructor, getCurrentInstance } from '../runtimeContext' +import { getVueConstructor } from '../runtimeContext' import { createRef, Ref } from '../reactivity' import { warn, @@ -6,6 +6,7 @@ import { defineComponentInstance, getVueInternalClasses, } from '../utils' +import { getCurrentScopeVM } from './effectScope' export interface ComputedRef extends WritableComputedRef { readonly value: T @@ -31,7 +32,7 @@ export function computed( export function computed( getterOrOptions: ComputedGetter | WritableComputedOptions ): ComputedRef | WritableComputedRef { - const vm = getCurrentInstance()?.proxy + const vm = getCurrentScopeVM() let getter: ComputedGetter let setter: ComputedSetter | undefined diff --git a/src/apis/effectScope.ts b/src/apis/effectScope.ts new file mode 100644 index 00000000..486a3871 --- /dev/null +++ b/src/apis/effectScope.ts @@ -0,0 +1,105 @@ +import { + getCurrentInstance, + getVueConstructor, + withCurrentInstanceTrackingDisabled, +} from '../runtimeContext' +import { defineComponentInstance } from '../utils' +import { warn } from './warn' + +let activeEffectScope: EffectScope | undefined +const effectScopeStack: EffectScope[] = [] + +export class EffectScope { + active = true + effects: EffectScope[] = [] + cleanups: (() => void)[] = [] + + /** + * @internal + **/ + vm: Vue + + constructor(detached = false) { + let vm: Vue = undefined! + withCurrentInstanceTrackingDisabled(() => { + vm = defineComponentInstance(getVueConstructor()) + }) + this.vm = vm + 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.`) + } + return + } + + 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.vm.$destroy() + this.effects.forEach((e) => e.stop()) + this.cleanups.forEach((cleanup) => cleanup()) + this.active = false + } + } +} + +export function recordEffectScope( + effect: EffectScope, + scope?: EffectScope | null +) { + scope = scope || activeEffectScope + if (scope && scope.active) { + scope.effects.push(effect) + } +} + +export function effectScope(detached?: boolean) { + return new EffectScope(detached) +} + +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.` + ) + } +} + +/** + * @internal + **/ +export function getCurrentScopeVM() { + return getCurrentScope()?.vm || getCurrentInstance()?.proxy +} diff --git a/src/apis/index.ts b/src/apis/index.ts index 19b715de..598dbff6 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,5 +1,16 @@ export * from '../reactivity' -export * from './lifecycle' +export { + onBeforeMount, + onMounted, + onBeforeUpdate, + onUpdated, + onBeforeUnmount, + onUnmounted, + onErrorCaptured, + onActivated, + onDeactivated, + onServerPrefetch, +} from './lifecycle' export * from './watch' export * from './computed' export * from './inject' @@ -8,3 +19,9 @@ export { App, createApp } from './createApp' export { nextTick } from './nextTick' export { createElement as h } from './createElement' export { warn } from './warn' +export { + effectScope, + EffectScope, + getCurrentScope, + onScopeDispose, +} from './effectScope' diff --git a/src/apis/watch.ts b/src/apis/watch.ts index 8f8ce5d6..e4c7a743 100644 --- a/src/apis/watch.ts +++ b/src/apis/watch.ts @@ -13,12 +13,13 @@ import { isMap, } from '../utils' import { defineComponentInstance } from '../utils/helper' -import { getCurrentInstance, getVueConstructor } from '../runtimeContext' +import { getVueConstructor } from '../runtimeContext' import { WatcherPreFlushQueueKey, WatcherPostFlushQueueKey, } from '../utils/symbols' import { ComputedRef } from './computed' +import { getCurrentScopeVM } from './effectScope' export type WatchEffect = (onInvalidate: InvalidateCbRegistrator) => void @@ -110,7 +111,7 @@ function getWatchEffectOption(options?: Partial): WatchOptions { } function getWatcherVM() { - let vm = getCurrentInstance()?.proxy + let vm = getCurrentScopeVM() if (!vm) { if (!fallbackVM) { fallbackVM = defineComponentInstance(getVueConstructor()) diff --git a/src/runtimeContext.ts b/src/runtimeContext.ts index bfdd68da..e3db5387 100644 --- a/src/runtimeContext.ts +++ b/src/runtimeContext.ts @@ -21,6 +21,7 @@ try { let vueConstructor: VueConstructor | null = null let currentInstance: ComponentInstance | null = null +let currentInstanceTracking = true const PluginInstalledFlag = '__composition_api_installed__' @@ -71,7 +72,22 @@ export function setVueConstructor(Vue: VueConstructor) { }) } +/** + * For `effectScope` to create instance without populate the current instance + * @internal + **/ +export function withCurrentInstanceTrackingDisabled(fn: () => void) { + const prev = currentInstanceTracking + currentInstanceTracking = false + try { + fn() + } finally { + currentInstanceTracking = prev + } +} + export function setCurrentInstance(vm: ComponentInstance | null) { + if (!currentInstanceTracking) return // currentInstance?.$scopedSlots currentInstance = vm } diff --git a/test/v3/reactivity/effectScope.spec.ts b/test/v3/reactivity/effectScope.spec.ts new file mode 100644 index 00000000..79cc8c8c --- /dev/null +++ b/test/v3/reactivity/effectScope.spec.ts @@ -0,0 +1,254 @@ +import { + nextTick, + watch, + watchEffect, + reactive, + EffectScope, + onScopeDispose, + computed, + ref, + ComputedRef, +} from '../../../src' +import { mockWarn } from '../../helpers' + +describe('reactivity/effect/scope', () => { + mockWarn(true) + + 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', async () => { + const scope = new EffectScope() + let dummy = 0 + scope.run(() => { + const counter = reactive({ num: 0 }) + watchEffect(() => (dummy = counter.num)) + + expect(dummy).toBe(0) + counter.num = 7 + }) + + await nextTick() + + expect(dummy).toBe(7) + // expect(scope.effects.length).toBe(1) + }) + + it('stop', async () => { + let dummy, doubled + const counter = reactive({ num: 0 }) + + const scope = new EffectScope() + scope.run(() => { + watchEffect(() => (dummy = counter.num)) + watchEffect(() => (doubled = counter.num * 2)) + }) + + // expect(scope.effects.length).toBe(2) + + expect(dummy).toBe(0) + counter.num = 7 + await nextTick() + expect(dummy).toBe(7) + expect(doubled).toBe(14) + + scope.stop() + + counter.num = 6 + await nextTick() + expect(dummy).toBe(7) + expect(doubled).toBe(14) + }) + + it('should collect nested scope', async () => { + let dummy, doubled + const counter = reactive({ num: 0 }) + + const scope = new EffectScope() + scope.run(() => { + // nested scope + new EffectScope().run(() => { + watchEffect(() => (doubled = counter.num * 2)) + }) + watchEffect(() => (dummy = counter.num)) + }) + + // expect(scope.effects.length).toBe(2) + expect(scope.effects[0]).toBeInstanceOf(EffectScope) + + expect(dummy).toBe(0) + counter.num = 7 + await nextTick() + expect(dummy).toBe(7) + expect(doubled).toBe(14) + + // stop the nested scope as well + scope.stop() + + counter.num = 6 + await nextTick() + expect(dummy).toBe(7) + expect(doubled).toBe(14) + }) + + it('nested scope can be escaped', async () => { + let dummy, doubled + const counter = reactive({ num: 0 }) + + const scope = new EffectScope() + scope.run(() => { + watchEffect(() => (dummy = counter.num)) + // nested scope + new EffectScope(true).run(() => { + watchEffect(() => (doubled = counter.num * 2)) + }) + }) + + expect(scope.effects.length).toBe(0) + + expect(dummy).toBe(0) + counter.num = 7 + await nextTick() + expect(dummy).toBe(7) + expect(doubled).toBe(14) + + scope.stop() + + counter.num = 6 + await nextTick() + expect(dummy).toBe(7) + + // nested scope should not be stoped + expect(doubled).toBe(12) + }) + + it('able to run the scope', async () => { + let dummy, doubled + const counter = reactive({ num: 0 }) + + const scope = new EffectScope() + scope.run(() => { + watchEffect(() => (dummy = counter.num)) + }) + + // expect(scope.effects.length).toBe(1) + + scope.run(() => { + watchEffect(() => (doubled = counter.num * 2)) + }) + + // expect(scope.effects.length).toBe(2) + + counter.num = 7 + await nextTick() + expect(dummy).toBe(7) + expect(doubled).toBe(14) + + scope.stop() + }) + + it('can not run an inactive scope', async () => { + let dummy, doubled + const counter = reactive({ num: 0 }) + + const scope = new EffectScope() + scope.run(() => { + watchEffect(() => (dummy = counter.num)) + }) + + // expect(scope.effects.length).toBe(1) + + scope.stop() + + scope.run(() => { + watchEffect(() => (doubled = counter.num * 2)) + }) + + expect( + '[Vue warn]: cannot run an inactive effect scope.' + ).toHaveBeenWarned() + + // expect(scope.effects.length).toBe(1) + + counter.num = 7 + await nextTick() + 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) + }) +})