From d8682e8f75376aca044b999cfc82aa51a0065a60 Mon Sep 17 00:00:00 2001 From: Rizumu Ayaka Date: Mon, 25 Dec 2023 22:09:38 +0800 Subject: [PATCH] feat: initial code of baseWatch --- packages/reactivity/src/baseWatch.ts | 397 +++++++++++++++++++++ packages/reactivity/src/index.ts | 1 + packages/runtime-core/src/apiWatch.ts | 56 +-- packages/runtime-core/src/errorHandling.ts | 16 +- 4 files changed, 440 insertions(+), 30 deletions(-) create mode 100644 packages/reactivity/src/baseWatch.ts diff --git a/packages/reactivity/src/baseWatch.ts b/packages/reactivity/src/baseWatch.ts new file mode 100644 index 00000000000..483b8a0b431 --- /dev/null +++ b/packages/reactivity/src/baseWatch.ts @@ -0,0 +1,397 @@ +import { + EMPTY_OBJ, + isObject, + isArray, + isFunction, + hasChanged, + NOOP, + isMap, + isSet, + isPlainObject, + isPromise +} from '@vue/shared' +import { warn } from './warning' +import { ComputedRef } from './computed' +import { ReactiveFlags } from './constants' +import { DebuggerOptions, ReactiveEffect, EffectScheduler } from './effect' +import { isShallow, isReactive } from './reactive' +import { Ref, isRef } from './ref' + +// contexts where user provided function may be executed, in addition to +// lifecycle hooks. +export enum BaseWatchErrorCodes { + WATCH_GETTER = 'BaseWatchErrorCodes_WATCH_GETTER', + WATCH_CALLBACK = 'BaseWatchErrorCodes_WATCH_CALLBACK', + WATCH_CLEANUP = 'BaseWatchErrorCodes_WATCH_CLEANUP' +} + +export interface SchedulerJob extends Function { + id?: number + pre?: boolean + active?: boolean + computed?: boolean + /** + * Indicates whether the effect is allowed to recursively trigger itself + * when managed by the scheduler. + * + * By default, a job cannot trigger itself because some built-in method calls, + * e.g. Array.prototype.push actually performs reads as well (#1740) which + * can lead to confusing infinite loops. + * The allowed cases are component update functions and watch callbacks. + * Component update functions may update child component props, which in turn + * trigger flush: "pre" watch callbacks that mutates state that the parent + * relies on (#1801). Watch callbacks doesn't track its dependencies so if it + * triggers itself again, it's likely intentional and it is the user's + * responsibility to perform recursive state mutation that eventually + * stabilizes (#1727). + */ + allowRecurse?: boolean +} + +export type WatchEffect = (onCleanup: OnCleanup) => void + +export type WatchSource = Ref | ComputedRef | (() => T) + +export type WatchCallback = ( + value: V, + oldValue: OV, + onCleanup: OnCleanup +) => any + +type OnCleanup = (cleanupFn: () => void) => void + +export interface BaseWatchOptions extends DebuggerOptions { + immediate?: Immediate + deep?: boolean + once?: boolean + scheduler?: Scheduler + handlerError?: HandleError + handlerWarn?: HandleWarn +} + +export type WatchStopHandle = () => void + +// initial value for watchers to trigger on undefined initial values +const INITIAL_WATCHER_VALUE = {} + +export type Scheduler = (context: { + effect: ReactiveEffect + job: SchedulerJob + isInit: boolean +}) => void + +const DEFAULT_SCHEDULER: Scheduler = ({ job }) => job() + +export type HandleError = (err: unknown, type: BaseWatchErrorCodes) => void + +const DEFAULT_HANDLE_ERROR: HandleError = (err: unknown) => { + throw err +} + +export type HandleWarn = (msg: string, ...args: any[]) => void + +const cleanupMap: WeakMap void)[]> = new WeakMap() +let activeEffect: ReactiveEffect | undefined = undefined + +export function onEffectCleanup(cleanupFn: () => void) { + if (activeEffect) { + const cleanups = + cleanupMap.get(activeEffect) || + cleanupMap.set(activeEffect, []).get(activeEffect)! + cleanups.push(cleanupFn) + } +} + +export function baseWatch( + source: WatchSource | WatchSource[] | WatchEffect | object, + cb: WatchCallback | null, + { + immediate, + deep, + once, + onTrack, + onTrigger, + scheduler = DEFAULT_SCHEDULER, + handlerError = DEFAULT_HANDLE_ERROR, + handlerWarn = warn + }: BaseWatchOptions = EMPTY_OBJ +): WatchStopHandle { + if (cb && once) { + const _cb = cb + cb = (...args) => { + _cb(...args) + unwatch() + } + } + + const warnInvalidSource = (s: unknown) => { + handlerWarn( + `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.` + ) + } + + let getter: () => any + let forceTrigger = false + let isMultiSource = false + + if (isRef(source)) { + getter = () => source.value + forceTrigger = isShallow(source) + } else if (isReactive(source)) { + getter = () => source + deep = 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 traverse(s) + } else if (isFunction(s)) { + return callWithErrorHandling( + s, + handlerError, + BaseWatchErrorCodes.WATCH_GETTER + ) + } else { + __DEV__ && warnInvalidSource(s) + } + }) + } else if (isFunction(source)) { + if (cb) { + // getter with cb + getter = () => + callWithErrorHandling( + source, + handlerError, + BaseWatchErrorCodes.WATCH_GETTER + ) + } else { + // no cb -> simple effect + getter = () => { + // TODO: move to scheduler + // if (instance && instance.isUnmounted) { + // return + // } + if (cleanup) { + cleanup() + } + const currentEffect = activeEffect + activeEffect = effect + try { + return callWithAsyncErrorHandling( + source, + handlerError, + BaseWatchErrorCodes.WATCH_CALLBACK, + [onEffectCleanup] + ) + } finally { + activeEffect = currentEffect + } + } + } + } else { + getter = NOOP + __DEV__ && warnInvalidSource(source) + } + + if (cb && deep) { + const baseGetter = getter + getter = () => traverse(baseGetter()) + } + + // TODO: support SSR + // 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, handlerError, BaseWatchErrorCodes.WATCH_CALLBACK, [ + // getter(), + // isMultiSource ? [] : undefined, + // onCleanup + // ]) + // } + // if (flush === 'sync') { + // const ctx = useSSRContext()! + // ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = []) + // } else { + // return NOOP + // } + // } + + let oldValue: any = isMultiSource + ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) + : INITIAL_WATCHER_VALUE + const job: SchedulerJob = () => { + if (!effect.active || !effect.dirty) { + 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 currentEffect = activeEffect + activeEffect = effect + try { + callWithAsyncErrorHandling( + cb, + handlerError, + BaseWatchErrorCodes.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, + onEffectCleanup + ] + ) + oldValue = newValue + } finally { + activeEffect = currentEffect + } + } + } else { + // watchEffect + effect.run() + } + } + + // important: mark the job as a watcher callback so that scheduler knows + // it is allowed to self-trigger (#1727) + job.allowRecurse = !!cb + + let effectScheduler: EffectScheduler = () => + scheduler({ + effect, + job, + isInit: false + }) + + const effect = new ReactiveEffect(getter, NOOP, effectScheduler) + + const cleanup = (effect.onStop = () => { + const cleanups = cleanupMap.get(effect) + if (cleanups) { + cleanups.forEach(cleanup => cleanup()) + cleanupMap.delete(effect) + } + }) + + const unwatch = () => { + effect.stop() + // TODO: move to doWatch + // if (instance && instance.scope) { + // remove(instance.scope.effects!, effect) + // } + } + + if (__DEV__) { + effect.onTrack = onTrack + effect.onTrigger = onTrigger + } + + // initial run + if (cb) { + if (immediate) { + job() + } else { + oldValue = effect.run() + } + } else { + scheduler({ + effect, + job, + isInit: true + }) + } + + return unwatch +} + +export function traverse(value: unknown, seen?: Set) { + if (!isObject(value) || (value as any)[ReactiveFlags.SKIP]) { + return value + } + seen = seen || new Set() + if (seen.has(value)) { + return value + } + seen.add(value) + if (isRef(value)) { + traverse(value.value, seen) + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + traverse(value[i], seen) + } + } else if (isSet(value) || isMap(value)) { + value.forEach((v: any) => { + traverse(v, seen) + }) + } else if (isPlainObject(value)) { + for (const key in value) { + traverse(value[key], seen) + } + } + return value +} + +export function callWithErrorHandling( + fn: Function, + handleError: HandleError, + type: BaseWatchErrorCodes, + args?: unknown[] +) { + let res + try { + res = args ? fn(...args) : fn() + } catch (err) { + handleError(err, type) + } + return res +} + +export function callWithAsyncErrorHandling( + fn: Function | Function[], + handleError: HandleError, + type: BaseWatchErrorCodes, + args?: unknown[] +): any[] { + if (isFunction(fn)) { + const res = callWithErrorHandling(fn, handleError, type, args) + if (res && isPromise(res)) { + res.catch(err => { + handleError(err, type) + }) + } + return res + } + + const values = [] + for (let i = 0; i < fn.length; i++) { + values.push(callWithAsyncErrorHandling(fn[i], handleError, type, args)) + } + return values +} diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 2a9615b14b7..3735548e7ff 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -69,3 +69,4 @@ export { onScopeDispose } from './effectScope' export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants' +export { baseWatch, BaseWatchErrorCodes } from './baseWatch' diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 9b93943cc70..5d343e61a60 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -8,7 +8,8 @@ import { ReactiveFlags, EffectScheduler, DebuggerOptions, - getCurrentScope + getCurrentScope, + BaseWatchErrorCodes } from '@vue/reactivity' import { SchedulerJob, queueJob } from './scheduler' import { @@ -33,7 +34,6 @@ import { unsetCurrentInstance } from './component' import { - ErrorCodes, callWithErrorHandling, callWithAsyncErrorHandling } from './errorHandling' @@ -234,7 +234,11 @@ function doWatch( } else if (isReactive(s)) { return traverse(s) } else if (isFunction(s)) { - return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER) + return callWithErrorHandling( + s, + instance, + BaseWatchErrorCodes.WATCH_GETTER + ) } else { __DEV__ && warnInvalidSource(s) } @@ -243,7 +247,11 @@ function doWatch( if (cb) { // getter with cb getter = () => - callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER) + callWithErrorHandling( + source, + instance, + BaseWatchErrorCodes.WATCH_GETTER + ) } else { // no cb -> simple effect getter = () => { @@ -256,7 +264,7 @@ function doWatch( return callWithAsyncErrorHandling( source, instance, - ErrorCodes.WATCH_CALLBACK, + BaseWatchErrorCodes.WATCH_CALLBACK, [onCleanup] ) } @@ -274,7 +282,7 @@ function doWatch( let cleanup: (() => void) | undefined let onCleanup: OnCleanup = (fn: () => void) => { cleanup = effect.onStop = () => { - callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP) + callWithErrorHandling(fn, instance, BaseWatchErrorCodes.WATCH_CLEANUP) cleanup = effect.onStop = undefined } } @@ -288,11 +296,12 @@ function doWatch( if (!cb) { getter() } else if (immediate) { - callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [ - getter(), - isMultiSource ? [] : undefined, - onCleanup - ]) + callWithAsyncErrorHandling( + cb, + instance, + BaseWatchErrorCodes.WATCH_CALLBACK, + [getter(), isMultiSource ? [] : undefined, onCleanup] + ) } if (flush === 'sync') { const ctx = useSSRContext()! @@ -323,16 +332,21 @@ function doWatch( 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 - ]) + callWithAsyncErrorHandling( + cb, + instance, + BaseWatchErrorCodes.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 { diff --git a/packages/runtime-core/src/errorHandling.ts b/packages/runtime-core/src/errorHandling.ts index aff4f5567fa..4f6a2e74b95 100644 --- a/packages/runtime-core/src/errorHandling.ts +++ b/packages/runtime-core/src/errorHandling.ts @@ -3,15 +3,13 @@ import { ComponentInternalInstance } from './component' import { warn, pushWarningContext, popWarningContext } from './warning' import { isPromise, isFunction } from '@vue/shared' import { LifecycleHooks } from './enums' +import { BaseWatchErrorCodes } 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, COMPONENT_EVENT_HANDLER, VNODE_HOOK, @@ -24,7 +22,9 @@ export enum ErrorCodes { SCHEDULER } -export const ErrorTypeStrings: Record = { +export type ErrorTypes = LifecycleHooks | ErrorCodes | BaseWatchErrorCodes + +export const ErrorTypeStrings: Record = { [LifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook', [LifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook', [LifecycleHooks.CREATED]: 'created hook', @@ -41,9 +41,9 @@ export const ErrorTypeStrings: Record = { [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', + [BaseWatchErrorCodes.WATCH_GETTER]: 'watcher getter', + [BaseWatchErrorCodes.WATCH_CALLBACK]: 'watcher callback', + [BaseWatchErrorCodes.WATCH_CLEANUP]: 'watcher cleanup function', [ErrorCodes.NATIVE_EVENT_HANDLER]: 'native event handler', [ErrorCodes.COMPONENT_EVENT_HANDLER]: 'component event handler', [ErrorCodes.VNODE_HOOK]: 'vnode hook', @@ -58,8 +58,6 @@ export const ErrorTypeStrings: Record = { 'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/core' } -export type ErrorTypes = LifecycleHooks | ErrorCodes - export function callWithErrorHandling( fn: Function, instance: ComponentInternalInstance | null,