diff --git a/src/model/defineModel.ts b/src/model/defineModel.ts index 422bdc2..6996649 100644 --- a/src/model/defineModel.ts +++ b/src/model/defineModel.ts @@ -25,6 +25,7 @@ import type { import { isFunction } from '../utils/isType'; import { Unsubscribe } from 'redux'; import { freeze, original, isDraft } from 'immer'; +import { lazyLoad } from './lazyLoad'; export const defineModel = < Name extends string, @@ -103,6 +104,7 @@ export const defineModel = < const getState = (obj: T): T & GetState => { return defineGetter(obj, 'state', () => { + lazyLoad(uniqueName); const state = modelStore.getState()[uniqueName]; return depsCollector.active ? new ObjectDeps(modelStore, uniqueName).start(state) @@ -218,7 +220,9 @@ export const defineModel = < } } - if (events) { + const onLazyLoaded = () => { + if (!events) return; + const { onInit, onChange, onDestroy } = events; const eventCtx: EventCtx = Object.assign( composeGetter({ name: uniqueName }, getState), @@ -226,7 +230,7 @@ export const defineModel = < enhancedMethods.internal, ); - modelStore.onInitialized().then(() => { + { const subscriptions: Unsubscribe[] = []; if (onChange) { @@ -258,8 +262,8 @@ export const defineModel = < } onInit && onInit.call(eventCtx); - }); - } + } + }; ModelStore.appendReducer.call( modelStore, @@ -269,6 +273,7 @@ export const defineModel = < initialState, allowRefresh: !skipRefresh, }), + onLazyLoaded, ); const model: InternalModel = diff --git a/src/model/enhanceAction.ts b/src/model/enhanceAction.ts index ca95f48..6c904ac 100644 --- a/src/model/enhanceAction.ts +++ b/src/model/enhanceAction.ts @@ -1,6 +1,7 @@ import type { PreModelAction } from '../actions/model'; import { modelStore } from '../store/modelStore'; import { toArgs } from '../utils/toArgs'; +import { lazyLoad } from './lazyLoad'; import type { ActionCtx } from './types'; export interface EnhancedAction { @@ -26,6 +27,7 @@ export const enhanceAction = ( }; const fn: EnhancedAction = function () { + lazyLoad(modelName); return modelStore.dispatch>({ type: actionType, model: modelName, diff --git a/src/model/lazyLoad.ts b/src/model/lazyLoad.ts new file mode 100644 index 0000000..5292d5f --- /dev/null +++ b/src/model/lazyLoad.ts @@ -0,0 +1,5 @@ +import { modelStore, ModelStore } from '../store/modelStore'; + +export function lazyLoad(modelName: string): void { + ModelStore.lazyLoad.call(modelStore, modelName); +} diff --git a/src/model/useDefined.ts b/src/model/useDefined.ts index e910bf4..b97993d 100644 --- a/src/model/useDefined.ts +++ b/src/model/useDefined.ts @@ -3,6 +3,7 @@ import { DestroyLodingAction, DESTROY_LOADING } from '../actions/loading'; import { loadingStore } from '../store/loadingStore'; import { ModelStore, modelStore } from '../store/modelStore'; import { cloneModel } from './cloneModel'; +import { lazyLoad } from './lazyLoad'; import { HookModel as HookModel, Model } from './types'; let nameCounter = 0; @@ -25,7 +26,9 @@ export const useDefined = < : useDevName(modelName, initialCount, new Error()); const hookModel = useMemo(() => { - return cloneModel(uniqueName, globalModel); + const model = cloneModel(uniqueName, globalModel); + lazyLoad(uniqueName); + return model; }, [uniqueName]); return hookModel as any; diff --git a/src/model/useModel.ts b/src/model/useModel.ts index 5bd8a6e..bd7bfb8 100644 --- a/src/model/useModel.ts +++ b/src/model/useModel.ts @@ -4,6 +4,7 @@ import type { HookModel, Model } from './types'; import { toArgs } from '../utils/toArgs'; import { useModelSelector } from '../redux/useSelector'; import { isFunction, isString } from '../utils/isType'; +import { lazyLoad } from './lazyLoad'; /** * hooks新旧数据的对比方式: @@ -212,7 +213,9 @@ export function useModel(): any { const reducerNames: string[] = []; for (i = 0; i < modelsLength; ++i) { - reducerNames.push(models[i]!.name); + const modelName = models[i]!.name; + lazyLoad(modelName); + reducerNames.push(modelName); } return useModelSelector((state: Record) => { diff --git a/src/persist/PersistItem.ts b/src/persist/PersistItem.ts index 3b158f7..5c0264b 100644 --- a/src/persist/PersistItem.ts +++ b/src/persist/PersistItem.ts @@ -1,4 +1,5 @@ import type { StorageEngine } from '../engines'; +import { lazyLoad } from '../model/lazyLoad'; import type { InternalModel, Model, ModelPersist } from '../model/types'; import { isObject, isString } from '../utils/isType'; import { parseState, stringifyState } from '../utils/serialize'; @@ -96,6 +97,7 @@ export class PersistItem { this.key = keyPrefix + key; models.forEach((model) => { + lazyLoad(model.name); const { decode = defaultDecodeFn, maxAge: customMaxAge = maxAge, diff --git a/src/store/modelStore.ts b/src/store/modelStore.ts index 75af068..9b1c200 100644 --- a/src/store/modelStore.ts +++ b/src/store/modelStore.ts @@ -28,7 +28,7 @@ interface CreateStoreOptions { persist?: PersistOptions[]; } -export class ModelStore extends StoreBasic> { +export class ModelStore> extends StoreBasic { public topic: Topic<{ init: []; ready: []; @@ -38,6 +38,10 @@ export class ModelStore extends StoreBasic> { protected _isReady: boolean = false; protected consumers: Record = {}; protected reducerKeys: string[] = []; + protected lazyKeys: Record< + string, + { consumer: Reducer; onComplete: () => void } + > = {}; /** * @protected */ @@ -125,12 +129,16 @@ export class ModelStore extends StoreBasic> { this.topic.publish('unmount'); } - onInitialized(): Promise { + onInitialized(maybeSync?: () => void): Promise { return new Promise((resolve) => { if (this._isReady) { + maybeSync?.(); resolve(); } else { - this.topic.subscribeOnce('ready', resolve); + this.topic.subscribeOnce('ready', () => { + maybeSync?.(); + resolve(); + }); } }); } @@ -189,11 +197,7 @@ export class ModelStore extends StoreBasic> { }; } - public static appendReducer( - this: ModelStore, - key: string, - consumer: Reducer, - ): void { + protected appendReducer(key: string, consumer: Reducer) { const store = this.origin; const consumers = this.consumers; const exists = store && consumers.hasOwnProperty(key); @@ -203,6 +207,27 @@ export class ModelStore extends StoreBasic> { store && !exists && store.replaceReducer(this.reducer); } + public static appendReducer( + this: ModelStore, + key: string, + consumer: Reducer, + onComplete: () => void, + ): void { + this.lazyKeys[key] = { + consumer, + onComplete, + }; + } + + public static lazyLoad(this: ModelStore, key: string) { + if (this.lazyKeys.hasOwnProperty(key)) { + const { consumer, onComplete } = this.lazyKeys[key]!; + delete this.lazyKeys[key]; + this.appendReducer(key, consumer); + this.onInitialized(onComplete); + } + } + public static removeReducer(this: ModelStore, key: string): void { const store = this.origin; const consumers = this.consumers; diff --git a/test/lazyLoad.test.ts b/test/lazyLoad.test.ts new file mode 100644 index 0000000..d8fa98d --- /dev/null +++ b/test/lazyLoad.test.ts @@ -0,0 +1,88 @@ +import { defineModel, store } from '../src'; +import { lazyLoad } from '../src/model/lazyLoad'; +import { ModelStore } from '../src/store/modelStore'; +import { basicModel } from './models/basicModel'; + +beforeEach(() => { + store.init(); +}); + +afterEach(() => { + store.unmount(); +}); + +test('reducer is not in store by default', () => { + expect(store.getState()).not.toHaveProperty(basicModel.name); +}); + +test('reducer is in store by access state', () => { + basicModel.state; + expect(store.getState()).toHaveProperty(basicModel.name); +}); + +test('reducer is in store by calling reducers', () => { + basicModel.plus(1); + expect(store.getState()).toHaveProperty(basicModel.name); +}); + +test('reducer is in store by manual calling lazyLoad', () => { + lazyLoad(basicModel.name); + expect(store.getState()).toHaveProperty(basicModel.name); +}); + +test('events are called once reducer is loaded', async () => { + const initFn = jest.fn(); + const changeFn = jest.fn(); + const unmountFn = jest.fn(); + + const model = defineModel('event' + Math.random(), { + initialState: { + count: 0, + }, + reducers: { + change(state) { + state.count++; + }, + }, + events: { + onInit: initFn, + onChange: changeFn, + onDestroy: unmountFn, + }, + }); + + await store.onInitialized(); + + expect(initFn).toBeCalledTimes(0); + expect(changeFn).toBeCalledTimes(0); + expect(unmountFn).toBeCalledTimes(0); + + model.state; + expect(initFn).toBeCalledTimes(1); + expect(changeFn).toBeCalledTimes(0); + expect(unmountFn).toBeCalledTimes(0); + + model.change(); + expect(initFn).toBeCalledTimes(1); + expect(changeFn).toBeCalledTimes(1); + expect(unmountFn).toBeCalledTimes(0); + + model.change(); + expect(initFn).toBeCalledTimes(1); + expect(changeFn).toBeCalledTimes(2); + expect(unmountFn).toBeCalledTimes(0); + + ModelStore.removeReducer.call(store, model.name); + expect(initFn).toBeCalledTimes(1); + expect(changeFn).toBeCalledTimes(2); + expect(unmountFn).toBeCalledTimes(1); +}); + +test('never load reducer again since it had been destroyed', () => { + lazyLoad(basicModel.name); + expect(store.getState()).toHaveProperty(basicModel.name); + + ModelStore.removeReducer.call(store, basicModel.name); + lazyLoad(basicModel.name); + expect(store.getState()).not.toHaveProperty(basicModel.name); +}); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 7acca31..70d46af 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -1,4 +1,5 @@ import { cloneModel, defineModel, engines, store } from '../src'; +import { lazyLoad } from '../src/model/lazyLoad'; import { PersistSchema } from '../src/persist/PersistItem'; import { ModelStore } from '../src/store/modelStore'; @@ -121,10 +122,8 @@ describe('onChange', () => { }, }, }); - model.plus(); - model.minus(); - expect(testMessage).toBe(''); + lazyLoad(model.name); await store.onInitialized(); expect(testMessage).toBe('onInit-prev-0-next-2-'); diff --git a/test/middleware.test.ts b/test/middleware.test.ts index c0528dc..f4a3329 100644 --- a/test/middleware.test.ts +++ b/test/middleware.test.ts @@ -1,5 +1,6 @@ import { defineModel, getLoading, store } from '../src'; import { DestroyLodingAction, DESTROY_LOADING } from '../src/actions/loading'; +import { lazyLoad } from '../src/model/lazyLoad'; import { loadingStore } from '../src/store/loadingStore'; import { basicModel } from './models/basicModel'; import { complexModel } from './models/complexModel'; @@ -14,6 +15,8 @@ afterEach(() => { test('dispatch the same state should be intercepted', () => { const fn = jest.fn(); + lazyLoad(basicModel.name); + lazyLoad(complexModel.name); const unsubscribe = store.subscribe(fn); expect(fn).toHaveBeenCalledTimes(0);