feat(reactivity): new effectScope API (#2195)
This commit is contained in:
238
packages/reactivity/__tests__/effectScope.spec.ts
Normal file
238
packages/reactivity/__tests__/effectScope.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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<T = any> {
|
||||
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<T = any> {
|
||||
} 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<T = any> {
|
||||
export interface ReactiveEffectOptions {
|
||||
lazy?: boolean
|
||||
scheduler?: EffectScheduler
|
||||
scope?: EffectScope
|
||||
allowRecurse?: boolean
|
||||
onStop?: () => void
|
||||
onTrack?: (event: DebuggerEvent) => void
|
||||
@@ -112,6 +116,7 @@ export function effect<T = any>(
|
||||
const _effect = new ReactiveEffect(fn)
|
||||
if (options) {
|
||||
extend(_effect, options)
|
||||
if (options.scope) recordEffectScope(_effect, options.scope)
|
||||
}
|
||||
if (!options || !options.lazy) {
|
||||
_effect.run()
|
||||
|
||||
81
packages/reactivity/src/effectScope.ts
Normal file
81
packages/reactivity/src/effectScope.ts
Normal 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.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -51,4 +51,10 @@ export {
|
||||
EffectScheduler,
|
||||
DebuggerEvent
|
||||
} from './effect'
|
||||
export {
|
||||
effectScope,
|
||||
EffectScope,
|
||||
getCurrentScope,
|
||||
onScopeDispose
|
||||
} from './effectScope'
|
||||
export { TrackOpTypes, TriggerOpTypes } from './operations'
|
||||
|
||||
3
packages/reactivity/src/warning.ts
Normal file
3
packages/reactivity/src/warning.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function warn(msg: string, ...args: any[]) {
|
||||
console.warn(`[Vue warn] ${msg}`, ...args)
|
||||
}
|
||||
Reference in New Issue
Block a user