feat(reactivity): base `watch`, `getCurrentWatcher`, and `onWatcherCleanup` (#9927)

This commit is contained in:
Rizumu Ayaka 2024-08-20 08:21:44 +08:00 committed by GitHub
parent 44973bb3e7
commit 205e5b5e27
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 725 additions and 326 deletions

View File

@ -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'])
})
})

View File

@ -80,3 +80,14 @@ export {
} from './effectScope' } from './effectScope'
export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations' export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations'
export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants' export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants'
export {
watch,
getCurrentWatcher,
traverse,
onWatcherCleanup,
WatchErrorCodes,
type WatchOptions,
type WatchScheduler,
type WatchStopHandle,
type WatchHandle,
} from './watch'

View File

@ -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
}

View File

@ -6,6 +6,7 @@ import {
getCurrentInstance, getCurrentInstance,
nextTick, nextTick,
onErrorCaptured, onErrorCaptured,
onWatcherCleanup,
reactive, reactive,
ref, ref,
watch, watch,
@ -435,6 +436,35 @@ describe('api: watch', () => {
expect(cleanup).toHaveBeenCalledTimes(2) 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 () => { it('flush timing: pre (default)', async () => {
const count = ref(0) const count = ref(0)
const count2 = ref(0) const count2 = ref(0)

View File

@ -1,50 +1,28 @@
import { import {
type WatchOptions as BaseWatchOptions,
type ComputedRef, type ComputedRef,
type DebuggerOptions, type DebuggerOptions,
EffectFlags,
type EffectScheduler,
ReactiveEffect,
ReactiveFlags,
type ReactiveMarker, type ReactiveMarker,
type Ref, type Ref,
getCurrentScope, type WatchHandle,
isReactive, watch as baseWatch,
isRef,
isShallow,
} from '@vue/reactivity' } from '@vue/reactivity'
import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler' import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler'
import { import { EMPTY_OBJ, NOOP, extend, isFunction, isString } from '@vue/shared'
EMPTY_OBJ,
NOOP,
extend,
hasChanged,
isArray,
isFunction,
isMap,
isObject,
isPlainObject,
isSet,
isString,
remove,
} from '@vue/shared'
import { import {
type ComponentInternalInstance, type ComponentInternalInstance,
currentInstance, currentInstance,
isInSSRComponentSetup, isInSSRComponentSetup,
setCurrentInstance, setCurrentInstance,
} from './component' } from './component'
import { import { callWithAsyncErrorHandling } from './errorHandling'
ErrorCodes,
callWithAsyncErrorHandling,
callWithErrorHandling,
} from './errorHandling'
import { queuePostRenderEffect } from './renderer' import { queuePostRenderEffect } from './renderer'
import { warn } from './warning' import { warn } from './warning'
import { DeprecationTypes } from './compat/compatConfig'
import { checkCompatEnabled, isCompatEnabled } from './compat/compatConfig'
import type { ObjectWatchOptionItem } from './componentOptions' import type { ObjectWatchOptionItem } from './componentOptions'
import { useSSRContext } from './helpers/useSsrContext' import { useSSRContext } from './helpers/useSsrContext'
export type { WatchHandle, WatchStopHandle } from '@vue/reactivity'
export type WatchEffect = (onCleanup: OnCleanup) => void export type WatchEffect = (onCleanup: OnCleanup) => void
export type WatchSource<T = any> = Ref<T, any> | ComputedRef<T> | (() => T) export type WatchSource<T = any> = Ref<T, any> | ComputedRef<T> | (() => T)
@ -77,14 +55,6 @@ export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
once?: boolean once?: boolean
} }
export type WatchStopHandle = () => void
export interface WatchHandle extends WatchStopHandle {
pause: () => void
resume: () => void
stop: () => void
}
// Simple effect. // Simple effect.
export function watchEffect( export function watchEffect(
effect: WatchEffect, effect: WatchEffect,
@ -96,7 +66,7 @@ export function watchEffect(
export function watchPostEffect( export function watchPostEffect(
effect: WatchEffect, effect: WatchEffect,
options?: DebuggerOptions, options?: DebuggerOptions,
): WatchStopHandle { ): WatchHandle {
return doWatch( return doWatch(
effect, effect,
null, null,
@ -107,7 +77,7 @@ export function watchPostEffect(
export function watchSyncEffect( export function watchSyncEffect(
effect: WatchEffect, effect: WatchEffect,
options?: DebuggerOptions, options?: DebuggerOptions,
): WatchStopHandle { ): WatchHandle {
return doWatch( return doWatch(
effect, effect,
null, 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)[] export type MultiWatchSources = (WatchSource<unknown> | object)[]
// overload: single source + cb // overload: single source + cb
@ -178,22 +145,9 @@ export function watch<T = any, Immediate extends Readonly<boolean> = false>(
function doWatch( function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object, source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null, cb: WatchCallback | null,
{ options: WatchOptions = EMPTY_OBJ,
immediate,
deep,
flush,
once,
onTrack,
onTrigger,
}: WatchOptions = EMPTY_OBJ,
): WatchHandle { ): WatchHandle {
if (cb && once) { const { immediate, deep, flush, once } = options
const _cb = cb
cb = (...args) => {
_cb(...args)
watchHandle()
}
}
if (__DEV__ && !cb) { if (__DEV__ && !cb) {
if (immediate !== undefined) { if (immediate !== undefined) {
@ -216,230 +170,65 @@ function doWatch(
} }
} }
const warnInvalidSource = (s: unknown) => { const baseWatchOptions: BaseWatchOptions = extend({}, options)
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 instance = currentInstance if (__DEV__) baseWatchOptions.onWarn = warn
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 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 let ssrCleanup: (() => void)[] | undefined
if (__SSR__ && isInSSRComponentSetup) { 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') { if (flush === 'sync') {
const ctx = useSSRContext()! const ctx = useSSRContext()!
ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = []) ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = [])
} else if (!cb || immediate) {
// immediately watch or watchEffect
baseWatchOptions.once = true
} else { } else {
const watchHandle: WatchHandle = () => {} return {
watchHandle.stop = NOOP stop: NOOP,
watchHandle.resume = NOOP resume: NOOP,
watchHandle.pause = NOOP pause: NOOP,
return watchHandle } as WatchHandle
} }
} }
let oldValue: any = isMultiSource const instance = currentInstance
? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) baseWatchOptions.call = (fn, type, args) =>
: INITIAL_WATCHER_VALUE callWithAsyncErrorHandling(fn, instance, type, args)
const job: SchedulerJob = (immediateFirstRun?: boolean) => {
if ( // scheduler
!(effect.flags & EffectFlags.ACTIVE) || let isPre = false
(!effect.dirty && !immediateFirstRun) if (flush === 'post') {
) { baseWatchOptions.scheduler = job => {
return 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 if (flush !== 'sync') {
// default: 'pre'
isPre = true
baseWatchOptions.scheduler = (job, isFirstRun) => {
if (isFirstRun) {
job()
} else { } else {
// watchEffect queueJob(job)
effect.run() }
} }
} }
baseWatchOptions.augmentJob = (job: SchedulerJob) => {
// important: mark the job as a watcher callback so that scheduler knows // important: mark the job as a watcher callback so that scheduler knows
// it is allowed to self-trigger (#1727) // it is allowed to self-trigger (#1727)
if (cb) job.flags! |= SchedulerJobFlags.ALLOW_RECURSE if (cb) {
job.flags! |= SchedulerJobFlags.ALLOW_RECURSE
const effect = new ReactiveEffect(getter) }
if (isPre) {
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 {
// default: 'pre'
job.flags! |= SchedulerJobFlags.PRE job.flags! |= SchedulerJobFlags.PRE
if (instance) { if (instance) {
job.id = instance.uid job.id = instance.uid
job.i = instance ;(job as SchedulerJob).i = instance
} }
scheduler = () => queueJob(job)
}
effect.scheduler = scheduler
const scope = getCurrentScope()
const watchHandle: WatchHandle = () => {
effect.stop()
if (scope) {
remove(scope.effects, effect)
} }
} }
watchHandle.pause = effect.pause.bind(effect) const watchHandle = baseWatch(source, cb, baseWatchOptions)
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()
}
} else if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense,
)
} else {
effect.run()
}
if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle) if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle)
return watchHandle return watchHandle
@ -481,41 +270,3 @@ export function createPathGetter(ctx: any, path: string) {
return cur 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
}

View File

@ -1,11 +1,12 @@
import type { import {
Component, type Component,
ComponentInternalInstance, type ComponentInternalInstance,
ComponentInternalOptions, type ComponentInternalOptions,
ConcreteComponent, type ConcreteComponent,
Data, type Data,
InternalRenderFunction, type InternalRenderFunction,
SetupContext, type SetupContext,
currentInstance,
} from './component' } from './component'
import { import {
type LooseRequired, type LooseRequired,
@ -18,7 +19,7 @@ import {
isPromise, isPromise,
isString, isString,
} from '@vue/shared' } from '@vue/shared'
import { type Ref, isRef } from '@vue/reactivity' import { type Ref, getCurrentScope, isRef, traverse } from '@vue/reactivity'
import { computed } from './apiComputed' import { computed } from './apiComputed'
import { import {
type WatchCallback, type WatchCallback,
@ -71,7 +72,7 @@ import { warn } from './warning'
import type { VNodeChild } from './vnode' import type { VNodeChild } from './vnode'
import { callWithAsyncErrorHandling } from './errorHandling' import { callWithAsyncErrorHandling } from './errorHandling'
import { deepMergeData } from './compat/data' import { deepMergeData } from './compat/data'
import { DeprecationTypes } from './compat/compatConfig' import { DeprecationTypes, checkCompatEnabled } from './compat/compatConfig'
import { import {
type CompatConfig, type CompatConfig,
isCompatEnabled, isCompatEnabled,
@ -848,18 +849,55 @@ export function createWatcher(
publicThis: ComponentPublicInstance, publicThis: ComponentPublicInstance,
key: string, key: string,
): void { ): void {
const getter = key.includes('.') let getter = key.includes('.')
? createPathGetter(publicThis, key) ? createPathGetter(publicThis, key)
: () => (publicThis as any)[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)) { if (isString(raw)) {
const handler = ctx[raw] const handler = ctx[raw]
if (isFunction(handler)) { if (isFunction(handler)) {
if (__COMPAT__) {
watch(getter, handler as WatchCallback, options)
} else {
watch(getter, handler as WatchCallback) watch(getter, handler as WatchCallback)
}
} else if (__DEV__) { } else if (__DEV__) {
warn(`Invalid watch handler specified by key "${raw}"`, handler) warn(`Invalid watch handler specified by key "${raw}"`, handler)
} }
} else if (isFunction(raw)) { } else if (isFunction(raw)) {
if (__COMPAT__) {
watch(getter, raw.bind(publicThis), options)
} else {
watch(getter, raw.bind(publicThis)) watch(getter, raw.bind(publicThis))
}
} else if (isObject(raw)) { } else if (isObject(raw)) {
if (isArray(raw)) { if (isArray(raw)) {
raw.forEach(r => createWatcher(r, ctx, publicThis, key)) raw.forEach(r => createWatcher(r, ctx, publicThis, key))
@ -868,7 +906,7 @@ export function createWatcher(
? raw.handler.bind(publicThis) ? raw.handler.bind(publicThis)
: (ctx[raw.handler] as WatchCallback) : (ctx[raw.handler] as WatchCallback)
if (isFunction(handler)) { if (isFunction(handler)) {
watch(getter, handler, raw) watch(getter, handler, __COMPAT__ ? extend(raw, options) : raw)
} else if (__DEV__) { } else if (__DEV__) {
warn(`Invalid watch handler specified by key "${raw.handler}"`, handler) warn(`Invalid watch handler specified by key "${raw.handler}"`, handler)
} }

View File

@ -23,8 +23,7 @@ import { currentRenderingInstance } from './componentRenderContext'
import { ErrorCodes, callWithAsyncErrorHandling } from './errorHandling' import { ErrorCodes, callWithAsyncErrorHandling } from './errorHandling'
import type { ComponentPublicInstance } from './componentPublicInstance' import type { ComponentPublicInstance } from './componentPublicInstance'
import { mapCompatDirectiveHook } from './compat/customDirective' import { mapCompatDirectiveHook } from './compat/customDirective'
import { pauseTracking, resetTracking } from '@vue/reactivity' import { pauseTracking, resetTracking, traverse } from '@vue/reactivity'
import { traverse } from './apiWatch'
export interface DirectiveBinding< export interface DirectiveBinding<
Value = any, Value = any,

View File

@ -4,16 +4,20 @@ import type { ComponentInternalInstance } from './component'
import { popWarningContext, pushWarningContext, warn } from './warning' import { popWarningContext, pushWarningContext, warn } from './warning'
import { EMPTY_OBJ, isArray, isFunction, isPromise } from '@vue/shared' import { EMPTY_OBJ, isArray, isFunction, isPromise } from '@vue/shared'
import { LifecycleHooks } from './enums' import { LifecycleHooks } from './enums'
import { WatchErrorCodes } from '@vue/reactivity'
// contexts where user provided function may be executed, in addition to // contexts where user provided function may be executed, in addition to
// lifecycle hooks. // lifecycle hooks.
export enum ErrorCodes { export enum ErrorCodes {
SETUP_FUNCTION, SETUP_FUNCTION,
RENDER_FUNCTION, RENDER_FUNCTION,
WATCH_GETTER, // The error codes for the watch have been transferred to the reactivity
WATCH_CALLBACK, // package along with baseWatch to maintain code compatibility. Hence,
WATCH_CLEANUP, // it is essential to keep these values unchanged.
NATIVE_EVENT_HANDLER, // WATCH_GETTER,
// WATCH_CALLBACK,
// WATCH_CLEANUP,
NATIVE_EVENT_HANDLER = 5,
COMPONENT_EVENT_HANDLER, COMPONENT_EVENT_HANDLER,
VNODE_HOOK, VNODE_HOOK,
DIRECTIVE_HOOK, DIRECTIVE_HOOK,
@ -27,7 +31,7 @@ export enum ErrorCodes {
APP_UNMOUNT_CLEANUP, APP_UNMOUNT_CLEANUP,
} }
export const ErrorTypeStrings: Record<LifecycleHooks | ErrorCodes, string> = { export const ErrorTypeStrings: Record<ErrorTypes, string> = {
[LifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook', [LifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook',
[LifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook', [LifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook',
[LifecycleHooks.CREATED]: 'created hook', [LifecycleHooks.CREATED]: 'created hook',
@ -44,9 +48,9 @@ export const ErrorTypeStrings: Record<LifecycleHooks | ErrorCodes, string> = {
[LifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook', [LifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook',
[ErrorCodes.SETUP_FUNCTION]: 'setup function', [ErrorCodes.SETUP_FUNCTION]: 'setup function',
[ErrorCodes.RENDER_FUNCTION]: 'render function', [ErrorCodes.RENDER_FUNCTION]: 'render function',
[ErrorCodes.WATCH_GETTER]: 'watcher getter', [WatchErrorCodes.WATCH_GETTER]: 'watcher getter',
[ErrorCodes.WATCH_CALLBACK]: 'watcher callback', [WatchErrorCodes.WATCH_CALLBACK]: 'watcher callback',
[ErrorCodes.WATCH_CLEANUP]: 'watcher cleanup function', [WatchErrorCodes.WATCH_CLEANUP]: 'watcher cleanup function',
[ErrorCodes.NATIVE_EVENT_HANDLER]: 'native event handler', [ErrorCodes.NATIVE_EVENT_HANDLER]: 'native event handler',
[ErrorCodes.COMPONENT_EVENT_HANDLER]: 'component event handler', [ErrorCodes.COMPONENT_EVENT_HANDLER]: 'component event handler',
[ErrorCodes.VNODE_HOOK]: 'vnode hook', [ErrorCodes.VNODE_HOOK]: 'vnode hook',
@ -61,7 +65,7 @@ export const ErrorTypeStrings: Record<LifecycleHooks | ErrorCodes, string> = {
[ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function', [ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function',
} }
export type ErrorTypes = LifecycleHooks | ErrorCodes export type ErrorTypes = LifecycleHooks | ErrorCodes | WatchErrorCodes
export function callWithErrorHandling( export function callWithErrorHandling(
fn: Function, fn: Function,

View File

@ -28,6 +28,8 @@ export {
// effect // effect
effect, effect,
stop, stop,
getCurrentWatcher,
onWatcherCleanup,
ReactiveEffect, ReactiveEffect,
// effect scope // effect scope
effectScope, effectScope,