diff --git a/libs/horizon/index.ts b/libs/horizon/index.ts index 9e29e71d..4c067a3e 100644 --- a/libs/horizon/index.ts +++ b/libs/horizon/index.ts @@ -25,7 +25,7 @@ import { useReducer, useRef, useState, - useDebugValue + useDebugValue, } from './src/renderer/hooks/HookExternal'; import { asyncUpdates } from './src/renderer/TreeBuilder'; import { callRenderQueueImmediate } from './src/renderer/taskExecutor/RenderQueue'; @@ -33,6 +33,7 @@ import { runAsyncEffects } from './src/renderer/submit/HookEffectHandler'; import { createStore, useStore, clearStore } from './src/horizonx/store/StoreHandler'; import * as reduxAdapter from './src/horizonx/adapters/redux'; +import { watch } from './src/horizonx/proxy/watch'; // act用于测试,作用是:如果fun触发了刷新(包含了异步刷新),可以保证在act后面的代码是在刷新完成后才执行。 const act = fun => { @@ -85,6 +86,7 @@ const Horizon = { useStore, clearStore, reduxAdapter, + watch, }; export const version = __VERSION__; @@ -125,6 +127,7 @@ export { useStore, clearStore, reduxAdapter, + watch, }; export default Horizon; diff --git a/libs/horizon/src/horizonx/proxy/Observer.ts b/libs/horizon/src/horizonx/proxy/Observer.ts index d93c7a05..e35701af 100644 --- a/libs/horizon/src/horizonx/proxy/Observer.ts +++ b/libs/horizon/src/horizonx/proxy/Observer.ts @@ -7,7 +7,6 @@ import { launchUpdateFromVNode } from '../../renderer/TreeBuilder'; import { getProcessingVNode } from '../../renderer/GlobalVar'; import { VNode } from '../../renderer/vnode/VNode'; export interface IObserver { - useProp: (key: string) => void; addListener: (listener: () => void) => void; @@ -21,17 +20,18 @@ export interface IObserver { triggerUpdate: (vNode: any) => void; allChange: () => void; - + clearByVNode: (vNode: any) => void; } export class Observer implements IObserver { - vNodeKeys = new WeakMap(); keyVNodes = new Map(); - listeners:(()=>void)[] = []; + listeners: (() => void)[] = []; + + watchers = {} as { [key: string]: ((key: string, oldValue: any, newValue: any) => void)[] }; useProp(key: string | symbol): void { const processingVNode = getProcessingVNode(); diff --git a/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts index a6812bde..391aee8c 100644 --- a/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts +++ b/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts @@ -12,6 +12,20 @@ export function createArrayProxy(rawObj: any[]): any[] { } function get(rawObj: any[], key: string, receiver: any) { + if (key === 'watch') { + const observer = getObserver(rawObj); + + return (prop: any, handler: (key: string, oldValue: any, newValue: any) => void) => { + if (!observer.watchers[prop]) { + observer.watchers[prop] = [] as ((key: string, oldValue: any, newValue: any) => void)[]; + } + observer.watchers[prop].push(handler); + return () => { + observer.watchers[prop] = observer.watchers[prop].filter(cb => cb !== handler); + }; + }; + } + if (isValidIntegerKey(key) || key === 'length') { return objectGet(rawObj, key, receiver); } @@ -29,6 +43,12 @@ function set(rawObj: any[], key: string, value: any, receiver: any) { const observer = getObserver(rawObj); if (!isSame(newValue, oldValue)) { + if (observer.watchers?.[key]) { + observer.watchers[key].forEach(cb => { + cb(key, oldValue, newValue); + }); + } + observer.setProp(key); } diff --git a/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts index bd044b44..99c3a26a 100644 --- a/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts +++ b/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts @@ -34,6 +34,18 @@ function get(rawObj: { size: number }, key: any, receiver: any): any { } else if (Object.prototype.hasOwnProperty.call(handler, key)) { const value = Reflect.get(handler, key, receiver); return value.bind(null, rawObj); + } else if (key === 'watch') { + const observer = getObserver(rawObj); + + return (prop: any, handler: (key: string, oldValue: any, newValue: any) => void) => { + if (!observer.watchers[prop]) { + observer.watchers[prop] = [] as ((key: string, oldValue: any, newValue: any) => void)[]; + } + observer.watchers[prop].push(handler); + return () => { + observer.watchers[prop] = observer.watchers[prop].filter(cb => cb !== handler); + }; + }; } return Reflect.get(rawObj, key, receiver); @@ -67,6 +79,12 @@ function set( } if (valChange) { + if (observer.watchers?.[key]) { + observer.watchers[key].forEach(cb => { + cb(key, oldValue, newValue); + }); + } + observer.setProp(key); } diff --git a/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts index 4dc27a0b..29ab2658 100644 --- a/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts +++ b/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts @@ -19,6 +19,18 @@ export function get(rawObj: object, key: string | symbol, receiver: any, singleL const observer = getObserver(rawObj); + if (key === 'watch'){ + return (prop, handler:(key:string, oldValue:any, newValue:any)=>void)=>{ + if(!observer.watchers[prop]){ + observer.watchers[prop]=[] as ((key:string, oldValue:any, newValue:any)=>void)[]; + } + observer.watchers[prop].push(handler); + return ()=>{ + observer.watchers[prop]=observer.watchers[prop].filter(cb=>cb!==handler); + } + } + } + if (key === 'addListener') { return observer.addListener.bind(observer); } @@ -54,6 +66,11 @@ export function set(rawObj: object, key: string, value: any, receiver: any): boo const ret = Reflect.set(rawObj, key, newValue, receiver); if (!isSame(newValue, oldValue)) { + if(observer.watchers?.[key]){ + observer.watchers[key].forEach(cb => { + cb(key, oldValue, newValue); + }); + } observer.setProp(key); } diff --git a/libs/horizon/src/horizonx/proxy/watch.ts b/libs/horizon/src/horizonx/proxy/watch.ts new file mode 100644 index 00000000..11b65b9e --- /dev/null +++ b/libs/horizon/src/horizonx/proxy/watch.ts @@ -0,0 +1,8 @@ +export function watch(stateVariable: any, listener: (state: any) => void) { + listener = listener.bind(null, stateVariable); + stateVariable.addListener(listener); + + return () => { + stateVariable.removeListener(listener); + }; +} diff --git a/libs/horizon/src/horizonx/store/StoreHandler.ts b/libs/horizon/src/horizonx/store/StoreHandler.ts index bdbf8f77..757a5b02 100644 --- a/libs/horizon/src/horizonx/store/StoreHandler.ts +++ b/libs/horizon/src/horizonx/store/StoreHandler.ts @@ -35,9 +35,7 @@ type StoreHandler, C extends UserComp $queue: QueuedStoreActions; $a: StoreActions; $c: UserComputedValues; -} & { [K in keyof S]: S[K] } & - { [K in keyof A]: Action } & - { [K in keyof C]: ReturnType }; +} & { [K in keyof S]: S[K] } & { [K in keyof A]: Action } & { [K in keyof C]: ReturnType }; type PlannedAction> = { action: string; @@ -103,7 +101,7 @@ export function createStore, C extend const $a: Partial> = {}; const $queue: Partial> = {}; const $c: Partial> = {}; - const handler = ({ + const handler = { $subscribe, $unsubscribe, $a: $a as StoreActions, @@ -111,7 +109,7 @@ export function createStore, C extend $c: $c as ComputedValues, $config: config, $queue: $queue as QueuedStoreActions, - } as unknown) as StoreHandler; + } as unknown as StoreHandler; function tryNextAction() { if (!plannedActions.length) { @@ -204,7 +202,8 @@ export function createStore, C extend return createStoreHook(handler); } -function clearVNodeObservers(vNode) { +export function clearVNodeObservers(vNode) { + if (!vNode.observers) return; vNode.observers.forEach(observer => { observer.clearByVNode(vNode); }); @@ -220,16 +219,13 @@ function hookStore() { return; } - if (processingVNode.observers) { - // 清除上一次缓存的Observer依赖 - clearVNodeObservers(processingVNode); - } else { + if (!processingVNode.observers) { processingVNode.observers = new Set(); } if (processingVNode.tag === FunctionComponent) { // from FunctionComponent - const vNodeRef = (useRef(null) as unknown) as { current: VNode }; + const vNodeRef = useRef(null) as unknown as { current: VNode }; vNodeRef.current = processingVNode; useEffect(() => { @@ -241,7 +237,7 @@ function hookStore() { } else if (processingVNode.tag === ClassComponent) { // from ClassComponent if (!processingVNode.classComponentWillUnmount) { - processingVNode.classComponentWillUnmount = function(vNode) { + processingVNode.classComponentWillUnmount = function (vNode) { clearVNodeObservers(vNode); vNode.observers = null; }; diff --git a/libs/horizon/src/renderer/render/BaseComponent.ts b/libs/horizon/src/renderer/render/BaseComponent.ts index 1ea097d3..e027696c 100644 --- a/libs/horizon/src/renderer/render/BaseComponent.ts +++ b/libs/horizon/src/renderer/render/BaseComponent.ts @@ -1,17 +1,12 @@ import type { VNode } from '../Types'; -import { - ContextProvider, - DomComponent, - DomPortal, - TreeRoot, - SuspenseComponent, -} from '../vnode/VNodeTags'; +import { ContextProvider, DomComponent, DomPortal, TreeRoot, SuspenseComponent } from '../vnode/VNodeTags'; import { setContext, setNamespaceCtx } from '../ContextSaver'; import { FlagUtils } from '../vnode/VNodeFlags'; -import {onlyUpdateChildVNodes} from '../vnode/VNodeCreator'; +import { onlyUpdateChildVNodes } from '../vnode/VNodeCreator'; import componentRenders from './index'; -import {setProcessingVNode} from '../GlobalVar'; +import { setProcessingVNode } from '../GlobalVar'; +import { clearVNodeObservers } from '../../horizonx/store/StoreHandler'; // 复用vNode时,也需对stack进行处理 function handlerContext(processing: VNode) { @@ -39,11 +34,7 @@ export function captureVNode(processing: VNode): VNode | null { if (processing.tag !== SuspenseComponent) { // 该vNode没有变化,不用进入capture,直接复用。 - if ( - !processing.isCreated && - processing.oldProps === processing.props && - !processing.shouldUpdate - ) { + if (!processing.isCreated && processing.oldProps === processing.props && !processing.shouldUpdate) { // 复用还需对stack进行处理 handlerContext(processing); @@ -55,6 +46,8 @@ export function captureVNode(processing: VNode): VNode | null { processing.shouldUpdate = false; setProcessingVNode(processing); + + if (processing.observers) clearVNodeObservers(processing); const child = component.captureRender(processing, shouldUpdate); setProcessingVNode(null); diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/watch.test.tsx b/scripts/__tests__/HorizonXText/StoreFunctionality/watch.test.tsx new file mode 100644 index 00000000..d423f3bf --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/watch.test.tsx @@ -0,0 +1,130 @@ +import { createStore } from '@cloudsop/horizon/src/horizonx/store/StoreHandler'; +import { watch } from '@cloudsop/horizon/src/horizonx/proxy/watch'; + +describe('watch', () => { + it('shouhld watch promitive state variable', async () => { + const useStore = createStore({ + state: { + variable: 'x', + }, + actions: { + change: state => (state.variable = 'a'), + }, + }); + + const store = useStore(); + let counter = 0; + + watch(store.$s, state => { + counter++; + expect(state.variable).toBe('a'); + }); + + store.change(); + + expect(counter).toBe(1); + }); + it('shouhld watch object variable', async () => { + const useStore = createStore({ + state: { + variable: 'x', + }, + actions: { + change: state => (state.variable = 'a'), + }, + }); + + const store = useStore(); + let counter = 0; + + store.$s.watch('variable', () => { + counter++; + }); + + store.change(); + + expect(counter).toBe(1); + }); + + it('shouhld watch array item', async () => { + const useStore = createStore({ + state: { + arr: ['x'], + }, + actions: { + change: state => (state.arr[0] = 'a'), + }, + }); + + const store = useStore(); + let counter = 0; + + store.arr.watch('0', () => { + counter++; + }); + + store.change(); + + expect(counter).toBe(1); + }); + + it('shouhld watch collection item', async () => { + const useStore = createStore({ + state: { + collection: new Map([['a', 'a']]), + }, + actions: { + change: state => state.collection.set('a', 'x'), + }, + }); + + const store = useStore(); + let counter = 0; + + store.collection.watch('a', () => { + counter++; + }); + + store.change(); + + expect(counter).toBe(1); + }); + + it('should watch multiple variables independedntly', async () => { + const useStore = createStore({ + state: { + bool1: true, + bool2: false, + }, + actions: { + toggle1: state => (state.bool1 = !state.bool1), + toggle2: state => (state.bool2 = !state.bool2), + }, + }); + + let counter1 = 0; + let counterAll = 0; + const store = useStore(); + + watch(store.$s, () => { + counterAll++; + }); + + store.$s.watch('bool1', () => { + counter1++; + }); + + store.toggle1(); + store.toggle1(); + + store.toggle2(); + + store.toggle1(); + + store.toggle2(); + store.toggle2(); + + expect(counter1).toBe(3); + expect(counterAll).toBe(6); + }); +}); diff --git a/scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx b/scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx new file mode 100644 index 00000000..9c9f847c --- /dev/null +++ b/scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx @@ -0,0 +1,197 @@ +//@ts-ignore +import Horizon, { createStore } from '@cloudsop/horizon/index.ts'; +import { triggerClickEvent } from '../../jest/commonComponents'; +import { describe, beforeEach, afterEach, it, expect } from '@jest/globals'; + +const { unmountComponentAtNode } = Horizon; + +const useStore1 = createStore({ + state: { counter: 1 }, + actions: { + add: state => state.counter++, + reset: state => (state.counter = 1), + }, +}); + +const useStore2 = createStore({ + state: { counter2: 1 }, + actions: { + add2: state => state.counter2++, + reset: state => (state.counter2 = 1), + }, +}); + +describe('Using multiple stores', () => { + let container: HTMLElement | null = null; + + const BUTTON_ID = 'btn'; + const BUTTON_ID2 = 'btn2'; + const RESULT_ID = 'result'; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + useStore1().reset(); + useStore2().reset(); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container?.remove(); + container = null; + }); + + it('Should use multiple stores in class component', () => { + class App extends Horizon.Component { + render() { + const { counter, add } = useStore1(); + const { counter2, add2 } = useStore2(); + + return ( +
+ + +

+ {counter} {counter2} +

+
+ ); + } + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('1 1'); + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 1'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID2); + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 2'); + }); + + it('Should use use stores in cycles and multiple methods', () => { + interface App { + store: any; + store2: any; + } + class App extends Horizon.Component { + constructor() { + super(); + this.store = useStore1(); + this.store2 = useStore2(); + } + + render() { + const { counter, add } = useStore1(); + const store2 = useStore2(); + const { counter2, add2 } = store2; + + for (let i = 0; i < 100; i++) { + const { counter, add } = useStore1(); + const store2 = useStore2(); + const { counter2, add2 } = store2; + } + + return ( +
+ + +

+ {counter} {counter2} +

+
+ ); + } + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('1 1'); + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 1'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID2); + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 2'); + }); + + it('Should use multiple stores in function component', () => { + function App() { + const { counter, add } = useStore1(); + const store2 = useStore2(); + const { counter2, add2 } = store2; + + return ( +
+ + +

+ {counter} {counter2} +

+
+ ); + } + + Horizon.render(, container); + + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('1 1'); + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 1'); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID2); + }); + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('2 2'); + }); +});