diff --git a/libs/horizon/index.ts b/libs/horizon/index.ts index 9e29e71d..f8035375 100644 --- a/libs/horizon/index.ts +++ b/libs/horizon/index.ts @@ -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..b00e942e 100644 --- a/libs/horizon/src/horizonx/proxy/Observer.ts +++ b/libs/horizon/src/horizonx/proxy/Observer.ts @@ -33,6 +33,8 @@ export class Observer implements IObserver { listeners:(()=>void)[] = []; + watchers={} as {[key:string]:((key:string, oldValue:any, newValue:any)=>void)[]} + useProp(key: string | symbol): void { const processingVNode = getProcessingVNode(); if (processingVNode === null || !processingVNode.observers) { diff --git a/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts index a6812bde..dd3fdb0b 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..15aaa682 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..79328142 --- /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); + } +} \ No newline at end of file diff --git a/libs/horizon/src/horizonx/store/StoreHandler.ts b/libs/horizon/src/horizonx/store/StoreHandler.ts index bdbf8f77..00d4695e 100644 --- a/libs/horizon/src/horizonx/store/StoreHandler.ts +++ b/libs/horizon/src/horizonx/store/StoreHandler.ts @@ -204,7 +204,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); }); @@ -219,11 +220,8 @@ function hookStore() { if (!processingVNode) { return; } - - if (processingVNode.observers) { - // 清除上一次缓存的Observer依赖 - clearVNodeObservers(processingVNode); - } else { + + if (!processingVNode.observers) { processingVNode.observers = new Set(); } diff --git a/libs/horizon/src/renderer/render/BaseComponent.ts b/libs/horizon/src/renderer/render/BaseComponent.ts index 1ea097d3..4192e100 100644 --- a/libs/horizon/src/renderer/render/BaseComponent.ts +++ b/libs/horizon/src/renderer/render/BaseComponent.ts @@ -12,6 +12,7 @@ import { FlagUtils } from '../vnode/VNodeFlags'; import {onlyUpdateChildVNodes} from '../vnode/VNodeCreator'; import componentRenders from './index'; import {setProcessingVNode} from '../GlobalVar'; +import { clearVNodeObservers } from '../../horizonx/store/StoreHandler'; // 复用vNode时,也需对stack进行处理 function handlerContext(processing: VNode) { @@ -55,6 +56,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..806c6029 --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/watch.test.tsx @@ -0,0 +1,132 @@ +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); + }) +}) \ No newline at end of file diff --git a/scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx b/scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx new file mode 100644 index 00000000..63fb74e5 --- /dev/null +++ b/scripts/__tests__/HorizonXText/edgeCases/multipleStores.test.tsx @@ -0,0 +1,199 @@ +//@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'); + }); +}); \ No newline at end of file