Skip to content

Commit f5617fc

Browse files
antfuyyx990803
authored andcommitted
feat(reactivity): new effectScope API (#2195)
1 parent 87f69fd commit f5617fc

16 files changed

+400
-89
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { nextTick, watch, watchEffect } from '@vue/runtime-core'
2+
import {
3+
reactive,
4+
effect,
5+
EffectScope,
6+
onScopeDispose,
7+
computed,
8+
ref,
9+
ComputedRef
10+
} from '../src'
11+
12+
describe('reactivity/effect/scope', () => {
13+
it('should run', () => {
14+
const fnSpy = jest.fn(() => {})
15+
new EffectScope().run(fnSpy)
16+
expect(fnSpy).toHaveBeenCalledTimes(1)
17+
})
18+
19+
it('should accept zero argument', () => {
20+
const scope = new EffectScope()
21+
expect(scope.effects.length).toBe(0)
22+
})
23+
24+
it('should return run value', () => {
25+
expect(new EffectScope().run(() => 1)).toBe(1)
26+
})
27+
28+
it('should collect the effects', () => {
29+
const scope = new EffectScope()
30+
scope.run(() => {
31+
let dummy
32+
const counter = reactive({ num: 0 })
33+
effect(() => (dummy = counter.num))
34+
35+
expect(dummy).toBe(0)
36+
counter.num = 7
37+
expect(dummy).toBe(7)
38+
})
39+
40+
expect(scope.effects.length).toBe(1)
41+
})
42+
43+
it('stop', () => {
44+
let dummy, doubled
45+
const counter = reactive({ num: 0 })
46+
47+
const scope = new EffectScope()
48+
scope.run(() => {
49+
effect(() => (dummy = counter.num))
50+
effect(() => (doubled = counter.num * 2))
51+
})
52+
53+
expect(scope.effects.length).toBe(2)
54+
55+
expect(dummy).toBe(0)
56+
counter.num = 7
57+
expect(dummy).toBe(7)
58+
expect(doubled).toBe(14)
59+
60+
scope.stop()
61+
62+
counter.num = 6
63+
expect(dummy).toBe(7)
64+
expect(doubled).toBe(14)
65+
})
66+
67+
it('should collect nested scope', () => {
68+
let dummy, doubled
69+
const counter = reactive({ num: 0 })
70+
71+
const scope = new EffectScope()
72+
scope.run(() => {
73+
effect(() => (dummy = counter.num))
74+
// nested scope
75+
new EffectScope().run(() => {
76+
effect(() => (doubled = counter.num * 2))
77+
})
78+
})
79+
80+
expect(scope.effects.length).toBe(2)
81+
expect(scope.effects[1]).toBeInstanceOf(EffectScope)
82+
83+
expect(dummy).toBe(0)
84+
counter.num = 7
85+
expect(dummy).toBe(7)
86+
expect(doubled).toBe(14)
87+
88+
// stop the nested scope as well
89+
scope.stop()
90+
91+
counter.num = 6
92+
expect(dummy).toBe(7)
93+
expect(doubled).toBe(14)
94+
})
95+
96+
it('nested scope can be escaped', () => {
97+
let dummy, doubled
98+
const counter = reactive({ num: 0 })
99+
100+
const scope = new EffectScope()
101+
scope.run(() => {
102+
effect(() => (dummy = counter.num))
103+
// nested scope
104+
new EffectScope(true).run(() => {
105+
effect(() => (doubled = counter.num * 2))
106+
})
107+
})
108+
109+
expect(scope.effects.length).toBe(1)
110+
111+
expect(dummy).toBe(0)
112+
counter.num = 7
113+
expect(dummy).toBe(7)
114+
expect(doubled).toBe(14)
115+
116+
scope.stop()
117+
118+
counter.num = 6
119+
expect(dummy).toBe(7)
120+
121+
// nested scope should not be stoped
122+
expect(doubled).toBe(12)
123+
})
124+
125+
it('able to run the scope', () => {
126+
let dummy, doubled
127+
const counter = reactive({ num: 0 })
128+
129+
const scope = new EffectScope()
130+
scope.run(() => {
131+
effect(() => (dummy = counter.num))
132+
})
133+
134+
expect(scope.effects.length).toBe(1)
135+
136+
scope.run(() => {
137+
effect(() => (doubled = counter.num * 2))
138+
})
139+
140+
expect(scope.effects.length).toBe(2)
141+
142+
counter.num = 7
143+
expect(dummy).toBe(7)
144+
expect(doubled).toBe(14)
145+
146+
scope.stop()
147+
})
148+
149+
it('can not run an inactive scope', () => {
150+
let dummy, doubled
151+
const counter = reactive({ num: 0 })
152+
153+
const scope = new EffectScope()
154+
scope.run(() => {
155+
effect(() => (dummy = counter.num))
156+
})
157+
158+
expect(scope.effects.length).toBe(1)
159+
160+
scope.stop()
161+
162+
scope.run(() => {
163+
effect(() => (doubled = counter.num * 2))
164+
})
165+
166+
expect('[Vue warn] cannot run an inactive effect scope.').toHaveBeenWarned()
167+
168+
expect(scope.effects.length).toBe(1)
169+
170+
counter.num = 7
171+
expect(dummy).toBe(0)
172+
expect(doubled).toBe(undefined)
173+
})
174+
175+
it('should fire onDispose hook', () => {
176+
let dummy = 0
177+
178+
const scope = new EffectScope()
179+
scope.run(() => {
180+
onScopeDispose(() => (dummy += 1))
181+
onScopeDispose(() => (dummy += 2))
182+
})
183+
184+
scope.run(() => {
185+
onScopeDispose(() => (dummy += 4))
186+
})
187+
188+
expect(dummy).toBe(0)
189+
190+
scope.stop()
191+
expect(dummy).toBe(7)
192+
})
193+
194+
it('test with higher level APIs', async () => {
195+
const r = ref(1)
196+
197+
const computedSpy = jest.fn()
198+
const watchSpy = jest.fn()
199+
const watchEffectSpy = jest.fn()
200+
201+
let c: ComputedRef
202+
const scope = new EffectScope()
203+
scope.run(() => {
204+
c = computed(() => {
205+
computedSpy()
206+
return r.value + 1
207+
})
208+
209+
watch(r, watchSpy)
210+
watchEffect(() => {
211+
watchEffectSpy()
212+
r.value
213+
})
214+
})
215+
216+
c!.value // computed is lazy so trigger collection
217+
expect(computedSpy).toHaveBeenCalledTimes(1)
218+
expect(watchSpy).toHaveBeenCalledTimes(0)
219+
expect(watchEffectSpy).toHaveBeenCalledTimes(1)
220+
221+
r.value++
222+
c!.value
223+
await nextTick()
224+
expect(computedSpy).toHaveBeenCalledTimes(2)
225+
expect(watchSpy).toHaveBeenCalledTimes(1)
226+
expect(watchEffectSpy).toHaveBeenCalledTimes(2)
227+
228+
scope.stop()
229+
230+
r.value++
231+
c!.value
232+
await nextTick()
233+
// should not trigger anymore
234+
expect(computedSpy).toHaveBeenCalledTimes(2)
235+
expect(watchSpy).toHaveBeenCalledTimes(1)
236+
expect(watchEffectSpy).toHaveBeenCalledTimes(2)
237+
})
238+
})

packages/reactivity/src/effect.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { TrackOpTypes, TriggerOpTypes } from './operations'
22
import { extend, isArray, isIntegerKey, isMap } from '@vue/shared'
3+
import { EffectScope, recordEffectScope } from './effectScope'
34

45
// The main WeakMap that stores {target -> key -> dep} connections.
56
// Conceptually, it's easier to think of a dependency as a Dep class
@@ -43,9 +44,12 @@ export class ReactiveEffect<T = any> {
4344
constructor(
4445
public fn: () => T,
4546
public scheduler: EffectScheduler | null = null,
47+
scope?: EffectScope | null,
4648
// allow recursive self-invocation
4749
public allowRecurse = false
48-
) {}
50+
) {
51+
recordEffectScope(this, scope)
52+
}
4953

5054
run() {
5155
if (!this.active) {
@@ -60,8 +64,7 @@ export class ReactiveEffect<T = any> {
6064
} finally {
6165
effectStack.pop()
6266
resetTracking()
63-
const n = effectStack.length
64-
activeEffect = n > 0 ? effectStack[n - 1] : undefined
67+
activeEffect = effectStack[effectStack.length - 1]
6568
}
6669
}
6770
}
@@ -90,6 +93,7 @@ export class ReactiveEffect<T = any> {
9093
export interface ReactiveEffectOptions {
9194
lazy?: boolean
9295
scheduler?: EffectScheduler
96+
scope?: EffectScope
9397
allowRecurse?: boolean
9498
onStop?: () => void
9599
onTrack?: (event: DebuggerEvent) => void
@@ -112,6 +116,7 @@ export function effect<T = any>(
112116
const _effect = new ReactiveEffect(fn)
113117
if (options) {
114118
extend(_effect, options)
119+
if (options.scope) recordEffectScope(_effect, options.scope)
115120
}
116121
if (!options || !options.lazy) {
117122
_effect.run()
+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { ReactiveEffect } from './effect'
2+
import { warn } from './warning'
3+
4+
let activeEffectScope: EffectScope | undefined
5+
const effectScopeStack: EffectScope[] = []
6+
7+
export class EffectScope {
8+
active = true
9+
effects: (ReactiveEffect | EffectScope)[] = []
10+
cleanups: (() => void)[] = []
11+
12+
constructor(detached = false) {
13+
if (!detached) {
14+
recordEffectScope(this)
15+
}
16+
}
17+
18+
run<T>(fn: () => T): T | undefined {
19+
if (this.active) {
20+
try {
21+
this.on()
22+
return fn()
23+
} finally {
24+
this.off()
25+
}
26+
} else if (__DEV__) {
27+
warn(`cannot run an inactive effect scope.`)
28+
}
29+
}
30+
31+
on() {
32+
if (this.active) {
33+
effectScopeStack.push(this)
34+
activeEffectScope = this
35+
}
36+
}
37+
38+
off() {
39+
if (this.active) {
40+
effectScopeStack.pop()
41+
activeEffectScope = effectScopeStack[effectScopeStack.length - 1]
42+
}
43+
}
44+
45+
stop() {
46+
if (this.active) {
47+
this.effects.forEach(e => e.stop())
48+
this.cleanups.forEach(cleanup => cleanup())
49+
this.active = false
50+
}
51+
}
52+
}
53+
54+
export function effectScope(detached?: boolean) {
55+
return new EffectScope(detached)
56+
}
57+
58+
export function recordEffectScope(
59+
effect: ReactiveEffect | EffectScope,
60+
scope?: EffectScope | null
61+
) {
62+
scope = scope || activeEffectScope
63+
if (scope && scope.active) {
64+
scope.effects.push(effect)
65+
}
66+
}
67+
68+
export function getCurrentScope() {
69+
return activeEffectScope
70+
}
71+
72+
export function onScopeDispose(fn: () => void) {
73+
if (activeEffectScope) {
74+
activeEffectScope.cleanups.push(fn)
75+
} else if (__DEV__) {
76+
warn(
77+
`onDispose() is called when there is no active effect scope ` +
78+
` to be associated with.`
79+
)
80+
}
81+
}

packages/reactivity/src/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,10 @@ export {
5151
EffectScheduler,
5252
DebuggerEvent
5353
} from './effect'
54+
export {
55+
effectScope,
56+
EffectScope,
57+
getCurrentScope,
58+
onScopeDispose
59+
} from './effectScope'
5460
export { TrackOpTypes, TriggerOpTypes } from './operations'

packages/reactivity/src/warning.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function warn(msg: string, ...args: any[]) {
2+
console.warn(`[Vue warn] ${msg}`, ...args)
3+
}

0 commit comments

Comments
 (0)