feat(reactivity/watch): add pause/resume for ReactiveEffect, EffectScope, and WatchHandle (#9651)

This commit is contained in:
远方os 2024-08-02 14:41:27 +08:00 committed by GitHub
parent 55acabe88c
commit 267093c314
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 193 additions and 14 deletions

View File

@ -1282,4 +1282,48 @@ describe('reactivity/effect', () => {
).not.toHaveBeenWarned()
})
})
test('should pause/resume effect', () => {
const obj = reactive({ foo: 1 })
const fnSpy = vi.fn(() => obj.foo)
const runner = effect(fnSpy)
expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(1)
runner.effect.pause()
obj.foo++
expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(2)
runner.effect.resume()
expect(fnSpy).toHaveBeenCalledTimes(2)
expect(obj.foo).toBe(2)
obj.foo++
expect(fnSpy).toHaveBeenCalledTimes(3)
expect(obj.foo).toBe(3)
})
test('should be executed once immediately when resume is called', () => {
const obj = reactive({ foo: 1 })
const fnSpy = vi.fn(() => obj.foo)
const runner = effect(fnSpy)
expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(1)
runner.effect.pause()
obj.foo++
expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(2)
obj.foo++
expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(3)
runner.effect.resume()
expect(fnSpy).toHaveBeenCalledTimes(2)
expect(obj.foo).toBe(3)
})
})

View File

@ -295,4 +295,31 @@ describe('reactivity/effect/scope', () => {
expect(getCurrentScope()).toBe(parentScope)
})
})
it('should pause/resume EffectScope', async () => {
const counter = reactive({ num: 0 })
const fnSpy = vi.fn(() => counter.num)
const scope = new EffectScope()
scope.run(() => {
effect(fnSpy)
})
expect(fnSpy).toHaveBeenCalledTimes(1)
counter.num++
await nextTick()
expect(fnSpy).toHaveBeenCalledTimes(2)
scope.pause()
counter.num++
await nextTick()
expect(fnSpy).toHaveBeenCalledTimes(2)
counter.num++
await nextTick()
expect(fnSpy).toHaveBeenCalledTimes(2)
scope.resume()
expect(fnSpy).toHaveBeenCalledTimes(3)
})
})

View File

@ -46,6 +46,7 @@ export enum EffectFlags {
DIRTY = 1 << 4,
ALLOW_RECURSE = 1 << 5,
NO_BATCH = 1 << 6,
PAUSED = 1 << 7,
}
/**
@ -107,6 +108,8 @@ export interface Link {
prevActiveLink?: Link
}
const pausedQueueEffects = new WeakSet<ReactiveEffect>()
export class ReactiveEffect<T = any>
implements Subscriber, ReactiveEffectOptions
{
@ -142,6 +145,20 @@ export class ReactiveEffect<T = any>
}
}
pause() {
this.flags |= EffectFlags.PAUSED
}
resume() {
if (this.flags & EffectFlags.PAUSED) {
this.flags &= ~EffectFlags.PAUSED
if (pausedQueueEffects.has(this)) {
pausedQueueEffects.delete(this)
this.trigger()
}
}
}
/**
* @internal
*/
@ -207,7 +224,9 @@ export class ReactiveEffect<T = any>
}
trigger() {
if (this.scheduler) {
if (this.flags & EffectFlags.PAUSED) {
pausedQueueEffects.add(this)
} else if (this.scheduler) {
this.scheduler()
} else {
this.runIfDirty()

View File

@ -17,6 +17,8 @@ export class EffectScope {
*/
cleanups: (() => void)[] = []
private _isPaused = false
/**
* only assigned by undetached scope
* @internal
@ -48,6 +50,39 @@ export class EffectScope {
return this._active
}
pause() {
if (this._active) {
this._isPaused = true
if (this.scopes) {
for (let i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].pause()
}
}
for (let i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].pause()
}
}
}
/**
* Resumes the effect scope, including all child scopes and effects.
*/
resume() {
if (this._active) {
if (this._isPaused) {
this._isPaused = false
if (this.scopes) {
for (let i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].resume()
}
}
for (let i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].resume()
}
}
}
}
run<T>(fn: () => T): T | undefined {
if (this._active) {
const currentEffectScope = activeEffectScope

View File

@ -1621,6 +1621,45 @@ describe('api: watch', () => {
expect(cb).toHaveBeenCalledTimes(4)
})
test('pause / resume', async () => {
const count = ref(0)
const cb = vi.fn()
const { pause, resume } = watch(count, cb)
count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function))
pause()
count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function))
resume()
count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(2)
expect(cb).toHaveBeenLastCalledWith(3, 1, expect.any(Function))
count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(3)
expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function))
pause()
count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(3)
expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function))
resume()
await nextTick()
expect(cb).toHaveBeenCalledTimes(4)
expect(cb).toHaveBeenLastCalledWith(5, 4, expect.any(Function))
})
it('shallowReactive', async () => {
const state = shallowReactive({
msg: ref('hello'),

View File

@ -79,11 +79,17 @@ export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
export type WatchStopHandle = () => void
export interface WatchHandle extends WatchStopHandle {
pause: () => void
resume: () => void
stop: () => void
}
// Simple effect.
export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase,
): WatchStopHandle {
): WatchHandle {
return doWatch(effect, null, options)
}
@ -119,7 +125,7 @@ export function watch<T, Immediate extends Readonly<boolean> = false>(
source: WatchSource<T>,
cb: WatchCallback<T, MaybeUndefined<T, Immediate>>,
options?: WatchOptions<Immediate>,
): WatchStopHandle
): WatchHandle
// overload: reactive array or tuple of multiple sources + cb
export function watch<
@ -131,7 +137,7 @@ export function watch<
? WatchCallback<T, MaybeUndefined<T, Immediate>>
: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
options?: WatchOptions<Immediate>,
): WatchStopHandle
): WatchHandle
// overload: array of multiple sources + cb
export function watch<
@ -141,7 +147,7 @@ export function watch<
sources: [...T],
cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
options?: WatchOptions<Immediate>,
): WatchStopHandle
): WatchHandle
// overload: watching reactive object w/ cb
export function watch<
@ -151,14 +157,14 @@ export function watch<
source: T,
cb: WatchCallback<T, MaybeUndefined<T, Immediate>>,
options?: WatchOptions<Immediate>,
): WatchStopHandle
): WatchHandle
// implementation
export function watch<T = any, Immediate extends Readonly<boolean> = false>(
source: T | WatchSource<T>,
cb: any,
options?: WatchOptions<Immediate>,
): WatchStopHandle {
): WatchHandle {
if (__DEV__ && !isFunction(cb)) {
warn(
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
@ -180,12 +186,12 @@ function doWatch(
onTrack,
onTrigger,
}: WatchOptions = EMPTY_OBJ,
): WatchStopHandle {
): WatchHandle {
if (cb && once) {
const _cb = cb
cb = (...args) => {
_cb(...args)
unwatch()
watchHandle()
}
}
@ -327,7 +333,11 @@ function doWatch(
const ctx = useSSRContext()!
ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = [])
} else {
return NOOP
const watchHandle: WatchHandle = () => {}
watchHandle.stop = NOOP
watchHandle.resume = NOOP
watchHandle.pause = NOOP
return watchHandle
}
}
@ -397,13 +407,17 @@ function doWatch(
effect.scheduler = scheduler
const scope = getCurrentScope()
const unwatch = () => {
const watchHandle: WatchHandle = () => {
effect.stop()
if (scope) {
remove(scope.effects, effect)
}
}
watchHandle.pause = effect.pause.bind(effect)
watchHandle.resume = effect.resume.bind(effect)
watchHandle.stop = watchHandle
if (__DEV__) {
effect.onTrack = onTrack
effect.onTrigger = onTrigger
@ -425,8 +439,8 @@ function doWatch(
effect.run()
}
if (__SSR__ && ssrCleanup) ssrCleanup.push(unwatch)
return unwatch
if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle)
return watchHandle
}
// this.$watch
@ -435,7 +449,7 @@ export function instanceWatch(
source: string | Function,
value: WatchCallback | ObjectWatchOptionItem,
options?: WatchOptions,
): WatchStopHandle {
): WatchHandle {
const publicThis = this.proxy as any
const getter = isString(source)
? source.includes('.')

View File

@ -230,6 +230,7 @@ export type {
WatchOptionsBase,
WatchCallback,
WatchSource,
WatchHandle,
WatchStopHandle,
} from './apiWatch'
export type { InjectionKey } from './apiInject'