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 and yyx990803 committed Jul 16, 2021
1 parent 87f69fd commit f5617fc
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 comment has been minimized.

Copy link
@basvanmeurs

basvanmeurs Jul 23, 2021

Contributor

When an effect scope has a parent scope, it should be removed from that parent scopes' effects array. Otherwise memory leaks can still occur.

This situation is made worse by the fact that the effects array isnt emptied here, which in turn keeps the effects referenced. @yyx990803

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 f5617fc

Please sign in to comment.