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'
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'

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,
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)

View File

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

View File

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

View File

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

View File

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

View File

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