diff --git a/src/contents.ts b/src/contents.ts index c2b6aae..424396a 100644 --- a/src/contents.ts +++ b/src/contents.ts @@ -1,3 +1,5 @@ export enum ReactiveFlags { IS_SIGNAL = '__v_isSignal', + SKIP = "__v_skip", + IS_SHALLOW = "__v_isShallow", } diff --git a/src/core.ts b/src/core.ts index 4147056..acb6c3a 100644 --- a/src/core.ts +++ b/src/core.ts @@ -39,6 +39,7 @@ export function signal(oldValue?: T): Signal { export class Signal implements Dependency { public readonly [ReactiveFlags.IS_SIGNAL] = true + public readonly [ReactiveFlags.SKIP] = true // Dependency fields subs: Link | undefined = undefined; subsTail: Link | undefined = undefined; @@ -145,13 +146,22 @@ export function effect(fn: () => T): Effect { return e; } +export enum EffectFlags { + /** + * ReactiveEffect only + */ + ALLOW_RECURSE = 1 << 7, + PAUSED = 1 << 8, + NOTIFIED = 1 << 9, + STOP = 1 << 10, +} + export class Effect implements Subscriber { readonly [ReactiveFlags.IS_SIGNAL] = true // Subscriber fields deps: Link | undefined = undefined; depsTail: Link | undefined = undefined; flags: SubscriberFlags = SubscriberFlags.Effect; - constructor( public fn: () => T ) { } @@ -162,8 +172,28 @@ export class Effect implements Subscriber { flags & SubscriberFlags.Dirty || (flags & SubscriberFlags.PendingComputed && updateDirtyFlag(this, flags)) ) { - this.run(); + this.scheduler(); + } + } + + scheduler(): void { + if (this.dirty) { + this.run() + } + } + get active(): boolean { + return !(this.flags & EffectFlags.STOP) + } + + get dirty(): boolean { + const flags = this.flags + if ( + flags & SubscriberFlags.Dirty || + (flags & SubscriberFlags.PendingComputed && updateDirtyFlag(this, flags)) + ) { + return true } + return false } run(): T { diff --git a/src/deepSignal.ts b/src/deepSignal.ts index e512397..6923fb5 100644 --- a/src/deepSignal.ts +++ b/src/deepSignal.ts @@ -1,3 +1,4 @@ +import { ReactiveFlags } from "./contents"; import { computed, Signal, signal } from "./core"; const proxyToSignals = new WeakMap(); @@ -9,6 +10,14 @@ const rg = /^\$/; const descriptor = Object.getOwnPropertyDescriptor; let peeking = false; +export const isDeepSignal = (source: any) => { + return proxyToSignals.has(source); +} + +export const isShallow = (source: any) => { + return ignore.has(source) +} + export const deepSignal = (obj: T): DeepSignal => { if (!shouldProxy(obj)) throw new Error("This object can't be observed."); if (!objToProxy.has(obj)) @@ -31,7 +40,7 @@ export const peek = < return value as RevertDeepSignal[K]>; }; -const isShallow = Symbol("shallow"); +const shallowFlag = Symbol(ReactiveFlags.IS_SHALLOW); export function shallow(obj: T): Shallow { ignore.add(obj); return obj as Shallow; @@ -84,6 +93,7 @@ const get = signals.set(key, signal(value)); } } + // deep getter return returnSignal ? signals.get(key) : signals.get(key).get(); }; @@ -153,7 +163,7 @@ const shouldProxy = (val: any): boolean => { export type DeepSignal = T extends Function ? T - : T extends { [isShallow]: true } + : T extends { [shallowFlag]: true } ? T : T extends Array ? DeepSignalArray @@ -274,7 +284,7 @@ type DeepSignalArray = DeepArray> & { $length?: Signal; }; -export type Shallow = T & { [isShallow]: true }; +export type Shallow = T & { [shallowFlag]: true }; export declare const useDeepSignal: (obj: T) => DeepSignal; // @ts-ignore diff --git a/src/test/index.test.ts b/src/test/index.test.ts index 1f91692..5382b57 100644 --- a/src/test/index.test.ts +++ b/src/test/index.test.ts @@ -1,6 +1,7 @@ import { deepSignal, peek, RevertDeepSignal, shallow } from "../index" -import { describe, it, expect, beforeEach } from "vitest" +import { describe, it, expect, beforeEach, test } from "vitest" import { signal, Signal, effect } from ".."; +import { watch } from "../watch"; type Store = { a?: number; nested: { b?: number }; @@ -1111,3 +1112,25 @@ describe("deepsignal/core", () => { }); }); }); + + +describe('watch', () => { + test('abc', () => { + const store = deepSignal({ + userinfo: { + name: "tom" + } + }) + const stop = watch(store, (newValue, oldValue) => { + console.log('newValue', newValue); + console.log('oldValue', oldValue); + }, { + immediate: true, + deep: true, + }) + + store.userinfo.name = "jon" + stop() + store.userinfo.name = 'jon2' + }) +}) diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..ef47787 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,80 @@ +import { ReactiveFlags } from "./contents" +import { isSignal } from "./core" + +export const objectToString: typeof Object.prototype.toString = + Object.prototype.toString +export const toTypeString = (value: unknown): string => + objectToString.call(value) +const hasOwnProperty = Object.prototype.hasOwnProperty +export const hasOwn = ( + val: object, + key: string | symbol, +): key is keyof typeof val => hasOwnProperty.call(val, key) + +export const isArray: typeof Array.isArray = Array.isArray +export const isMap = (val: unknown): val is Map => + toTypeString(val) === '[object Map]' +export const isSet = (val: unknown): val is Set => + toTypeString(val) === '[object Set]' + +export const isDate = (val: unknown): val is Date => + toTypeString(val) === '[object Date]' +export const isRegExp = (val: unknown): val is RegExp => + toTypeString(val) === '[object RegExp]' +export const isFunction = (val: unknown): val is Function => + typeof val === 'function' +export const isString = (val: unknown): val is string => typeof val === 'string' +export const isSymbol = (val: unknown): val is symbol => typeof val === 'symbol' +export const isObject = (val: unknown): val is Record => + val !== null && typeof val === 'object' + +export const isPromise = (val: unknown): val is Promise => { + return ( + (isObject(val) || isFunction(val)) && + isFunction((val as any).then) && + isFunction((val as any).catch) + ) +} +export const isPlainObject = (val: unknown): val is object => + toTypeString(val) === '[object Object]' + +export const hasChanged = (value: any, oldValue: any): boolean => + !Object.is(value, oldValue) + +export function traverse( + value: unknown, + depth: number = Infinity, + seen?: Set, +): 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 (isSignal(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 +} diff --git a/src/watch.ts b/src/watch.ts new file mode 100644 index 0000000..646e2f8 --- /dev/null +++ b/src/watch.ts @@ -0,0 +1,152 @@ +import { Computed, Effect, isSignal, Signal, } from './core'; +import { hasChanged, isArray, traverse } from './utils'; +import { isDeepSignal, isShallow } from "./deepSignal" + +export type OnCleanup = (cleanupFn: () => void) => void +export type WatchEffect = (onCleanup: OnCleanup) => void + +export type WatchSource = Signal | Computed | (() => T) + +export interface WatchOptions { + immediate?: Immediate + deep?: boolean | number + once?: boolean +} + +export type WatchCallback = ( + value: V, + oldValue: OV, + onCleanup: OnCleanup, +) => any + +const INITIAL_WATCHER_VALUE = {} +let activeWatcher!: Effect + +export const remove = (arr: T[], el: T): void => { + const i = arr.indexOf(el) + if (i > -1) { + arr.splice(i, 1) + } +} + +export function watch( + source: WatchSource | WatchSource[] | WatchEffect | object, + cb?: WatchCallback, + options: WatchOptions = {} +) { + const { once, immediate, deep } = options + + let effect!: Effect + let getter!: () => any + // let boundCleanup: typeof onWatcherCleanup + let forceTrigger = false + let isMultiSource = false + + const signalGetter = (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) + } + + const watchHandle = () => { + effect.stop() + return effect + } + + if (once && cb) { + const _cb = cb + cb = (...args) => { + _cb(...args) + watchHandle() + } + } + + if (isSignal(source)) { + getter = () => source.value + forceTrigger = isShallow(source) + } else if (isDeepSignal(source)) { + getter = () => { + return signalGetter(source) + } + forceTrigger = true + } else if (isArray(source)) { + isMultiSource = true + forceTrigger = source.some(s => isDeepSignal(s) || isShallow(s)) + getter = () => + source.map(s => { + if (isSignal(s)) { + return s.value + } else if (isDeepSignal(s)) { + return signalGetter(s) + } + }) + } + if (cb && deep) { + const baseGetter = getter + const depth = deep === true ? Infinity : deep + getter = () => traverse(baseGetter(), depth) + } + + let oldValue: any = isMultiSource + ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) + : INITIAL_WATCHER_VALUE + + const job = (immediateFirstRun?: boolean) => { + if (!effect.active || (!immediateFirstRun && !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)) + ) { + 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, + effect.stop, + ] + // @ts-ignore + cb!(...args) + oldValue = newValue + } finally { + activeWatcher = currentWatcher + } + } + } else { + // watchEffect + effect.run() + } + } + + effect = new Effect(getter) + effect.scheduler = job + + if (cb) { + if (immediate) { + job(true) + } else { + oldValue = effect.run() + } + } else { + effect.run() + } + return watchHandle +}