feat(reactivity): base `watch`, `getCurrentWatcher`, and `onWatcherCleanup` (#9927)
This commit is contained in:
parent
44973bb3e7
commit
205e5b5e27
|
@ -0,0 +1,196 @@
|
|||
import {
|
||||
EffectScope,
|
||||
type Ref,
|
||||
WatchErrorCodes,
|
||||
type WatchOptions,
|
||||
type WatchScheduler,
|
||||
onWatcherCleanup,
|
||||
ref,
|
||||
watch,
|
||||
} from '../src'
|
||||
|
||||
const queue: (() => void)[] = []
|
||||
|
||||
// a simple scheduler for testing purposes
|
||||
let isFlushPending = false
|
||||
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
|
||||
const nextTick = (fn?: () => any) =>
|
||||
fn ? resolvedPromise.then(fn) : resolvedPromise
|
||||
|
||||
const scheduler: WatchScheduler = (job, isFirstRun) => {
|
||||
if (isFirstRun) {
|
||||
job()
|
||||
} else {
|
||||
queue.push(job)
|
||||
flushJobs()
|
||||
}
|
||||
}
|
||||
|
||||
const flushJobs = () => {
|
||||
if (isFlushPending) return
|
||||
isFlushPending = true
|
||||
resolvedPromise.then(() => {
|
||||
queue.forEach(job => job())
|
||||
queue.length = 0
|
||||
isFlushPending = false
|
||||
})
|
||||
}
|
||||
|
||||
describe('watch', () => {
|
||||
test('effect', () => {
|
||||
let dummy: any
|
||||
const source = ref(0)
|
||||
watch(() => {
|
||||
dummy = source.value
|
||||
})
|
||||
expect(dummy).toBe(0)
|
||||
source.value++
|
||||
expect(dummy).toBe(1)
|
||||
})
|
||||
|
||||
test('with callback', () => {
|
||||
let dummy: any
|
||||
const source = ref(0)
|
||||
watch(source, () => {
|
||||
dummy = source.value
|
||||
})
|
||||
expect(dummy).toBe(undefined)
|
||||
source.value++
|
||||
expect(dummy).toBe(1)
|
||||
})
|
||||
|
||||
test('call option with error handling', () => {
|
||||
const onError = vi.fn()
|
||||
const call: WatchOptions['call'] = function call(fn, type, args) {
|
||||
if (Array.isArray(fn)) {
|
||||
fn.forEach(f => call(f, type, args))
|
||||
return
|
||||
}
|
||||
try {
|
||||
fn.apply(null, args)
|
||||
} catch (e) {
|
||||
onError(e, type)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => {
|
||||
throw 'oops in effect'
|
||||
},
|
||||
null,
|
||||
{ call },
|
||||
)
|
||||
|
||||
const source = ref(0)
|
||||
const effect = watch(
|
||||
source,
|
||||
() => {
|
||||
onWatcherCleanup(() => {
|
||||
throw 'oops in cleanup'
|
||||
})
|
||||
throw 'oops in watch'
|
||||
},
|
||||
{ call },
|
||||
)
|
||||
|
||||
expect(onError.mock.calls.length).toBe(1)
|
||||
expect(onError.mock.calls[0]).toMatchObject([
|
||||
'oops in effect',
|
||||
WatchErrorCodes.WATCH_CALLBACK,
|
||||
])
|
||||
|
||||
source.value++
|
||||
expect(onError.mock.calls.length).toBe(2)
|
||||
expect(onError.mock.calls[1]).toMatchObject([
|
||||
'oops in watch',
|
||||
WatchErrorCodes.WATCH_CALLBACK,
|
||||
])
|
||||
|
||||
effect!.stop()
|
||||
source.value++
|
||||
expect(onError.mock.calls.length).toBe(3)
|
||||
expect(onError.mock.calls[2]).toMatchObject([
|
||||
'oops in cleanup',
|
||||
WatchErrorCodes.WATCH_CLEANUP,
|
||||
])
|
||||
})
|
||||
|
||||
test('watch with onWatcherCleanup', async () => {
|
||||
let dummy = 0
|
||||
let source: Ref<number>
|
||||
const scope = new EffectScope()
|
||||
|
||||
scope.run(() => {
|
||||
source = ref(0)
|
||||
watch(onCleanup => {
|
||||
source.value
|
||||
|
||||
onCleanup(() => (dummy += 2))
|
||||
onWatcherCleanup(() => (dummy += 3))
|
||||
onWatcherCleanup(() => (dummy += 5))
|
||||
})
|
||||
})
|
||||
expect(dummy).toBe(0)
|
||||
|
||||
scope.run(() => {
|
||||
source.value++
|
||||
})
|
||||
expect(dummy).toBe(10)
|
||||
|
||||
scope.run(() => {
|
||||
source.value++
|
||||
})
|
||||
expect(dummy).toBe(20)
|
||||
|
||||
scope.stop()
|
||||
expect(dummy).toBe(30)
|
||||
})
|
||||
|
||||
test('nested calls to baseWatch and onWatcherCleanup', async () => {
|
||||
let calls: string[] = []
|
||||
let source: Ref<number>
|
||||
let copyist: Ref<number>
|
||||
const scope = new EffectScope()
|
||||
|
||||
scope.run(() => {
|
||||
source = ref(0)
|
||||
copyist = ref(0)
|
||||
// sync by default
|
||||
watch(
|
||||
() => {
|
||||
const current = (copyist.value = source.value)
|
||||
onWatcherCleanup(() => calls.push(`sync ${current}`))
|
||||
},
|
||||
null,
|
||||
{},
|
||||
)
|
||||
// with scheduler
|
||||
watch(
|
||||
() => {
|
||||
const current = copyist.value
|
||||
onWatcherCleanup(() => calls.push(`post ${current}`))
|
||||
},
|
||||
null,
|
||||
{ scheduler },
|
||||
)
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
expect(calls).toEqual([])
|
||||
|
||||
scope.run(() => source.value++)
|
||||
expect(calls).toEqual(['sync 0'])
|
||||
await nextTick()
|
||||
expect(calls).toEqual(['sync 0', 'post 0'])
|
||||
calls.length = 0
|
||||
|
||||
scope.run(() => source.value++)
|
||||
expect(calls).toEqual(['sync 1'])
|
||||
await nextTick()
|
||||
expect(calls).toEqual(['sync 1', 'post 1'])
|
||||
calls.length = 0
|
||||
|
||||
scope.stop()
|
||||
expect(calls).toEqual(['sync 2', 'post 2'])
|
||||
})
|
||||
})
|
|
@ -80,3 +80,14 @@ export {
|
|||
} from './effectScope'
|
||||
export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations'
|
||||
export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants'
|
||||
export {
|
||||
watch,
|
||||
getCurrentWatcher,
|
||||
traverse,
|
||||
onWatcherCleanup,
|
||||
WatchErrorCodes,
|
||||
type WatchOptions,
|
||||
type WatchScheduler,
|
||||
type WatchStopHandle,
|
||||
type WatchHandle,
|
||||
} from './watch'
|
||||
|
|
|
@ -0,0 +1,368 @@
|
|||
import {
|
||||
EMPTY_OBJ,
|
||||
NOOP,
|
||||
hasChanged,
|
||||
isArray,
|
||||
isFunction,
|
||||
isMap,
|
||||
isObject,
|
||||
isPlainObject,
|
||||
isSet,
|
||||
remove,
|
||||
} from '@vue/shared'
|
||||
import { warn } from './warning'
|
||||
import type { ComputedRef } from './computed'
|
||||
import { ReactiveFlags } from './constants'
|
||||
import {
|
||||
type DebuggerOptions,
|
||||
EffectFlags,
|
||||
type EffectScheduler,
|
||||
ReactiveEffect,
|
||||
pauseTracking,
|
||||
resetTracking,
|
||||
} from './effect'
|
||||
import { isReactive, isShallow } from './reactive'
|
||||
import { type Ref, isRef } from './ref'
|
||||
import { getCurrentScope } from './effectScope'
|
||||
|
||||
// These errors were transferred from `packages/runtime-core/src/errorHandling.ts`
|
||||
// to @vue/reactivity to allow co-location with the moved base watch logic, hence
|
||||
// it is essential to keep these values unchanged.
|
||||
export enum WatchErrorCodes {
|
||||
WATCH_GETTER = 2,
|
||||
WATCH_CALLBACK,
|
||||
WATCH_CLEANUP,
|
||||
}
|
||||
|
||||
type WatchEffect = (onCleanup: OnCleanup) => void
|
||||
type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)
|
||||
type WatchCallback<V = any, OV = any> = (
|
||||
value: V,
|
||||
oldValue: OV,
|
||||
onCleanup: OnCleanup,
|
||||
) => any
|
||||
type OnCleanup = (cleanupFn: () => void) => void
|
||||
|
||||
export interface WatchOptions<Immediate = boolean> extends DebuggerOptions {
|
||||
immediate?: Immediate
|
||||
deep?: boolean | number
|
||||
once?: boolean
|
||||
scheduler?: WatchScheduler
|
||||
onWarn?: (msg: string, ...args: any[]) => void
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
augmentJob?: (job: (...args: any[]) => void) => void
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
call?: (
|
||||
fn: Function | Function[],
|
||||
type: WatchErrorCodes,
|
||||
args?: unknown[],
|
||||
) => void
|
||||
}
|
||||
|
||||
export type WatchStopHandle = () => void
|
||||
|
||||
export interface WatchHandle extends WatchStopHandle {
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
// initial value for watchers to trigger on undefined initial values
|
||||
const INITIAL_WATCHER_VALUE = {}
|
||||
|
||||
export type WatchScheduler = (job: () => void, isFirstRun: boolean) => void
|
||||
|
||||
const cleanupMap: WeakMap<ReactiveEffect, (() => void)[]> = new WeakMap()
|
||||
let activeWatcher: ReactiveEffect | undefined = undefined
|
||||
|
||||
/**
|
||||
* Returns the current active effect if there is one.
|
||||
*/
|
||||
export function getCurrentWatcher(): ReactiveEffect<any> | undefined {
|
||||
return activeWatcher
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a cleanup callback on the current active effect. This
|
||||
* registered cleanup callback will be invoked right before the
|
||||
* associated effect re-runs.
|
||||
*
|
||||
* @param cleanupFn - The callback function to attach to the effect's cleanup.
|
||||
*/
|
||||
export function onWatcherCleanup(
|
||||
cleanupFn: () => void,
|
||||
failSilently = false,
|
||||
owner: ReactiveEffect | undefined = activeWatcher,
|
||||
): void {
|
||||
if (owner) {
|
||||
let cleanups = cleanupMap.get(owner)
|
||||
if (!cleanups) cleanupMap.set(owner, (cleanups = []))
|
||||
cleanups.push(cleanupFn)
|
||||
} else if (__DEV__ && !failSilently) {
|
||||
warn(
|
||||
`onWatcherCleanup() was called when there was no active watcher` +
|
||||
` to associate with.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function watch(
|
||||
source: WatchSource | WatchSource[] | WatchEffect | object,
|
||||
cb?: WatchCallback | null,
|
||||
options: WatchOptions = EMPTY_OBJ,
|
||||
): WatchHandle {
|
||||
const { immediate, deep, once, scheduler, augmentJob, call } = options
|
||||
|
||||
const warnInvalidSource = (s: unknown) => {
|
||||
;(options.onWarn || warn)(
|
||||
`Invalid watch source: `,
|
||||
s,
|
||||
`A watch source can only be a getter/effect function, a ref, ` +
|
||||
`a reactive object, or an array of these types.`,
|
||||
)
|
||||
}
|
||||
|
||||
const reactiveGetter = (source: object) => {
|
||||
// traverse will happen in wrapped getter below
|
||||
if (deep) return source
|
||||
// for `deep: false | 0` or shallow reactive, only traverse root-level properties
|
||||
if (isShallow(source) || deep === false || deep === 0)
|
||||
return traverse(source, 1)
|
||||
// for `deep: undefined` on a reactive object, deeply traverse all properties
|
||||
return traverse(source)
|
||||
}
|
||||
|
||||
let effect: ReactiveEffect
|
||||
let getter: () => any
|
||||
let cleanup: (() => void) | undefined
|
||||
let boundCleanup: typeof onWatcherCleanup
|
||||
let forceTrigger = false
|
||||
let isMultiSource = false
|
||||
|
||||
if (isRef(source)) {
|
||||
getter = () => source.value
|
||||
forceTrigger = isShallow(source)
|
||||
} else if (isReactive(source)) {
|
||||
getter = () => reactiveGetter(source)
|
||||
forceTrigger = true
|
||||
} else if (isArray(source)) {
|
||||
isMultiSource = true
|
||||
forceTrigger = source.some(s => isReactive(s) || isShallow(s))
|
||||
getter = () =>
|
||||
source.map(s => {
|
||||
if (isRef(s)) {
|
||||
return s.value
|
||||
} else if (isReactive(s)) {
|
||||
return reactiveGetter(s)
|
||||
} else if (isFunction(s)) {
|
||||
return call ? call(s, WatchErrorCodes.WATCH_GETTER) : s()
|
||||
} else {
|
||||
__DEV__ && warnInvalidSource(s)
|
||||
}
|
||||
})
|
||||
} else if (isFunction(source)) {
|
||||
if (cb) {
|
||||
// getter with cb
|
||||
getter = call
|
||||
? () => call(source, WatchErrorCodes.WATCH_GETTER)
|
||||
: (source as () => any)
|
||||
} else {
|
||||
// no cb -> simple effect
|
||||
getter = () => {
|
||||
if (cleanup) {
|
||||
pauseTracking()
|
||||
try {
|
||||
cleanup()
|
||||
} finally {
|
||||
resetTracking()
|
||||
}
|
||||
}
|
||||
const currentEffect = activeWatcher
|
||||
activeWatcher = effect
|
||||
try {
|
||||
return call
|
||||
? call(source, WatchErrorCodes.WATCH_CALLBACK, [boundCleanup])
|
||||
: source(boundCleanup)
|
||||
} finally {
|
||||
activeWatcher = currentEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getter = NOOP
|
||||
__DEV__ && warnInvalidSource(source)
|
||||
}
|
||||
|
||||
if (cb && deep) {
|
||||
const baseGetter = getter
|
||||
const depth = deep === true ? Infinity : deep
|
||||
getter = () => traverse(baseGetter(), depth)
|
||||
}
|
||||
|
||||
if (once) {
|
||||
if (cb) {
|
||||
const _cb = cb
|
||||
cb = (...args) => {
|
||||
_cb(...args)
|
||||
effect.stop()
|
||||
}
|
||||
} else {
|
||||
const _getter = getter
|
||||
getter = () => {
|
||||
_getter()
|
||||
effect.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let oldValue: any = isMultiSource
|
||||
? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
|
||||
: INITIAL_WATCHER_VALUE
|
||||
|
||||
const job = (immediateFirstRun?: boolean) => {
|
||||
if (
|
||||
!(effect.flags & EffectFlags.ACTIVE) ||
|
||||
(!effect.dirty && !immediateFirstRun)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (cb) {
|
||||
// watch(source, cb)
|
||||
const newValue = effect.run()
|
||||
if (
|
||||
deep ||
|
||||
forceTrigger ||
|
||||
(isMultiSource
|
||||
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
|
||||
: hasChanged(newValue, oldValue))
|
||||
) {
|
||||
// cleanup before running cb again
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
}
|
||||
const currentWatcher = activeWatcher
|
||||
activeWatcher = effect
|
||||
try {
|
||||
const args = [
|
||||
newValue,
|
||||
// pass undefined as the old value when it's changed for the first time
|
||||
oldValue === INITIAL_WATCHER_VALUE
|
||||
? undefined
|
||||
: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
|
||||
? []
|
||||
: oldValue,
|
||||
boundCleanup,
|
||||
]
|
||||
call
|
||||
? call(cb!, WatchErrorCodes.WATCH_CALLBACK, args)
|
||||
: // @ts-expect-error
|
||||
cb!(...args)
|
||||
oldValue = newValue
|
||||
} finally {
|
||||
activeWatcher = currentWatcher
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// watchEffect
|
||||
effect.run()
|
||||
}
|
||||
}
|
||||
|
||||
if (augmentJob) {
|
||||
augmentJob(job)
|
||||
}
|
||||
|
||||
effect = new ReactiveEffect(getter)
|
||||
|
||||
effect.scheduler = scheduler
|
||||
? () => scheduler(job, false)
|
||||
: (job as EffectScheduler)
|
||||
|
||||
boundCleanup = fn => onWatcherCleanup(fn, false, effect)
|
||||
|
||||
cleanup = effect.onStop = () => {
|
||||
const cleanups = cleanupMap.get(effect)
|
||||
if (cleanups) {
|
||||
if (call) {
|
||||
call(cleanups, WatchErrorCodes.WATCH_CLEANUP)
|
||||
} else {
|
||||
for (const cleanup of cleanups) cleanup()
|
||||
}
|
||||
cleanupMap.delete(effect)
|
||||
}
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
effect.onTrack = options.onTrack
|
||||
effect.onTrigger = options.onTrigger
|
||||
}
|
||||
|
||||
// initial run
|
||||
if (cb) {
|
||||
if (immediate) {
|
||||
job(true)
|
||||
} else {
|
||||
oldValue = effect.run()
|
||||
}
|
||||
} else if (scheduler) {
|
||||
scheduler(job.bind(null, true), true)
|
||||
} else {
|
||||
effect.run()
|
||||
}
|
||||
|
||||
const scope = getCurrentScope()
|
||||
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
|
||||
|
||||
return watchHandle
|
||||
}
|
||||
|
||||
export function traverse(
|
||||
value: unknown,
|
||||
depth: number = Infinity,
|
||||
seen?: Set<unknown>,
|
||||
): unknown {
|
||||
if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
|
||||
return value
|
||||
}
|
||||
|
||||
seen = seen || new Set()
|
||||
if (seen.has(value)) {
|
||||
return value
|
||||
}
|
||||
seen.add(value)
|
||||
depth--
|
||||
if (isRef(value)) {
|
||||
traverse(value.value, depth, seen)
|
||||
} else if (isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
traverse(value[i], depth, seen)
|
||||
}
|
||||
} else if (isSet(value) || isMap(value)) {
|
||||
value.forEach((v: any) => {
|
||||
traverse(v, depth, seen)
|
||||
})
|
||||
} else if (isPlainObject(value)) {
|
||||
for (const key in value) {
|
||||
traverse(value[key], depth, seen)
|
||||
}
|
||||
for (const key of Object.getOwnPropertySymbols(value)) {
|
||||
if (Object.prototype.propertyIsEnumerable.call(value, key)) {
|
||||
traverse(value[key as any], depth, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
|
@ -6,6 +6,7 @@ import {
|
|||
getCurrentInstance,
|
||||
nextTick,
|
||||
onErrorCaptured,
|
||||
onWatcherCleanup,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
|
@ -435,6 +436,35 @@ describe('api: watch', () => {
|
|||
expect(cleanup).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('onWatcherCleanup', async () => {
|
||||
const count = ref(0)
|
||||
const cleanupEffect = vi.fn()
|
||||
const cleanupWatch = vi.fn()
|
||||
|
||||
const stopEffect = watchEffect(() => {
|
||||
onWatcherCleanup(cleanupEffect)
|
||||
count.value
|
||||
})
|
||||
const stopWatch = watch(count, () => {
|
||||
onWatcherCleanup(cleanupWatch)
|
||||
})
|
||||
|
||||
count.value++
|
||||
await nextTick()
|
||||
expect(cleanupEffect).toHaveBeenCalledTimes(1)
|
||||
expect(cleanupWatch).toHaveBeenCalledTimes(0)
|
||||
|
||||
count.value++
|
||||
await nextTick()
|
||||
expect(cleanupEffect).toHaveBeenCalledTimes(2)
|
||||
expect(cleanupWatch).toHaveBeenCalledTimes(1)
|
||||
|
||||
stopEffect()
|
||||
expect(cleanupEffect).toHaveBeenCalledTimes(3)
|
||||
stopWatch()
|
||||
expect(cleanupWatch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('flush timing: pre (default)', async () => {
|
||||
const count = ref(0)
|
||||
const count2 = ref(0)
|
||||
|
|
|
@ -1,50 +1,28 @@
|
|||
import {
|
||||
type WatchOptions as BaseWatchOptions,
|
||||
type ComputedRef,
|
||||
type DebuggerOptions,
|
||||
EffectFlags,
|
||||
type EffectScheduler,
|
||||
ReactiveEffect,
|
||||
ReactiveFlags,
|
||||
type ReactiveMarker,
|
||||
type Ref,
|
||||
getCurrentScope,
|
||||
isReactive,
|
||||
isRef,
|
||||
isShallow,
|
||||
type WatchHandle,
|
||||
watch as baseWatch,
|
||||
} from '@vue/reactivity'
|
||||
import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler'
|
||||
import {
|
||||
EMPTY_OBJ,
|
||||
NOOP,
|
||||
extend,
|
||||
hasChanged,
|
||||
isArray,
|
||||
isFunction,
|
||||
isMap,
|
||||
isObject,
|
||||
isPlainObject,
|
||||
isSet,
|
||||
isString,
|
||||
remove,
|
||||
} from '@vue/shared'
|
||||
import { EMPTY_OBJ, NOOP, extend, isFunction, isString } from '@vue/shared'
|
||||
import {
|
||||
type ComponentInternalInstance,
|
||||
currentInstance,
|
||||
isInSSRComponentSetup,
|
||||
setCurrentInstance,
|
||||
} from './component'
|
||||
import {
|
||||
ErrorCodes,
|
||||
callWithAsyncErrorHandling,
|
||||
callWithErrorHandling,
|
||||
} from './errorHandling'
|
||||
import { callWithAsyncErrorHandling } from './errorHandling'
|
||||
import { queuePostRenderEffect } from './renderer'
|
||||
import { warn } from './warning'
|
||||
import { DeprecationTypes } from './compat/compatConfig'
|
||||
import { checkCompatEnabled, isCompatEnabled } from './compat/compatConfig'
|
||||
import type { ObjectWatchOptionItem } from './componentOptions'
|
||||
import { useSSRContext } from './helpers/useSsrContext'
|
||||
|
||||
export type { WatchHandle, WatchStopHandle } from '@vue/reactivity'
|
||||
|
||||
export type WatchEffect = (onCleanup: OnCleanup) => void
|
||||
|
||||
export type WatchSource<T = any> = Ref<T, any> | ComputedRef<T> | (() => T)
|
||||
|
@ -77,14 +55,6 @@ export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
|
|||
once?: boolean
|
||||
}
|
||||
|
||||
export type WatchStopHandle = () => void
|
||||
|
||||
export interface WatchHandle extends WatchStopHandle {
|
||||
pause: () => void
|
||||
resume: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
// Simple effect.
|
||||
export function watchEffect(
|
||||
effect: WatchEffect,
|
||||
|
@ -96,7 +66,7 @@ export function watchEffect(
|
|||
export function watchPostEffect(
|
||||
effect: WatchEffect,
|
||||
options?: DebuggerOptions,
|
||||
): WatchStopHandle {
|
||||
): WatchHandle {
|
||||
return doWatch(
|
||||
effect,
|
||||
null,
|
||||
|
@ -107,7 +77,7 @@ export function watchPostEffect(
|
|||
export function watchSyncEffect(
|
||||
effect: WatchEffect,
|
||||
options?: DebuggerOptions,
|
||||
): WatchStopHandle {
|
||||
): WatchHandle {
|
||||
return doWatch(
|
||||
effect,
|
||||
null,
|
||||
|
@ -115,9 +85,6 @@ export function watchSyncEffect(
|
|||
)
|
||||
}
|
||||
|
||||
// initial value for watchers to trigger on undefined initial values
|
||||
const INITIAL_WATCHER_VALUE = {}
|
||||
|
||||
export type MultiWatchSources = (WatchSource<unknown> | object)[]
|
||||
|
||||
// overload: single source + cb
|
||||
|
@ -178,22 +145,9 @@ export function watch<T = any, Immediate extends Readonly<boolean> = false>(
|
|||
function doWatch(
|
||||
source: WatchSource | WatchSource[] | WatchEffect | object,
|
||||
cb: WatchCallback | null,
|
||||
{
|
||||
immediate,
|
||||
deep,
|
||||
flush,
|
||||
once,
|
||||
onTrack,
|
||||
onTrigger,
|
||||
}: WatchOptions = EMPTY_OBJ,
|
||||
options: WatchOptions = EMPTY_OBJ,
|
||||
): WatchHandle {
|
||||
if (cb && once) {
|
||||
const _cb = cb
|
||||
cb = (...args) => {
|
||||
_cb(...args)
|
||||
watchHandle()
|
||||
}
|
||||
}
|
||||
const { immediate, deep, flush, once } = options
|
||||
|
||||
if (__DEV__ && !cb) {
|
||||
if (immediate !== undefined) {
|
||||
|
@ -216,231 +170,66 @@ function doWatch(
|
|||
}
|
||||
}
|
||||
|
||||
const warnInvalidSource = (s: unknown) => {
|
||||
warn(
|
||||
`Invalid watch source: `,
|
||||
s,
|
||||
`A watch source can only be a getter/effect function, a ref, ` +
|
||||
`a reactive object, or an array of these types.`,
|
||||
)
|
||||
}
|
||||
const baseWatchOptions: BaseWatchOptions = extend({}, options)
|
||||
|
||||
const instance = currentInstance
|
||||
const reactiveGetter = (source: object) => {
|
||||
// traverse will happen in wrapped getter below
|
||||
if (deep) return source
|
||||
// for `deep: false | 0` or shallow reactive, only traverse root-level properties
|
||||
if (isShallow(source) || deep === false || deep === 0)
|
||||
return traverse(source, 1)
|
||||
// for `deep: undefined` on a reactive object, deeply traverse all properties
|
||||
return traverse(source)
|
||||
}
|
||||
if (__DEV__) baseWatchOptions.onWarn = warn
|
||||
|
||||
let getter: () => any
|
||||
let forceTrigger = false
|
||||
let isMultiSource = false
|
||||
|
||||
if (isRef(source)) {
|
||||
getter = () => source.value
|
||||
forceTrigger = isShallow(source)
|
||||
} else if (isReactive(source)) {
|
||||
getter = () => reactiveGetter(source)
|
||||
forceTrigger = true
|
||||
} else if (isArray(source)) {
|
||||
isMultiSource = true
|
||||
forceTrigger = source.some(s => isReactive(s) || isShallow(s))
|
||||
getter = () =>
|
||||
source.map(s => {
|
||||
if (isRef(s)) {
|
||||
return s.value
|
||||
} else if (isReactive(s)) {
|
||||
return reactiveGetter(s)
|
||||
} else if (isFunction(s)) {
|
||||
return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
|
||||
} else {
|
||||
__DEV__ && warnInvalidSource(s)
|
||||
}
|
||||
})
|
||||
} else if (isFunction(source)) {
|
||||
if (cb) {
|
||||
// getter with cb
|
||||
getter = () =>
|
||||
callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
|
||||
} else {
|
||||
// no cb -> simple effect
|
||||
getter = () => {
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
}
|
||||
return callWithAsyncErrorHandling(
|
||||
source,
|
||||
instance,
|
||||
ErrorCodes.WATCH_CALLBACK,
|
||||
[onCleanup],
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getter = NOOP
|
||||
__DEV__ && warnInvalidSource(source)
|
||||
}
|
||||
|
||||
// 2.x array mutation watch compat
|
||||
if (__COMPAT__ && cb && !deep) {
|
||||
const baseGetter = getter
|
||||
getter = () => {
|
||||
const val = baseGetter()
|
||||
if (
|
||||
isArray(val) &&
|
||||
checkCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance)
|
||||
) {
|
||||
traverse(val)
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
if (cb && deep) {
|
||||
const baseGetter = getter
|
||||
const depth = deep === true ? Infinity : deep
|
||||
getter = () => traverse(baseGetter(), depth)
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | undefined
|
||||
let onCleanup: OnCleanup = (fn: () => void) => {
|
||||
cleanup = effect.onStop = () => {
|
||||
callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
|
||||
cleanup = effect.onStop = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// in SSR there is no need to setup an actual effect, and it should be noop
|
||||
// unless it's eager or sync flush
|
||||
let ssrCleanup: (() => void)[] | undefined
|
||||
if (__SSR__ && isInSSRComponentSetup) {
|
||||
// we will also not call the invalidate callback (+ runner is not set up)
|
||||
onCleanup = NOOP
|
||||
if (!cb) {
|
||||
getter()
|
||||
} else if (immediate) {
|
||||
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
|
||||
getter(),
|
||||
isMultiSource ? [] : undefined,
|
||||
onCleanup,
|
||||
])
|
||||
}
|
||||
if (flush === 'sync') {
|
||||
const ctx = useSSRContext()!
|
||||
ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = [])
|
||||
} else if (!cb || immediate) {
|
||||
// immediately watch or watchEffect
|
||||
baseWatchOptions.once = true
|
||||
} else {
|
||||
const watchHandle: WatchHandle = () => {}
|
||||
watchHandle.stop = NOOP
|
||||
watchHandle.resume = NOOP
|
||||
watchHandle.pause = NOOP
|
||||
return watchHandle
|
||||
return {
|
||||
stop: NOOP,
|
||||
resume: NOOP,
|
||||
pause: NOOP,
|
||||
} as WatchHandle
|
||||
}
|
||||
}
|
||||
|
||||
let oldValue: any = isMultiSource
|
||||
? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
|
||||
: INITIAL_WATCHER_VALUE
|
||||
const job: SchedulerJob = (immediateFirstRun?: boolean) => {
|
||||
if (
|
||||
!(effect.flags & EffectFlags.ACTIVE) ||
|
||||
(!effect.dirty && !immediateFirstRun)
|
||||
) {
|
||||
return
|
||||
const instance = currentInstance
|
||||
baseWatchOptions.call = (fn, type, args) =>
|
||||
callWithAsyncErrorHandling(fn, instance, type, args)
|
||||
|
||||
// scheduler
|
||||
let isPre = false
|
||||
if (flush === 'post') {
|
||||
baseWatchOptions.scheduler = job => {
|
||||
queuePostRenderEffect(job, instance && instance.suspense)
|
||||
}
|
||||
if (cb) {
|
||||
// watch(source, cb)
|
||||
const newValue = effect.run()
|
||||
if (
|
||||
deep ||
|
||||
forceTrigger ||
|
||||
(isMultiSource
|
||||
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
|
||||
: hasChanged(newValue, oldValue)) ||
|
||||
(__COMPAT__ &&
|
||||
isArray(newValue) &&
|
||||
isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance))
|
||||
) {
|
||||
// cleanup before running cb again
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
}
|
||||
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
|
||||
newValue,
|
||||
// pass undefined as the old value when it's changed for the first time
|
||||
oldValue === INITIAL_WATCHER_VALUE
|
||||
? undefined
|
||||
: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
|
||||
? []
|
||||
: oldValue,
|
||||
onCleanup,
|
||||
])
|
||||
oldValue = newValue
|
||||
}
|
||||
} else {
|
||||
// watchEffect
|
||||
effect.run()
|
||||
}
|
||||
}
|
||||
|
||||
// important: mark the job as a watcher callback so that scheduler knows
|
||||
// it is allowed to self-trigger (#1727)
|
||||
if (cb) job.flags! |= SchedulerJobFlags.ALLOW_RECURSE
|
||||
|
||||
const effect = new ReactiveEffect(getter)
|
||||
|
||||
let scheduler: EffectScheduler
|
||||
if (flush === 'sync') {
|
||||
scheduler = job as any // the scheduler function gets called directly
|
||||
} else if (flush === 'post') {
|
||||
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
|
||||
} else {
|
||||
} else if (flush !== 'sync') {
|
||||
// default: 'pre'
|
||||
job.flags! |= SchedulerJobFlags.PRE
|
||||
if (instance) {
|
||||
job.id = instance.uid
|
||||
job.i = instance
|
||||
}
|
||||
scheduler = () => queueJob(job)
|
||||
}
|
||||
effect.scheduler = scheduler
|
||||
|
||||
const scope = getCurrentScope()
|
||||
const watchHandle: WatchHandle = () => {
|
||||
effect.stop()
|
||||
if (scope) {
|
||||
remove(scope.effects, effect)
|
||||
isPre = true
|
||||
baseWatchOptions.scheduler = (job, isFirstRun) => {
|
||||
if (isFirstRun) {
|
||||
job()
|
||||
} else {
|
||||
queueJob(job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watchHandle.pause = effect.pause.bind(effect)
|
||||
watchHandle.resume = effect.resume.bind(effect)
|
||||
watchHandle.stop = watchHandle
|
||||
|
||||
if (__DEV__) {
|
||||
effect.onTrack = onTrack
|
||||
effect.onTrigger = onTrigger
|
||||
}
|
||||
|
||||
// initial run
|
||||
if (cb) {
|
||||
if (immediate) {
|
||||
job(true)
|
||||
} else {
|
||||
oldValue = effect.run()
|
||||
baseWatchOptions.augmentJob = (job: SchedulerJob) => {
|
||||
// important: mark the job as a watcher callback so that scheduler knows
|
||||
// it is allowed to self-trigger (#1727)
|
||||
if (cb) {
|
||||
job.flags! |= SchedulerJobFlags.ALLOW_RECURSE
|
||||
}
|
||||
if (isPre) {
|
||||
job.flags! |= SchedulerJobFlags.PRE
|
||||
if (instance) {
|
||||
job.id = instance.uid
|
||||
;(job as SchedulerJob).i = instance
|
||||
}
|
||||
}
|
||||
} else if (flush === 'post') {
|
||||
queuePostRenderEffect(
|
||||
effect.run.bind(effect),
|
||||
instance && instance.suspense,
|
||||
)
|
||||
} else {
|
||||
effect.run()
|
||||
}
|
||||
|
||||
const watchHandle = baseWatch(source, cb, baseWatchOptions)
|
||||
|
||||
if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle)
|
||||
return watchHandle
|
||||
}
|
||||
|
@ -481,41 +270,3 @@ export function createPathGetter(ctx: any, path: string) {
|
|||
return cur
|
||||
}
|
||||
}
|
||||
|
||||
export function traverse(
|
||||
value: unknown,
|
||||
depth: number = Infinity,
|
||||
seen?: Set<unknown>,
|
||||
): unknown {
|
||||
if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
|
||||
return value
|
||||
}
|
||||
|
||||
seen = seen || new Set()
|
||||
if (seen.has(value)) {
|
||||
return value
|
||||
}
|
||||
seen.add(value)
|
||||
depth--
|
||||
if (isRef(value)) {
|
||||
traverse(value.value, depth, seen)
|
||||
} else if (isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
traverse(value[i], depth, seen)
|
||||
}
|
||||
} else if (isSet(value) || isMap(value)) {
|
||||
value.forEach((v: any) => {
|
||||
traverse(v, depth, seen)
|
||||
})
|
||||
} else if (isPlainObject(value)) {
|
||||
for (const key in value) {
|
||||
traverse(value[key], depth, seen)
|
||||
}
|
||||
for (const key of Object.getOwnPropertySymbols(value)) {
|
||||
if (Object.prototype.propertyIsEnumerable.call(value, key)) {
|
||||
traverse(value[key as any], depth, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import type {
|
||||
Component,
|
||||
ComponentInternalInstance,
|
||||
ComponentInternalOptions,
|
||||
ConcreteComponent,
|
||||
Data,
|
||||
InternalRenderFunction,
|
||||
SetupContext,
|
||||
import {
|
||||
type Component,
|
||||
type ComponentInternalInstance,
|
||||
type ComponentInternalOptions,
|
||||
type ConcreteComponent,
|
||||
type Data,
|
||||
type InternalRenderFunction,
|
||||
type SetupContext,
|
||||
currentInstance,
|
||||
} from './component'
|
||||
import {
|
||||
type LooseRequired,
|
||||
|
@ -18,7 +19,7 @@ import {
|
|||
isPromise,
|
||||
isString,
|
||||
} from '@vue/shared'
|
||||
import { type Ref, isRef } from '@vue/reactivity'
|
||||
import { type Ref, getCurrentScope, isRef, traverse } from '@vue/reactivity'
|
||||
import { computed } from './apiComputed'
|
||||
import {
|
||||
type WatchCallback,
|
||||
|
@ -71,7 +72,7 @@ import { warn } from './warning'
|
|||
import type { VNodeChild } from './vnode'
|
||||
import { callWithAsyncErrorHandling } from './errorHandling'
|
||||
import { deepMergeData } from './compat/data'
|
||||
import { DeprecationTypes } from './compat/compatConfig'
|
||||
import { DeprecationTypes, checkCompatEnabled } from './compat/compatConfig'
|
||||
import {
|
||||
type CompatConfig,
|
||||
isCompatEnabled,
|
||||
|
@ -848,18 +849,55 @@ export function createWatcher(
|
|||
publicThis: ComponentPublicInstance,
|
||||
key: string,
|
||||
): void {
|
||||
const getter = key.includes('.')
|
||||
let getter = key.includes('.')
|
||||
? createPathGetter(publicThis, key)
|
||||
: () => (publicThis as any)[key]
|
||||
|
||||
const options: WatchOptions = {}
|
||||
if (__COMPAT__) {
|
||||
const instance =
|
||||
currentInstance && getCurrentScope() === currentInstance.scope
|
||||
? currentInstance
|
||||
: null
|
||||
|
||||
const newValue = getter()
|
||||
if (
|
||||
isArray(newValue) &&
|
||||
isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance)
|
||||
) {
|
||||
options.deep = true
|
||||
}
|
||||
|
||||
const baseGetter = getter
|
||||
getter = () => {
|
||||
const val = baseGetter()
|
||||
if (
|
||||
isArray(val) &&
|
||||
checkCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance)
|
||||
) {
|
||||
traverse(val)
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
if (isString(raw)) {
|
||||
const handler = ctx[raw]
|
||||
if (isFunction(handler)) {
|
||||
watch(getter, handler as WatchCallback)
|
||||
if (__COMPAT__) {
|
||||
watch(getter, handler as WatchCallback, options)
|
||||
} else {
|
||||
watch(getter, handler as WatchCallback)
|
||||
}
|
||||
} else if (__DEV__) {
|
||||
warn(`Invalid watch handler specified by key "${raw}"`, handler)
|
||||
}
|
||||
} else if (isFunction(raw)) {
|
||||
watch(getter, raw.bind(publicThis))
|
||||
if (__COMPAT__) {
|
||||
watch(getter, raw.bind(publicThis), options)
|
||||
} else {
|
||||
watch(getter, raw.bind(publicThis))
|
||||
}
|
||||
} else if (isObject(raw)) {
|
||||
if (isArray(raw)) {
|
||||
raw.forEach(r => createWatcher(r, ctx, publicThis, key))
|
||||
|
@ -868,7 +906,7 @@ export function createWatcher(
|
|||
? raw.handler.bind(publicThis)
|
||||
: (ctx[raw.handler] as WatchCallback)
|
||||
if (isFunction(handler)) {
|
||||
watch(getter, handler, raw)
|
||||
watch(getter, handler, __COMPAT__ ? extend(raw, options) : raw)
|
||||
} else if (__DEV__) {
|
||||
warn(`Invalid watch handler specified by key "${raw.handler}"`, handler)
|
||||
}
|
||||
|
|
|
@ -23,8 +23,7 @@ import { currentRenderingInstance } from './componentRenderContext'
|
|||
import { ErrorCodes, callWithAsyncErrorHandling } from './errorHandling'
|
||||
import type { ComponentPublicInstance } from './componentPublicInstance'
|
||||
import { mapCompatDirectiveHook } from './compat/customDirective'
|
||||
import { pauseTracking, resetTracking } from '@vue/reactivity'
|
||||
import { traverse } from './apiWatch'
|
||||
import { pauseTracking, resetTracking, traverse } from '@vue/reactivity'
|
||||
|
||||
export interface DirectiveBinding<
|
||||
Value = any,
|
||||
|
|
|
@ -4,16 +4,20 @@ import type { ComponentInternalInstance } from './component'
|
|||
import { popWarningContext, pushWarningContext, warn } from './warning'
|
||||
import { EMPTY_OBJ, isArray, isFunction, isPromise } from '@vue/shared'
|
||||
import { LifecycleHooks } from './enums'
|
||||
import { WatchErrorCodes } from '@vue/reactivity'
|
||||
|
||||
// contexts where user provided function may be executed, in addition to
|
||||
// lifecycle hooks.
|
||||
export enum ErrorCodes {
|
||||
SETUP_FUNCTION,
|
||||
RENDER_FUNCTION,
|
||||
WATCH_GETTER,
|
||||
WATCH_CALLBACK,
|
||||
WATCH_CLEANUP,
|
||||
NATIVE_EVENT_HANDLER,
|
||||
// The error codes for the watch have been transferred to the reactivity
|
||||
// package along with baseWatch to maintain code compatibility. Hence,
|
||||
// it is essential to keep these values unchanged.
|
||||
// WATCH_GETTER,
|
||||
// WATCH_CALLBACK,
|
||||
// WATCH_CLEANUP,
|
||||
NATIVE_EVENT_HANDLER = 5,
|
||||
COMPONENT_EVENT_HANDLER,
|
||||
VNODE_HOOK,
|
||||
DIRECTIVE_HOOK,
|
||||
|
@ -27,7 +31,7 @@ export enum ErrorCodes {
|
|||
APP_UNMOUNT_CLEANUP,
|
||||
}
|
||||
|
||||
export const ErrorTypeStrings: Record<LifecycleHooks | ErrorCodes, string> = {
|
||||
export const ErrorTypeStrings: Record<ErrorTypes, string> = {
|
||||
[LifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook',
|
||||
[LifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook',
|
||||
[LifecycleHooks.CREATED]: 'created hook',
|
||||
|
@ -44,9 +48,9 @@ export const ErrorTypeStrings: Record<LifecycleHooks | ErrorCodes, string> = {
|
|||
[LifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook',
|
||||
[ErrorCodes.SETUP_FUNCTION]: 'setup function',
|
||||
[ErrorCodes.RENDER_FUNCTION]: 'render function',
|
||||
[ErrorCodes.WATCH_GETTER]: 'watcher getter',
|
||||
[ErrorCodes.WATCH_CALLBACK]: 'watcher callback',
|
||||
[ErrorCodes.WATCH_CLEANUP]: 'watcher cleanup function',
|
||||
[WatchErrorCodes.WATCH_GETTER]: 'watcher getter',
|
||||
[WatchErrorCodes.WATCH_CALLBACK]: 'watcher callback',
|
||||
[WatchErrorCodes.WATCH_CLEANUP]: 'watcher cleanup function',
|
||||
[ErrorCodes.NATIVE_EVENT_HANDLER]: 'native event handler',
|
||||
[ErrorCodes.COMPONENT_EVENT_HANDLER]: 'component event handler',
|
||||
[ErrorCodes.VNODE_HOOK]: 'vnode hook',
|
||||
|
@ -61,7 +65,7 @@ export const ErrorTypeStrings: Record<LifecycleHooks | ErrorCodes, string> = {
|
|||
[ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function',
|
||||
}
|
||||
|
||||
export type ErrorTypes = LifecycleHooks | ErrorCodes
|
||||
export type ErrorTypes = LifecycleHooks | ErrorCodes | WatchErrorCodes
|
||||
|
||||
export function callWithErrorHandling(
|
||||
fn: Function,
|
||||
|
|
|
@ -28,6 +28,8 @@ export {
|
|||
// effect
|
||||
effect,
|
||||
stop,
|
||||
getCurrentWatcher,
|
||||
onWatcherCleanup,
|
||||
ReactiveEffect,
|
||||
// effect scope
|
||||
effectScope,
|
||||
|
|
Loading…
Reference in New Issue