Skip to content

Commit

Permalink
feat(reactivity): new effectScope API (#2195)
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu authored Jul 7, 2021
1 parent ba1d97c commit 9e4c85d
Show file tree
Hide file tree
Showing 16 changed files with 400 additions and 89 deletions.
238 changes: 238 additions & 0 deletions packages/reactivity/__tests__/effectScope.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
11 changes: 8 additions & 3 deletions packages/reactivity/src/effect.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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]
}
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
81 changes: 81 additions & 0 deletions packages/reactivity/src/effectScope.ts
Original file line number Diff line number Diff line change
@@ -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.`
)
}
}
6 changes: 6 additions & 0 deletions packages/reactivity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,10 @@ export {
EffectScheduler,
DebuggerEvent
} from './effect'
export {
effectScope,
EffectScope,
getCurrentScope,
onScopeDispose
} from './effectScope'
export { TrackOpTypes, TriggerOpTypes } from './operations'
3 changes: 3 additions & 0 deletions packages/reactivity/src/warning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function warn(msg: string, ...args: any[]) {
console.warn(`[Vue warn] ${msg}`, ...args)
}
Loading

0 comments on commit 9e4c85d

Please sign in to comment.