Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(reactivity): new effectScope API #2195

Merged
merged 8 commits into from
Jul 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Member Author

@antfu antfu Nov 27, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since warn for Vue is presented in runtime-core binding with the component model, is it ok to introduce this util in reactivity?

}
Loading