├── docs ├── .nojekyll ├── CNAME ├── alipay.png ├── wepay.png ├── favicon.ico ├── changelog.md ├── _sidebar.md ├── donate.md ├── troubleshooting.md ├── events.md ├── test.md ├── index.html ├── api.md ├── advanced.md ├── initialize.md ├── persist.md ├── model.md └── home.md ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── src ├── utils │ ├── to-promise.ts │ ├── get-method-category.ts │ ├── to-args.ts │ ├── immer.ts │ ├── is-promise.ts │ ├── symbol-observable.ts │ ├── is-type.ts │ ├── serialize.ts │ ├── deep-equal.ts │ └── getter.ts ├── engines │ ├── storage-engine.ts │ └── memory.ts ├── redux │ ├── use-selector.ts │ ├── connect.ts │ ├── contexts.ts │ ├── create-reducer.ts │ └── foca-provider.tsx ├── model │ ├── guard.ts │ ├── enhance-action.ts │ ├── enhance-computed.ts │ ├── clone-model.ts │ └── enhance-effect.ts ├── middleware │ ├── freeze-state.middleware.ts │ ├── destroy-loading.interceptor.ts │ ├── model.interceptor.ts │ ├── loading.interceptor.ts │ └── action-in-action.interceptor.ts ├── store │ ├── proxy-store.ts │ ├── store-basic.ts │ ├── loading-store.ts │ └── model-store.ts ├── reactive │ ├── create-computed-deps.ts │ ├── deps-collector.ts │ ├── computed-value.ts │ └── object-deps.ts ├── actions │ ├── refresh.ts │ ├── persist.ts │ ├── model.ts │ └── loading.ts ├── index.ts ├── persist │ ├── persist-gate.tsx │ ├── persist-manager.ts │ └── persist-item.ts └── api │ ├── use-computed.ts │ ├── use-loading.ts │ ├── get-loading.ts │ ├── use-isolate.ts │ └── use-model.ts ├── .gitignore ├── test ├── helpers │ ├── render-hook.tsx │ └── slow-engine.ts ├── deep-equal.test.ts ├── __snapshots__ │ └── serialize.test.ts.snap ├── typescript │ ├── get-loading.check.ts │ ├── use-model.check.ts │ ├── use-loading.check.ts │ ├── use-isolate.check.ts │ ├── computed.check.ts │ ├── persist.check.ts │ └── define-model.check.ts ├── models │ ├── complex.model.ts │ ├── persist.model.ts │ ├── basic.model.ts │ └── computed.model.ts ├── provider.test.tsx ├── build.test.ts ├── serialize.test.ts ├── fixtures │ ├── equals.ts │ └── not-equals.ts ├── use-computed.test.ts ├── action-in-action.test.tsx ├── connect.test.tsx ├── engine.test.ts ├── get-loading.test.ts ├── persist.gate.test.tsx ├── use-loading.test.ts ├── clone.test.ts ├── middleware.test.ts ├── use-model.test.ts ├── use-isolate.test.tsx ├── model.test.ts └── lifecycle.test.ts ├── tsup.config.ts ├── .github └── workflows │ ├── release.yml │ ├── prerelease.yml │ ├── codeql.yml │ └── test.yml ├── .prettierrc.yml ├── vitest.config.ts ├── LICENSE ├── tsconfig.json └── package.json /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | foca.js.org -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install prettier --cache --check . 2 | -------------------------------------------------------------------------------- /docs/alipay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foca-js/foca/HEAD/docs/alipay.png -------------------------------------------------------------------------------- /docs/wepay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foca-js/foca/HEAD/docs/wepay.png -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foca-js/foca/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | coverage/ 4 | pnpm-lock.yaml 5 | _sidebar.md 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/to-promise.ts: -------------------------------------------------------------------------------- 1 | export const toPromise = (fn: () => T | Promise): Promise => { 2 | return Promise.resolve().then(fn); 3 | }; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | dist/ 4 | *.log 5 | node_modules/ 6 | coverage 7 | .nyc_output 8 | TODO 9 | /test.* 10 | /dist/ 11 | /build/ 12 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | [changelog](https://raw.githubusercontent.com/foca-js/foca/master/CHANGELOG.md ':include') 4 | -------------------------------------------------------------------------------- /src/utils/get-method-category.ts: -------------------------------------------------------------------------------- 1 | export const getMethodCategory = (methodName: string) => 2 | methodName.indexOf('_') === 0 ? 'internal' : 'external'; 3 | -------------------------------------------------------------------------------- /src/utils/to-args.ts: -------------------------------------------------------------------------------- 1 | const slice = Array.prototype.slice; 2 | 3 | export const toArgs = (args: IArguments, start?: number): T => 4 | slice.call(args, start) as unknown as T; 5 | -------------------------------------------------------------------------------- /src/engines/storage-engine.ts: -------------------------------------------------------------------------------- 1 | export interface StorageEngine { 2 | getItem(key: string): string | null | Promise; 3 | setItem(key: string, value: string): any; 4 | removeItem(key: string): any; 5 | clear(): any; 6 | } 7 | -------------------------------------------------------------------------------- /src/redux/use-selector.ts: -------------------------------------------------------------------------------- 1 | import { createSelectorHook } from 'react-redux'; 2 | import { ModelContext, LoadingContext } from './contexts'; 3 | 4 | export const useModelSelector = createSelectorHook(ModelContext); 5 | 6 | export const useLoadingSelector = createSelectorHook(LoadingContext); 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.preferences.quoteStyle": "single", 3 | "typescript.suggest.autoImports": true, 4 | "editor.tabSize": 2, 5 | "typescript.tsdk": "node_modules/typescript/lib", 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "editor.formatOnSave": true 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/immer.ts: -------------------------------------------------------------------------------- 1 | import { Immer, enableES5 } from 'immer'; 2 | 3 | /** 4 | * 支持ES5,毕竟Proxy无法polyfill。有些用户手机可以10年不换!! 5 | * @link https://immerjs.github.io/immer/docs/installation#pick-your-immer-version 6 | * @since immer@6.0 7 | */ 8 | enableES5(); 9 | 10 | export const immer = new Immer({ 11 | autoFreeze: false, 12 | }); 13 | -------------------------------------------------------------------------------- /src/utils/is-promise.ts: -------------------------------------------------------------------------------- 1 | import { FUNCTION, isFunction, isObject } from './is-type'; 2 | 3 | const hasPromise = typeof Promise === FUNCTION; 4 | 5 | export const isPromise = (value: any): value is Promise => { 6 | return ( 7 | (hasPromise && value instanceof Promise) || 8 | ((isObject(value) || isFunction(value)) && isFunction(value.then)) 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /test/helpers/render-hook.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook as originRenderHook } from '@testing-library/react'; 2 | import { FocaProvider } from '../../src'; 3 | 4 | export const renderHook: typeof originRenderHook = ( 5 | renderCallback, 6 | options, 7 | ) => { 8 | return originRenderHook(renderCallback, { 9 | wrapper: FocaProvider, 10 | ...options, 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | * [关于Foca](/) 2 | 3 | - [更新日志](/changelog.md) 4 | 5 | * [开始使用](/initialize.md) 6 | 7 | - [模型](/model.md) 8 | 9 | * [接口](/api.md) 10 | 11 | - [事件钩子](/events.md) 12 | 13 | * [持久化](/persist.md) 14 | 15 | - [进阶用法](/advanced.md) 16 | 17 | * [问题解答](/troubleshooting.md) 18 | 19 | - [编写测试](/test.md) 20 | 21 | * [与Redux-Toolkit对比](/redux-toolkit.md) 22 | 23 | - [捐赠](/donate.md) 24 | -------------------------------------------------------------------------------- /src/model/guard.ts: -------------------------------------------------------------------------------- 1 | const counter: Record = {}; 2 | 3 | export const guard = (modelName: string) => { 4 | counter[modelName] ||= 0; 5 | 6 | if (process.env.NODE_ENV !== 'production') { 7 | setTimeout(() => { 8 | --counter[modelName]!; 9 | }); 10 | } 11 | 12 | if (++counter[modelName] > 1) { 13 | throw new Error(`模型名称'${modelName}'被重复使用`); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/redux/connect.ts: -------------------------------------------------------------------------------- 1 | import { Connect, connect as originalConnect } from 'react-redux'; 2 | import { ProxyContext } from './contexts'; 3 | import { toArgs } from '../utils/to-args'; 4 | 5 | export const connect: Connect = function () { 6 | const args = toArgs>(arguments); 7 | (args[3] ||= {}).context = ProxyContext; 8 | 9 | return originalConnect.apply(null, args); 10 | }; 11 | -------------------------------------------------------------------------------- /src/middleware/freeze-state.middleware.ts: -------------------------------------------------------------------------------- 1 | import { freeze } from 'immer'; 2 | import type { Middleware } from 'redux'; 3 | 4 | export const freezeStateMiddleware: Middleware = (api) => { 5 | freeze(api.getState(), true); 6 | 7 | return (dispatch) => (action) => { 8 | try { 9 | return dispatch(action); 10 | } finally { 11 | freeze(api.getState(), true); 12 | } 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/redux/contexts.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import type { ReactReduxContextValue } from 'react-redux'; 3 | 4 | export const ModelContext = createContext(null); 5 | 6 | export const LoadingContext = createContext( 7 | null, 8 | ); 9 | 10 | export const ProxyContext = createContext(null); 11 | -------------------------------------------------------------------------------- /src/store/proxy-store.ts: -------------------------------------------------------------------------------- 1 | import { legacy_createStore as createStore, Store } from 'redux'; 2 | 3 | export const proxyStore = createStore(() => ({})); 4 | 5 | const dispatch = () => { 6 | proxyStore.dispatch({ 7 | type: '-', 8 | }); 9 | }; 10 | 11 | /** 12 | * 为了触发connect(),需要将实体store都注册到代理的store。 13 | */ 14 | export const combine = (otherStore: Store) => { 15 | otherStore.subscribe(dispatch); 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/symbol-observable.ts: -------------------------------------------------------------------------------- 1 | import { FUNCTION } from './is-type'; 2 | 3 | /** 4 | * Inlined version of the `symbol-observable` polyfill 5 | * @link https://github.com/reduxjs/redux/blob/master/src/utils/symbol-observable.ts 6 | */ 7 | export const $$observable: typeof Symbol.observable = 8 | (typeof Symbol === FUNCTION && Symbol.observable) || 9 | ('@@observable' as unknown as typeof Symbol.observable); 10 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | splitting: true, 6 | sourcemap: true, 7 | clean: true, 8 | format: ['cjs', 'esm'], 9 | platform: 'node', 10 | tsconfig: './tsconfig.json', 11 | target: 'es2020', 12 | legacyOutput: true, 13 | shims: false, 14 | dts: true, 15 | onSuccess: 'echo {\\"type\\": \\"module\\"} > dist/esm/package.json', 16 | }); 17 | -------------------------------------------------------------------------------- /src/engines/memory.ts: -------------------------------------------------------------------------------- 1 | import type { StorageEngine } from './storage-engine'; 2 | 3 | let cache: Partial> = {}; 4 | 5 | export const memoryStorage: StorageEngine = { 6 | getItem(key) { 7 | return cache[key] === void 0 ? null : cache[key]!; 8 | }, 9 | setItem(key, value) { 10 | cache[key] = value; 11 | }, 12 | removeItem(key) { 13 | cache[key] = void 0; 14 | }, 15 | clear() { 16 | cache = {}; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/middleware/destroy-loading.interceptor.ts: -------------------------------------------------------------------------------- 1 | import type { Middleware } from 'redux'; 2 | import { isDestroyLoadingAction } from '../actions/loading'; 3 | 4 | export const destroyLoadingInterceptor: Middleware = 5 | (api) => (dispatch) => (action) => { 6 | if ( 7 | !isDestroyLoadingAction(action) || 8 | api.getState().hasOwnProperty(action.model) 9 | ) { 10 | return dispatch(action); 11 | } 12 | 13 | return action; 14 | }; 15 | -------------------------------------------------------------------------------- /test/deep-equal.test.ts: -------------------------------------------------------------------------------- 1 | import { deepEqual } from '../src/utils/deep-equal'; 2 | import { equals } from './fixtures/equals'; 3 | import { notEquals } from './fixtures/not-equals'; 4 | 5 | Object.entries(equals).map(([title, { a, b }]) => { 6 | test(`[equal] ${title}`, () => { 7 | expect(deepEqual(a, b)).toBeTruthy(); 8 | }); 9 | }); 10 | 11 | Object.entries(notEquals).map(([title, { a, b }]) => { 12 | test(`[not equal] ${title}`, () => { 13 | expect(deepEqual(a, b)).toBeFalsy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | cache: 'pnpm' 16 | node-version-file: 'package.json' 17 | - run: pnpm install 18 | - run: npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }} 19 | - run: npm publish 20 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | semi: true 2 | singleQuote: true 3 | # Change when properties in objects are quoted. 4 | # If at least one property in an object requires quotes, quote all properties. 5 | quoteProps: consistent 6 | tabWidth: 2 7 | printWidth: 80 8 | endOfLine: lf 9 | trailingComma: all 10 | bracketSpacing: true 11 | # Include parentheses around a sole arrow function parameter. 12 | arrowParens: always 13 | proseWrap: preserve 14 | jsxSingleQuote: false 15 | # Put > on the last line instead of at a new line. 16 | bracketSameLine: false 17 | -------------------------------------------------------------------------------- /src/reactive/create-computed-deps.ts: -------------------------------------------------------------------------------- 1 | import { shallowEqual } from 'react-redux'; 2 | import type { ComputedValue } from './computed-value'; 3 | import type { Deps } from './object-deps'; 4 | 5 | export const createComputedDeps = (body: ComputedValue): Deps => { 6 | let snapshot: any; 7 | 8 | return { 9 | id: `c-${body.model}-${body.property}`, 10 | end(): void { 11 | snapshot = body.snapshot; 12 | }, 13 | isDirty(): boolean { 14 | return !shallowEqual(snapshot, body.value); 15 | }, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /test/__snapshots__/serialize.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`can clone null and undefined 1`] = `"{"x":["__JSON_UNDEFINED__",1,{"test":"__JSON_UNDEFINED__","test1":"hello"},null,"__JSON_UNDEFINED__"],"y":null,"z":"__JSON_UNDEFINED__"}"`; 4 | 5 | exports[`can clone null and undefined 2`] = ` 6 | { 7 | "x": [ 8 | undefined, 9 | 1, 10 | { 11 | "test": undefined, 12 | "test1": "hello", 13 | }, 14 | null, 15 | undefined, 16 | ], 17 | "y": null, 18 | "z": undefined, 19 | } 20 | `; 21 | -------------------------------------------------------------------------------- /.github/workflows/prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Pre Release 2 | 3 | on: 4 | release: 5 | types: [prereleased] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | cache: 'pnpm' 16 | node-version-file: 'package.json' 17 | - run: pnpm install 18 | - uses: JS-DevTools/npm-publish@v3 19 | with: 20 | token: ${{ secrets.NPM_TOKEN }} 21 | access: public 22 | tag: next 23 | -------------------------------------------------------------------------------- /src/utils/is-type.ts: -------------------------------------------------------------------------------- 1 | export const OBJECT = 'object'; 2 | export const FUNCTION = 'function'; 3 | 4 | export const isFunction = (value: any): value is T => 5 | !!value && typeof value === FUNCTION; 6 | 7 | export const isObject = (value: any): value is T => 8 | !!value && typeof value === OBJECT; 9 | 10 | export const isPlainObject = (value: any): value is T => 11 | !!value && Object.prototype.toString.call(value) === '[object Object]'; 12 | 13 | export const isString = (value: any): value is T => 14 | typeof value === 'string'; 15 | -------------------------------------------------------------------------------- /test/typescript/get-loading.check.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'ts-expect'; 2 | import { getLoading } from '../../src'; 3 | import { basicModel } from '../models/basic.model'; 4 | 5 | expectType(getLoading(basicModel.foo)); 6 | expectType(getLoading(basicModel.foo.room).find('xx')); 7 | expectType(getLoading(basicModel.foo.room, 'xx')); 8 | // @ts-expect-error 9 | getLoading(basicModel.foo.room, basicModel.foo); 10 | // @ts-expect-error 11 | getLoading(basicModel.foo.room, true); 12 | // @ts-expect-error 13 | getLoading(basicModel.foo.room, false); 14 | // @ts-expect-error 15 | getLoading(basicModel.normalMethod.room); 16 | -------------------------------------------------------------------------------- /src/actions/refresh.ts: -------------------------------------------------------------------------------- 1 | import type { UnknownAction } from 'redux'; 2 | 3 | const TYPE_REFRESH_STORE = '@@store/refresh'; 4 | 5 | export interface RefreshAction extends UnknownAction { 6 | type: typeof TYPE_REFRESH_STORE; 7 | payload: { 8 | force: boolean; 9 | }; 10 | } 11 | 12 | export const actionRefresh = (force: boolean): RefreshAction => { 13 | return { 14 | type: TYPE_REFRESH_STORE, 15 | payload: { 16 | force, 17 | }, 18 | }; 19 | }; 20 | 21 | export const isRefreshAction = ( 22 | action: UnknownAction, 23 | ): action is RefreshAction => { 24 | return (action as RefreshAction).type === TYPE_REFRESH_STORE; 25 | }; 26 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | coverage: { 7 | provider: 'istanbul', 8 | enabled: true, 9 | include: ['src/**'], 10 | thresholds: { 11 | lines: 99, 12 | functions: 99, 13 | branches: 99, 14 | statements: 99, 15 | }, 16 | reporter: ['html', 'lcovonly', 'text-summary'], 17 | }, 18 | environment: 'jsdom', 19 | globals: true, 20 | snapshotFormat: { 21 | escapeString: false, 22 | printBasicPrototype: false, 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/actions/persist.ts: -------------------------------------------------------------------------------- 1 | import type { UnknownAction } from 'redux'; 2 | 3 | const TYPE_PERSIST_HYDRATE = '@@persist/hydrate'; 4 | 5 | export interface PersistHydrateAction extends UnknownAction { 6 | type: typeof TYPE_PERSIST_HYDRATE; 7 | payload: Record; 8 | } 9 | 10 | export const actionHydrate = ( 11 | states: Record, 12 | ): PersistHydrateAction => { 13 | return { 14 | type: TYPE_PERSIST_HYDRATE, 15 | payload: states, 16 | }; 17 | }; 18 | 19 | export const isHydrateAction = ( 20 | action: UnknownAction, 21 | ): action is PersistHydrateAction => { 22 | return (action as PersistHydrateAction).type === TYPE_PERSIST_HYDRATE; 23 | }; 24 | -------------------------------------------------------------------------------- /test/models/complex.model.ts: -------------------------------------------------------------------------------- 1 | import { defineModel } from '../../src'; 2 | 3 | const initialState: { 4 | users: Record; 5 | ids: Array; 6 | } = { 7 | users: {}, 8 | ids: [], 9 | }; 10 | 11 | export const complexModel = defineModel('complex', { 12 | initialState, 13 | reducers: { 14 | addUser(state, id: number, name: string) { 15 | state.users[id] = name; 16 | !state.ids.includes(id) && state.ids.push(id); 17 | }, 18 | deleteUser(state, id: number) { 19 | delete state.users[id]; 20 | state.ids = state.ids.filter((item) => item !== id); 21 | }, 22 | updateUser(state, id: number, name: string) { 23 | state.users[id] = name; 24 | }, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /test/helpers/slow-engine.ts: -------------------------------------------------------------------------------- 1 | import type { StorageEngine } from '../../src'; 2 | import { toPromise } from '../../src/utils/to-promise'; 3 | 4 | let cache: Partial> = {}; 5 | 6 | export const slowEngine: StorageEngine = { 7 | getItem(key) { 8 | return new Promise((resolve) => { 9 | setTimeout(() => { 10 | resolve(cache[key] === void 0 ? null : cache[key]!); 11 | }, 300); 12 | }); 13 | }, 14 | setItem(key, value) { 15 | return toPromise(() => { 16 | cache[key] = value; 17 | }); 18 | }, 19 | removeItem(key) { 20 | return toPromise(() => { 21 | cache[key] = void 0; 22 | }); 23 | }, 24 | clear() { 25 | return toPromise(() => { 26 | cache = {}; 27 | }); 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /test/provider.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { FocaProvider, store } from '../src'; 3 | 4 | beforeEach(() => { 5 | store.init(); 6 | }); 7 | 8 | afterEach(() => { 9 | store.unmount(); 10 | }); 11 | 12 | test('render normal tag', () => { 13 | render( 14 | 15 |
Hello World
16 |
, 17 | ); 18 | 19 | expect(screen.queryByTestId('root')!.innerHTML).toBe('Hello World'); 20 | }); 21 | 22 | test('render function tag', () => { 23 | render( 24 | 25 | {() =>
Hello World
} 26 |
, 27 | ); 28 | 29 | expect(screen.queryByTestId('root')!.innerHTML).toBe('Hello World'); 30 | }); 31 | -------------------------------------------------------------------------------- /test/typescript/use-model.check.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'ts-expect'; 2 | import { useModel } from '../../src'; 3 | import { basicModel } from '../models/basic.model'; 4 | import { complexModel } from '../models/complex.model'; 5 | 6 | const basic = useModel(basicModel); 7 | 8 | expectType(basic.count); 9 | expectType(basic.hello); 10 | // @ts-expect-error 11 | basic.notExist; 12 | 13 | const count = useModel(basicModel, (state) => state.count); 14 | expectType(count); 15 | 16 | const obj = useModel(basicModel, complexModel); 17 | expectType(obj.basic.count); 18 | expectType(obj.complex.ids); 19 | // @ts-expect-error 20 | obj.notExists; 21 | 22 | const hello = useModel( 23 | basicModel, 24 | complexModel, 25 | (basic, complex) => basic.hello + complex.ids.length, 26 | ); 27 | 28 | expectType(hello); 29 | -------------------------------------------------------------------------------- /src/utils/serialize.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from './is-type'; 2 | 3 | const JSON_UNDEFINED = '__JSON_UNDEFINED__'; 4 | 5 | const replacer = (_key: string, value: any) => { 6 | return value === void 0 ? JSON_UNDEFINED : value; 7 | }; 8 | 9 | const reviver = (_key: string, value: any) => { 10 | if (isObject>(value)) { 11 | const keys = Object.keys(value); 12 | for (let i = keys.length; i-- > 0; ) { 13 | const key = keys[i]!; 14 | if (value[key] === JSON_UNDEFINED) { 15 | value[key] = void 0; 16 | } 17 | } 18 | } 19 | 20 | return value; 21 | }; 22 | 23 | export const stringifyState = (value: any) => { 24 | return JSON.stringify(value, replacer); 25 | }; 26 | 27 | export const parseState = (value: string) => { 28 | return JSON.parse( 29 | value, 30 | value.indexOf(JSON_UNDEFINED) >= 0 ? reviver : void 0, 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /test/build.test.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs'; 2 | import { execSync, exec } from 'child_process'; 3 | 4 | function testFile(filename: string, expectCode: number) { 5 | return new Promise((resolve) => { 6 | const child = exec(`node ${filename}`); 7 | child.on('exit', (code) => { 8 | try { 9 | expect(code).toBe(expectCode); 10 | } finally { 11 | resolve(code); 12 | } 13 | }); 14 | }); 15 | } 16 | 17 | beforeEach(() => { 18 | execSync('npx tsup'); 19 | }, 10000); 20 | 21 | test('ESM with type=module', async () => { 22 | await testFile('dist/esm/index.js', 0); 23 | }); 24 | 25 | test('ESM with type=commonjs', async () => { 26 | writeFileSync('dist/esm/package.json', '{"type": "commonjs"}'); 27 | await testFile('dist/esm/index.js', 1); 28 | }); 29 | 30 | test('pure commonjs', async () => { 31 | await testFile('dist/index.js', 0); 32 | }); 33 | -------------------------------------------------------------------------------- /src/reactive/deps-collector.ts: -------------------------------------------------------------------------------- 1 | import type { Deps } from './object-deps'; 2 | 3 | const deps: Deps[][] = []; 4 | let level = -1; 5 | 6 | export const depsCollector = { 7 | get active(): boolean { 8 | return level >= 0; 9 | }, 10 | produce(callback: Function): Deps[] { 11 | const current: Deps[] = (deps[++level] = []); 12 | callback(); 13 | deps.length = level--; 14 | 15 | const uniqueDeps: Deps[] = []; 16 | const uniqueID: string[] = []; 17 | 18 | for (let i = 0; i < current.length; ++i) { 19 | const dep = current[i]!; 20 | const id = dep.id; 21 | if (uniqueID.indexOf(id) === -1) { 22 | uniqueID.push(id); 23 | uniqueDeps.push(dep); 24 | dep.end(); 25 | } 26 | } 27 | 28 | return uniqueDeps; 29 | }, 30 | append(dep: Deps) { 31 | deps[level]!.push(dep); 32 | }, 33 | prepend(dep: Deps) { 34 | deps[level]!.unshift(dep); 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/middleware/model.interceptor.ts: -------------------------------------------------------------------------------- 1 | import type { Middleware } from 'redux'; 2 | import { deepEqual } from '../utils/deep-equal'; 3 | import { isPreModelAction, PostModelAction } from '../actions/model'; 4 | import { immer } from '../utils/immer'; 5 | 6 | export const modelInterceptor: Middleware<{}, Record> = 7 | (api) => (dispatch) => (action) => { 8 | if (!isPreModelAction(action)) { 9 | return dispatch(action); 10 | } 11 | 12 | const prev = api.getState()[action.model]!; 13 | const next = immer.produce(prev, (draft) => { 14 | return action.consumer(draft, action); 15 | }); 16 | 17 | action.actionInActionGuard && action.actionInActionGuard(); 18 | 19 | if (deepEqual(prev, next)) return action; 20 | 21 | return dispatch({ 22 | type: action.type, 23 | model: action.model, 24 | postModel: true, 25 | next: next, 26 | } satisfies PostModelAction); 27 | }; 28 | -------------------------------------------------------------------------------- /test/models/persist.model.ts: -------------------------------------------------------------------------------- 1 | import { cloneModel, defineModel } from '../../src'; 2 | 3 | const initialState: { 4 | counter: number; 5 | } = { 6 | counter: 0, 7 | }; 8 | 9 | export const persistModel = defineModel('persist', { 10 | initialState, 11 | reducers: { 12 | plus(state, step: number) { 13 | state.counter += step; 14 | }, 15 | minus(state, step: number) { 16 | state.counter -= step; 17 | }, 18 | }, 19 | persist: {}, 20 | }); 21 | 22 | export const hasVersionPersistModel = cloneModel('persist1', persistModel, { 23 | initialState: { 24 | counter: 56, 25 | }, 26 | persist: { 27 | version: 10, 28 | }, 29 | }); 30 | 31 | export const hasFilterPersistModel = cloneModel('persist2', persistModel, { 32 | persist: { 33 | dump(state) { 34 | return state.counter; 35 | }, 36 | load(counter) { 37 | return { ...this.initialState, counter: counter + 1 }; 38 | }, 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // 模型中使用 2 | export { defineModel } from './model/define-model'; 3 | export { cloneModel } from './model/clone-model'; 4 | 5 | // 组件中使用 6 | export { useModel } from './api/use-model'; 7 | export { useLoading } from './api/use-loading'; 8 | export { getLoading } from './api/get-loading'; 9 | export { useComputed } from './api/use-computed'; 10 | export { useIsolate } from './api/use-isolate'; 11 | export { connect } from './redux/connect'; 12 | 13 | // 入口使用 14 | export { compose } from 'redux'; 15 | export { modelStore as store } from './store/model-store'; 16 | export { FocaProvider } from './redux/foca-provider'; 17 | export { memoryStorage } from './engines/memory'; 18 | 19 | // 可能用到的TS类型 20 | export type { 21 | Action, 22 | UnknownAction, 23 | Dispatch, 24 | MiddlewareAPI, 25 | Middleware, 26 | StoreEnhancer, 27 | Unsubscribe, 28 | } from 'redux'; 29 | export type { Model } from './model/types'; 30 | export type { StorageEngine } from './engines/storage-engine'; 31 | -------------------------------------------------------------------------------- /src/redux/create-reducer.ts: -------------------------------------------------------------------------------- 1 | import type { Reducer } from 'redux'; 2 | import { isPostModelAction } from '../actions/model'; 3 | import { isRefreshAction } from '../actions/refresh'; 4 | 5 | interface Options { 6 | readonly name: string; 7 | readonly initialState: State; 8 | readonly allowRefresh: boolean; 9 | } 10 | 11 | export const createReducer = ( 12 | options: Options, 13 | ): Reducer => { 14 | const allowRefresh = options.allowRefresh; 15 | const reducerName = options.name; 16 | const initialState = options.initialState; 17 | 18 | return function reducer(state, action) { 19 | if (state === void 0) return initialState; 20 | 21 | if (isPostModelAction(action) && action.model === reducerName) { 22 | return action.next; 23 | } 24 | 25 | if (isRefreshAction(action) && (allowRefresh || action.payload.force)) { 26 | return initialState; 27 | } 28 | 29 | return state; 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /test/typescript/use-loading.check.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'ts-expect'; 2 | import { useLoading } from '../../src'; 3 | import { basicModel } from '../models/basic.model'; 4 | 5 | expectType(useLoading(basicModel.bar)); 6 | expectType(useLoading(basicModel.foo, basicModel.bar)); 7 | // @ts-expect-error 8 | useLoading(basicModel.minus); 9 | // @ts-expect-error 10 | useLoading(basicModel); 11 | // @ts-expect-error 12 | useLoading({}); 13 | 14 | expectType(useLoading(basicModel.foo.room).find('xx')); 15 | expectType(useLoading(basicModel.foo.room, 'xx')); 16 | // @ts-expect-error 17 | useLoading(basicModel.foo.room, basicModel.foo); 18 | // @ts-expect-error 19 | useLoading(basicModel.foo.room, true); 20 | // @ts-expect-error 21 | useLoading(basicModel.foo.room, false); 22 | // @ts-expect-error 23 | useLoading(basicModel.normalMethod.room); 24 | // @ts-expect-error 25 | useLoading(basicModel.normalMethod.assign); 26 | // @ts-expect-error 27 | useLoading(basicModel.minus.room); 28 | -------------------------------------------------------------------------------- /src/middleware/loading.interceptor.ts: -------------------------------------------------------------------------------- 1 | import type { Middleware } from 'redux'; 2 | import type { LoadingStore, LoadingStoreState } from '../store/loading-store'; 3 | import { isLoadingAction } from '../actions/loading'; 4 | 5 | export const loadingInterceptor = ( 6 | loadingStore: LoadingStore, 7 | ): Middleware<{}, LoadingStoreState> => { 8 | return () => (dispatch) => (action) => { 9 | if (!isLoadingAction(action)) { 10 | return dispatch(action); 11 | } 12 | 13 | const { 14 | model, 15 | method, 16 | payload: { category, loading }, 17 | } = action; 18 | 19 | if (loadingStore.isModelInitializing(model)) { 20 | loadingStore.activate(model, method); 21 | } else if (!loadingStore.isActive(model, method)) { 22 | return; 23 | } 24 | 25 | const record = loadingStore.getItem(model, method); 26 | 27 | if (!record || record.loadings.data[category] !== loading) { 28 | return dispatch(action); 29 | } 30 | 31 | return action; 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: 7 | branches: ['master'] 8 | schedule: 9 | - cron: '29 3 * * 1' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [javascript] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: '/language:${{ matrix.language }}' 42 | -------------------------------------------------------------------------------- /src/store/store-basic.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | import { $$observable } from '../utils/symbol-observable'; 3 | 4 | export abstract class StoreBasic implements Store { 5 | protected origin: Store | null = null; 6 | 7 | /** 8 | * @deprecated 请勿使用该方法,因为它其实没有被实现 9 | */ 10 | declare replaceReducer: Store['replaceReducer']; 11 | 12 | dispatch: Store['dispatch'] = (action) => { 13 | return this.store.dispatch(action); 14 | }; 15 | 16 | getState: Store['getState'] = () => { 17 | return this.store.getState(); 18 | }; 19 | 20 | subscribe: Store['subscribe'] = (listener) => { 21 | return this.store.subscribe(listener); 22 | }; 23 | 24 | [$$observable]: Store[typeof $$observable] = () => { 25 | return this.store[$$observable](); 26 | }; 27 | 28 | protected get store(): Store { 29 | if (!this.origin) { 30 | throw new Error(`[store] 当前无实例,忘记执行'store.init()'了吗?`); 31 | } 32 | return this.origin; 33 | } 34 | 35 | abstract init(): void; 36 | abstract unmount(): void; 37 | } 38 | -------------------------------------------------------------------------------- /src/persist/persist-gate.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, FC, useState, useEffect } from 'react'; 2 | import { modelStore } from '../store/model-store'; 3 | import { isFunction } from '../utils/is-type'; 4 | 5 | export interface PersistGateProps { 6 | loading?: ReactNode; 7 | children?: ReactNode | ((isReady: boolean) => ReactNode); 8 | } 9 | 10 | export const PersistGate: FC = (props) => { 11 | const state = useState(() => modelStore.isReady), 12 | isReady = state[0], 13 | setIsReady = state[1]; 14 | const { loading = null, children } = props; 15 | 16 | useEffect(() => { 17 | isReady || 18 | modelStore.onInitialized().then(() => { 19 | setIsReady(true); 20 | }); 21 | }, []); 22 | 23 | /* istanbul ignore else -- @preserve */ 24 | if (process.env.NODE_ENV !== 'production') { 25 | if (loading && isFunction(children)) { 26 | console.error('[PersistGate] 当前children为函数类型,loading属性无效'); 27 | } 28 | } 29 | 30 | return ( 31 | <> 32 | {isFunction(children) ? children(isReady) : isReady ? children : loading} 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/actions/model.ts: -------------------------------------------------------------------------------- 1 | import type { Action, UnknownAction } from 'redux'; 2 | import { isFunction } from '../utils/is-type'; 3 | 4 | export interface PreModelAction 5 | extends UnknownAction, 6 | Action { 7 | model: string; 8 | preModel: true; 9 | payload: Payload; 10 | actionInActionGuard?: () => void; 11 | consumer(state: State, action: PreModelAction): State | void; 12 | } 13 | 14 | export interface PostModelAction 15 | extends UnknownAction, 16 | Action { 17 | model: string; 18 | postModel: true; 19 | next: State; 20 | } 21 | 22 | export const isPreModelAction = ( 23 | action: UnknownAction | unknown, 24 | ): action is PreModelAction => { 25 | const test = action as PreModelAction; 26 | return test.preModel && !!test.model && isFunction(test.consumer); 27 | }; 28 | 29 | export const isPostModelAction = ( 30 | action: UnknownAction, 31 | ): action is PostModelAction => { 32 | const test = action as PostModelAction; 33 | return test.postModel && !!test.next; 34 | }; 35 | -------------------------------------------------------------------------------- /src/utils/deep-equal.ts: -------------------------------------------------------------------------------- 1 | import { OBJECT } from './is-type'; 2 | 3 | export const deepEqual = (a: any, b: any): boolean => { 4 | if (a === b) return true; 5 | 6 | if (a && b && typeof a == OBJECT && typeof b == OBJECT) { 7 | if (a.constructor !== b.constructor) return false; 8 | 9 | let i: number; 10 | let len: number; 11 | let key: string; 12 | 13 | if (Array.isArray(a)) { 14 | len = a.length; 15 | 16 | if (len != b.length) return false; 17 | 18 | for (i = len; i-- > 0; ) { 19 | if (!deepEqual(a[i], b[i])) return false; 20 | } 21 | 22 | return true; 23 | } 24 | 25 | const keys = Object.keys(a); 26 | len = keys.length; 27 | 28 | if (len !== Object.keys(b).length) return false; 29 | 30 | for (i = len; i-- > 0; ) { 31 | if (!hasOwn.call(b, keys[i]!)) return false; 32 | } 33 | 34 | for (i = len; i-- > 0; ) { 35 | key = keys[i]!; 36 | if (!deepEqual(a[key], b[key])) return false; 37 | } 38 | 39 | return true; 40 | } 41 | 42 | return a !== a && b !== b; 43 | }; 44 | 45 | const hasOwn = Object.prototype.hasOwnProperty; 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 geekact 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping 4 | "target": "ES2015", 5 | "module": "ES2015", 6 | "lib": ["ESNext", "DOM"], 7 | "allowJs": false, 8 | "declaration": true, 9 | "outDir": "./build", 10 | "rootDir": ".", 11 | "importHelpers": false, 12 | "jsx": "react-jsx", 13 | "noEmit": true, 14 | "strict": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "strictFunctionTypes": true, 18 | "strictBindCallApply": true, 19 | "strictPropertyInitialization": true, 20 | "noImplicitThis": true, 21 | "alwaysStrict": true, 22 | 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noImplicitReturns": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "noUncheckedIndexedAccess": true, 28 | 29 | "moduleResolution": "node", 30 | "esModuleInterop": true, 31 | "resolveJsonModule": true, 32 | 33 | "skipLibCheck": true, 34 | "forceConsistentCasingInFileNames": true, 35 | "noImplicitOverride": true, 36 | "useUnknownInCatchVariables": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/donate.md: -------------------------------------------------------------------------------- 1 | # 捐赠 2 | 3 | 开源不易,升级维护框架和解决各种 issue 需要十分多的精力和时间。希望能得到你的支持,让项目处于良性发展的状态。 4 | 5 | > 捐赠时请备注你的 github 账号,对于每一个捐赠者,我都会放到 README.md 以及当前页表示感谢。 6 | 7 | ### 二维码 8 | 9 |
10 | 11 | 12 | 13 |
14 | 15 | ### 鸣谢 16 | 17 | 18 | 19 | 20 | 21 | 27 | 33 | 34 |
22 | 23 | 24 |
25 | arcsin1 26 |
28 | 29 | 30 |
31 | xiongxliu 32 |
35 | -------------------------------------------------------------------------------- /src/actions/loading.ts: -------------------------------------------------------------------------------- 1 | import type { UnknownAction } from 'redux'; 2 | 3 | export const TYPE_SET_LOADING = '@@store/loading'; 4 | 5 | export const LOADING_CATEGORY = '##' + Math.random(); 6 | 7 | export const DESTROY_LOADING = TYPE_SET_LOADING + '/destroy'; 8 | 9 | export interface LoadingAction extends UnknownAction { 10 | type: typeof TYPE_SET_LOADING; 11 | model: string; 12 | method: string; 13 | payload: { 14 | loading: boolean; 15 | category: string | number; 16 | }; 17 | } 18 | 19 | export const isLoadingAction = ( 20 | action: UnknownAction | unknown, 21 | ): action is LoadingAction => { 22 | const tester = action as LoadingAction; 23 | return ( 24 | tester.type === TYPE_SET_LOADING && 25 | !!tester.model && 26 | !!tester.method && 27 | !!tester.payload 28 | ); 29 | }; 30 | 31 | export interface DestroyLoadingAction extends UnknownAction { 32 | type: typeof DESTROY_LOADING; 33 | model: string; 34 | } 35 | 36 | export const isDestroyLoadingAction = ( 37 | action: UnknownAction | unknown, 38 | ): action is DestroyLoadingAction => { 39 | const tester = action as DestroyLoadingAction; 40 | return tester.type === DESTROY_LOADING && !!tester.model; 41 | }; 42 | -------------------------------------------------------------------------------- /test/serialize.test.ts: -------------------------------------------------------------------------------- 1 | import { parseState, stringifyState } from '../src/utils/serialize'; 2 | 3 | it('can clone basic data', () => { 4 | expect( 5 | parseState( 6 | stringifyState({ 7 | x: 1, 8 | y: 'y', 9 | z: true, 10 | }), 11 | ), 12 | ).toMatchObject({ 13 | x: 1, 14 | y: 'y', 15 | z: true, 16 | }); 17 | }); 18 | 19 | it('can clone complex data', () => { 20 | expect( 21 | parseState( 22 | stringifyState({ 23 | x: 1, 24 | y: { 25 | z: [1, 2, '3'], 26 | }, 27 | }), 28 | ), 29 | ).toMatchObject({ 30 | x: 1, 31 | y: { 32 | z: [1, 2, '3'], 33 | }, 34 | }); 35 | }); 36 | 37 | it('can clone null and undefined', () => { 38 | const data = { 39 | x: [ 40 | undefined, 41 | 1, 42 | { 43 | test: undefined, 44 | test1: 'hello', 45 | }, 46 | null, 47 | undefined, 48 | ], 49 | y: null, 50 | z: undefined, 51 | }; 52 | 53 | expect(stringifyState(data)).toMatchSnapshot(); 54 | expect(parseState(stringifyState(data))).toMatchSnapshot(); 55 | expect(parseState(stringifyState(data))).toMatchObject(data); 56 | }); 57 | -------------------------------------------------------------------------------- /src/model/enhance-action.ts: -------------------------------------------------------------------------------- 1 | import type { PreModelAction } from '../actions/model'; 2 | import { modelStore } from '../store/model-store'; 3 | import { toArgs } from '../utils/to-args'; 4 | import type { ActionCtx } from './types'; 5 | 6 | export interface EnhancedAction { 7 | (payload: any): PreModelAction; 8 | } 9 | 10 | export const enhanceAction = ( 11 | ctx: ActionCtx, 12 | actionName: string, 13 | consumer: (state: State, ...args: any[]) => any, 14 | ): EnhancedAction => { 15 | const modelName = ctx.name; 16 | const actionType = modelName + '.' + actionName; 17 | 18 | const enhancedConsumer: PreModelAction['consumer'] = ( 19 | state, 20 | action, 21 | ) => { 22 | return consumer.apply( 23 | ctx, 24 | [state].concat(action.payload) as [state: State, ...args: any[]], 25 | ); 26 | }; 27 | 28 | const fn: EnhancedAction = function () { 29 | return modelStore.dispatch>({ 30 | type: actionType, 31 | model: modelName, 32 | preModel: true, 33 | payload: toArgs(arguments), 34 | consumer: enhancedConsumer, 35 | }); 36 | }; 37 | 38 | return fn; 39 | }; 40 | -------------------------------------------------------------------------------- /test/typescript/use-isolate.check.ts: -------------------------------------------------------------------------------- 1 | import { TypeEqual, expectType } from 'ts-expect'; 2 | import { defineModel, useIsolate, useLoading, useModel } from '../../src'; 3 | import { basicModel } from '../models/basic.model'; 4 | 5 | const isolatedModel = useIsolate(basicModel); 6 | 7 | useModel(isolatedModel); 8 | useModel(isolatedModel, (state) => state.count); 9 | useLoading(isolatedModel.pureAsync); 10 | useLoading(isolatedModel.pureAsync.room); 11 | 12 | useModel(basicModel, isolatedModel); 13 | { 14 | const model = useModel(isolatedModel, basicModel); 15 | expectType< 16 | TypeEqual< 17 | { 18 | basic: { count: number; hello: string }; 19 | } & { 20 | [x: string]: { 21 | count: number; 22 | hello: string; 23 | }; 24 | }, 25 | typeof model 26 | > 27 | >(true); 28 | } 29 | 30 | useModel(isolatedModel, basicModel, () => {}); 31 | 32 | { 33 | const model = useIsolate(isolatedModel); 34 | expectType>(true); 35 | } 36 | // @ts-expect-error 37 | cloneModel(isolatedModel); 38 | 39 | defineModel('', { 40 | initialState: {}, 41 | events: { 42 | onDestroy() { 43 | expectType(this); 44 | }, 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /test/fixtures/equals.ts: -------------------------------------------------------------------------------- 1 | export const equals: Record = { 2 | '0 and -0': { 3 | a: 0, 4 | b: -0, 5 | }, 6 | 'NaN': { 7 | a: NaN, 8 | b: NaN, 9 | }, 10 | 'number': { 11 | a: 1, 12 | b: 1, 13 | }, 14 | 'string': { 15 | a: 'x', 16 | b: 'x', 17 | }, 18 | 'empty array': { 19 | a: [], 20 | b: [], 21 | }, 22 | 'array': { 23 | a: [1, 2, 'x'], 24 | b: [1, 2, 'x'], 25 | }, 26 | 'complex array': { 27 | a: [1, { x: { y: 2, z: 3, x: [3, 6] } }, 5], 28 | b: [1, { x: { y: 2, z: 3, x: [3, 6] } }, 5], 29 | }, 30 | 'object': { 31 | a: { x: 1, y: 2 }, 32 | b: { x: 1, y: 2 }, 33 | }, 34 | 'complex object': { 35 | a: { 36 | x: { 37 | y: { 38 | z: [3, 6], 39 | p: [ 40 | { 41 | m: 6, 42 | }, 43 | ], 44 | }, 45 | }, 46 | }, 47 | b: { 48 | x: { 49 | y: { 50 | z: [3, 6], 51 | p: [ 52 | { 53 | m: 6, 54 | }, 55 | ], 56 | }, 57 | }, 58 | }, 59 | }, 60 | 'object without prototype': { 61 | a: Object.create(null), 62 | b: Object.create(null), 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /src/api/use-computed.ts: -------------------------------------------------------------------------------- 1 | import { ComputedFlag } from '../model/types'; 2 | import { useModelSelector } from '../redux/use-selector'; 3 | import { toArgs } from '../utils/to-args'; 4 | 5 | export interface UseComputedFlag extends ComputedFlag { 6 | (...args: any[]): any; 7 | } 8 | 9 | /** 10 | * 计算属性hooks函数,第二个参数开始传入计算属性的参数(如果有) 11 | * 12 | * ```typescript 13 | * 14 | * const App: FC = () => { 15 | * const fullName = useComputed(model.fullName); 16 | * const profile = useComputed(model.profile, 25); 17 | * return

{profile}

; 18 | * } 19 | * 20 | * const model = defineModel('my-model', { 21 | * initialState: { firstName: '', lastName: '' }, 22 | * computed: { 23 | * fullName() { 24 | * return this.state.firstName + this.state.lastName; 25 | * }, 26 | * profile(age: number, address?: string) { 27 | * return this.fullName() + age + address; 28 | * } 29 | * } 30 | * }); 31 | * 32 | * ``` 33 | */ 34 | export function useComputed( 35 | ref: T, 36 | ...args: Parameters 37 | ): T extends (...args: any[]) => infer R ? R : never; 38 | 39 | export function useComputed(ref: UseComputedFlag) { 40 | const args = toArgs(arguments, 1); 41 | return useModelSelector(() => ref.apply(null, args)); 42 | } 43 | -------------------------------------------------------------------------------- /src/api/use-loading.ts: -------------------------------------------------------------------------------- 1 | import { PromiseRoomEffect, PromiseEffect } from '../model/enhance-effect'; 2 | import { FindLoading } from '../store/loading-store'; 3 | import { useLoadingSelector } from '../redux/use-selector'; 4 | import { getLoading } from './get-loading'; 5 | 6 | /** 7 | * 检测给定的effect方法中是否有正在执行的。支持多个方法同时传入。 8 | * 9 | * ```typescript 10 | * loading = useLoading(effect); 11 | * loading = useLoading(effect1, effect2, ...); 12 | * ``` 13 | * 14 | */ 15 | export function useLoading( 16 | effect: PromiseEffect, 17 | ...more: PromiseEffect[] 18 | ): boolean; 19 | 20 | /** 21 | * 检测给定的effect方法是否正在执行。 22 | * 23 | * ```typescript 24 | * loadings = useLoading(effect.room); 25 | * loading = loadings.find(CATEGORY); 26 | * ``` 27 | */ 28 | export function useLoading(effect: PromiseRoomEffect): FindLoading; 29 | 30 | /** 31 | * 检测给定的effect方法是否正在执行。 32 | * 33 | * ```typescript 34 | * loading = useLoading(effect.room, CATEGORY); 35 | * ``` 36 | */ 37 | export function useLoading( 38 | effect: PromiseRoomEffect, 39 | category: string | number, 40 | ): boolean; 41 | 42 | export function useLoading(): boolean | FindLoading { 43 | const args = arguments as unknown as Parameters; 44 | 45 | return useLoadingSelector(() => { 46 | return getLoading.apply(null, args); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /test/use-computed.test.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react'; 2 | import { renderHook } from './helpers/render-hook'; 3 | import { store, useComputed } from '../src'; 4 | import { computedModel } from './models/computed.model'; 5 | 6 | beforeEach(() => { 7 | store.init(); 8 | }); 9 | 10 | afterEach(() => { 11 | store.unmount(); 12 | }); 13 | 14 | test('get state from computed value', () => { 15 | const { result } = renderHook(() => useComputed(computedModel.fullName)); 16 | 17 | expect(result.current).toEqual('ticktock'); 18 | 19 | act(() => { 20 | computedModel.changeFirstName('hello'); 21 | }); 22 | expect(result.current).toEqual('hellotock'); 23 | 24 | act(() => { 25 | computedModel.changeFirstName('tick'); 26 | }); 27 | expect(result.current).toEqual('ticktock'); 28 | 29 | act(() => { 30 | computedModel.changeLastName('world'); 31 | }); 32 | expect(result.current).toEqual('tickworld'); 33 | }); 34 | 35 | test('with parameters', () => { 36 | const { result } = renderHook(() => 37 | useComputed(computedModel.withMultipleParameters, 43, 'address'), 38 | ); 39 | 40 | expect(result.current).toEqual('tick-age-43-addr-address'); 41 | 42 | act(() => { 43 | computedModel.changeFirstName('musk'); 44 | }); 45 | expect(result.current).toEqual('musk-age-43-addr-address'); 46 | }); 47 | -------------------------------------------------------------------------------- /src/redux/foca-provider.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { ProxyContext, ModelContext, LoadingContext } from './contexts'; 4 | import { modelStore } from '../store/model-store'; 5 | import { PersistGate, PersistGateProps } from '../persist/persist-gate'; 6 | import { proxyStore } from '../store/proxy-store'; 7 | import { loadingStore } from '../store/loading-store'; 8 | import { isFunction } from '../utils/is-type'; 9 | 10 | interface OwnProps extends PersistGateProps {} 11 | 12 | /** 13 | * 状态上下文组件,请挂载到入口文件。 14 | * 请确保您已经初始化了store仓库。 15 | * 16 | * @see store.init() 17 | * 18 | * ```typescript 19 | * ReactDOM.render( 20 | * 21 | * 22 | * 23 | * ); 24 | * ``` 25 | */ 26 | export const FocaProvider: FC = ({ children, loading }) => { 27 | return ( 28 | 29 | 30 | 31 | {modelStore['persister'] ? ( 32 | 33 | ) : isFunction(children) ? ( 34 | children(true) 35 | ) : ( 36 | children 37 | )} 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/utils/getter.ts: -------------------------------------------------------------------------------- 1 | import { toArgs } from './to-args'; 2 | 3 | export function composeGetter< 4 | T extends object, 5 | U1 extends (...args: any[]) => any, 6 | >(obj: T, getter1: U1): T & ReturnType; 7 | 8 | export function composeGetter< 9 | T extends object, 10 | U1 extends (...args: any[]) => any, 11 | U2 extends (...args: any[]) => any, 12 | >(obj: T, getter1: U1, getter2: U2): T & ReturnType & ReturnType; 13 | 14 | export function composeGetter< 15 | T extends object, 16 | U1 extends (...args: any[]) => any, 17 | U2 extends (...args: any[]) => any, 18 | U3 extends (...args: any[]) => any, 19 | >( 20 | obj: T, 21 | getter1: U1, 22 | getter2: U2, 23 | getter3: U3, 24 | ): T & ReturnType & ReturnType & ReturnType; 25 | 26 | export function composeGetter< 27 | T extends object, 28 | U1 extends (...args: any[]) => any, 29 | U2 extends (...args: any[]) => any, 30 | U3 extends (...args: any[]) => any, 31 | U4 extends (...args: any[]) => any, 32 | >( 33 | obj: T, 34 | getter1: U1, 35 | getter2: U2, 36 | getter3: U3, 37 | getter4: U4, 38 | ): T & ReturnType & ReturnType & ReturnType & ReturnType; 39 | 40 | export function composeGetter() { 41 | const args = toArgs(arguments); 42 | 43 | return args.reduce((carry, getter) => getter(carry), args.shift() as object); 44 | } 45 | 46 | export const defineGetter = (obj: object, key: string, get: () => any): any => { 47 | Object.defineProperty(obj, key, { 48 | get, 49 | }); 50 | return obj; 51 | }; 52 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | # 函数里 this 的类型是 any 4 | 5 | 需要在文件 **tsconfig.json** 中开启`"strict": true`或者`"noImplicitThis": true`。 6 | 7 | # 为什么要用 this 8 | 9 | 1. 可调用额外的内部属性和方法; 10 | 2. 可调用自定义的私有方法; 11 | 3. 方便克隆(cloneModel),this 作为 context 是可变的。 12 | 13 | # 没找到持久化守卫组件 14 | 15 | 内置在入口组件 `FocaProvider` 里了,初始化 store 的时候如果配置了 persist 属性,守卫会自动开启。 16 | 17 | # setState 和 reducers 的区别 18 | 19 | 互补关系。methods.setState 是专门为网络请求和一些组合业务设置的快捷操作(直接传入 state 或者回调)。相对于一些不需要复用的 reducer 函数,用 setState 反而能让模型对外暴露更少的接口,组件里用起来就会更舒服一些。 20 | 21 | # 追踪 methods 的执行状态有性能问题吗 22 | 23 | 没有。我们已经知道如果想获得状态,就必须通过`useLoading`, `getLoading` 这些 api 获取,但如果你没有显性地通过这些 api 获取某个函数的状态,就不会触发该函数的状态追踪逻辑,即自动忽略。 24 | 25 | 状态数据使用独立的内部 store 存储,任何变动都不会触发模型数据(useModel, connect)的重新检查。 26 | 27 | # 为什么不支持 SSR 28 | 29 | 因为 foca 是遵循单一 store 存储(单例),它的优点就是 model 创建后无需手动注册,在 CSR(Client-Side-Rendering) 中用起来很流畅。而 SSR(Server-Side-Rendering) 方案中,node 进程常驻于内存,这意味着所有的请求都会共享同一个 store,数据也必然会乱套。所以一些 SSR 框架比如 next.js, remix 都无法使用了。 30 | 31 | 再者,需要SSR的页面,一般是需要 SEO 的展示页,这种项目也用不上状态管理。并且 SSR 其实不是唯一的 SEO 优化方案,利用 user-agent 配合服务端动态渲染一样可以搞定,参考文章:https://segmentfault.com/a/1190000023481810 32 | 33 | # this.initialState 是否多余 34 | 35 | 大部分情况下你会觉得多余,直到你使用`cloneModel`复制出一个新的模型。我们允许复制模型的同时修改初始值,所以`this.initialState`就和`this.state`一样能明确自己归属于哪个模型。 36 | 37 | 同时,每次获取`this.initialState`,框架都会返回给你一份全新的数据(deep clone),这样再也不怕你会改动初始值了。 38 | 39 | # 命名有什么建议 40 | 41 | 模型文件名建议采用 `some-word.model.ts` 这种命名方式,可读性好。
42 | 模型内容建议采用 `export const someWordModel = defineModel('some-word')` 驼峰的方式来创建,变量名和模型名具有一定的关联性,也不容易与其它模型冲突。 43 | -------------------------------------------------------------------------------- /src/middleware/action-in-action.interceptor.ts: -------------------------------------------------------------------------------- 1 | import type { Middleware, UnknownAction } from 'redux'; 2 | import { isPreModelAction } from '../actions/model'; 3 | 4 | // 开发者有可能在action中执行action,这是十分不规范的操作。 5 | export const actionInActionInterceptor: Middleware = () => { 6 | let dispatching = false; 7 | let prevAction: UnknownAction | null = null; 8 | 9 | return (dispatch) => (action) => { 10 | if (!isPreModelAction(action)) { 11 | // 非model的action会直接进入redux,redux中已经有dispatch保护机制。 12 | return dispatch(action); 13 | } 14 | 15 | // model的action如果没有变化则不会进入redux,所以需要在这里额外保护。 16 | if (dispatching) { 17 | throw new Error( 18 | '[dispatch] 派发任务冲突,请检查是否在reducers函数中直接或者间接执行了其他reducers或者methods函数。\nreducers的唯一职责是更新当前的state,有额外的业务逻辑时请把methods作为执行入口并按需调用reducers。\n\n当前冲突的reducer:\n\n' + 19 | JSON.stringify(action, null, 4) + 20 | '\n\n上次执行未完成的reducer:\n\n' + 21 | JSON.stringify(prevAction, null, 4) + 22 | '\n\n', 23 | ); 24 | } 25 | 26 | try { 27 | dispatching = true; 28 | prevAction = action; 29 | /** 30 | * react-redux@8+ 主要服务于react18 31 | * 在react17中,有可能出现redux遍历subscriber时立即触发dispatch,然后这边来不及设置dispatching=false 32 | * 在react18中,如果使用`ReactDOM.render()`旧入口,则依旧会有这个问题。 33 | * @link https://github.com/foca-js/foca/issues/20 34 | */ 35 | action.actionInActionGuard = () => { 36 | dispatching = false; 37 | prevAction = null; 38 | }; 39 | return dispatch(action); 40 | } catch (e) { 41 | prevAction = null; 42 | dispatching = false; 43 | throw e; 44 | } 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/model/enhance-computed.ts: -------------------------------------------------------------------------------- 1 | import { ComputedValue } from '../reactive/computed-value'; 2 | import { modelStore } from '../store/model-store'; 3 | import { toArgs } from '../utils/to-args'; 4 | import { ComputedCtx, ComputedFlag } from './types'; 5 | 6 | export const enhanceComputed = ( 7 | ctx: ComputedCtx, 8 | modelName: string, 9 | computedName: string, 10 | fn: (...args: any[]) => any, 11 | ): ComputedFlag => { 12 | let caches: { 13 | deps: any[]; 14 | skipCount: number; 15 | ref: ComputedValue; 16 | }[] = []; 17 | 18 | function anonymousFn() { 19 | const args = toArgs(arguments); 20 | let hitCache: (typeof caches)[number] | undefined; 21 | 22 | searchCache: for (let i = 0; i < caches.length; ++i) { 23 | const cache = caches[i]!; 24 | if (hitCache) { 25 | ++cache.skipCount; 26 | continue; 27 | } 28 | for (let j = 0; j < cache.deps.length; ++j) { 29 | if (args[j] !== cache.deps[j]) { 30 | ++cache.skipCount; 31 | continue searchCache; 32 | } 33 | } 34 | cache.skipCount = 0; 35 | hitCache = cache; 36 | } 37 | 38 | if (hitCache) return hitCache.ref.value; 39 | 40 | if (caches.length > 10) { 41 | caches = caches.filter((cache) => cache.skipCount < 15); 42 | } 43 | 44 | hitCache = { 45 | deps: args, 46 | skipCount: 0, 47 | ref: new ComputedValue(modelStore, modelName, computedName, () => 48 | fn.apply(ctx, args), 49 | ), 50 | }; 51 | caches.push(hitCache); 52 | return hitCache.ref.value; 53 | } 54 | 55 | return anonymousFn as any; 56 | }; 57 | -------------------------------------------------------------------------------- /src/api/get-loading.ts: -------------------------------------------------------------------------------- 1 | import { LOADING_CATEGORY } from '../actions/loading'; 2 | import { PromiseRoomEffect, PromiseEffect } from '../model/enhance-effect'; 3 | import { loadingStore, FindLoading } from '../store/loading-store'; 4 | import { isFunction } from '../utils/is-type'; 5 | 6 | /** 7 | * 检测给定的effect方法中是否有正在执行的。支持多个方法同时传入。 8 | * 9 | * ```typescript 10 | * loading = getLoading(effect); 11 | * loading = getLoading(effect1, effect2, ...); 12 | * ``` 13 | * 14 | */ 15 | export function getLoading( 16 | effect: PromiseEffect, 17 | ...more: PromiseEffect[] 18 | ): boolean; 19 | 20 | /** 21 | * 检测给定的effect方法是否正在执行。 22 | * 23 | * ```typescript 24 | * loadings = getLoading(effect.room); 25 | * loading = loadings.find(CATEGORY) 26 | * ``` 27 | */ 28 | export function getLoading(effect: PromiseRoomEffect): FindLoading; 29 | 30 | /** 31 | * 检测给定的effect方法是否正在执行。 32 | * 33 | * ```typescript 34 | * loading = getLoading(effect.room, CATEGORY); 35 | * ``` 36 | */ 37 | export function getLoading( 38 | effect: PromiseRoomEffect, 39 | category: string | number, 40 | ): boolean; 41 | 42 | export function getLoading( 43 | effect: PromiseEffect | PromiseRoomEffect, 44 | category?: string | number | PromiseEffect, 45 | ): boolean | FindLoading { 46 | const args = arguments; 47 | 48 | if (effect._.hasRoom && !isFunction(category)) { 49 | const loadings = loadingStore.get(effect).loadings; 50 | return category === void 0 ? loadings : loadings.find(category); 51 | } 52 | 53 | for (let i = args.length; i-- > 0; ) { 54 | if (loadingStore.get(args[i]).loadings.find(LOADING_CATEGORY)) { 55 | return true; 56 | } 57 | } 58 | return false; 59 | } 60 | -------------------------------------------------------------------------------- /src/reactive/computed-value.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from 'redux'; 2 | import { depsCollector } from './deps-collector'; 3 | import { createComputedDeps } from './create-computed-deps'; 4 | import type { Deps } from './object-deps'; 5 | 6 | export class ComputedValue { 7 | public deps: Deps[] = []; 8 | public snapshot: any; 9 | 10 | protected active?: boolean; 11 | protected root?: any; 12 | 13 | constructor( 14 | protected readonly store: Pick>, 'getState'>, 15 | public readonly model: string, 16 | public readonly property: string, 17 | protected readonly fn: () => any, 18 | ) {} 19 | 20 | public get value(): T { 21 | if (this.active) { 22 | throw new Error( 23 | `[model:${this.model}] 计算属性"${this.property}"正在被循环引用`, 24 | ); 25 | } 26 | 27 | this.active = true; 28 | this.isDirty() && this.updateSnapshot(); 29 | this.active = false; 30 | 31 | if (depsCollector.active) { 32 | // 作为其他computed的依赖 33 | depsCollector.prepend(createComputedDeps(this)); 34 | } 35 | 36 | return this.snapshot; 37 | } 38 | 39 | isDirty(): boolean { 40 | if (!this.root) return true; 41 | 42 | const rootState = this.store.getState(); 43 | 44 | if (this.root !== rootState) { 45 | const deps = this.deps; 46 | // 前置的元素是createComputedDeps()生成的对象,执行isDirty()会触发ref.value 47 | // 后置的元素是state,所以需要从后往前判断 48 | for (let i = deps.length; i-- > 0; ) { 49 | if (deps[i]!.isDirty()) return true; 50 | } 51 | } 52 | 53 | this.root = rootState; 54 | return false; 55 | } 56 | 57 | protected updateSnapshot() { 58 | this.deps = depsCollector.produce(() => { 59 | this.snapshot = this.fn(); 60 | this.root = this.store.getState(); 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /test/action-in-action.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render } from '@testing-library/react'; 2 | import { FC, useEffect, version } from 'react'; 3 | import sleep from 'sleep-promise'; 4 | import { defineModel, FocaProvider, store, useModel } from '../src'; 5 | 6 | const model = defineModel('aia' + Math.random(), { 7 | initialState: { 8 | open: false, 9 | count: 1, 10 | }, 11 | reducers: { 12 | plus(state) { 13 | state.count += 1; 14 | }, 15 | toggle(state) { 16 | state.open = !state.open; 17 | }, 18 | }, 19 | }); 20 | 21 | const OtherComponent: FC = () => { 22 | useEffect(() => { 23 | model.plus(); 24 | }, []); 25 | return null; 26 | }; 27 | 28 | const App: FC = () => { 29 | const { open } = useModel(model); 30 | return <>{open && }; 31 | }; 32 | 33 | test.runIf(version.split('.')[0] === '18').each([true, false])( 34 | `[legacy: %s] forceUpdate should not cause action in action error`, 35 | async (legacy) => { 36 | store.init(); 37 | 38 | render( 39 | 40 | 41 | , 42 | { 43 | legacyRoot: legacy, 44 | }, 45 | ); 46 | 47 | // console.error 48 | // Warning: You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one. 49 | const spy = vitest.spyOn(console, 'error').mockImplementation(() => {}); 50 | 51 | await expect( 52 | Promise.all( 53 | Array(3) 54 | .fill('') 55 | .map((_, i) => 56 | act(async () => { 57 | await sleep(i * 2); 58 | model.toggle(); 59 | }), 60 | ), 61 | ), 62 | ).resolves.toStrictEqual(Array(3).fill(void 0)); 63 | 64 | // 等待useEffect 65 | await sleep(1000); 66 | store.unmount(); 67 | spy.mockRestore(); 68 | }, 69 | ); 70 | -------------------------------------------------------------------------------- /src/persist/persist-manager.ts: -------------------------------------------------------------------------------- 1 | import type { Reducer, Store, Unsubscribe } from 'redux'; 2 | import { actionHydrate, isHydrateAction } from '../actions/persist'; 3 | import { PersistItem, PersistOptions } from './persist-item'; 4 | 5 | export class PersistManager { 6 | protected initialized: boolean = false; 7 | protected readonly list: PersistItem[]; 8 | protected timer?: ReturnType; 9 | protected unsubscribeStore!: Unsubscribe; 10 | 11 | constructor(options: PersistOptions[]) { 12 | this.list = options.map((option) => new PersistItem(option)); 13 | } 14 | 15 | init(store: Store, hydrate: boolean) { 16 | this.unsubscribeStore = store.subscribe(() => { 17 | this.initialized && this.update(store); 18 | }); 19 | 20 | return Promise.all(this.list.map((item) => item.init())).then(() => { 21 | hydrate && store.dispatch(actionHydrate(this.collect())); 22 | this.initialized = true; 23 | }); 24 | } 25 | 26 | destroy() { 27 | this.unsubscribeStore(); 28 | this.initialized = false; 29 | } 30 | 31 | collect(): Record { 32 | return this.list.reduce>((stateMaps, item) => { 33 | return Object.assign(stateMaps, item.collect()); 34 | }, {}); 35 | } 36 | 37 | combineReducer(original: Reducer): Reducer> { 38 | return (state, action) => { 39 | if (state === void 0) state = {}; 40 | 41 | if (isHydrateAction(action)) { 42 | return Object.assign({}, state, action.payload); 43 | } 44 | 45 | return original(state, action); 46 | }; 47 | } 48 | 49 | protected update(store: Store) { 50 | this.timer ||= setTimeout(() => { 51 | const nextState = store.getState(); 52 | this.timer = void 0; 53 | for (let i = this.list.length; i-- > 0; ) { 54 | this.list[i]!.update(nextState); 55 | } 56 | }, 50); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | 每个模型都有针对自身的事件回调,在某些复杂的业务场景下,事件和其它属性的组合将变得十分灵活。 2 | 3 | ## onInit 4 | 5 | 当 store 初始化完成 并且持久化(如果有)数据已经恢复时,onInit 就会被自动触发。你可以调用 methods 或者 reducers 做一些额外操作。 6 | 7 | ```typescript 8 | import { defineModel } from 'foca'; 9 | 10 | // 如果是持久化的模型,则初始值不一定是0 11 | const initialState = { count: 0 }; 12 | 13 | export const myModel = defineModel('my', { 14 | initialState, 15 | reducers: { 16 | add(state, step: number) { 17 | state.count += step; 18 | }, 19 | }, 20 | methods: { 21 | async requestApi() { 22 | const result = await http.get('/path/to'); 23 | // ... 24 | }, 25 | }, 26 | events: { 27 | onInit() { 28 | this.add(10); 29 | this.requestApi(); 30 | }, 31 | }, 32 | }); 33 | ``` 34 | 35 | ## onChange 36 | 37 | 每当 state 有变化时的回调通知。初始化(onInit)执行之前不会触发该回调。如果在 onInit 中做了修改 state 的操作,则会触发该回调。 38 | 39 | ```typescript 40 | import { defineModel } from 'foca'; 41 | 42 | const initialState = { count: 0 }; 43 | 44 | export const testModel = defineModel('test', { 45 | initialState, 46 | reducers: { 47 | add(state, step: number) { 48 | state.count += step; 49 | }, 50 | }, 51 | methods: { 52 | _notify() { 53 | // do something 54 | }, 55 | }, 56 | events: { 57 | onChange(prevState, nextState) { 58 | if (prevState.count !== nextState.count) { 59 | // 达到watch的效果 60 | this._notify(); 61 | } 62 | }, 63 | }, 64 | }); 65 | ``` 66 | 67 | ## onDestroy 68 | 69 | 模型数据从 store 卸载时的回调通知。onDestroy 事件只针对[局部模型](/advanced?id=局部模型),即通过`useIsolate`这个 hooks api 创建的模型才会触发,因为局部模型是跟随组件一起创建和销毁的。 70 | 71 | 注意,当触发 onDestroy 回调时,模型已经被卸载了,所以无法再拿到当前数据,而且`this`上下文也被限制使用了。 72 | 73 | ```typescript 74 | import { defineModel } from 'foca'; 75 | 76 | export const testModel = defineModel('test', { 77 | initialState: { count: 0 }, 78 | events: { 79 | onDestroy(modelName) { 80 | console.log('Destroyed', modelName); 81 | }, 82 | }, 83 | }); 84 | ``` 85 | -------------------------------------------------------------------------------- /test/models/basic.model.ts: -------------------------------------------------------------------------------- 1 | import sleep from 'sleep-promise'; 2 | import { cloneModel, defineModel } from '../../src'; 3 | 4 | const initialState: { 5 | count: number; 6 | hello: string; 7 | } = { 8 | count: 0, 9 | hello: 'world', 10 | }; 11 | 12 | export const basicModel = defineModel('basic', { 13 | initialState, 14 | reducers: { 15 | plus(state, step: number) { 16 | state.count += step; 17 | }, 18 | minus(state, step: number) { 19 | state.count -= step; 20 | }, 21 | moreParams(state, step: number, hello: string) { 22 | state.count += step; 23 | state.hello += ', ' + hello; 24 | }, 25 | set(state, count: number) { 26 | state.count = count; 27 | }, 28 | reset() { 29 | return this.initialState; 30 | }, 31 | _actionIsPrivate() {}, 32 | ____alsoPrivateAction() {}, 33 | }, 34 | methods: { 35 | async foo(hello: string, step: number) { 36 | await sleep(20); 37 | 38 | this.setState((state) => { 39 | state.count += step; 40 | state.hello = hello; 41 | }); 42 | 43 | return 'OK'; 44 | }, 45 | setWithoutFn(step: number) { 46 | this.setState({ 47 | count: step, 48 | hello: 'earth', 49 | }); 50 | }, 51 | setPartialState(step: number) { 52 | this.setState({ 53 | count: step, 54 | }); 55 | }, 56 | async bar() { 57 | return this.foo('', 100); 58 | }, 59 | async bos() { 60 | return this.plus(4); 61 | }, 62 | async hasError(msg: string = 'my-test') { 63 | throw new Error(msg); 64 | }, 65 | async pureAsync() { 66 | await sleep(300); 67 | return 'OK'; 68 | }, 69 | normalMethod() { 70 | return 'YES'; 71 | }, 72 | async _effectIsPrivate() {}, 73 | ____alsoPrivateEffect() {}, 74 | }, 75 | }); 76 | 77 | export const basicSkipRefreshModel = cloneModel( 78 | 'basicSkipRefresh', 79 | basicModel, 80 | { 81 | skipRefresh: true, 82 | }, 83 | ); 84 | -------------------------------------------------------------------------------- /test/connect.test.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { act, render, screen } from '@testing-library/react'; 3 | import { store, connect, FocaProvider, getLoading } from '../src'; 4 | import { basicModel } from './models/basic.model'; 5 | import { complexModel } from './models/complex.model'; 6 | 7 | let App: FC> = ({ count, loading }) => { 8 | return ( 9 | <> 10 |
{count}
11 |
{loading.toString()}
12 | 13 | ); 14 | }; 15 | 16 | const mapStateToProps = () => { 17 | return { 18 | count: basicModel.state.count + complexModel.state.ids.length, 19 | loading: getLoading(basicModel.pureAsync), 20 | }; 21 | }; 22 | 23 | const Wrapped = connect(mapStateToProps)(App); 24 | 25 | const Root: FC = () => { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | beforeEach(() => { 34 | store.init(); 35 | }); 36 | 37 | afterEach(() => { 38 | store.unmount(); 39 | }); 40 | 41 | test('Get state from connect', async () => { 42 | render(); 43 | const $count = screen.queryByTestId('count')!; 44 | const $loading = screen.queryByTestId('loading')!; 45 | 46 | expect($count.innerHTML).toBe('0'); 47 | expect($loading.innerHTML).toBe('false'); 48 | 49 | act(() => { 50 | basicModel.plus(0); 51 | }); 52 | expect($count.innerHTML).toBe('0'); 53 | 54 | act(() => { 55 | basicModel.plus(1); 56 | }); 57 | expect($count.innerHTML).toBe('1'); 58 | 59 | act(() => { 60 | basicModel.plus(20.5); 61 | }); 62 | expect($count.innerHTML).toBe('21.5'); 63 | 64 | act(() => { 65 | complexModel.addUser(40, ''); 66 | }); 67 | expect($count.innerHTML).toBe('22.5'); 68 | 69 | let promise!: Promise; 70 | 71 | act(() => { 72 | promise = basicModel.pureAsync(); 73 | }); 74 | expect($loading.innerHTML).toBe('true'); 75 | 76 | await act(async () => { 77 | await promise; 78 | }); 79 | expect($loading.innerHTML).toBe('false'); 80 | }); 81 | -------------------------------------------------------------------------------- /src/model/clone-model.ts: -------------------------------------------------------------------------------- 1 | import { isFunction } from '../utils/is-type'; 2 | import { defineModel } from './define-model'; 3 | import type { DefineModelOptions, InternalModel, Model } from './types'; 4 | 5 | const editableKeys = [ 6 | 'initialState', 7 | 'events', 8 | 'persist', 9 | 'skipRefresh', 10 | ] as const; 11 | 12 | type EditableKeys = (typeof editableKeys)[number]; 13 | 14 | type OverrideOptions< 15 | State extends object, 16 | Action extends object, 17 | Effect extends object, 18 | Computed extends object, 19 | PersistDump, 20 | > = Pick< 21 | DefineModelOptions, 22 | EditableKeys 23 | >; 24 | 25 | export const cloneModel = < 26 | Name extends string, 27 | State extends object, 28 | Action extends object, 29 | Effect extends object, 30 | Computed extends object, 31 | PersistDump, 32 | >( 33 | uniqueName: Name, 34 | model: Model, 35 | options?: 36 | | Partial> 37 | | (( 38 | prev: OverrideOptions, 39 | ) => Partial< 40 | OverrideOptions 41 | >), 42 | ): Model => { 43 | const realModel = model as unknown as InternalModel< 44 | string, 45 | State, 46 | Action, 47 | Effect, 48 | Computed 49 | >; 50 | 51 | const prevOpts = realModel._$opts; 52 | const nextOpts = Object.assign({}, prevOpts); 53 | 54 | if (options) { 55 | Object.assign(nextOpts, isFunction(options) ? options(nextOpts) : options); 56 | 57 | /* istanbul ignore else -- @preserve */ 58 | if (process.env.NODE_ENV !== 'production') { 59 | (Object.keys(nextOpts) as EditableKeys[]).forEach((key) => { 60 | if ( 61 | nextOpts[key] !== prevOpts[key] && 62 | editableKeys.indexOf(key) === -1 63 | ) { 64 | throw new Error( 65 | `[model:${uniqueName}] 复制模型时禁止重写属性'${key}'`, 66 | ); 67 | } 68 | }); 69 | } 70 | } 71 | 72 | return defineModel(uniqueName, nextOpts); 73 | }; 74 | -------------------------------------------------------------------------------- /test/engine.test.ts: -------------------------------------------------------------------------------- 1 | import 'fake-indexeddb/auto'; 2 | import localforage from 'localforage'; 3 | import ReactNativeStorage from '@react-native-async-storage/async-storage'; 4 | import { toPromise } from '../src/utils/to-promise'; 5 | import { memoryStorage } from '../src'; 6 | 7 | const storages = [ 8 | [localStorage, 'local'], 9 | [sessionStorage, 'session'], 10 | [memoryStorage, 'memory'], 11 | [ 12 | localforage.createInstance({ driver: localforage.LOCALSTORAGE }), 13 | 'localforage local', 14 | ], 15 | [ 16 | localforage.createInstance({ driver: localforage.INDEXEDDB }), 17 | 'localforage indexedDb', 18 | ], 19 | [ReactNativeStorage, 'react-native'], 20 | ] as const; 21 | 22 | describe.each(storages)('storage io', (storage, name) => { 23 | beforeEach(() => storage.clear()); 24 | afterEach(() => storage.clear()); 25 | 26 | test(`[${name}] Get and set data`, async () => { 27 | await expect(toPromise(() => storage.getItem('test1'))).resolves.toBeNull(); 28 | await storage.setItem('test1', 'yes'); 29 | await expect(toPromise(() => storage.getItem('test1'))).resolves.toBe( 30 | 'yes', 31 | ); 32 | }); 33 | 34 | test(`[${name}] Update data`, async () => { 35 | await storage.setItem('test2', 'yes'); 36 | await expect(toPromise(() => storage.getItem('test2'))).resolves.toBe( 37 | 'yes', 38 | ); 39 | await storage.setItem('test2', 'no'); 40 | await expect(toPromise(() => storage.getItem('test2'))).resolves.toBe('no'); 41 | }); 42 | 43 | test(`[${name}] Delete data`, async () => { 44 | await storage.setItem('test3', 'yes'); 45 | await expect(toPromise(() => storage.getItem('test3'))).resolves.toBe( 46 | 'yes', 47 | ); 48 | await storage.removeItem('test3'); 49 | await expect(toPromise(() => storage.getItem('test3'))).resolves.toBeNull(); 50 | }); 51 | 52 | test(`[${name}] Clear all data`, async () => { 53 | await storage.setItem('test4', 'yes'); 54 | await storage.setItem('test5', 'yes'); 55 | 56 | await storage.clear(); 57 | 58 | await expect(toPromise(() => storage.getItem('test4'))).resolves.toBeNull(); 59 | await expect(toPromise(() => storage.getItem('test5'))).resolves.toBeNull(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/fixtures/not-equals.ts: -------------------------------------------------------------------------------- 1 | export const notEquals: Record = { 2 | '0 and NaN': { 3 | a: 0, 4 | b: NaN, 5 | }, 6 | '0 and null': { 7 | a: 0, 8 | b: null, 9 | }, 10 | '0 and undefined': { 11 | a: 0, 12 | b: undefined, 13 | }, 14 | '0 and false': { 15 | a: 0, 16 | b: false, 17 | }, 18 | 'null and undefined': { 19 | a: null, 20 | b: undefined, 21 | }, 22 | 'array and arrayLike': { 23 | a: [], 24 | b: { length: 0 }, 25 | }, 26 | 'array and arrayLike with length in prototype': { 27 | a: [], 28 | b: Object.create({ length: 0 }), 29 | }, 30 | 'array and arrayLike with items': { 31 | a: [1, 'x'], 32 | b: { 0: 1, 1: 'x', length: 2 }, 33 | }, 34 | 'number': { 35 | a: 1, 36 | b: 2, 37 | }, 38 | 'string': { 39 | a: 'x', 40 | b: 'y', 41 | }, 42 | 'number and object': { 43 | a: 1, 44 | b: {}, 45 | }, 46 | 'number and array': { 47 | a: 3, 48 | b: [], 49 | }, 50 | 'object and array': { 51 | a: [], 52 | b: {}, 53 | }, 54 | 'array': { 55 | a: [1], 56 | b: [2], 57 | }, 58 | 'complex array': { 59 | a: [1, { x: { y: 2, z: 3, x: [3] } }, 5], 60 | b: [1, { x: { y: 2, z: 3, x: [3, 6] } }, 5], 61 | }, 62 | 'array with different length': { 63 | a: [1], 64 | b: [1, 2, 3], 65 | }, 66 | 'object': { 67 | a: { x: 1 }, 68 | b: { x: 2 }, 69 | }, 70 | 'complex object': { 71 | a: { 72 | x: { 73 | y: { 74 | z: [3, 6], 75 | }, 76 | }, 77 | }, 78 | b: { 79 | x: { 80 | y: { 81 | z: [3, 5], 82 | }, 83 | }, 84 | }, 85 | }, 86 | 'object with different properties': { 87 | a: { x: 1 }, 88 | b: { x: 1, y: 2 }, 89 | }, 90 | 'with different constructor': { 91 | a: new (class {})(), 92 | b: new (class {})(), 93 | }, 94 | 'Object.keys() get same length but not own property': { 95 | a: { x: 3, y: 4 }, 96 | b: (() => { 97 | const b = Object.create({ x: 3 }); 98 | b.y = 4; 99 | b.z = 5; 100 | return b; 101 | })(), 102 | }, 103 | }; 104 | -------------------------------------------------------------------------------- /test/typescript/computed.check.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'ts-expect'; 2 | import { cloneModel, defineModel, useComputed } from '../../src'; 3 | import { ComputedFlag } from '../../src/model/types'; 4 | 5 | const model = defineModel('test', { 6 | initialState: { 7 | firstName: 't', 8 | lastName: 'r', 9 | }, 10 | computed: { 11 | fullName() { 12 | return this.state.firstName + '.' + this.state.lastName; 13 | }, 14 | nickName() { 15 | return [this.fullName() + '-nick']; 16 | }, 17 | _dirname() { 18 | return 'whatever'; 19 | }, 20 | withAge(age: number = 20) { 21 | return this.state.firstName + '-age-' + age; 22 | }, 23 | withRequiredParameter(address: string) { 24 | return this.state.firstName + this.withAge(15) + '-address-' + address; 25 | }, 26 | withMultipleParameter(address: string, age: number, extra?: boolean) { 27 | return address + age + extra; 28 | }, 29 | }, 30 | }); 31 | 32 | expectType<(() => string[]) & ComputedFlag>(model.nickName); 33 | 34 | // @ts-expect-error 35 | model.fullName = 'modify'; 36 | // @ts-expect-error 37 | model.nickName = 'modify'; 38 | // @ts-expect-error 39 | model._dirname; 40 | // @ts-expect-error 41 | model.firstName; 42 | 43 | // @ts-expect-error 44 | useComputed(model); 45 | expectType(useComputed(model.fullName)); 46 | expectType(useComputed(model.nickName)); 47 | // @ts-expect-error 48 | useComputed(model.fullName, 20); 49 | useComputed(model.withAge); 50 | useComputed(model.withAge, 20); 51 | // @ts-expect-error 52 | useComputed(model.withAge, '20'); 53 | // @ts-expect-error 54 | useComputed(() => {}); 55 | // @ts-expect-error 56 | useComputed(model.withRequiredParameter); 57 | expectType(useComputed(model.withRequiredParameter, 'addr')); 58 | useComputed(model.withRequiredParameter, 'addr').endsWith('ss'); 59 | // @ts-expect-error 60 | useComputed(model.withMultipleParameter, ''); 61 | // @ts-expect-error 62 | useComputed(model.withMultipleParameter, 0); 63 | useComputed(model.withMultipleParameter, '', 0); 64 | useComputed(model.withMultipleParameter, '', 0, false); 65 | 66 | { 67 | const model1 = cloneModel('clone-model', model); 68 | model1.fullName(); 69 | model1.withMultipleParameter('', 20); 70 | } 71 | -------------------------------------------------------------------------------- /docs/test.md: -------------------------------------------------------------------------------- 1 | 前端的需求变化总是太快导致测试用例跟不上,甚至部分程序员根本就没想过为自己写的代码编写测试,他们心里总是想着`出错了再说`。对于要求拥有高质量体验的项目,测试是必不可少的,它能使得代码更加稳健,并且在新增功能和重构代码时,都无需太担心会破坏原有的逻辑。在多人协作的项目中,充足的测试可以让其他人员对相应的逻辑有更充分的了解。 2 | 3 | ## 测试框架 4 | 5 | - [Vitest](https://cn.vitest.dev/) 6 | - [Jest](https://jestjs.io/zh-Hans/) 7 | - [Mocha](https://mochajs.org/) 8 | - [node:test](https://nodejs.org/dist/latest-v18.x/docs/api/test.html#test-runner) node@18.8.0开始提供 9 | 10 | ## 准备工作 11 | 12 | 我们已经知道,foca 是基于 redux 存储数据的,所以在测试模型之前,需要先激活 store,并且在测试完毕后销毁以免影响其他测试。 13 | 14 | ```typescript 15 | // test/model.test.ts 16 | import { store } from 'foca'; 17 | 18 | beforeEach(() => { 19 | store.init(); 20 | }); 21 | 22 | afterEach(() => { 23 | store.unmount(); 24 | }); 25 | ``` 26 | 27 | ## 单元测试 28 | 29 | 我们假设你已经写好了一个模型 30 | 31 | ```typescript 32 | // src/models/my-custom.model.ts 33 | import { defineModel } from 'foca'; 34 | 35 | export const myCustomModel = defineModel('my-model', { 36 | initialState: { count: 0 }, 37 | reducers: { 38 | plus(state, step: number = 1) { 39 | state.count += step; 40 | }, 41 | minus(state, step: number = 1) { 42 | state.count -= step; 43 | }, 44 | }, 45 | }); 46 | ``` 47 | 48 | 对,它现在很简洁,但是已经满足测试条件了 49 | 50 | ```typescript 51 | // test/model.test.ts 52 | import { store } from 'foca'; 53 | import { myCustomModel } from '../src/models/my-custom.model.ts'; 54 | 55 | beforeEach(() => { 56 | store.init(); 57 | }); 58 | 59 | afterEach(() => { 60 | store.unmount(); 61 | }); 62 | 63 | test('initial state', () => { 64 | expect(myCustomModel.state.count).toBe(0); 65 | }); 66 | 67 | test('myCustomModel.plus', () => { 68 | myCustomModel.plus(); 69 | expect(myCustomModel.state.count).toBe(1); 70 | myCustomModel.plus(5); 71 | expect(myCustomModel.state.count).toBe(6); 72 | myCustomModel.plus(100); 73 | expect(myCustomModel.state.count).toBe(106); 74 | }); 75 | 76 | test('myCustomModel.minus', () => { 77 | myCustomModel.minus(); 78 | expect(myCustomModel.state.count).toBe(-1); 79 | myCustomModel.minus(10); 80 | expect(myCustomModel.state.count).toBe(-11); 81 | myCustomModel.minus(28); 82 | expect(myCustomModel.state.count).toBe(-39); 83 | }); 84 | ``` 85 | 86 | **只测试**业务上的那部分逻辑,每处逻辑分开测试,这就是 `Unit Test` 和你该做的。 87 | 88 | ## 覆盖率 89 | 90 | 对于大一些的项目,你很难保证所有逻辑都已经写进了测试,则建议打开测试框架的覆盖率功能并检查每一行的覆盖情况。一般情况下,覆盖率的报告会放到`coverage`目录,你只需要在浏览器中打开`coverage/index.html`就可以查看了。 91 | -------------------------------------------------------------------------------- /test/get-loading.test.ts: -------------------------------------------------------------------------------- 1 | import sleep from 'sleep-promise'; 2 | import { defineModel, getLoading, store } from '../src'; 3 | import { basicModel } from './models/basic.model'; 4 | 5 | beforeEach(() => { 6 | store.init(); 7 | }); 8 | 9 | afterEach(() => { 10 | store.unmount(); 11 | }); 12 | 13 | test('Collect loading status for method', async () => { 14 | expect(getLoading(basicModel.bos)).toBeFalsy(); 15 | const promise = basicModel.bos(); 16 | expect(getLoading(basicModel.bos)).toBeTruthy(); 17 | await promise; 18 | expect(getLoading(basicModel.bos)).toBeFalsy(); 19 | }); 20 | 21 | test('Collect error message for method', async () => { 22 | expect(getLoading(basicModel.hasError)).toBeFalsy(); 23 | 24 | const promise = basicModel.hasError(); 25 | expect(getLoading(basicModel.hasError)).toBeTruthy(); 26 | 27 | await expect(promise).rejects.toThrowError('my-test'); 28 | 29 | expect(getLoading(basicModel.hasError)).toBeFalsy(); 30 | }); 31 | 32 | test('Trace loadings', async () => { 33 | expect(getLoading(basicModel.bos.room, 'x')).toBeFalsy(); 34 | expect(getLoading(basicModel.bos.room).find('x')).toBeFalsy(); 35 | 36 | const promise = basicModel.bos.room('x').execute(); 37 | expect(getLoading(basicModel.bos.room, 'x')).toBeTruthy(); 38 | expect(getLoading(basicModel.bos.room).find('x')).toBeTruthy(); 39 | expect(getLoading(basicModel.bos.room, 'y')).toBeFalsy(); 40 | expect(getLoading(basicModel.bos.room).find('y')).toBeFalsy(); 41 | expect(getLoading(basicModel.bos)).toBeFalsy(); 42 | 43 | await promise; 44 | expect(getLoading(basicModel.bos.room, 'x')).toBeFalsy(); 45 | expect(getLoading(basicModel.bos.room).find('x')).toBeFalsy(); 46 | }); 47 | 48 | test('async method in model.onInit should be activated automatically', async () => { 49 | const hookModel = defineModel('loading' + Math.random(), { 50 | initialState: {}, 51 | methods: { 52 | async myMethod() { 53 | await sleep(200); 54 | }, 55 | async myMethod2() { 56 | await sleep(200); 57 | }, 58 | }, 59 | events: { 60 | async onInit() { 61 | await this.myMethod(); 62 | await this.myMethod2(); 63 | }, 64 | }, 65 | }); 66 | await store.onInitialized(); 67 | expect(getLoading(hookModel.myMethod)).toBeTruthy(); 68 | await sleep(220); 69 | expect(getLoading(hookModel.myMethod)).toBeFalsy(); 70 | 71 | expect(getLoading(hookModel.myMethod2)).toBeTruthy(); 72 | await sleep(220); 73 | expect(getLoading(hookModel.myMethod2)).toBeFalsy(); 74 | }); 75 | -------------------------------------------------------------------------------- /test/models/computed.model.ts: -------------------------------------------------------------------------------- 1 | import { defineModel } from '../../src'; 2 | 3 | const initialState: { 4 | firstName: string; 5 | lastName: string; 6 | statusList: [string, string]; 7 | translate: Record; 8 | } = { 9 | firstName: 'tick', 10 | lastName: 'tock', 11 | statusList: ['online', 'offline'], 12 | translate: { 13 | online: 'Online', 14 | offline: 'Offline', 15 | }, 16 | }; 17 | 18 | export const computedModel = defineModel('computed-model', { 19 | initialState, 20 | reducers: { 21 | changeFirstName(state, value: string) { 22 | state.firstName = value; 23 | }, 24 | changeLastName(state, value) { 25 | state.lastName = value; 26 | }, 27 | }, 28 | methods: { 29 | effectsGetFullName() { 30 | return this.fullName(); 31 | }, 32 | }, 33 | computed: { 34 | fullName() { 35 | return this.state.firstName + this.state.lastName; 36 | }, 37 | _privateFullname() { 38 | return this.state.firstName + this.state.lastName; 39 | }, 40 | testDependentOtherComputed() { 41 | const status = 42 | this.fullName() === 'ticktock' 43 | ? this.state.statusList[0] 44 | : this.state.statusList[1]; 45 | return `${this.fullName().trim()} [${status}]`; 46 | }, 47 | isOnline() { 48 | return this.fullName() === 'helloworld'; 49 | }, 50 | testArrayLength() { 51 | return this.state.statusList.length; 52 | }, 53 | testObjectKeys() { 54 | return Object.keys(this.state.translate); 55 | }, 56 | testFind() { 57 | return this.state.statusList.find((item) => item.startsWith('off')); 58 | }, 59 | testVisitArray() { 60 | return this.state.statusList; 61 | }, 62 | testJSON() { 63 | return JSON.stringify(this.state); 64 | }, 65 | testExtendObject() { 66 | this.state.statusList.push('k'); 67 | }, 68 | testModifyValue() { 69 | this.state.statusList[0] = 'BALA'; 70 | }, 71 | withParameter(age: number) { 72 | return this.state.firstName + '-age-' + age; 73 | }, 74 | withDefaultParameter(age: number = 20) { 75 | return this.state.firstName + '-age-' + age; 76 | }, 77 | withMultipleParameters(age: number = 20, address: string) { 78 | return this.state.firstName + '-age-' + age + '-addr-' + address; 79 | }, 80 | withMultipleAndDefaultParameters(age: number = 20, address?: string) { 81 | return this.state.firstName + '-age-' + age + '-addr-' + address; 82 | }, 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /test/typescript/persist.check.ts: -------------------------------------------------------------------------------- 1 | import { TypeEqual, expectType } from 'ts-expect'; 2 | import { cloneModel, defineModel } from '../../src'; 3 | import { GetInitialState } from '../../src/model/types'; 4 | 5 | const state: { hello: string } = { hello: 'world' }; 6 | 7 | defineModel('model', { 8 | initialState: state, 9 | // @ts-expect-error 10 | persist: { 11 | dump() { 12 | return ''; 13 | }, 14 | }, 15 | }); 16 | 17 | defineModel('model', { 18 | initialState: state, 19 | // @ts-expect-error 20 | persist: { 21 | load() { 22 | return {} as typeof state; 23 | }, 24 | }, 25 | }); 26 | 27 | defineModel('model', { 28 | initialState: state, 29 | persist: { 30 | dump() { 31 | return ''; 32 | }, 33 | load() { 34 | return {} as typeof state; 35 | }, 36 | }, 37 | }); 38 | 39 | defineModel('model', { 40 | initialState: state, 41 | persist: {}, 42 | }); 43 | 44 | defineModel('model', { 45 | initialState: state, 46 | persist: { 47 | version: 1, 48 | }, 49 | }); 50 | 51 | defineModel('model', { 52 | initialState: state, 53 | persist: { 54 | version: '1.0.0', 55 | }, 56 | }); 57 | 58 | const model = defineModel('model', { 59 | initialState: state, 60 | persist: { 61 | dump(state) { 62 | return state.hello; 63 | }, 64 | load(s) { 65 | expectType>(true); 66 | expectType, typeof this>>(true); 67 | return { hello: s }; 68 | }, 69 | }, 70 | }); 71 | 72 | cloneModel('model-1', model, { 73 | persist: {}, 74 | }); 75 | 76 | cloneModel('model-1', model, { 77 | persist: { 78 | version: '', 79 | }, 80 | }); 81 | 82 | cloneModel('model-1', model, { 83 | persist: { 84 | dump(state) { 85 | return state.hello; 86 | }, 87 | load(s) { 88 | expectType>(true); 89 | expectType, typeof this>>(true); 90 | return { hello: s }; 91 | }, 92 | }, 93 | }); 94 | 95 | cloneModel('model-1', model, { 96 | persist: { 97 | dump() { 98 | return 0; 99 | }, 100 | load(s) { 101 | expectType>(true); 102 | return { hello: String(s) }; 103 | }, 104 | }, 105 | }); 106 | 107 | cloneModel('model-1', model, { 108 | // @ts-expect-error 109 | persist: { 110 | dump() { 111 | return 0; 112 | }, 113 | }, 114 | }); 115 | 116 | cloneModel('model-1', model, { 117 | // @ts-expect-error 118 | persist: { 119 | load(dumpData) { 120 | return dumpData as typeof state; 121 | }, 122 | }, 123 | }); 124 | -------------------------------------------------------------------------------- /test/persist.gate.test.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { act, render, screen } from '@testing-library/react'; 3 | import { FocaProvider, store } from '../src'; 4 | import { PersistGateProps } from '../src/persist/persist-gate'; 5 | import { basicModel } from './models/basic.model'; 6 | import { slowEngine } from './helpers/slow-engine'; 7 | 8 | const Loading: FC = () =>
Yes
; 9 | 10 | const Root: FC = ({ 11 | loading, 12 | useFunction, 13 | }) => { 14 | return ( 15 | 16 | {useFunction ? ( 17 | (isReady: boolean) => ( 18 | <> 19 |
{String(isReady)}
20 |
21 | 22 | ) 23 | ) : ( 24 |
25 | )} 26 | 27 | ); 28 | }; 29 | 30 | beforeEach(() => { 31 | store.init({ 32 | persist: [ 33 | { 34 | version: 1, 35 | key: 'test1', 36 | models: [basicModel], 37 | engine: slowEngine, 38 | }, 39 | ], 40 | }); 41 | }); 42 | 43 | afterEach(() => { 44 | store.unmount(); 45 | }); 46 | 47 | test('PersistGate will inject to shadow dom', async () => { 48 | render(); 49 | expect(screen.queryByTestId('inner')).toBeNull(); 50 | 51 | await act(async () => { 52 | await store.onInitialized(); 53 | }); 54 | expect(screen.queryByTestId('inner')).not.toBeNull(); 55 | }); 56 | 57 | test('PersistGate allows function children', async () => { 58 | render(); 59 | expect(screen.queryByTestId('isReady')!.innerHTML).toBe('false'); 60 | 61 | await act(async () => { 62 | await store.onInitialized(); 63 | }); 64 | expect(screen.queryByTestId('isReady')!.innerHTML).toBe('true'); 65 | }); 66 | 67 | test('PersistGate allows loading children', async () => { 68 | render(} />); 69 | expect(screen.queryByTestId('inner')).toBeNull(); 70 | expect(screen.queryByTestId('gateLoading')!.innerHTML).toBe('Yes'); 71 | 72 | await act(async () => { 73 | await store.onInitialized(); 74 | }); 75 | 76 | expect(screen.queryByTestId('inner')).not.toBeNull(); 77 | expect(screen.queryByTestId('gateLoading')).toBeNull(); 78 | }); 79 | 80 | test('PersistGate will warning for both function children and loading children', async () => { 81 | const spy = vitest.spyOn(console, 'error').mockImplementation(() => {}); 82 | 83 | render(} />); 84 | expect(spy).toHaveBeenCalledTimes(1); 85 | 86 | await act(() => store.onInitialized()); 87 | expect(spy).toHaveBeenCalledTimes(2); 88 | 89 | spy.mockRestore(); 90 | }); 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foca", 3 | "version": "4.0.1", 4 | "repository": "git@github.com:foca-js/foca.git", 5 | "homepage": "https://foca.js.org", 6 | "keywords": [ 7 | "redux", 8 | "redux-model", 9 | "redux-typescript", 10 | "react-redux", 11 | "react-model", 12 | "redux-toolkit" 13 | ], 14 | "description": "流畅的React状态管理库", 15 | "contributors": [ 16 | "罪 (https://github.com/geekact)" 17 | ], 18 | "license": "MIT", 19 | "main": "dist/index.js", 20 | "module": "dist/esm/index.js", 21 | "types": "dist/index.d.ts", 22 | "sideEffects": false, 23 | "scripts": { 24 | "test": "vitest run", 25 | "prepublishOnly": "tsup", 26 | "docs": "docsify serve ./docs", 27 | "prepare": "husky" 28 | }, 29 | "exports": { 30 | ".": { 31 | "types": "./dist/index.d.ts", 32 | "import": "./dist/esm/index.js", 33 | "require": "./dist/index.js" 34 | }, 35 | "./package.json": "./package.json" 36 | }, 37 | "files": [ 38 | "dist", 39 | "LICENSE", 40 | "package.json", 41 | "README.md", 42 | "CHANGELOG.md" 43 | ], 44 | "commitlint": { 45 | "extends": [ 46 | "@commitlint/config-conventional" 47 | ] 48 | }, 49 | "volta": { 50 | "node": "18.16.0", 51 | "pnpm": "9.15.6" 52 | }, 53 | "packageManager": "pnpm@9.15.6", 54 | "peerDependencies": { 55 | "react": "^18 || ^19", 56 | "react-native": ">=0.69", 57 | "typescript": "^5" 58 | }, 59 | "peerDependenciesMeta": { 60 | "typescript": { 61 | "optional": true 62 | }, 63 | "react-native": { 64 | "optional": true 65 | } 66 | }, 67 | "dependencies": { 68 | "immer": "^9.0.21", 69 | "react-redux": "^9.2.0", 70 | "redux": "^5.0.1", 71 | "topic": "^3.0.2" 72 | }, 73 | "devDependencies": { 74 | "@commitlint/cli": "^19.7.1", 75 | "@commitlint/config-conventional": "^19.7.1", 76 | "@react-native-async-storage/async-storage": "^2.1.2", 77 | "@redux-devtools/extension": "^3.3.0", 78 | "@testing-library/react": "^16.2.0", 79 | "@types/node": "^22.13.8", 80 | "@types/react": "^19.0.10", 81 | "@types/react-dom": "^19.0.4", 82 | "@vitest/coverage-istanbul": "^3.0.7", 83 | "docsify-cli": "^4.4.4", 84 | "fake-indexeddb": "^6.0.0", 85 | "husky": "^9.1.7", 86 | "jsdom": "^26.0.0", 87 | "localforage": "^1.10.0", 88 | "prettier": "^3.5.3", 89 | "react": "^19.0.0", 90 | "react-dom": "^19.0.0", 91 | "react-test-renderer": "^19.0.0", 92 | "rxjs": "^7.8.2", 93 | "sleep-promise": "^9.1.0", 94 | "ts-expect": "^1.3.0", 95 | "tsup": "^8.4.0", 96 | "typescript": "^5.8.2", 97 | "vitest": "^3.0.7" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/use-loading.test.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react'; 2 | import { renderHook } from './helpers/render-hook'; 3 | import { store, useLoading } from '../src'; 4 | import { basicModel } from './models/basic.model'; 5 | 6 | beforeEach(() => { 7 | store.init(); 8 | }); 9 | 10 | afterEach(() => { 11 | store.unmount(); 12 | }); 13 | 14 | test('Trace loading', async () => { 15 | const { result } = renderHook(() => useLoading(basicModel.pureAsync)); 16 | 17 | expect(result.current).toBeFalsy(); 18 | 19 | let promise!: Promise; 20 | 21 | act(() => { 22 | promise = basicModel.pureAsync(); 23 | }); 24 | 25 | expect(result.current).toBeTruthy(); 26 | 27 | await act(async () => { 28 | await promise; 29 | }); 30 | 31 | expect(result.current).toBeFalsy(); 32 | }); 33 | 34 | test('Compose the loadings', async () => { 35 | const { result } = renderHook(() => 36 | useLoading(basicModel.pureAsync, basicModel.foo, basicModel.bar), 37 | ); 38 | 39 | expect(result.current).toBeFalsy(); 40 | 41 | let promise1!: Promise; 42 | 43 | act(() => { 44 | promise1 = basicModel.pureAsync(); 45 | }); 46 | 47 | expect(result.current).toBeTruthy(); 48 | 49 | let promise2!: Promise; 50 | 51 | await act(async () => { 52 | await promise1; 53 | promise2 = basicModel.foo('', 2); 54 | }); 55 | 56 | expect(result.current).toBeTruthy(); 57 | 58 | await act(async () => { 59 | await promise2; 60 | }); 61 | 62 | expect(result.current).toBeFalsy(); 63 | }); 64 | 65 | test('Trace loadings', async () => { 66 | const { result } = renderHook(() => 67 | useLoading(basicModel.pureAsync.room, 'x'), 68 | ); 69 | 70 | expect(result.current).toBeFalsy(); 71 | 72 | let promise!: Promise; 73 | 74 | act(() => { 75 | promise = basicModel.pureAsync.room('x').execute(); 76 | }); 77 | 78 | expect(result.current).toBeTruthy(); 79 | 80 | await act(async () => { 81 | await promise; 82 | }); 83 | 84 | expect(result.current).toBeFalsy(); 85 | }); 86 | 87 | test('Pick loading from loadings', async () => { 88 | const { result } = renderHook(() => useLoading(basicModel.pureAsync.room)); 89 | 90 | expect(result.current.find('m')).toBeFalsy(); 91 | expect(result.current.find('n')).toBeFalsy(); 92 | 93 | let promise!: Promise; 94 | 95 | act(() => { 96 | promise = basicModel.pureAsync.room('m').execute(); 97 | }); 98 | 99 | expect(result.current.find('m')).toBeTruthy(); 100 | expect(result.current.find('n')).toBeFalsy(); 101 | 102 | await act(async () => { 103 | await promise; 104 | }); 105 | 106 | expect(result.current.find('m')).toBeFalsy(); 107 | expect(result.current.find('n')).toBeFalsy(); 108 | }); 109 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags-ignore: 8 | - '*' 9 | pull_request: 10 | branches: 11 | 12 | jobs: 13 | formatting: 14 | if: "!contains(toJson(github.event.commits), '[skip ci]')" 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version-file: 'package.json' 22 | cache: 'pnpm' 23 | - run: pnpm install 24 | - run: npx --no-install prettier --cache --verbose --check . 25 | 26 | type-checking: 27 | if: "!contains(toJson(github.event.commits), '[skip ci]')" 28 | strategy: 29 | matrix: 30 | ts-version: 31 | [5.0.x, 5.1.x, 5.2.x, 5.3.x, 5.4.x, 5.5.x, 5.6.x, 5.7.x, 5.8.x] 32 | react-version: [18.x, 19.x] 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: pnpm/action-setup@v4 37 | - name: Use Typescript ${{ matrix.ts-version }} & React ${{ matrix.react-version }} 38 | uses: actions/setup-node@v4 39 | with: 40 | cache: 'pnpm' 41 | node-version-file: 'package.json' 42 | - run: | 43 | pnpm install 44 | pnpm add -D \ 45 | typescript@${{ matrix.ts-version }} \ 46 | @types/react@${{ matrix.react-version }} \ 47 | @types/react-dom@${{ matrix.react-version }} 48 | - run: npx --no-install tsc --noEmit 49 | 50 | test: 51 | if: "!contains(toJson(github.event.commits), '[skip ci]')" 52 | strategy: 53 | matrix: 54 | node-version: [18.x, 20.x, 22.x] 55 | react-version: [18.x, 19.x] 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: pnpm/action-setup@v4 60 | - name: Use Node.js ${{ matrix.node-version }} & React ${{ matrix.react-version }} 61 | uses: actions/setup-node@v4 62 | with: 63 | cache: 'pnpm' 64 | node-version: ${{ matrix.node-version }} 65 | - run: | 66 | pnpm install 67 | pnpm add -D \ 68 | react@${{ matrix.react-version }} \ 69 | react-dom@${{ matrix.react-version }} \ 70 | react-test-renderer@${{ matrix.react-version }} 71 | - run: pnpm run test 72 | - name: Upload Coverage 73 | uses: actions/upload-artifact@v4 74 | if: github.ref == 'refs/heads/master' && strategy.job-index == 0 75 | with: 76 | name: coverage 77 | path: coverage 78 | if-no-files-found: error 79 | retention-days: 1 80 | 81 | coverage: 82 | if: github.ref == 'refs/heads/master' 83 | needs: test 84 | runs-on: ubuntu-latest 85 | steps: 86 | - uses: actions/checkout@v4 87 | - name: Download Coverage 88 | uses: actions/download-artifact@v4 89 | with: 90 | name: coverage 91 | path: coverage 92 | - uses: codecov/codecov-action@v5 93 | -------------------------------------------------------------------------------- /src/reactive/object-deps.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from 'redux'; 2 | import { isObject } from '../utils/is-type'; 3 | import { depsCollector } from './deps-collector'; 4 | 5 | export interface Deps { 6 | id: string; 7 | end(): void; 8 | isDirty(): boolean; 9 | } 10 | 11 | export class ObjectDeps implements Deps { 12 | protected active: boolean = true; 13 | protected snapshot: any; 14 | protected root: any; 15 | 16 | constructor( 17 | protected readonly store: Pick>, 'getState'>, 18 | protected readonly model: string, 19 | protected readonly deps: string[] = [], 20 | ) { 21 | this.root = this.getState(); 22 | } 23 | 24 | isDirty(): boolean { 25 | const rootState = this.getState(); 26 | if (this.root === rootState) return false; 27 | const { pathChanged, snapshot: nextSnapshot } = this.getSnapshot(rootState); 28 | if (pathChanged || this.snapshot !== nextSnapshot) return true; 29 | this.root = rootState; 30 | return false; 31 | } 32 | 33 | get id(): string { 34 | return this.model + '.' + this.deps.join('.'); 35 | } 36 | 37 | start>(startState: T): T { 38 | depsCollector.append(this); 39 | return this.proxy(startState); 40 | } 41 | 42 | end(): void { 43 | this.active = false; 44 | } 45 | 46 | protected getState(): T { 47 | return this.store.getState()[this.model]; 48 | } 49 | 50 | protected getSnapshot(state: any): { pathChanged: boolean; snapshot: any } { 51 | const deps = this.deps; 52 | let snapshot = state; 53 | for (let i = 0; i < deps.length; ++i) { 54 | if (!isObject>(snapshot)) { 55 | return { pathChanged: true, snapshot }; 56 | } 57 | snapshot = snapshot[deps[i]!]; 58 | } 59 | 60 | return { pathChanged: false, snapshot }; 61 | } 62 | 63 | protected proxy(currentState: Record): any { 64 | if ( 65 | currentState === null || 66 | !isObject>(currentState) || 67 | Array.isArray(currentState) 68 | ) { 69 | return currentState; 70 | } 71 | 72 | const nextState = {}; 73 | const keys = Object.keys(currentState); 74 | const currentDeps = this.deps.slice(); 75 | let visited = false; 76 | 77 | for (let i = keys.length; i-- > 0; ) { 78 | const key = keys[i]!; 79 | 80 | Object.defineProperty(nextState, key, { 81 | enumerable: true, 82 | get: () => { 83 | if (!this.active) return currentState[key]; 84 | 85 | if (visited) { 86 | return new ObjectDeps( 87 | this.store, 88 | this.model, 89 | currentDeps.slice(), 90 | ).start(currentState)[key]; 91 | } 92 | 93 | visited = true; 94 | this.deps.push(key); 95 | return this.proxy((this.snapshot = currentState[key])); 96 | }, 97 | }); 98 | } 99 | 100 | /* istanbul ignore else -- @preserve */ 101 | if (process.env.NODE_ENV !== 'production') { 102 | Object.freeze(nextState); 103 | } 104 | 105 | return nextState; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /test/clone.test.ts: -------------------------------------------------------------------------------- 1 | import { defineModel, cloneModel, store } from '../src'; 2 | import type { InternalModel } from '../src/model/types'; 3 | import { basicModel } from './models/basic.model'; 4 | 5 | let modelIndex = 0; 6 | 7 | beforeEach(() => { 8 | store.init(); 9 | }); 10 | 11 | afterEach(() => { 12 | store.unmount(); 13 | }); 14 | 15 | test('Model can be cloned', async () => { 16 | const model = cloneModel('model' + ++modelIndex, basicModel); 17 | 18 | expect(model.state.hello).toBe('world'); 19 | expect(model.state.count).toBe(0); 20 | 21 | model.plus(11); 22 | expect(model.state.count).toBe(11); 23 | 24 | await expect(model.foo('earth', 5)).resolves.toBe('OK'); 25 | expect(model.state.hello).toBe('earth'); 26 | expect(model.state.count).toBe(16); 27 | }); 28 | 29 | test('Cloned model name', () => { 30 | const model = cloneModel('model' + ++modelIndex, basicModel); 31 | expect(model.name).toBe('model' + modelIndex); 32 | }); 33 | 34 | test('Clone model with same name is invalid', () => { 35 | const model = defineModel(Date.now().toString(), { initialState: {} }); 36 | expect(() => cloneModel(model.name, model)).toThrowError(); 37 | }); 38 | 39 | test('Override state', () => { 40 | const model = cloneModel('model' + ++modelIndex, basicModel, { 41 | initialState: { 42 | count: 20, 43 | hello: 'cat', 44 | }, 45 | }) as unknown as InternalModel; 46 | 47 | expect(model._$opts.initialState).toStrictEqual({ 48 | count: 20, 49 | hello: 'cat', 50 | }); 51 | }); 52 | 53 | test('Override persist', () => { 54 | const model1 = defineModel('model' + ++modelIndex, { 55 | initialState: {}, 56 | persist: { 57 | dump: (state) => state, 58 | load: (state) => state, 59 | }, 60 | methods: { 61 | cc() { 62 | return 3; 63 | }, 64 | }, 65 | }); 66 | 67 | const model2 = cloneModel('model' + ++modelIndex, model1, { 68 | persist: {}, 69 | }) as unknown as InternalModel; 70 | 71 | expect(model2._$opts.persist).not.toHaveProperty('maxAge'); 72 | 73 | const model3 = cloneModel('model' + ++modelIndex, model1, (prev) => { 74 | return { 75 | persist: { 76 | ...prev.persist, 77 | maxAge: 30, 78 | }, 79 | }; 80 | }) as unknown as InternalModel; 81 | 82 | expect(model3._$opts.persist).toHaveProperty('maxAge'); 83 | expect(model3._$opts.persist).toHaveProperty('dump'); 84 | expect(model3._$opts.persist).toHaveProperty('load'); 85 | }); 86 | 87 | test('override methods or unknown option can cause error', () => { 88 | const model = defineModel('model' + ++modelIndex, { initialState: {} }); 89 | 90 | expect(() => 91 | cloneModel('a', model, { 92 | // @ts-expect-error 93 | reducers: {}, 94 | }), 95 | ).toThrowError(); 96 | 97 | expect(() => 98 | cloneModel('b', model, { 99 | // @ts-expect-error 100 | methods: {}, 101 | }), 102 | ).toThrowError(); 103 | 104 | expect(() => 105 | cloneModel('c', model, { 106 | // @ts-expect-error 107 | computed: {}, 108 | }), 109 | ).toThrowError(); 110 | 111 | expect(() => 112 | cloneModel('d', model, { 113 | // @ts-expect-error 114 | whateverblabla: {}, 115 | }), 116 | ).toThrowError(); 117 | }); 118 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Foca 6 | 7 | 8 | 12 | 13 | 17 | 18 | 71 | 72 | 73 |
加载中...
74 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /src/model/enhance-effect.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoadingAction, 3 | LOADING_CATEGORY, 4 | TYPE_SET_LOADING, 5 | } from '../actions/loading'; 6 | import type { EffectCtx } from './types'; 7 | import { isPromise } from '../utils/is-promise'; 8 | import { toArgs } from '../utils/to-args'; 9 | import { loadingStore } from '../store/loading-store'; 10 | 11 | interface RoomFunc

> { 12 | (category: number | string): { 13 | execute(...args: P): R; 14 | }; 15 | } 16 | 17 | interface AsyncRoomEffect

> 18 | extends RoomFunc { 19 | readonly _: { 20 | readonly model: string; 21 | readonly method: string; 22 | readonly hasRoom: true; 23 | }; 24 | } 25 | 26 | interface AsyncEffect

> 27 | extends EffectFunc { 28 | readonly _: { 29 | readonly model: string; 30 | readonly method: string; 31 | readonly hasRoom: ''; 32 | }; 33 | /** 34 | * 对同一effect函数的执行状态进行分类以实现独立保存。好处有: 35 | * 36 | * 1. 并发请求同一个请求时不会互相覆盖执行状态。 37 | *
38 | * 2. 可以精确地判断业务中是哪个控件或者逻辑正在执行。 39 | * 40 | * ```typescript 41 | * model.effect.room(CATEGORY).execute(...); 42 | * ``` 43 | * 44 | * @see useLoading(effect.room) 45 | * @see getLoading(effect.room) 46 | * @since 0.11.4 47 | * 48 | */ 49 | readonly room: AsyncRoomEffect; 50 | } 51 | 52 | export type PromiseEffect = AsyncEffect; 53 | export type PromiseRoomEffect = AsyncRoomEffect; 54 | 55 | interface EffectFunc

> { 56 | (...args: P): R; 57 | } 58 | 59 | export type EnhancedEffect

> = 60 | R extends Promise ? AsyncEffect : EffectFunc; 61 | 62 | type NonReadonly = { 63 | -readonly [K in keyof T]: T[K]; 64 | }; 65 | 66 | export const enhanceEffect = ( 67 | ctx: EffectCtx, 68 | methodName: string, 69 | effect: (...args: any[]) => any, 70 | ): EnhancedEffect => { 71 | const fn: NonReadonly & EffectFunc = function () { 72 | return execute(ctx, methodName, effect, toArgs(arguments)); 73 | }; 74 | 75 | fn._ = { 76 | model: ctx.name, 77 | method: methodName, 78 | hasRoom: '', 79 | }; 80 | 81 | const room: NonReadonly & RoomFunc = ( 82 | category: number | string, 83 | ) => ({ 84 | execute() { 85 | return execute(ctx, methodName, effect, toArgs(arguments), category); 86 | }, 87 | }); 88 | 89 | room._ = Object.assign({}, fn._, { 90 | hasRoom: true as const, 91 | }); 92 | 93 | fn.room = room; 94 | 95 | return fn; 96 | }; 97 | 98 | const dispatchLoading = ( 99 | modelName: string, 100 | methodName: string, 101 | loading: boolean, 102 | category?: number | string, 103 | ) => { 104 | loadingStore.dispatch({ 105 | type: TYPE_SET_LOADING, 106 | model: modelName, 107 | method: methodName, 108 | payload: { 109 | category: category === void 0 ? LOADING_CATEGORY : category, 110 | loading, 111 | }, 112 | }); 113 | }; 114 | 115 | const execute = ( 116 | ctx: EffectCtx, 117 | methodName: string, 118 | effect: (...args: any[]) => any, 119 | args: any[], 120 | category?: number | string, 121 | ) => { 122 | const modelName = ctx.name; 123 | const resultOrPromise = effect.apply(ctx, args); 124 | 125 | if (!isPromise(resultOrPromise)) return resultOrPromise; 126 | 127 | dispatchLoading(modelName, methodName, true, category); 128 | 129 | return resultOrPromise.then( 130 | (result) => { 131 | return dispatchLoading(modelName, methodName, false, category), result; 132 | }, 133 | (e: unknown) => { 134 | dispatchLoading(modelName, methodName, false, category); 135 | throw e; 136 | }, 137 | ); 138 | }; 139 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | # useModel 4 | 5 | 使用频率::star2::star2::star2::star2::star2: 6 | 7 | 你绝对想不到在 React 组件中获取一个模型的数据有多简单,试试: 8 | 9 | ```tsx 10 | // File: App.tsx 11 | import { FC } from 'react'; 12 | import { useModel } from 'foca'; 13 | import { userModel } from './userModel'; 14 | 15 | const App: FC = () => { 16 | const users = useModel(userModel); 17 | 18 | return ( 19 | <> 20 | {users.map((user) => ( 21 |

{user.name}
22 | ))} 23 | 24 | ); 25 | }; 26 | 27 | export default App; 28 | ``` 29 | 30 | 就这么一小行,朴实无华,你也可以对数据进行改造,返回你当前需要的数据: 31 | 32 | ```typescript 33 | const userIds = useModel(userModel, (state) => state.map((item) => item.id)); 34 | ``` 35 | 36 | 不错,你拿到了所有用户的 id 编号,同时 userIds 的类型会自动推断为`number[]`。 37 | 38 | !> 只要 useModel() 返回的最终值不变,就不会触发 react 组件刷新。foca 使用 **深对比** 来判断值是否变化。 39 | 40 | --- 41 | 42 | 等等,事情还没结束!useModel 还能增加更多模型上去,这样可以少用几个 hooks: 43 | 44 | ```typescript 45 | const { users, agents, teachers } = useModel( 46 | userModel, 47 | agentModel, 48 | teacherModel, 49 | ); 50 | ``` 51 | 52 | 传递超过一个模型作为参数时,返回值将变成对象,而 key 就是模型的名称,value 就是模型的 state。这很酷,而且你仍然不用担心类型的问题。 53 | 54 | 如果你有一些数据需要多个模型才能计算出来,那么现在就是 useModel 大展身手的时候了: 55 | 56 | ```typescript 57 | const count = useModel( 58 | userModel, 59 | agentModel, 60 | teacherModel, 61 | (users, agents, teachers) => users.length + agents.length + teachers.length, 62 | ); 63 | ``` 64 | 65 | 返回的是一个数字,如假包换,TS 也自动推导出来了这是`number`类型 66 | 67 | # useLoading 68 | 69 | 使用频率::star2::star2::star2::star2::star2: 70 | 71 | methods 函数大部分是异步的,你可能正在函数里执行一个请求 api 的操作。在用户等待期间,你需要为用户渲染`loading...`之类的字样或者图标以缓解用户的焦虑心情。利用 foca 提供的逻辑,你可以轻松地知道某个函数是否正在执行: 72 | 73 | ```tsx 74 | import { useLoading } from 'foca'; 75 | 76 | const App: FC = () => { 77 | const loading = useLoading(userModel.getUser); 78 | 79 | const handleClick = () => { 80 | userModel.getUser(1); 81 | }; 82 | 83 | return
{loading ? 'loading...' : 'OK'}
; 84 | }; 85 | ``` 86 | 87 | 每次开始执行`getUser`函数,loading 自动变成`true`,从而触发组件刷新。 88 | 89 | 在某些编辑表单的场景,很可能会同时有新增和修改两种操作。对于 restful API,你需要写两个异步函数来处理请求。但你的表单保存按钮只有一个,很显然不管是新增还是修改,你都想让保存按钮渲染`保存中...`的字样。 90 | 91 | 为了减少业务代码,useLoading 允许你传入多个异步函数,只要有任何一个函数在执行,那么最终值就会是 true。 92 | 93 | ```typescript 94 | const loading = useLoading(userModel.create, userModel.update, ...); 95 | ``` 96 | 97 | # useComputed 98 | 99 | 使用频率::star2::star2::star2: 100 | 101 | 配合 computed 计算属性使用。携带参数的情况下,则从第二个参数开始依次传入 102 | 103 | ```tsx 104 | import { useComputed } from 'foca'; 105 | 106 | // 假设有这么一个model 107 | const userModel = defineModel('user', { 108 | initialState: { 109 | firstName: 'tick', 110 | lastName: 'tock', 111 | }, 112 | computed: { 113 | fullName() { 114 | return this.state.firstName + '.' + this.state.lastName; 115 | }, 116 | profile(age: number) { 117 | return this.fullName() + '-' + age; 118 | }, 119 | }, 120 | }); 121 | 122 | const App: FC = () => { 123 | // 只有当 firstName 或者 lastName 变化,才会重新刷新该组件 124 | const fullName = useComputed(userModel.fullName); 125 | // 这里会有TS提示你应该传几个参数,以及参数类型 126 | const profile = useComputed(userModel.profile, 24); 127 | 128 | return
{fullName}
; 129 | }; 130 | ``` 131 | 132 | # getLoading 133 | 134 | 使用频率::star2: 135 | 136 | 如果想实时获取异步函数的执行状态,则可以通过 `getLoading(...)` 的方式获取。它与 **useLoading** 的唯一区别就是是否hooks。 137 | 138 | ```typescript 139 | const loading = getLoading(userModel.create); 140 | ``` 141 | 142 | # connect 143 | 144 | 使用频率::star2: 145 | 146 | 如果你写烦了函数式组件,偶尔想写一下 class 组件,那么 foca 已经为你准备好了`connect()`函数。如果不知道这是什么,可以参考[react-redux](https://github.com/reduxjs/react-redux)的文档。事实上,我们内置了这个库并对其做了一些封装。 147 | 148 | ```typescript 149 | import { PureComponent } from 'react'; 150 | import { connect } from 'foca'; 151 | import { userModel } from './userModel'; 152 | 153 | type Props = ReturnType; 154 | 155 | class App extends PureComponent { 156 | render() { 157 | const { users, loading } = this.props; 158 | 159 | if (loading) { 160 | return

Loading...

; 161 | } 162 | 163 | return

Hello, {users.length} people

; 164 | } 165 | } 166 | 167 | const mapStateToProps = () => { 168 | return { 169 | users: userModel.state, 170 | loading: getLoading(userModel.fetchUser), 171 | }; 172 | }; 173 | 174 | export default connect(mapStateToProps)(App); 175 | ``` 176 | 177 | 没有了 hooks 的帮忙,我们只能从模型或者方法上获取实时的数据。但只要你是在 mapStateToProps 中获取的数据,foca 就会自动为你更新并注入到组件里。 178 | -------------------------------------------------------------------------------- /src/api/use-isolate.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef, useState } from 'react'; 2 | import { DestroyLoadingAction, DESTROY_LOADING } from '../actions/loading'; 3 | import { loadingStore } from '../store/loading-store'; 4 | import { modelStore } from '../store/model-store'; 5 | import { cloneModel } from '../model/clone-model'; 6 | import { Model } from '../model/types'; 7 | 8 | let globalCounter = 0; 9 | const hotReloadCounter: Record = {}; 10 | 11 | /** 12 | * 创建局部模型,它的数据变化不会影响到全局模型,而且会随着组件一起销毁 13 | * ```typescript 14 | * const testModel = defineModel({ 15 | * initialState, 16 | * events: { 17 | * onInit() {}, // 挂载回调 18 | * onDestroy() {}, // 销毁回调 19 | * } 20 | * }); 21 | * 22 | * function App() { 23 | * const model = useIsolate(testModel); 24 | * const state = useModel(model); 25 | * 26 | * return

Hello

; 27 | * } 28 | * ``` 29 | */ 30 | export const useIsolate = < 31 | State extends object = object, 32 | Action extends object = object, 33 | Effect extends object = object, 34 | Computed extends object = object, 35 | >( 36 | globalModel: Model, 37 | ): Model => { 38 | const initialCount = useState(() => globalCounter++)[0]; 39 | const uniqueName = 40 | process.env.NODE_ENV === 'production' 41 | ? useProdName(globalModel.name, initialCount) 42 | : useDevName(globalModel, initialCount, new Error()); 43 | 44 | const localModelRef = useRef(undefined); 45 | useEffect(() => { 46 | localModelRef.current = isolateModel; 47 | }); 48 | 49 | // 热更时会重新执行useMemo,因此只能用ref 50 | const isolateModel = 51 | localModelRef.current?.name === uniqueName 52 | ? localModelRef.current 53 | : cloneModel(uniqueName, globalModel); 54 | 55 | return isolateModel; 56 | }; 57 | 58 | const useProdName = (modelName: string, count: number) => { 59 | const uniqueName = `@isolate:${modelName}#${count}`; 60 | 61 | useEffect( 62 | () => () => { 63 | setTimeout(unmountModel, 0, uniqueName); 64 | }, 65 | [uniqueName], 66 | ); 67 | 68 | return uniqueName; 69 | }; 70 | 71 | /** 72 | * 开发模式下,需要Hot Reload。 73 | * 必须保证数据不会丢,即如果用户一直保持`model.name`不变,就被判定为可以共享热更新之前的数据。 74 | * 75 | * 必须严格控制count在组件内的自增次数,否则在第一次修改model的name时,总是会报错: 76 | * Warning: Cannot update a component (`XXX`) while rendering a different component (`XXX`) 77 | */ 78 | const useDevName = (model: Model, count: number, err: Error) => { 79 | const componentName = useMemo((): string => { 80 | try { 81 | const stacks = err.stack!.split('\n'); 82 | const innerNamePattern = new RegExp( 83 | // vitest测试框架的stack增加了 Module. 84 | `at\\s(?:Module\\.)?${useIsolate.name}\\s\\(`, 85 | 'i', 86 | ); 87 | const componentNamePattern = /at\s(.+?)\s\(/i; 88 | for (let i = 0; i < stacks.length; ++i) { 89 | if (innerNamePattern.test(stacks[i]!)) { 90 | return stacks[i + 1]!.match(componentNamePattern)![1]!; 91 | } 92 | } 93 | } catch {} 94 | return 'Component'; 95 | }, [err.stack]); 96 | 97 | /** 98 | * 模型文件重新保存时组件会导入新的对象,需根据这个特性重新克隆模型 99 | */ 100 | const globalModelRef = useRef<{ model?: Model; count: number }>({ count: 0 }); 101 | useEffect(() => { 102 | if (globalModelRef.current.model !== model) { 103 | globalModelRef.current = { model, count: ++globalModelRef.current.count }; 104 | } 105 | }); 106 | 107 | const uniqueName = `@isolate:${model.name}:${componentName}#${count}-${ 108 | globalModelRef.current.count + 109 | Number(globalModelRef.current.model !== model) 110 | }`; 111 | 112 | /** 113 | * 计算热更次数,如果停止热更,说明组件被卸载 114 | */ 115 | useMemo(() => { 116 | hotReloadCounter[uniqueName] ||= 0; 117 | ++hotReloadCounter[uniqueName]; 118 | }, [uniqueName]); 119 | 120 | /** 121 | * 热更新时会重新执行一次useEffect 122 | * setTimeout可以让其他useEffect有充分的时间使用model 123 | * 124 | * 需要卸载模型的场景是: 125 | * 1. 组件hooks增减或者调换顺序(initialCount会自增) 126 | * 2. 组件卸载 127 | * 3. model.name变更 128 | * 4. model逻辑变更 129 | */ 130 | useEffect(() => { 131 | const prev = hotReloadCounter[uniqueName]; 132 | return () => { 133 | setTimeout(() => { 134 | const unmounted = prev === hotReloadCounter[uniqueName]; 135 | unmounted && unmountModel(uniqueName); 136 | }); 137 | }; 138 | }, [uniqueName]); 139 | 140 | return uniqueName; 141 | }; 142 | 143 | const unmountModel = (modelName: string) => { 144 | modelStore['removeReducer'](modelName); 145 | loadingStore.dispatch({ 146 | type: DESTROY_LOADING, 147 | model: modelName, 148 | }); 149 | }; 150 | -------------------------------------------------------------------------------- /test/middleware.test.ts: -------------------------------------------------------------------------------- 1 | import { defineModel, getLoading, store } from '../src'; 2 | import { DestroyLoadingAction, DESTROY_LOADING } from '../src/actions/loading'; 3 | import { loadingStore } from '../src/store/loading-store'; 4 | import { basicModel } from './models/basic.model'; 5 | import { complexModel } from './models/complex.model'; 6 | 7 | beforeEach(() => { 8 | store.init(); 9 | }); 10 | 11 | afterEach(() => { 12 | store.unmount(); 13 | }); 14 | 15 | test('dispatch the same state should be intercepted', () => { 16 | const fn = vitest.fn(); 17 | const unsubscribe = store.subscribe(fn); 18 | 19 | expect(fn).toHaveBeenCalledTimes(0); 20 | basicModel.set(100); 21 | expect(fn).toHaveBeenCalledTimes(1); 22 | basicModel.set(100); 23 | basicModel.set(100); 24 | expect(fn).toHaveBeenCalledTimes(1); 25 | basicModel.set(101); 26 | expect(fn).toHaveBeenCalledTimes(2); 27 | 28 | complexModel.deleteUser(30); 29 | complexModel.deleteUser(34); 30 | expect(fn).toHaveBeenCalledTimes(2); 31 | 32 | complexModel.addUser(5, 'L'); 33 | expect(fn).toHaveBeenCalledTimes(3); 34 | complexModel.addUser(5, 'L'); 35 | expect(fn).toHaveBeenCalledTimes(3); 36 | complexModel.addUser(5, 'LT'); 37 | expect(fn).toHaveBeenCalledTimes(4); 38 | 39 | unsubscribe(); 40 | fn.mockRestore(); 41 | }); 42 | 43 | test('dispatch the same loading should be intercepted', async () => { 44 | const fn = vitest.fn(); 45 | const unsubscribe = loadingStore.subscribe(fn); 46 | 47 | loadingStore.inactivate(basicModel.name, 'pureAsync'); 48 | 49 | expect(fn).toHaveBeenCalledTimes(0); 50 | await basicModel.pureAsync(); 51 | await basicModel.pureAsync(); 52 | expect(fn).toHaveBeenCalledTimes(0); 53 | 54 | loadingStore.activate(basicModel.name, 'pureAsync'); 55 | 56 | await basicModel.pureAsync(); 57 | expect(fn).toHaveBeenCalledTimes(2); 58 | await basicModel.pureAsync(); 59 | await basicModel.pureAsync(); 60 | expect(fn).toHaveBeenCalledTimes(6); 61 | await Promise.all([basicModel.pureAsync(), basicModel.pureAsync()]); 62 | expect(fn).toHaveBeenCalledTimes(8); 63 | 64 | unsubscribe(); 65 | fn.mockRestore(); 66 | }); 67 | 68 | test('destroy model will not trigger reducer without method called', () => { 69 | const spy = vitest.fn(); 70 | loadingStore.subscribe(spy); 71 | loadingStore.dispatch({ 72 | type: DESTROY_LOADING, 73 | model: basicModel.name, 74 | }); 75 | expect(spy).toBeCalledTimes(0); 76 | spy.mockRestore(); 77 | }); 78 | 79 | test('destroy model will trigger reducer with method called', async () => { 80 | await basicModel.pureAsync(); 81 | const spy = vitest.fn(); 82 | loadingStore.subscribe(spy); 83 | loadingStore.dispatch({ 84 | type: DESTROY_LOADING, 85 | model: basicModel.name, 86 | }); 87 | expect(spy).toBeCalledTimes(1); 88 | spy.mockRestore(); 89 | }); 90 | 91 | test('reducer in reducer is invalid operation', () => { 92 | const model1 = defineModel('aia-1', { 93 | initialState: {}, 94 | reducers: { 95 | test1() {}, 96 | }, 97 | methods: { 98 | async ok() {}, 99 | notOk() { 100 | this.test1(); 101 | }, 102 | }, 103 | }); 104 | const model2 = defineModel('aia-2', { 105 | initialState: {}, 106 | reducers: { 107 | test2() { 108 | model1.test1(); 109 | }, 110 | test3() { 111 | model1.ok(); 112 | }, 113 | test4() { 114 | model1.notOk(); 115 | }, 116 | }, 117 | }); 118 | 119 | expect(() => model2.test2()).toThrowError('[dispatch]'); 120 | 121 | getLoading(model1.ok); 122 | expect(() => model2.test3()).not.toThrowError(); 123 | 124 | expect(() => model2.test4()).toThrowError(); 125 | }); 126 | 127 | test('freeze model state', () => { 128 | expect(Object.isFrozen(store.getState())).toBeTruthy(); 129 | 130 | expect(Object.isFrozen(complexModel.state)).toBeTruthy(); 131 | expect(Object.isFrozen(complexModel.state.ids)).toBeTruthy(); 132 | expect(Object.isFrozen(complexModel.state.users)).toBeTruthy(); 133 | 134 | complexModel.addUser(10, 'tom'); 135 | expect(Object.isFrozen(complexModel.state)).toBeTruthy(); 136 | expect(Object.isFrozen(complexModel.state.ids)).toBeTruthy(); 137 | expect(Object.isFrozen(complexModel.state.users)).toBeTruthy(); 138 | expect(() => complexModel.state.ids.push(2)).toThrowError(); 139 | }); 140 | 141 | test('freeze loading state', async () => { 142 | expect(Object.isFrozen(loadingStore.getState())).toBeTruthy(); 143 | expect(Object.isFrozen(getLoading(basicModel.pureAsync))).toBeTruthy(); 144 | 145 | loadingStore.activate(basicModel.name, 'pureAsync'); 146 | 147 | const promise = basicModel.pureAsync(); 148 | expect(Object.isFrozen(getLoading(basicModel.pureAsync.room))).toBeTruthy(); 149 | await promise; 150 | expect(Object.isFrozen(getLoading(basicModel.pureAsync.room))).toBeTruthy(); 151 | }); 152 | -------------------------------------------------------------------------------- /docs/advanced.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | # 克隆模型 4 | 5 | 虽然比较不常用,但有时候为了同一个页面的不同模块能独立使用模型数据,你就得需要复制这个模型,并把名字改掉。其实也不用这么麻烦,foca 给你来个惊喜: 6 | 7 | ```typescript 8 | import { defineModel, cloneModel } from 'foca'; 9 | 10 | // 你打算用在各个普通页面里。 11 | cosnt userModel = defineModel('users', { ... }); 12 | 13 | // 你打算用在通用的用户列表弹窗里。 14 | const user1Model = cloneModel('users1', userModel); 15 | // 你打算用在页头或页脚模块里。 16 | const user2Model = cloneModel('users2', userModel); 17 | ``` 18 | 19 | 共享方法但状态是独立的,这是个不错的主意,你只要维护一份代码就行了。 20 | 21 | 克隆时支持修改 `initialState, events, persist, skipRefresh` 这些属性 22 | 23 | ```typescript 24 | const user3Model = cloneModel('users3', userModel, { 25 | initialState: {...}, 26 | ... 27 | }); 28 | 29 | const user3Model = cloneModel('users3', userModel, (prev) => { 30 | return { 31 | initialState: { 32 | ...prev.initialState, 33 | customData: 'xyz', 34 | }, 35 | ... 36 | } 37 | }); 38 | ``` 39 | 40 | # 局部模型 41 | 42 | 通过`defineModel`和`cloneModel`创建的模型均为全局类别的模型,数据一直保持在内存中,直到应用关闭或者退出才会释放,对于比较大的项目,这可能会有性能问题。所以有时候你其实想要一种`用完就扔`的模型,即在 React 组件初始化时把模型数据扔到 store 中,当 React 组件被销毁时,模型的数据也跟着销毁。现在,局部模型很适合你的需求: 43 | 44 | ```diff 45 | import { useEffect } from 'react'; 46 | import { defineModel, useIsolate } from 'foca'; 47 | 48 | // test.model.ts 49 | export const testModel = defineModel('test', { 50 | initialState: { count: 0 }, 51 | reducers: { 52 | plus(state, value: number) { 53 | state.count += value; 54 | }, 55 | }, 56 | }); 57 | 58 | // App.tsx 59 | const App: FC = () => { 60 | + const model = useIsolate(testModel); 61 | const { count } = useModel(model); 62 | 63 | useEffect(() => { 64 | model.plus(1); 65 | }, []); 66 | 67 | return
{count}
; 68 | }; 69 | ``` 70 | 71 | 只需增加一行代码的工作量,利用 `useIsolate` 函数根据全局模型创建一个新的局部模型。局部模型拥有一份独立的状态数据,任何操作都不会影响到原来的全局模型,而且会随着组件一起 `挂载/销毁`,能有效降低内存占用。 72 | 73 | 另外,别忘了模型上还有两个对应的事件`onInit`和`onDestroy`可以使用 74 | 75 | ```typescript 76 | export const testModel = defineModel('test', { 77 | initialState: { count: 0 }, 78 | events: { 79 | onInit() { 80 | // 全局模型创建时触发 81 | // 局部模型随组件一起挂载时触发 82 | }, 83 | onDestroy() { 84 | // 局部模型随组件一起销毁时触发 85 | }, 86 | }, 87 | }); 88 | ``` 89 | 90 | !> 如果不需要持久化,那么它可以完全代替克隆模型 91 | 92 | # loadings 93 | 94 | 默认地,methods 函数只会保存一份执行状态,如果你在同一时间多次执行同一个函数,那么状态就会互相覆盖,产生错乱的数据。如果现在有 10 个按钮,点击每个按钮都会执行`model.methodX(id)`,那么我们如何知道是哪个按钮执行的呢?这时候我们需要为执行状态开辟一个独立的存储空间,让同一个函数拥有多个状态互不干扰。 95 | 96 | ```tsx 97 | import { useLoading } from 'foca'; 98 | 99 | const App: FC = () => { 100 | const loadings = useLoading(model.myMethod.room); 101 | 102 | const handleClick = (id: number) => { 103 | model.myMethod.room(id).execute(id); 104 | }; 105 | 106 | return ( 107 |
108 | 111 | 114 | 117 |
118 | ); 119 | }; 120 | ``` 121 | 122 | 这种场景也常出现在一些表格里,每一行通常都带有切换(switch UI)控件,点击后该控件需要被禁用或者出现 loading 图标,提前是你得知道是谁。 123 | 124 | 如果你能确定 find 的参数,那么也可以直接传递: 125 | 126 | ```typescript 127 | // 适用于明确地知道编号的场景,比如是从组件props直接传入 128 | const loading = useLoading(model.myMethod.room, 100); // boolean 129 | 130 | // 适用于列表,编号只能在for循环中获取的场景 131 | const loadings = useLoading(model.myMethod.room); 132 | list.forEach(({ id }) => { 133 | const loading = loadings.find(id); 134 | }); 135 | ``` 136 | 137 | # 重置所有数据 138 | 139 | 当用户退出登录时,你需要清理与用户相关的一些数据,然后把页面切换到`登录页`。清理操作其实是比较麻烦的,首先 model 太多了,然后就是后期也可能再增加其它模型,不可能手动一个个清理。这时候可以用上 store 自带的方法: 140 | 141 | ```diff 142 | import { store } from 'foca'; 143 | 144 | // onLogout是你的业务方法 145 | onLogout().then(() => { 146 | + store.refresh(); 147 | }); 148 | ``` 149 | 150 | 一个方法就能把所有数据都恢复成初始值状态,太方便了吧? 151 | 152 | 重置时,你也可以保留部分模型的数据不被影响(可能是一些全局的配置数据),在相应的模型下加入关键词`skipRefresh`即可: 153 | 154 | ```diff 155 | defineModel('my-global-model', { 156 | initialState: {}, 157 | + skipRefresh: true, 158 | }); 159 | ``` 160 | 161 | 对了,如果你实在是想无情地删除所有数据(即无视 skipRefresh 参数),那么就用`强制模式`好了: 162 | 163 | ```typescript 164 | store.refresh(true); 165 | ``` 166 | 167 | # 私有方法 168 | 169 | 我们总是会想抽出一些逻辑作为独立的方法调用,但又不想暴露给模型外部使用,而且方法一多,调用方法时 TS 会提示长长的一串方法列表,显得十分混乱。是时候声明一些私有方法了,foca 使用约定俗成的`前置下划线(_)`来代表私有方法 170 | 171 | ```typescript 172 | const userModel = defineModel('users', { 173 | initialState, 174 | reducers: { 175 | addUser(state, user: UserItem) {}, 176 | _deleteUser(state, userId: number) {}, 177 | }, 178 | methods: { 179 | async retrieve(id: number) { 180 | const user = await http.get(`/users/${id}`); 181 | this.addUser(user); 182 | 183 | // 私有reducers方法 184 | this._deleteUser(15); 185 | // 私有methods方法 186 | this._myLogic(); 187 | // 私有computed变量 188 | this._fullname.value; 189 | }, 190 | async _myLogic() {}, 191 | }, 192 | computed: { 193 | _fullname() {}, 194 | }, 195 | }); 196 | 197 | userModel.retrieve; // OK 198 | userModel._deleteUser; // 报错了,找不到属性 _deleteUser 199 | userModel._myLogic; // 报错了,找不到属性 _myLogic 200 | userModel._fullname; // 报错了,找不到属性 _fullname 201 | ``` 202 | 203 | 对外接口变得十分清爽,减少出错概率的同时,也提升了数据的安全性。 204 | -------------------------------------------------------------------------------- /docs/initialize.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | # 安装 4 | 5 | ```bash 6 | # npm 7 | npm install foca 8 | # yarn 9 | yarn add foca 10 | # pnpm 11 | pnpm add foca 12 | ``` 13 | 14 | # 激活 15 | 16 | foca 遵循`唯一store`原则,并提供了快速初始化的入口。 17 | 18 | ```typescript 19 | // File: store.ts 20 | import { store } from 'foca'; 21 | 22 | store.init(); 23 | ``` 24 | 25 | 好吧,就是这么简单! 26 | 27 | # 导入 28 | 29 | 与原生 react-redux 类似,你需要把 foca 提供的 Provider 组件放置到入口文件,这样才能在业务组件中获取到数据。 30 | 31 | 32 | 33 | #### ** React ** 34 | 35 | ```diff 36 | + import './store'; 37 | + import { FocaProvider } from 'foca'; 38 | import ReactDOM from 'react-dom/client'; 39 | import App from './App'; 40 | 41 | const container = document.getElementById('root'); 42 | const root = ReactDOM.createRoot(container); 43 | 44 | root.render( 45 | + 46 | 47 | + 48 | ); 49 | ``` 50 | 51 | #### ** React-Native ** 52 | 53 | ```diff 54 | + import './store'; 55 | + import { FocaProvider } from 'foca'; 56 | import { Text, View } from 'react-native'; 57 | 58 | export default function App() { 59 | return ( 60 | + 61 | 62 | Hello World 63 | 64 | + 65 | ) 66 | } 67 | ``` 68 | 69 | #### ** Taro.js ** 70 | 71 | ```diff 72 | + import './store'; 73 | + import { FocaProvider } from 'foca'; 74 | import { Component } from 'react'; 75 | 76 | export default class App extends Component { 77 | render() { 78 | - return this.props.children; 79 | + return {this.props.children}; 80 | } 81 | } 82 | ``` 83 | 84 | 85 | 86 | # 日志 87 | 88 | 在开发阶段,如果你想实时查看状态的操作过程以及数据的变化细节,那么开启可视化界面是必不可少的一个环节。 89 | 90 | 91 | 92 | #### ** 全局软件 ** 93 | 94 | **优势:** 一次安装,所有项目都可以无缝使用。 95 | 96 | - 对于 Web 项目,可以安装 Chrome 浏览器的 [redux-devtools](https://github.com/reduxjs/redux-devtools) 扩展,然后打开控制台查看。 97 | - 对于 React-Native 项目,可以安装并启动软件 [react-native-debugger](https://github.com/jhen0409/react-native-debugger),然后点击 App 里的按钮 `Debug with Chrome`即可连接软件,其本质也是 Chrome 的控制台 98 | 99 | 接着,我们在 store 里注入增强函数: 100 | 101 | ```typescript 102 | store.init({ 103 | // 字符串 redux-devtools 即 window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 的缩写 104 | // 设置 redux-devtools 在生产环境(process.env.NODE_ENV === 'production')下会自动关闭 105 | // 你也可以安装等效的插件包 @redux-devtools/extension 自由控制 106 | compose: 'redux-devtools', 107 | }); 108 | ``` 109 | 110 | compose 也支持回调形式,目的是为了注入更多插件。 111 | 112 | ```typescript 113 | import { composeWithDevTools as compose } from '@redux-devtools/extension'; 114 | // 或者使用原生的compose 115 | // import { compose } from 'foca'; 116 | 117 | store.init({ 118 | compose(enhancer) { 119 | return compose(enhancer, ...more[]); 120 | }, 121 | }); 122 | ``` 123 | 124 | #### ** 项目插件 ** 125 | 126 | **优势:** 可选配置参数多,且在 Web 和 React-Native 中都能使用。 127 | 128 | ```bash 129 | # npm 130 | npm install redux-logger @types/redux-logger --save-dev 131 | # yarn 132 | yarn add redux-logger @types/redux-logger --dev 133 | # pnpm 134 | pnpm add redux-logger @types/redux-logger -D 135 | ``` 136 | 137 | 接着我们把这个包注入 store: 138 | 139 | ```typescript 140 | import { store, Middleware } from 'foca'; 141 | import { createLogger } from 'redux-logger'; 142 | 143 | const middleware: Middleware[] = []; 144 | 145 | if (process.env.NODE_ENV !== 'production') { 146 | middleware.push( 147 | createLogger({ 148 | collapsed: true, 149 | diff: true, 150 | duration: true, 151 | logErrors: true, 152 | }), 153 | ); 154 | } 155 | 156 | store.init({ 157 | middleware, 158 | }); 159 | ``` 160 | 161 | 大功告成,下次你对 store 的数据做操作时,控制台就会有相应的通知输出。 162 | 163 | 164 | 165 | # 开发热更 166 | 167 | 如果是 React-Native,你可以跳过这一节。 168 | 169 | 因为 store.ts 需要被入口文件引入,而 store.ts 又引入了部分 model(持久化需要这么做),所以如果相应的 model 做了修改操作时,会导致浏览器页面全量刷新而非热更新。如果你正在使用当前流行的打包工具,强烈建议加上`hot.accept`手动处理模块更新。 170 | 171 | 172 | 173 | #### ** Vite ** 174 | 175 | ```typescript 176 | // File: store.ts 177 | 178 | store.init(...); 179 | 180 | // https://cn.vitejs.dev/guide/api-hmr.html#hot-acceptcb 181 | if (import.meta.hot) { 182 | import.meta.hot.accept(() => { 183 | console.log('Hot updated: store'); 184 | }); 185 | } 186 | ``` 187 | 188 | #### ** Webpack ** 189 | 190 | ```typescript 191 | // File: store.ts 192 | 193 | // ################################################## 194 | // ###### ####### 195 | // ###### yarn add @types/webpack-env --dev ####### 196 | // ###### ####### 197 | // ################################################## 198 | 199 | store.init(...); 200 | 201 | // https://webpack.docschina.org/api/hot-module-replacement/ 202 | if (module.hot) { 203 | module.hot.accept(() => { 204 | console.log('Hot updated: store'); 205 | }); 206 | } 207 | ``` 208 | 209 | #### ** Webpack ESM ** 210 | 211 | ```typescript 212 | // File: store.ts 213 | 214 | // ################################################## 215 | // ###### ####### 216 | // ###### yarn add @types/webpack-env --dev ####### 217 | // ###### ####### 218 | // ################################################## 219 | 220 | store.init(...); 221 | 222 | // https://webpack.docschina.org/api/hot-module-replacement/ 223 | if (import.meta.webpackHot) { 224 | import.meta.webpackHot.accept(() => { 225 | console.log('Hot updated: store'); 226 | }); 227 | } 228 | ``` 229 | 230 | 231 | -------------------------------------------------------------------------------- /src/store/loading-store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UnknownAction, 3 | applyMiddleware, 4 | legacy_createStore as createStore, 5 | Middleware, 6 | } from 'redux'; 7 | import type { PromiseRoomEffect, PromiseEffect } from '../model/enhance-effect'; 8 | import { loadingInterceptor } from '../middleware/loading.interceptor'; 9 | import { isDestroyLoadingAction, isLoadingAction } from '../actions/loading'; 10 | import { actionRefresh, isRefreshAction } from '../actions/refresh'; 11 | import { combine } from './proxy-store'; 12 | import { destroyLoadingInterceptor } from '../middleware/destroy-loading.interceptor'; 13 | import { immer } from '../utils/immer'; 14 | import { StoreBasic } from './store-basic'; 15 | import { modelStore } from './model-store'; 16 | import { freeze } from 'immer'; 17 | import { freezeStateMiddleware } from '../middleware/freeze-state.middleware'; 18 | 19 | export interface FindLoading { 20 | find(category: number | string): boolean; 21 | } 22 | 23 | interface LoadingState extends FindLoading { 24 | data: { 25 | [category: string]: boolean; 26 | }; 27 | } 28 | 29 | interface LoadingStoreStateItem { 30 | loadings: LoadingState; 31 | } 32 | 33 | export type LoadingStoreState = Partial<{ 34 | [model: string]: Partial<{ 35 | [method: string]: LoadingStoreStateItem; 36 | }>; 37 | }>; 38 | 39 | const findLoading: FindLoading['find'] = function ( 40 | this: LoadingState, 41 | category, 42 | ) { 43 | return !!this.data[category]; 44 | }; 45 | 46 | const createDefaultRecord = (): LoadingStoreStateItem => { 47 | return { 48 | loadings: { 49 | find: findLoading, 50 | data: {}, 51 | }, 52 | }; 53 | }; 54 | 55 | export class LoadingStore extends StoreBasic { 56 | protected initializingModels: string[] = []; 57 | protected status: Partial<{ 58 | [model: string]: Partial<{ 59 | [method: string]: boolean; 60 | }>; 61 | }> = {}; 62 | protected defaultRecord: LoadingStoreStateItem = freeze( 63 | createDefaultRecord(), 64 | true, 65 | ); 66 | 67 | constructor() { 68 | super(); 69 | const topic = modelStore.topic; 70 | topic.subscribe('init', this.init.bind(this)); 71 | topic.subscribe('refresh', this.refresh.bind(this)); 72 | topic.subscribe('unmount', this.unmount.bind(this)); 73 | topic.subscribe('modelPreInit', (modelName) => { 74 | this.initializingModels.push(modelName); 75 | }); 76 | topic.subscribe('modelPostInit', (modelName) => { 77 | this.initializingModels = this.initializingModels.filter( 78 | (item) => item !== modelName, 79 | ); 80 | }); 81 | } 82 | 83 | init() { 84 | const middleware: Middleware[] = [ 85 | loadingInterceptor(this), 86 | destroyLoadingInterceptor, 87 | ]; 88 | 89 | /* istanbul ignore else -- @preserve */ 90 | if (process.env.NODE_ENV !== 'production') { 91 | middleware.push(freezeStateMiddleware); 92 | } 93 | 94 | this.origin = createStore( 95 | this.reducer.bind(this), 96 | applyMiddleware.apply(null, middleware), 97 | ); 98 | 99 | combine(this.store); 100 | } 101 | 102 | unmount(): void { 103 | this.origin = null; 104 | } 105 | 106 | reducer( 107 | state: LoadingStoreState | undefined, 108 | action: UnknownAction, 109 | ): LoadingStoreState { 110 | if (state === void 0) { 111 | state = {}; 112 | } 113 | 114 | if (isLoadingAction(action)) { 115 | const { 116 | model, 117 | method, 118 | payload: { category, loading }, 119 | } = action; 120 | const next = immer.produce(state, (draft) => { 121 | draft[model] ||= {}; 122 | const { loadings } = (draft[model]![method] ||= createDefaultRecord()); 123 | loadings.data[category] = loading; 124 | }); 125 | 126 | return next; 127 | } 128 | 129 | if (isDestroyLoadingAction(action)) { 130 | const next = Object.assign({}, state); 131 | delete next[action.model]; 132 | delete this.status[action.model]; 133 | return next; 134 | } 135 | 136 | if (isRefreshAction(action)) return {}; 137 | 138 | return state; 139 | } 140 | 141 | get(effect: PromiseEffect | PromiseRoomEffect): LoadingStoreStateItem { 142 | const { 143 | _: { model, method }, 144 | } = effect; 145 | let record: LoadingStoreStateItem | undefined; 146 | 147 | if (this.isActive(model, method)) { 148 | record = this.getItem(model, method); 149 | } else { 150 | this.activate(model, method); 151 | } 152 | 153 | return record || this.defaultRecord; 154 | } 155 | 156 | getItem(model: string, method: string): LoadingStoreStateItem | undefined { 157 | const level1 = this.getState()[model]; 158 | return level1 && level1[method]; 159 | } 160 | 161 | isModelInitializing(model: string): boolean { 162 | return ( 163 | this.initializingModels.length > 0 && 164 | this.initializingModels.includes(model) 165 | ); 166 | } 167 | 168 | isActive(model: string, method: string): boolean { 169 | const level1 = this.status[model]; 170 | return level1 !== void 0 && level1[method] === true; 171 | } 172 | 173 | activate(model: string, method: string) { 174 | (this.status[model] ||= {})[method] = true; 175 | } 176 | 177 | inactivate(model: string, method: string) { 178 | (this.status[model] ||= {})[method] = false; 179 | } 180 | 181 | refresh() { 182 | return this.dispatch(actionRefresh(true)); 183 | } 184 | } 185 | 186 | export const loadingStore = new LoadingStore(); 187 | -------------------------------------------------------------------------------- /test/use-model.test.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react'; 2 | import { renderHook } from './helpers/render-hook'; 3 | import { store, useModel } from '../src'; 4 | import { basicModel, basicSkipRefreshModel } from './models/basic.model'; 5 | import { complexModel } from './models/complex.model'; 6 | 7 | beforeEach(() => { 8 | store.init(); 9 | }); 10 | 11 | afterEach(() => { 12 | store.unmount(); 13 | }); 14 | 15 | test('get state from one model', () => { 16 | const { result } = renderHook(() => useModel(basicModel)); 17 | 18 | expect(result.current.count).toEqual(0); 19 | 20 | act(() => { 21 | basicModel.plus(1); 22 | }); 23 | 24 | expect(result.current.count).toEqual(1); 25 | }); 26 | 27 | test('get state from multiple models', () => { 28 | const { result } = renderHook(() => useModel(basicModel, complexModel)); 29 | 30 | expect(result.current).toMatchObject({ 31 | basic: {}, 32 | complex: {}, 33 | }); 34 | 35 | act(() => { 36 | basicModel.plus(1); 37 | complexModel.addUser(10, 'Lucifer'); 38 | }); 39 | 40 | expect(result.current.basic.count).toEqual(1); 41 | expect(result.current.complex.users[10]).toEqual('Lucifer'); 42 | }); 43 | 44 | test('get state with selector', () => { 45 | const { result } = renderHook(() => 46 | useModel(basicModel, (state) => state.count * 10), 47 | ); 48 | 49 | expect(result.current).toEqual(0); 50 | 51 | act(() => { 52 | basicModel.plus(2); 53 | }); 54 | 55 | expect(result.current).toEqual(20); 56 | 57 | act(() => { 58 | basicModel.plus(3); 59 | }); 60 | 61 | expect(result.current).toEqual(50); 62 | }); 63 | 64 | test('get multiple state with selector', () => { 65 | const { result } = renderHook(() => 66 | useModel(basicModel, complexModel, (a, b) => a.count + b.ids.length), 67 | ); 68 | 69 | expect(result.current).toEqual(0); 70 | 71 | act(() => { 72 | basicModel.plus(1); 73 | complexModel.addUser(5, 'li'); 74 | complexModel.addUser(6, 'lu'); 75 | }); 76 | 77 | expect(result.current).toEqual(3); 78 | }); 79 | 80 | test('specific compare algorithm', async () => { 81 | { 82 | const hook = renderHook(() => 83 | useModel( 84 | basicModel, 85 | complexModel, 86 | (a, b) => ({ 87 | a, 88 | b, 89 | }), 90 | 'strictEqual', 91 | ), 92 | ); 93 | const prevValue = hook.result.current; 94 | act(() => { 95 | hook.rerender(); 96 | }); 97 | expect(hook.result.current !== prevValue).toBeTruthy(); 98 | } 99 | 100 | { 101 | const hook = renderHook(() => 102 | useModel( 103 | basicModel, 104 | complexModel, 105 | (a, b) => ({ 106 | a, 107 | b, 108 | }), 109 | 'shallowEqual', 110 | ), 111 | ); 112 | const prevValue = hook.result.current; 113 | act(() => { 114 | hook.rerender(); 115 | }); 116 | expect(hook.result.current === prevValue).toBeTruthy(); 117 | } 118 | 119 | { 120 | const hook = renderHook(() => 121 | useModel( 122 | basicModel, 123 | complexModel, 124 | (a, b) => ({ 125 | a, 126 | b, 127 | }), 128 | 'deepEqual', 129 | ), 130 | ); 131 | const prevValue = hook.result.current; 132 | act(() => { 133 | hook.rerender(); 134 | }); 135 | expect(hook.result.current === prevValue).toBeTruthy(); 136 | } 137 | }); 138 | 139 | test('Memoize the selector result', () => { 140 | const fn1 = vitest.fn(); 141 | const fn2 = vitest.fn(); 142 | 143 | const { result: result1 } = renderHook(() => { 144 | return useModel(basicModel, (state) => { 145 | fn1(); 146 | return state.count; 147 | }); 148 | }); 149 | 150 | const { result: result2 } = renderHook(() => { 151 | return useModel(basicModel, basicSkipRefreshModel, (state1, state2) => { 152 | fn2(); 153 | return state1.count + state2.count; 154 | }); 155 | }); 156 | 157 | expect(fn1).toBeCalledTimes(1); 158 | expect(fn2).toBeCalledTimes(1); 159 | 160 | act(() => { 161 | basicModel.plus(6); 162 | }); 163 | 164 | expect(result1.current).toBe(6); 165 | expect(result2.current).toBe(6); 166 | expect(fn1).toBeCalledTimes(3); 167 | expect(fn2).toBeCalledTimes(3); 168 | 169 | act(() => { 170 | // Sure not basicModel, we need trigger subscriptions 171 | complexModel.addUser(1, ''); 172 | }); 173 | expect(result1.current).toBe(6); 174 | expect(result2.current).toBe(6); 175 | expect(fn1).toBeCalledTimes(3); 176 | expect(fn2).toBeCalledTimes(3); 177 | 178 | act(() => { 179 | // Sure not basicModel, we need trigger subscriptions 180 | complexModel.addUser(2, 'L'); 181 | }); 182 | expect(result1.current).toBe(6); 183 | expect(result2.current).toBe(6); 184 | expect(fn1).toBeCalledTimes(3); 185 | expect(fn2).toBeCalledTimes(3); 186 | 187 | act(() => { 188 | basicModel.plus(1); 189 | }); 190 | expect(result1.current).toBe(7); 191 | expect(result2.current).toBe(7); 192 | expect(fn1).toBeCalledTimes(5); 193 | expect(fn2).toBeCalledTimes(5); 194 | 195 | act(() => { 196 | basicSkipRefreshModel.plus(1); 197 | }); 198 | expect(result1.current).toBe(7); 199 | expect(result2.current).toBe(8); 200 | expect(fn1).toBeCalledTimes(5); 201 | expect(fn2).toBeCalledTimes(7); 202 | 203 | fn1.mockRestore(); 204 | fn2.mockRestore(); 205 | }); 206 | 207 | test('Hooks keep working after hot reload', async () => { 208 | const { result } = renderHook(() => useModel(basicModel)); 209 | 210 | expect(result.current.count).toEqual(0); 211 | 212 | store.init(); 213 | 214 | act(() => { 215 | basicModel.plus(1); 216 | }); 217 | 218 | expect(result.current.count).toEqual(1); 219 | }); 220 | -------------------------------------------------------------------------------- /docs/persist.md: -------------------------------------------------------------------------------- 1 | # 持久化 2 | 3 | 持久化是自动把数据通过引擎存储到某个空间的过程。 4 | 5 | 如果你的某个 api 数据常年不变,那么建议你把它扔到本地做个缓存,这样用户下次再访问你的页面时,可以第一时间看到缓存的内容。如果你不想让用户每次刷新页面就重新登录,那么持久化很适合你。 6 | 7 | # 入口 8 | 9 | 你需要在初始化 store 时开启持久化 10 | 11 | ```typescript 12 | // File: store.ts 13 | import { store } from 'foca'; 14 | import { userModel } from './userModel'; 15 | import { agentModel } from './agentModel'; 16 | 17 | store.init({ 18 | persist: [ 19 | { 20 | key: '$PROJECT_$ENV', 21 | version: 1, 22 | engine: localStorage, 23 | // 模型白名单列表 24 | models: [userModel, agentModel], 25 | }, 26 | ], 27 | }); 28 | ``` 29 | 30 | 把需要持久化的模型扔进去,foca 就能自动帮你存取数据。 31 | 32 | `key`即为存储路径,最好采用**项目名-环境名**的形式组织。纯前端项目如果和其他前端项目共用一个域名,或者在同一域名下,则有可能使用共同的存储空间,因此需要保证`key`是唯一的值。 33 | 34 | # 存储引擎 35 | 36 | 不同的引擎会把数据存储到不同的位置,使用哪个引擎取决于项目跑在什么环境。引擎操作可以是同步的也可以是异步的。下面列举的第三方库也可以**直接当作**存储引擎: 37 | 38 | - window.localStorage - 浏览器自带 39 | - window.sessionStorage - 浏览器自带 40 | - [localforage](https://www.npmjs.com/package/localforage) (IndexedDB, WebSQL) - 浏览器专用 41 | - [@react-native-async-storage/async-storage](https://www.npmjs.com/package/@react-native-async-storage/async-storage) - React-Native专用 42 | - [foca-mmkv-storage](https://github.com/foca-js/foca-mmkv-storage) - React-Native专用 43 | - [foca-taro-storage](https://github.com/foca-js/foca-taro-storage) - Taro专用 44 | - [foca-electron-storage](https://github.com/foca-js/foca-taro-storage) - Electron专用 45 | - [foca-cookie-storage](https://github.com/foca-js/foca-cookie-storage) - 浏览器专用,存储到cookie 46 | 47 | 如果有必要,你也可以自己实现一个引擎: 48 | 49 | ```typescript 50 | // import { StorageEngine } from 'foca'; 51 | 52 | interface StorageEngine { 53 | getItem(key: string): string | null | Promise; 54 | setItem(key: string, value: string): any; 55 | removeItem(key: string): any; 56 | clear(): any; 57 | } 58 | ``` 59 | 60 | 如果是在测试环境,也可以尝试使用内置的内存引擎 61 | 62 | ```typescript 63 | import { memoryStorage } from 'foca'; 64 | ``` 65 | 66 | # 设置版本号 67 | 68 | 当数据结构变化,我们不得不升级版本号来`删除`持久化数据,版本号又分为`全局版本`和`模型版本`两种。当修改模型内版本号时,仅删除该模型的持久化数据,而修改全局版本号时,白名单内所有模型的持久化数据都被删除。 69 | 70 | 建议优先修改模型内版本号!! 71 | 72 | ```diff 73 | const stockModel = defineModel('stock', { 74 | initialState: {}, 75 | persist: { 76 | // 模型版本号,影响当前模型 77 | + version: '2.0', 78 | }, 79 | }); 80 | 81 | store.init({ 82 | persist: [ 83 | { 84 | key: '$PROJECT_normal_$ENV', 85 | // 全局版本号,影响白名单全部模型 86 | + version: '3.6', 87 | engine: engines.localStorage, 88 | models: [musicModel, stockModel], 89 | }, 90 | ], 91 | }); 92 | ``` 93 | 94 | # 数据合并 95 | 96 | > v3.0.0 97 | 98 | 在项目的推进过程中,难免需要根据产品需求更新模型数据结构,结构变化后,我们可以简单粗暴地通过`版本号+1`的方式来删除持久化的数据。但如果只是新增了某一个字段,我们希望持久化恢复时能自动识别。试试推荐的`合并模式`吧: 99 | 100 | ```diff 101 | store.init({ 102 | persist: [ 103 | { 104 | key: 'myproject-a-prod', 105 | version: 1, 106 | + merge: 'merge', 107 | engine: engines.localStorage, 108 | models: [userModel], 109 | }, 110 | ], 111 | }); 112 | ``` 113 | 114 | 很轻松就设置上了,合并模式目前有3种可选的类型: 115 | 116 | - `replace` - 覆盖模式。数据从存储引擎取出后直接覆盖初始数据 117 | - `merge` - 合并模式(默认)。数据从存储引擎取出后,与初始数据多余部分进行合并,可以理解为 **Object.assign()** 操作 118 | - `deep-merge` - 二级合并模式。在合并模式的基础上,如果某个key的值为对象,则该对象也会执行合并操作 119 | 120 | 如果某个模型比较特殊,我们也可以在里面单独设置合并模式。 121 | 122 | ```diff 123 | const userModel = defineModel('user', { 124 | initialState: {}, 125 | persist: { 126 | + merge: 'deep-merge', 127 | }, 128 | }); 129 | ``` 130 | 131 | 接下来看看它的具体表现: 132 | 133 | ```typescript 134 | const persistState = { obj: { test1: 'persist' } }; 135 | const initialState = { obj: { test2: 'initial' }, foo: 'bar' }; 136 | 137 | // replace 效果 138 | const state = { obj: { test1: 'persist' } }; 139 | // merge 效果 140 | const state = { obj: { test1: 'persist' }, foo: 'bar' }; 141 | // deep-merge 效果 142 | const state = { obj: { test1: 'persist', test2: 'initial' }, foo: 'bar' }; 143 | ``` 144 | 145 | 需要注意的是合并模式对`数组无效`,当持久化数据和初始数据都为数组类型时,会强制使用持久化数据。当持久化数据和初始数据任何一边为数组类型时,会强制使用初始化数据。 146 | 147 | ```typescript 148 | const persistState = [1, 2, 3]; ✅ 149 | const initialState = [4, 5, 6, 7]; ❌ 150 | // 合并效果 151 | const state = [1, 2, 3]; 152 | 153 | // ------------------------- 154 | 155 | const persistState = [1, 2, 3]; ❌ 156 | const initialState = { foo: 'bar' }; ✅ 157 | // 合并效果 158 | const state = { foo: 'bar' }; 159 | 160 | // ------------------------- 161 | 162 | const persistState = { foo: 'bar' }; ❌ 163 | const initialState = [1, 2, 3]; ✅ 164 | // 合并效果 165 | const state = [1, 2, 3]; 166 | ``` 167 | 168 | # 系列化钩子 169 | 170 | > v3.0.0 171 | 172 | 数据在模型与持久化引擎互相转换期间,我们希望对它进行一些额外操作以满足业务需求。比如: 173 | 174 | - 只缓存部分字段,避免存储尺寸超过存储空间限制 175 | - 改变数据结构或者内容 176 | - 更新时间等动态信息 177 | 178 | foca提供了一对实用的过滤函数`dump`和`load`。**dump** 即 model->persist,**load** 即 persist->model。 179 | 180 | ```typescript 181 | const model = defineModel('model', { 182 | initialState: { 183 | mode: 'foo', // 本地的设置,需要持久化缓存 184 | hugeDate: [], // API请求数据,数据量太大 185 | }, 186 | persist: { 187 | // 系列化 188 | dump(state) { 189 | return state.mode; 190 | }, 191 | // 反系列化 192 | load(mode) { 193 | return { ...this.initialState, mode }; 194 | }, 195 | }, 196 | }); 197 | ``` 198 | 199 | # 分组 200 | 201 | 我们注意到 persist 其实是个数组,这意味着你可以多填几组配置上去,把不同的模型数据存储到不同的地方。这看起来很酷,但我猜你不一定需要! 202 | 203 | ```typescript 204 | import { store, engines } from 'foca'; 205 | 206 | store.init({ 207 | persist: [ 208 | { 209 | key: 'myproject-a-prod', 210 | version: 1, 211 | engine: engines.localStorage, 212 | models: [userModel], 213 | }, 214 | { 215 | key: 'myproject-b-prod', 216 | version: 5, 217 | engine: engines.sessionStorage, 218 | models: [agentModel, teacherModel], 219 | }, 220 | { 221 | key: 'myproject-vip-prod', 222 | version: 1, 223 | engine: engines.localStorage, 224 | models: [customModel, otherModel], 225 | }, 226 | ], 227 | }); 228 | ``` 229 | -------------------------------------------------------------------------------- /test/use-isolate.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, cleanup, render } from '@testing-library/react'; 2 | import { useEffect, useState } from 'react'; 3 | import sleep from 'sleep-promise'; 4 | import { 5 | defineModel, 6 | FocaProvider, 7 | store, 8 | useLoading, 9 | useIsolate, 10 | Model, 11 | useModel, 12 | } from '../src'; 13 | import { loadingStore } from '../src/store/loading-store'; 14 | import { renderHook } from './helpers/render-hook'; 15 | import { basicModel } from './models/basic.model'; 16 | 17 | (['development', 'production'] as const).forEach((env) => { 18 | describe(`[${env} mode]`, () => { 19 | beforeEach(() => { 20 | store.init(); 21 | process.env.NODE_ENV = env; 22 | }); 23 | 24 | afterEach(async () => { 25 | process.env.NODE_ENV = 'testing'; 26 | cleanup(); 27 | await sleep(10); 28 | store.unmount(); 29 | }); 30 | 31 | test('can register to modelStore and remove from modelStore', async () => { 32 | const { result, unmount } = renderHook(() => useIsolate(basicModel)); 33 | 34 | expect(result.current).not.toBe(basicModel); 35 | expect(store.getState()).toHaveProperty( 36 | result.current.name, 37 | result.current.state, 38 | ); 39 | 40 | unmount(); 41 | await sleep(1); 42 | expect(store.getState()).not.toHaveProperty(result.current.name); 43 | }); 44 | 45 | test('can register to loadingStore and remove from loadingStore', async () => { 46 | const { result, unmount } = renderHook(() => { 47 | const model = useIsolate(basicModel); 48 | useLoading(basicModel.pureAsync); 49 | useLoading(model.pureAsync); 50 | 51 | return model; 52 | }); 53 | 54 | const key1 = `${result.current.name}.pureAsync`; 55 | const key2 = `${basicModel.name}.pureAsync`; 56 | expect(loadingStore.getState()).not.toHaveProperty(key1); 57 | 58 | await act(async () => { 59 | const promise1 = result.current.pureAsync(); 60 | const promise2 = basicModel.pureAsync(); 61 | 62 | expect(loadingStore.getState()).toHaveProperty(key1); 63 | expect(loadingStore.getState()).toHaveProperty(key2); 64 | 65 | await promise1; 66 | await promise2; 67 | }); 68 | 69 | expect(loadingStore.getState()).toHaveProperty(key1); 70 | expect(loadingStore.getState()).toHaveProperty(key2); 71 | 72 | unmount(); 73 | await sleep(1); 74 | expect(loadingStore.getState()).not.toHaveProperty(key1); 75 | expect(loadingStore.getState()).toHaveProperty(key2); 76 | }); 77 | 78 | test('call onDestroy event when local model is destroyed', async () => { 79 | const spy = vitest.fn(); 80 | const globalModel = defineModel('isolate-demo-1', { 81 | initialState: {}, 82 | events: { 83 | onDestroy: spy, 84 | }, 85 | }); 86 | 87 | const { unmount } = renderHook(() => useIsolate(globalModel)); 88 | 89 | expect(spy).toBeCalledTimes(0); 90 | unmount(); 91 | await sleep(1); 92 | expect(spy).toBeCalledTimes(1); 93 | basicModel.plus(1); 94 | expect(spy).toBeCalledTimes(1); 95 | }); 96 | 97 | test('recreate isolated model when global model changed', async () => { 98 | const globalModel = defineModel('isolate-demo-2', { 99 | initialState: {}, 100 | }); 101 | 102 | const { result } = renderHook(() => { 103 | const [state, setState] = useState(basicModel); 104 | 105 | const model = useIsolate(state); 106 | 107 | useEffect(() => { 108 | setTimeout(() => { 109 | setState(globalModel); 110 | }, 20); 111 | }, []); 112 | 113 | return model; 114 | }); 115 | 116 | const name1 = result.current.name; 117 | expect(name1).toMatch(basicModel.name); 118 | expect(store.getState()).toHaveProperty(name1); 119 | 120 | await act(async () => { 121 | await sleep(30); 122 | }); 123 | 124 | await sleep(10); 125 | 126 | expect(result.current.name).not.toBe('isolate-demo-2'); 127 | expect(result.current.name).toMatch('isolate-demo-2'); 128 | expect(store.getState()).not.toHaveProperty(name1); 129 | }); 130 | 131 | test.runIf(env === 'development')( 132 | 'Can get component name in dev mode', 133 | () => { 134 | let model!: Model; 135 | function MyApp() { 136 | model = useIsolate(basicModel); 137 | return null; 138 | } 139 | 140 | render( 141 | 142 | 143 | , 144 | ); 145 | 146 | expect(model.name).toMatch('MyApp#'); 147 | }, 148 | ); 149 | 150 | test('can use with global model', async () => { 151 | let model: typeof basicModel; 152 | const { result } = renderHook(() => { 153 | // @ts-expect-error 154 | model = useIsolate(basicModel); 155 | return useModel(model, basicModel, (local, basic) => { 156 | return `local: ${local.count}, basic: ${basic.count}`; 157 | }); 158 | }); 159 | 160 | expect(result.current).toBe('local: 0, basic: 0'); 161 | act(() => { 162 | basicModel.plus(12); 163 | }); 164 | expect(result.current).toBe('local: 0, basic: 12'); 165 | act(() => { 166 | model.plus(7); 167 | }); 168 | expect(result.current).toBe('local: 7, basic: 12'); 169 | }); 170 | 171 | test('can isolate from isolate model', async () => { 172 | let model1: typeof basicModel; 173 | let model2: typeof basicModel; 174 | const { result } = renderHook(() => { 175 | // @ts-expect-error 176 | model1 = useIsolate(basicModel); 177 | // @ts-expect-error 178 | model2 = useIsolate(model1); 179 | return useModel(model1, model2, (local1, local2) => { 180 | return `local1: ${local1.count}, local2: ${local2.count}`; 181 | }); 182 | }); 183 | 184 | expect(result.current).toBe('local1: 0, local2: 0'); 185 | act(() => { 186 | model1.plus(12); 187 | }); 188 | expect(result.current).toBe('local1: 12, local2: 0'); 189 | act(() => { 190 | model2.plus(7); 191 | }); 192 | expect(result.current).toBe('local1: 12, local2: 7'); 193 | }); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /docs/model.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 | # Model 4 | 5 | 原生的 redux 由 action/type/reducer 三个部分组成,大多数情况我们会分成 3 个文件分别存储。在实际使用中,这种模板式的书写方式不仅繁琐,而且难以将他们关联起来,类型提示就更麻烦了。 6 | 7 | 基于此,我们提出了模型概念,以 state 为核心,任何更改 state 的操作都应该放在一起。 8 | 9 | ```typescript 10 | // models/user.model.ts 11 | import { defineModel } from 'foca'; 12 | 13 | export interface UserItem { 14 | id: number; 15 | name: string; 16 | age: number; 17 | } 18 | 19 | const initialState: UserItem[] = []; 20 | 21 | export const userModel = defineModel('users', { 22 | initialState, 23 | }); 24 | ``` 25 | 26 | 你已`defineModel`经定义了一个最基础的模型,其中第一个字符串参数为redux中的`唯一标识`,请确保其它模型不会再使用这个名字。 27 | 28 | 对了,怎么注册到store?躺着别动!foca 已经自动把模型注册到 store 中心,也让你享受一下 **DRY** (Don't Repeat Yourself) 原则,因此在业务文件内直接导入模型就能使用。 29 | 30 | # State 31 | 32 | foca 基于 redux 深度定制,所以 state 必须是个纯对象或者数组。 33 | 34 | ```typescript 35 | // 对象 36 | const initialState: { [K: string]: string } = {}; 37 | const objModel = defineModel('model-object', { 38 | initialState, 39 | }); 40 | 41 | // 数组 42 | const initialState: number[] = []; 43 | const arrayModel = defineModel('model-array', { 44 | initialState, 45 | }); 46 | ``` 47 | 48 | # Reducers 49 | 50 | 模型光有 state 也不行,你现在拿到的就是一个空数组([])。加点数据上去吧,这时候就要用到 reducers: 51 | 52 | ```typescript 53 | export const userModel = defineModel('users', { 54 | initialState, 55 | reducers: { 56 | addUser(state, user: UserItem) { 57 | state.push(user); 58 | }, 59 | updateName(state, id: number, name: string) { 60 | const user = state.find((item) => item.id === id); 61 | if (user) { 62 | user.name = name; 63 | } 64 | }, 65 | removeUser(state, id: number) { 66 | const index = state.findIndex((item) => item.id === id); 67 | if (index >= 0) { 68 | state.splice(index, 1); 69 | } 70 | }, 71 | clear() { 72 | // 返回初始值 73 | return this.initialState; 74 | }, 75 | }, 76 | }); 77 | ``` 78 | 79 | 就这么干,你已经赋予了模型生命,你等下可以和它互动了。现在,我们来说说这些 reducers 需要注意的几个点: 80 | 81 | - 函数的第一个参数一定是 state ,而且它是能自动识别到类型`State`的,你不用刻意地去指定。 82 | - 函数是可以带多个参数的,这全凭你自己的喜好。 83 | - 函数体内可以直接修改 state 对象(数组也属于对象)里的任何内容,这得益于 [immer](https://github.com/immerjs/immer) 的功劳。 84 | - 函数返回值必须是`State`类型。当然你也可以不返回,这时 foca 会认为你正在直接修改 state。 85 | - 如果你想使用`this`上下文,比如上面的 **clear()** 函数返回了初始值,那么请~~不要使用箭头函数~~。 86 | 87 | # Methods 88 | 89 | 不可否认,你的数据不可能总是凭空捏造,在真实的业务场景中,数据总是通过接口获得,然后保存到 state 中。foca 贴心地为你准备了组合逻辑的函数,快来试试吧: 90 | 91 | ```typescript 92 | const userModel = defineModel('users', { 93 | initialState, 94 | methods: { 95 | async get() { 96 | const users = await http.get('/users'); 97 | this.setState(users); 98 | return users; 99 | }, 100 | async retrieve(id: number) { 101 | const user = await http.get(`/users/${id}`); 102 | this.setState((state) => { 103 | state.push(user); 104 | }); 105 | }, 106 | // 也可以是非异步的普通函数 107 | findUser(id: number) { 108 | return this.state.users.find((user) => user.id === id); 109 | }, 110 | }, 111 | }); 112 | ``` 113 | 114 | 瞧见没,你可以在 methods 里自由地使用 async/await 方案,然后通过上下文`this.setState`快速更新 state。 115 | 116 | 接下来我们说说`setState`,这其实完全就是 reducers 的快捷方式,你可以直接传入数据或者使用匿名函数来操作,十分方便。这不禁让我们想起了 React Component 里的 setState?咳咳~~读书人的事,那能叫抄吗? 117 | 118 | 119 | 120 | #### ** 直接修改 ** 121 | 122 | 依赖 immer 的能力,你可以直接修改回调函数给的 state 参数,这也是框架最推荐的方式 123 | 124 | ```typescript 125 | this.setState((state) => { 126 | state.b = 2; 127 | }); 128 | 129 | this.setState((state) => { 130 | state.push('a'); 131 | state.shift(); 132 | }); 133 | ``` 134 | 135 | #### ** 部分更新 ** 136 | 137 | 是的,你可以返回一部分数据,而且这个特性很简洁高效,框架会使用`Object.assign`帮你把剩余的属性加回去。 138 | 139 | !> 只针对 object 类型,而且只有第一级属性可以缺省(参考 React Class Component) 140 | 141 | ```typescript 142 | this.setState({ a: 1 }); 143 | 144 | this.setState((state) => { 145 | return { a: 1 }; // <==> state.a = 1; 146 | }); 147 | ``` 148 | 149 | #### ** 全量更新 ** 150 | 151 | 就是重新设置所有数据 152 | 153 | ```typescript 154 | this.setState({ a: 1, b: 2 }); 155 | this.setState((state) => { 156 | return { a: 1, b: 2 }; 157 | }); 158 | 159 | this.setState(['a', 'b', 'c']); 160 | this.setState((state) => { 161 | return ['a', 'b', 'c']; 162 | }); 163 | 164 | // 重新设置成初始值 165 | this.setState(this.initialState); 166 | ``` 167 | 168 | 169 | 170 | 嗯?你压根就不想用`setState`,你觉得这样看起来很混乱?Hold on,你突然想起可以使用 reducers 去改变 state 不是吗? 171 | 172 | ```typescript 173 | const userModel = defineModel('users', { 174 | initialState, 175 | reducers: { 176 | addUser(state, user: UserItem) { 177 | state.push(user); 178 | }, 179 | }, 180 | methods: { 181 | async retrieve(id: number) { 182 | const user = await http.get(`/users/${id}`); 183 | // 调用reducers里的函数 184 | this.addUser(user); 185 | }, 186 | }, 187 | }); 188 | ``` 189 | 190 | 好吧,这样看起来更纯粹一些,代价就是要委屈你多写几行代码了。 191 | 192 | # Computed 193 | 194 | 对于一些数据,其实是需要经过比较冗长的拼接或者复杂的计算才能得出结果,同时你想自动缓存这些结果?来吧,展示: 195 | 196 | ```typescript 197 | const initialState = { 198 | firstName: 'tick', 199 | lastName: 'tock', 200 | country: 0, 201 | }; 202 | 203 | const userModel = defineModel('users', { 204 | initialState, 205 | computed: { 206 | fullName() { 207 | return this.state.firstName + '.' + this.state.lastName; 208 | }, 209 | profile(age: number, address?: string) { 210 | return this.fullName() + age + (address || 'empty'); 211 | }, 212 | }, 213 | }); 214 | ``` 215 | 216 | 恕我直言,有点 Methods 的味道了。味道是这个味道,但是本质不一样,当我们多次执行computed函数时,因为存在缓存的概念,所以不会真正地执行该函数。 217 | 218 | ```typescript 219 | userModel.fullName(); // 执行函数,生成缓存 220 | userModel.fullName(); // 使用缓存 221 | userModel.fullName(); // 使用缓存 222 | ``` 223 | 224 | 带参数的计算属性可以理解为所有参数就是一个key,每个key都会生成一个计算属性实例,互不干扰。 225 | 226 | ```typescript 227 | userModel.profile(20); // 执行函数,生成实例1缓存 228 | userModel.profile(20); // 实例1缓存 229 | userModel.profile(123); // 执行函数,生成实例2缓存 230 | userModel.profile(123); // 实例2缓存 231 | 232 | userModel.profile(20); // 实例1缓存 233 | userModel.profile(123); // 实例2缓存 234 | ``` 235 | 236 | 参数尽量使用基本类型,**不建议**使用对象或者数组作为计算属性的实参,因为如果每次都传新建的复合类型,无法起到缓存的效果,执行速度反而变慢,这和`useMemo(callback, deps)`函数的第二个参数(依赖项)是一个原理。如果实在想用复合类型作为参数,不烦考虑一下放到`Methods`里? 237 | 238 | --- 239 | 240 | 缓存什么时候才会更新?框架自动收集依赖,只有其中某个依赖更新了,计算属性才会更新。上面的例子中,当`firstName`或者`lastName`有变化时,fullName 将被标记为`dirty`状态,下一次访问则会重新计算结果。而当`country`变化时,不影响 fullName 的结果,下一次访问仍使用缓存作为结果。 241 | 242 | !> 可以在 computed 中使用其它 model 的数据。 243 | -------------------------------------------------------------------------------- /test/model.test.ts: -------------------------------------------------------------------------------- 1 | import { defineModel, store } from '../src'; 2 | import { basicModel } from './models/basic.model'; 3 | 4 | beforeEach(() => { 5 | store.init(); 6 | }); 7 | 8 | afterEach(() => { 9 | store.unmount(); 10 | }); 11 | 12 | test('Model name', () => { 13 | expect(basicModel.name).toBe('basic'); 14 | }); 15 | 16 | test('initialState should be serializable', () => { 17 | const createModel = (initialState: any) => { 18 | return defineModel('model' + Math.random(), { initialState }); 19 | }; 20 | 21 | [ 22 | { x: Symbol('test') }, 23 | [Symbol('test')], 24 | { x: function () {} }, 25 | { x: /test/ }, 26 | { x: new Map() }, 27 | { x: new Set() }, 28 | { x: new Date() }, 29 | [new (class {})()], 30 | new (class {})(), 31 | ].forEach((initialState) => { 32 | expect(() => createModel(initialState)).toThrowError(); 33 | }); 34 | 35 | [ 36 | { x: undefined }, 37 | { x: undefined, y: null }, 38 | { x: 0 }, 39 | [0, 1, '2', {}, { x: null }], 40 | { x: { y: { z: [{}, {}] } } }, 41 | ].forEach((initialState) => { 42 | createModel(initialState); 43 | }); 44 | }); 45 | 46 | test('Reset model state', () => { 47 | basicModel.moreParams(3, 'earth'); 48 | expect(basicModel.state.count).toBe(3); 49 | expect(basicModel.state.hello).toBe('world, earth'); 50 | 51 | basicModel.reset(); 52 | expect(basicModel.state.count).toBe(0); 53 | expect(basicModel.state.hello).toBe('world'); 54 | }); 55 | 56 | test('Call reducer', () => { 57 | expect(basicModel.state.count).toBe(0); 58 | 59 | basicModel.plus(1); 60 | expect(basicModel.state.count).toBe(1); 61 | 62 | basicModel.plus(6); 63 | expect(basicModel.state.count).toBe(7); 64 | 65 | basicModel.minus(3); 66 | expect(basicModel.state.count).toBe(4); 67 | }); 68 | 69 | test('call reducer with multiple parameters', () => { 70 | expect(basicModel.state.count).toBe(0); 71 | expect(basicModel.state.hello).toBe('world'); 72 | 73 | basicModel.moreParams(13, 'timi'); 74 | expect(basicModel.state.count).toBe(13); 75 | expect(basicModel.state.hello).toBe('world, timi'); 76 | }); 77 | 78 | test('Set state in methods', async () => { 79 | expect(basicModel.state.count).toBe(0); 80 | expect(basicModel.state.hello).toBe('world'); 81 | 82 | await expect(basicModel.foo('earth', 15)).resolves.toBe('OK'); 83 | 84 | expect(basicModel.state.count).toBe(15); 85 | expect(basicModel.state.hello).toBe('earth'); 86 | }); 87 | 88 | test('Set state without function callback in methods', () => { 89 | expect(basicModel.state.count).toBe(0); 90 | 91 | basicModel.setWithoutFn(15); 92 | expect(basicModel.state.count).toBe(15); 93 | 94 | basicModel.setWithoutFn(26); 95 | expect(basicModel.state.count).toBe(26); 96 | 97 | basicModel.setWithoutFn(54.3); 98 | expect(basicModel.state.count).toBe(54.3); 99 | }); 100 | 101 | test('set partial object state in methods', () => { 102 | type State = { 103 | test: { count: number }; 104 | hello: string | undefined; 105 | name: string; 106 | }; 107 | const model = defineModel('partial-object-model', { 108 | initialState: { 109 | test: { 110 | count: 0, 111 | }, 112 | hello: 'world', 113 | name: 'timi', 114 | }, 115 | methods: { 116 | setNothing() { 117 | this.setState({}); 118 | }, 119 | setCount() { 120 | this.setState({ 121 | test: { 122 | count: 2, 123 | }, 124 | }); 125 | }, 126 | setHello() { 127 | this.setState({ 128 | hello: 'x', 129 | }); 130 | }, 131 | setHelloByFn() { 132 | this.setState(() => { 133 | return { 134 | hello: 'xx', 135 | }; 136 | }); 137 | }, 138 | setNothingByFn() { 139 | this.setState(() => ({})); 140 | }, 141 | override() { 142 | this.setState({ 143 | // @ts-expect-error 144 | test: 123, 145 | }); 146 | }, 147 | }, 148 | }); 149 | 150 | model.setCount(); 151 | expect(model.state.test).toStrictEqual({ 152 | count: 2, 153 | }); 154 | expect(model.state.hello).toBe('world'); 155 | 156 | model.setHello(); 157 | expect(model.state.test).toStrictEqual({ 158 | count: 2, 159 | }); 160 | expect(model.state.hello).toBe('x'); 161 | 162 | model.setNothing(); 163 | expect(model.state.test).toStrictEqual({ 164 | count: 2, 165 | }); 166 | expect(model.state.hello).toBe('x'); 167 | 168 | model.setHelloByFn(); 169 | expect(model.state.test).toStrictEqual({ 170 | count: 2, 171 | }); 172 | expect(model.state.hello).toStrictEqual('xx'); 173 | 174 | model.setNothingByFn(); 175 | expect(model.state.test).toStrictEqual({ 176 | count: 2, 177 | }); 178 | expect(model.state.hello).toStrictEqual('xx'); 179 | 180 | model.override(); 181 | expect(model.state.test).toBe(123); 182 | }); 183 | 184 | test('set partial array state in methods', () => { 185 | const model = defineModel('partial-array-model', { 186 | initialState: ['2'], 187 | methods: { 188 | set() { 189 | this.setState(['20', '30']); 190 | }, 191 | }, 192 | }); 193 | 194 | model.set(); 195 | expect(model.state).toStrictEqual(['20', '30']); 196 | }); 197 | 198 | test('private reducer and method', () => { 199 | expect( 200 | // @ts-expect-error 201 | basicModel._actionIsPrivate, 202 | ).toBeUndefined(); 203 | 204 | expect( 205 | // @ts-expect-error 206 | basicModel._effectIsPrivate, 207 | ).toBeUndefined(); 208 | 209 | expect( 210 | // @ts-expect-error 211 | basicModel.____alsoPrivateAction, 212 | ).toBeUndefined(); 213 | 214 | expect( 215 | // @ts-expect-error 216 | basicModel.____alsoPrivateEffect, 217 | ).toBeUndefined(); 218 | 219 | expect(basicModel.plus).toBeInstanceOf(Function); 220 | expect(basicModel.pureAsync).toBeInstanceOf(Function); 221 | }); 222 | 223 | test('define duplicated method keys will throw error', () => { 224 | expect(() => 225 | defineModel('x' + Math.random(), { 226 | initialState: {}, 227 | reducers: { 228 | test1() {}, 229 | }, 230 | methods: { 231 | test1() {}, 232 | test2() {}, 233 | }, 234 | }), 235 | ).toThrowError('test1'); 236 | 237 | expect(() => 238 | defineModel('x' + Math.random(), { 239 | initialState: {}, 240 | reducers: { 241 | test2() {}, 242 | }, 243 | computed: { 244 | test1() {}, 245 | test2() {}, 246 | }, 247 | }), 248 | ).toThrowError('test2'); 249 | 250 | expect(() => 251 | defineModel('x' + Math.random(), { 252 | initialState: {}, 253 | methods: { 254 | test2() {}, 255 | }, 256 | computed: { 257 | test1() {}, 258 | test2() {}, 259 | }, 260 | }), 261 | ).toThrowError('test2'); 262 | }); 263 | -------------------------------------------------------------------------------- /src/store/model-store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | applyMiddleware, 3 | compose, 4 | legacy_createStore as createStore, 5 | Middleware, 6 | Reducer, 7 | Store, 8 | StoreEnhancer, 9 | } from 'redux'; 10 | import { Topic } from 'topic'; 11 | import { actionRefresh, RefreshAction } from '../actions/refresh'; 12 | import { modelInterceptor } from '../middleware/model.interceptor'; 13 | import type { PersistOptions } from '../persist/persist-item'; 14 | import { PersistManager } from '../persist/persist-manager'; 15 | import { combine } from './proxy-store'; 16 | import { OBJECT } from '../utils/is-type'; 17 | import { StoreBasic } from './store-basic'; 18 | import { actionInActionInterceptor } from '../middleware/action-in-action.interceptor'; 19 | import { freezeStateMiddleware } from '../middleware/freeze-state.middleware'; 20 | 21 | type Compose = 22 | | typeof compose 23 | | ((...funcs: StoreEnhancer[]) => StoreEnhancer); 24 | 25 | interface CreateStoreOptions { 26 | preloadedState?: Record; 27 | compose?: 'redux-devtools' | Compose; 28 | middleware?: Middleware[]; 29 | persist?: PersistOptions[]; 30 | } 31 | 32 | export class ModelStore extends StoreBasic> { 33 | public topic: Topic<{ 34 | init: []; 35 | ready: []; 36 | refresh: []; 37 | unmount: []; 38 | modelPreInit: [modelName: string]; 39 | modelPostInit: [modelName: string]; 40 | }> = new Topic(); 41 | protected _isReady: boolean = false; 42 | protected consumers: Record = {}; 43 | protected reducerKeys: string[] = []; 44 | protected persister: PersistManager | null = null; 45 | 46 | protected reducer!: Reducer; 47 | 48 | constructor() { 49 | super(); 50 | this.topic.keep('ready', () => this._isReady); 51 | } 52 | 53 | get isReady(): boolean { 54 | return this._isReady; 55 | } 56 | 57 | init(options: CreateStoreOptions = {}) { 58 | const prevStore = this.origin; 59 | const firstInitialize = !prevStore; 60 | 61 | if (!firstInitialize) { 62 | if (process.env.NODE_ENV === 'production') { 63 | throw new Error(`[store] 请勿多次执行'store.init()'`); 64 | } 65 | } 66 | 67 | this._isReady = false; 68 | this.reducer = this.combineReducers(); 69 | 70 | const persistOptions = options.persist; 71 | let persister = this.persister; 72 | persister && persister.destroy(); 73 | if (persistOptions && persistOptions.length) { 74 | persister = this.persister = new PersistManager(persistOptions); 75 | this.reducer = persister.combineReducer(this.reducer); 76 | } else { 77 | persister = this.persister = null; 78 | } 79 | 80 | let store: Store; 81 | 82 | if (firstInitialize) { 83 | const middleware = (options.middleware || []).concat(modelInterceptor); 84 | /* istanbul ignore else -- @preserve */ 85 | if (process.env.NODE_ENV !== 'production') { 86 | middleware.unshift(actionInActionInterceptor); 87 | middleware.push(freezeStateMiddleware); 88 | } 89 | 90 | const enhancer = applyMiddleware.apply(null, middleware); 91 | 92 | store = this.origin = createStore( 93 | this.reducer, 94 | options.preloadedState, 95 | this.getCompose(options.compose)(enhancer), 96 | ); 97 | this.topic.publish('init'); 98 | 99 | combine(store); 100 | } else { 101 | // 重新创建store会导致组件里的subscription都失效 102 | store = prevStore; 103 | store.replaceReducer(this.reducer); 104 | } 105 | 106 | if (persister) { 107 | persister.init(store, firstInitialize).then(() => { 108 | this.ready(); 109 | }); 110 | } else { 111 | this.ready(); 112 | } 113 | 114 | return this; 115 | } 116 | 117 | refresh(force: boolean = false): RefreshAction { 118 | const action = this.dispatch(actionRefresh(force)); 119 | this.topic.publish('refresh'); 120 | return action; 121 | } 122 | 123 | unmount() { 124 | this.origin = null; 125 | this._isReady = false; 126 | this.topic.publish('unmount'); 127 | } 128 | 129 | onInitialized(maybeSync?: () => void): Promise { 130 | return new Promise((resolve) => { 131 | if (this._isReady) { 132 | maybeSync && maybeSync(); 133 | resolve(); 134 | } else { 135 | this.topic.subscribeOnce('ready', () => { 136 | maybeSync && maybeSync(); 137 | resolve(); 138 | }); 139 | } 140 | }); 141 | } 142 | 143 | protected ready() { 144 | this._isReady = true; 145 | this.topic.publish('ready'); 146 | } 147 | 148 | protected getCompose(customCompose: CreateStoreOptions['compose']): Compose { 149 | if (customCompose === 'redux-devtools') { 150 | /* istanbul ignore if -- @preserve */ 151 | if (process.env.NODE_ENV !== 'production') { 152 | return ( 153 | /** @ts-expect-error */ 154 | (typeof window === OBJECT 155 | ? window 156 | : typeof global === OBJECT 157 | ? global 158 | : {})['__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'] || compose 159 | ); 160 | } 161 | 162 | return compose; 163 | } 164 | 165 | return customCompose || compose; 166 | } 167 | 168 | protected combineReducers(): Reducer> { 169 | return (state, action) => { 170 | if (state === void 0) { 171 | state = {}; 172 | } 173 | 174 | const reducerKeys = this.reducerKeys; 175 | const consumers = this.consumers; 176 | const keyLength = reducerKeys.length; 177 | const nextState: Record = {}; 178 | let hasChanged = false; 179 | let i = keyLength; 180 | 181 | while (i-- > 0) { 182 | const key = reducerKeys[i]!; 183 | const prevForKey = state[key]; 184 | const nextForKey = (nextState[key] = consumers[key]!( 185 | prevForKey, 186 | action, 187 | )); 188 | hasChanged ||= nextForKey !== prevForKey; 189 | } 190 | 191 | return hasChanged || keyLength !== Object.keys(state).length 192 | ? nextState 193 | : state; 194 | }; 195 | } 196 | 197 | protected appendReducer(key: string, consumer: Reducer): void { 198 | const store = this.origin; 199 | const consumers = this.consumers; 200 | const exists = store && consumers.hasOwnProperty(key); 201 | 202 | consumers[key] = consumer; 203 | this.reducerKeys = Object.keys(consumers); 204 | store && !exists && store.replaceReducer(this.reducer); 205 | } 206 | 207 | protected removeReducer(key: string): void { 208 | const store = this.origin; 209 | const consumers = this.consumers; 210 | 211 | if (consumers.hasOwnProperty(key)) { 212 | delete consumers[key]; 213 | this.reducerKeys = Object.keys(consumers); 214 | store && store.replaceReducer(this.reducer); 215 | } 216 | } 217 | } 218 | 219 | export const modelStore = new ModelStore(); 220 | -------------------------------------------------------------------------------- /test/lifecycle.test.ts: -------------------------------------------------------------------------------- 1 | import sleep from 'sleep-promise'; 2 | import { cloneModel, defineModel, memoryStorage, store } from '../src'; 3 | import { PersistSchema } from '../src/persist/persist-item'; 4 | 5 | describe('onInit', () => { 6 | afterEach(() => { 7 | store.unmount(); 8 | }); 9 | 10 | const createModel = () => { 11 | return defineModel('events' + Math.random(), { 12 | initialState: { count: 0 }, 13 | methods: { 14 | invokeByReadyHook() { 15 | this.setState((state) => { 16 | state.count += 101; 17 | }); 18 | }, 19 | }, 20 | events: { 21 | onInit() { 22 | this.invokeByReadyHook(); 23 | }, 24 | }, 25 | }); 26 | }; 27 | 28 | test('trigger ready events on store ready', async () => { 29 | const hookModel = createModel(); 30 | store.init(); 31 | 32 | const hook2Model = createModel(); 33 | const clonedModel = cloneModel('events' + Math.random(), hookModel); 34 | 35 | await Promise.resolve(); 36 | 37 | expect(hookModel.state.count).toBe(101); 38 | expect(hook2Model.state.count).toBe(101); 39 | expect(clonedModel.state.count).toBe(101); 40 | }); 41 | 42 | test('trigger ready events on store and persist ready', async () => { 43 | const hookModel = createModel(); 44 | 45 | await memoryStorage.setItem( 46 | 'mm:z', 47 | JSON.stringify({ 48 | v: 1, 49 | d: { 50 | [hookModel.name]: { 51 | v: 0, 52 | d: JSON.stringify({ count: 20 }), 53 | }, 54 | }, 55 | }), 56 | ); 57 | 58 | store.init({ 59 | persist: [ 60 | { 61 | key: 'z', 62 | keyPrefix: 'mm:', 63 | version: 1, 64 | engine: memoryStorage, 65 | models: [hookModel], 66 | }, 67 | ], 68 | }); 69 | 70 | const hook2Model = createModel(); 71 | const clonedModel = cloneModel('events' + Math.random(), hookModel); 72 | 73 | expect(hookModel.state.count).toBe(0); 74 | expect(hook2Model.state.count).toBe(0); 75 | expect(clonedModel.state.count).toBe(0); 76 | 77 | await store.onInitialized(); 78 | 79 | expect(hookModel.state.count).toBe(101 + 20); 80 | expect(hook2Model.state.count).toBe(101); 81 | expect(clonedModel.state.count).toBe(101); 82 | }); 83 | 84 | test('should call modelPreInit and modelPostInit', async () => { 85 | const hookModel = createModel(); 86 | let publishCount = 0; 87 | const token1 = store.topic.subscribe('modelPreInit', (modelName) => { 88 | if (modelName === hookModel.name) { 89 | publishCount += 1; 90 | } 91 | }); 92 | const token2 = store.topic.subscribe('modelPostInit', (modelName) => { 93 | if (modelName === hookModel.name) { 94 | publishCount += 0.4; 95 | } 96 | }); 97 | 98 | store.init(); 99 | await store.onInitialized(); 100 | expect(publishCount).toBe(1 + 0.4); 101 | token1.unsubscribe(); 102 | token2.unsubscribe(); 103 | }); 104 | 105 | test('should call modelPreInit and modelPostInit for promise returning', async () => { 106 | const hookModel = defineModel('events' + Math.random(), { 107 | initialState: {}, 108 | events: { 109 | async onInit() { 110 | await sleep(200); 111 | }, 112 | }, 113 | }); 114 | let publishCount = 0; 115 | const token1 = store.topic.subscribe('modelPreInit', (modelName) => { 116 | if (modelName === hookModel.name) { 117 | publishCount += 1; 118 | } 119 | }); 120 | const token2 = store.topic.subscribe('modelPostInit', (modelName) => { 121 | if (modelName === hookModel.name) { 122 | publishCount += 0.4; 123 | } 124 | }); 125 | 126 | store.init(); 127 | await store.onInitialized(); 128 | expect(publishCount).toBe(1); 129 | await sleep(210); 130 | expect(publishCount).toBe(1 + 0.4); 131 | token1.unsubscribe(); 132 | token2.unsubscribe(); 133 | }); 134 | }); 135 | 136 | describe('onChange', () => { 137 | beforeEach(() => { 138 | store.init(); 139 | }); 140 | 141 | afterEach(() => { 142 | store.unmount(); 143 | }); 144 | 145 | test('onChange should call after onInit', async () => { 146 | let testMessage = ''; 147 | const model = defineModel('events' + Math.random(), { 148 | initialState: { count: 0 }, 149 | reducers: { 150 | plus(state) { 151 | state.count += 1; 152 | }, 153 | minus(state) { 154 | state.count -= 1; 155 | }, 156 | }, 157 | methods: { 158 | _invokeByReadyHook() { 159 | this.setState((state) => { 160 | state.count += 2; 161 | }); 162 | }, 163 | }, 164 | events: { 165 | onInit() { 166 | testMessage += 'onInit-'; 167 | this._invokeByReadyHook(); 168 | }, 169 | onChange(prevState, nextState) { 170 | testMessage += `prev-${prevState.count}-next-${nextState.count}-`; 171 | }, 172 | }, 173 | }); 174 | model.plus(); 175 | model.minus(); 176 | expect(testMessage).toBe(''); 177 | 178 | await store.onInitialized(); 179 | 180 | expect(testMessage).toBe('onInit-prev-0-next-2-'); 181 | model.plus(); 182 | expect(testMessage).toBe('onInit-prev-0-next-2-prev-2-next-3-'); 183 | store.refresh(); 184 | expect(testMessage).toBe( 185 | 'onInit-prev-0-next-2-prev-2-next-3-prev-3-next-0-', 186 | ); 187 | }); 188 | }); 189 | 190 | describe('onDestroy', () => { 191 | beforeEach(() => { 192 | store.init(); 193 | }); 194 | 195 | afterEach(() => { 196 | store.unmount(); 197 | }); 198 | 199 | test('call onDestroy when invoke store.destroy()', async () => { 200 | const spy = vitest.fn(); 201 | const model = defineModel('events' + Math.random(), { 202 | initialState: { count: 0 }, 203 | reducers: { 204 | update(state) { 205 | state.count += 1; 206 | }, 207 | }, 208 | events: { 209 | onDestroy: spy, 210 | }, 211 | }); 212 | 213 | await store.onInitialized(); 214 | 215 | model.update(); 216 | expect(spy).toBeCalledTimes(0); 217 | store['removeReducer'](model.name); 218 | expect(spy).toBeCalledTimes(1); 219 | spy.mockRestore(); 220 | }); 221 | 222 | test('should not call onChange', async () => { 223 | const destroySpy = vitest.fn(); 224 | const changeSpy = vitest.fn(); 225 | const model = defineModel('events' + Math.random(), { 226 | initialState: { count: 0 }, 227 | reducers: { 228 | update(state) { 229 | state.count += 1; 230 | }, 231 | }, 232 | events: { 233 | onChange: changeSpy, 234 | onDestroy: destroySpy, 235 | }, 236 | }); 237 | 238 | await store.onInitialized(); 239 | 240 | model.update(); 241 | expect(destroySpy).toBeCalledTimes(0); 242 | expect(changeSpy).toBeCalledTimes(1); 243 | store['removeReducer'](model.name); 244 | expect(destroySpy).toBeCalledTimes(1); 245 | expect(changeSpy).toBeCalledTimes(1); 246 | destroySpy.mockRestore(); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /test/typescript/define-model.check.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'ts-expect'; 2 | import { UnknownAction, defineModel } from '../../src'; 3 | 4 | // @ts-expect-error 5 | defineModel('no-initial-state', {}); 6 | 7 | defineModel('null-state', { 8 | // @ts-expect-error 9 | initialState: null, 10 | }); 11 | 12 | defineModel('string-state', { 13 | // @ts-expect-error 14 | initialState: '', 15 | }); 16 | 17 | defineModel('array-state-reducers', { 18 | initialState: [] as { test: number }[], 19 | reducers: { 20 | returnNormal(_) { 21 | return []; 22 | }, 23 | returnInitialize(_) { 24 | return this.initialState; 25 | }, 26 | }, 27 | methods: { 28 | returnNormal() { 29 | this.setState([]); 30 | this.setState([{ test: 3 }]); 31 | // @ts-expect-error 32 | this.setState(); 33 | // @ts-expect-error 34 | this.setState({}); 35 | // @ts-expect-error 36 | this.setState(2); 37 | // @ts-expect-error 38 | this.setState(); 39 | // @ts-expect-error 40 | this.setState([undefined]); 41 | // @ts-expect-error 42 | this.setState([{ test: 3 }, {}]); 43 | // @ts-expect-error 44 | this.setState(undefined); 45 | 46 | this.setState((_) => { 47 | return []; 48 | }); 49 | // @ts-expect-error 50 | this.setState((_) => { 51 | return [] as object[]; 52 | }); 53 | this.setState((state) => { 54 | state.push({ test: 3 }); 55 | // @ts-expect-error 56 | state.push(4); 57 | }); 58 | }, 59 | returnInitialize() { 60 | this.setState(() => { 61 | return this.initialState; 62 | }); 63 | this.setState(this.initialState); 64 | }, 65 | }, 66 | }); 67 | 68 | defineModel('object-state-reducers', { 69 | initialState: {} as { 70 | test: { test1: number }; 71 | test2: string; 72 | test3?: string; 73 | }, 74 | reducers: { 75 | returnNormal(_state) { 76 | return { test: { test1: 3 }, test2: 'bar' }; 77 | }, 78 | returnInitialize() { 79 | return this.initialState; 80 | }, 81 | }, 82 | methods: { 83 | returnNormal() { 84 | this.setState((_) => { 85 | return {}; 86 | }); 87 | this.setState((_) => { 88 | return { test: { test1: 2 } }; 89 | }); 90 | this.setState((_) => { 91 | return { test: { test1: 2 }, test2: 'bar' }; 92 | }); 93 | // FIXME: 94 | this.setState((_) => { 95 | return { test: { test1: 2 }, test2: 'bar', test3: '', other: '' }; 96 | }); 97 | // @ts-expect-error 98 | this.setState((_) => { 99 | return { test: { test1: 2 }, foo1: 'baz' }; 100 | }); 101 | // @ts-expect-error 102 | this.setState((_) => { 103 | return { xxx: 2 }; 104 | }); 105 | // @ts-expect-error 106 | this.setState((_) => { 107 | return { test: {} }; 108 | }); 109 | // @ts-expect-error 110 | this.setState((_) => { 111 | return { test: { test1: 2 }, t: 3 }; 112 | }); 113 | // FIXME: 114 | this.setState((_) => { 115 | return { test: { test1: 2, t: 3 } }; 116 | }); 117 | // @ts-expect-error 118 | this.setState((_) => { 119 | return { test: { test2: 2 } }; 120 | }); 121 | this.setState((state) => { 122 | state.test.test1 = 4; 123 | }); 124 | }, 125 | returnPartial() { 126 | this.setState({}); 127 | this.setState({ test: { test1: 3 } }); 128 | // @ts-expect-error 129 | this.setState({ test: { test1: 3, more: 4 } }); 130 | // @ts-expect-error 131 | this.setState({ test: { test1: 3, more: undefined } }); 132 | 133 | this.setState({ test3: undefined }); 134 | this.setState({ test2: 'x', test3: undefined }); 135 | // @ts-expect-error 136 | this.setState({ test: { test1: undefined } }); 137 | 138 | // @ts-expect-error 139 | this.setState({ test2: undefined }); 140 | // @ts-expect-error 141 | this.setState({ test: 'x' }); 142 | // @ts-expect-error 143 | this.setState({ test: { test1: 'x' } }); 144 | // @ts-expect-error 145 | this.setState({ test: { test1: 3 }, ok: 'test', more: 'test1' }); 146 | // @ts-expect-error 147 | this.setState(); 148 | // @ts-expect-error 149 | this.setState({ test: {} }); 150 | // @ts-expect-error 151 | this.setState([]); 152 | // @ts-expect-error 153 | this.setState(2); 154 | // @ts-expect-error 155 | this.setState({ xxx: 2 }); 156 | this.setState({ test2: 'x' }); 157 | // @ts-expect-error 158 | this.setState({ test2: 'x', more: 'y' }); 159 | }, 160 | returnInitialize() { 161 | this.setState(() => { 162 | return this.initialState; 163 | }); 164 | this.setState(this.initialState); 165 | }, 166 | }, 167 | }); 168 | 169 | defineModel('wrong-reducer-state-1', { 170 | initialState: {} as { test: { test1: number } }, 171 | // @ts-expect-error 172 | reducers: { 173 | returnUnexpected(_) { 174 | return { test: {} }; 175 | }, 176 | right() { 177 | return { test: { test1: 3 } }; 178 | }, 179 | }, 180 | }); 181 | 182 | defineModel('wrong-reducer-state-2', { 183 | initialState: {} as { test: { test1: number } }, 184 | // @ts-expect-error 185 | reducers: { 186 | returnUnexpected(_) { 187 | return {}; 188 | }, 189 | 190 | right() { 191 | return { test: { test1: 3 } }; 192 | }, 193 | }, 194 | }); 195 | 196 | defineModel('wrong-reducer-state-3', { 197 | initialState: {} as { test: { test1: number } }, 198 | // @ts-expect-error 199 | reducers: { 200 | returnUnexpected(_) { 201 | return []; 202 | }, 203 | right() { 204 | return { test: { test1: 3 } }; 205 | }, 206 | }, 207 | }); 208 | 209 | defineModel('private-and-context', { 210 | initialState: {}, 211 | reducers: { 212 | _action1() {}, 213 | _action2() {}, 214 | action3() { 215 | // @ts-expect-error 216 | this.method3; 217 | // @ts-expect-error 218 | this.xxx; 219 | // @ts-expect-error 220 | this._fullname; 221 | }, 222 | }, 223 | methods: { 224 | _method1() { 225 | this._action1(); 226 | }, 227 | async _method2() {}, 228 | method3() { 229 | this._method1(); 230 | this.xxx().endsWith('/'); 231 | this._fullname().endsWith('/'); 232 | }, 233 | }, 234 | computed: { 235 | xxx() { 236 | // @ts-expect-error 237 | this._method1; 238 | // @ts-expect-error 239 | this._action1; 240 | // @ts-expect-error 241 | this.method3; 242 | 243 | return ''; 244 | }, 245 | yyy() { 246 | this.xxx(); 247 | return this._fullname(); 248 | }, 249 | _fullname() { 250 | return ''; 251 | }, 252 | }, 253 | events: { 254 | onInit() { 255 | this._action1(); 256 | this._method1(); 257 | this.action3(); 258 | this.method3(); 259 | this.state; 260 | // @ts-expect-error 261 | this.initialState; 262 | // @ts-expect-error 263 | this.onInit; 264 | 265 | expectType(this._fullname()); 266 | expectType<() => Promise>(this._method2); 267 | expectType<() => UnknownAction>(this._action1); 268 | expectType<() => UnknownAction>(this._action2); 269 | }, 270 | }, 271 | }); 272 | -------------------------------------------------------------------------------- /src/api/use-model.ts: -------------------------------------------------------------------------------- 1 | import { shallowEqual } from 'react-redux'; 2 | import { deepEqual } from '../utils/deep-equal'; 3 | import type { Model } from '../model/types'; 4 | import { toArgs } from '../utils/to-args'; 5 | import { useModelSelector } from '../redux/use-selector'; 6 | import { isFunction, isString } from '../utils/is-type'; 7 | 8 | /** 9 | * hooks新旧数据的对比方式: 10 | * 11 | * - `deepEqual` 深度对比,对比所有层级的内容。传递selector时默认使用。 12 | * - `shallowEqual` 浅对比,只比较对象第一层。传递多个模型但没有selector时默认使用。 13 | * - `strictEqual` 全等(===)对比。只传一个模型但没有selector时默认使用。 14 | */ 15 | export type Algorithm = 'strictEqual' | 'shallowEqual' | 'deepEqual'; 16 | 17 | /** 18 | * * 获取模型的状态数据。 19 | * * 传入一个模型时,将返回该模型的状态。 20 | * * 传入多个模型时,则返回一个以模型名称为key、状态为value的大对象。 21 | * * 最后一个参数如果是**函数**,则为状态过滤函数,过滤函数的结果视为最终返回值。 22 | */ 23 | export function useModel( 24 | model: Model, 25 | ): State; 26 | export function useModel( 27 | model: Model, 28 | selector: (state: State) => T, 29 | algorithm?: Algorithm, 30 | ): T; 31 | 32 | export function useModel< 33 | Name1 extends string, 34 | State1 extends object, 35 | Name2 extends string, 36 | State2 extends object, 37 | >( 38 | model1: Model, 39 | model2: Model, 40 | ): { 41 | [K in Name1]: State1; 42 | } & { 43 | [K in Name2]: State2; 44 | }; 45 | export function useModel( 46 | model1: Model, 47 | model2: Model, 48 | selector: (state1: State1, state2: State2) => T, 49 | algorithm?: Algorithm, 50 | ): T; 51 | 52 | export function useModel< 53 | Name1 extends string, 54 | State1 extends object, 55 | Name2 extends string, 56 | State2 extends object, 57 | Name3 extends string, 58 | State3 extends object, 59 | >( 60 | model1: Model, 61 | model2: Model, 62 | model3: Model, 63 | ): { 64 | [K in Name1]: State1; 65 | } & { 66 | [K in Name2]: State2; 67 | } & { 68 | [K in Name3]: State3; 69 | }; 70 | export function useModel< 71 | State1 extends object, 72 | State2 extends object, 73 | State3 extends object, 74 | T, 75 | >( 76 | model1: Model, 77 | model2: Model, 78 | model3: Model, 79 | selector: (state1: State1, state2: State2, state3: State3) => T, 80 | algorithm?: Algorithm, 81 | ): T; 82 | 83 | export function useModel< 84 | Name1 extends string, 85 | State1 extends object, 86 | Name2 extends string, 87 | State2 extends object, 88 | Name3 extends string, 89 | State3 extends object, 90 | Name4 extends string, 91 | State4 extends object, 92 | >( 93 | model1: Model, 94 | model2: Model, 95 | model3: Model, 96 | model4: Model, 97 | ): { 98 | [K in Name1]: State1; 99 | } & { 100 | [K in Name2]: State2; 101 | } & { 102 | [K in Name3]: State3; 103 | } & { 104 | [K in Name4]: State4; 105 | }; 106 | export function useModel< 107 | State1 extends object, 108 | State2 extends object, 109 | State3 extends object, 110 | State4 extends object, 111 | T, 112 | >( 113 | model1: Model, 114 | model2: Model, 115 | model3: Model, 116 | model4: Model, 117 | selector: ( 118 | state1: State1, 119 | state2: State2, 120 | state3: State3, 121 | state4: State4, 122 | ) => T, 123 | algorithm?: Algorithm, 124 | ): T; 125 | 126 | export function useModel< 127 | Name1 extends string, 128 | State1 extends object, 129 | Name2 extends string, 130 | State2 extends object, 131 | Name3 extends string, 132 | State3 extends object, 133 | Name4 extends string, 134 | State4 extends object, 135 | Name5 extends string, 136 | State5 extends object, 137 | >( 138 | model1: Model, 139 | model2: Model, 140 | model3: Model, 141 | model4: Model, 142 | model5: Model, 143 | ): { 144 | [K in Name1]: State1; 145 | } & { 146 | [K in Name2]: State2; 147 | } & { 148 | [K in Name3]: State3; 149 | } & { 150 | [K in Name4]: State4; 151 | } & { 152 | [K in Name5]: State5; 153 | }; 154 | export function useModel< 155 | State1 extends object, 156 | State2 extends object, 157 | State3 extends object, 158 | State4 extends object, 159 | State5 extends object, 160 | T, 161 | >( 162 | model1: Model, 163 | model2: Model, 164 | model3: Model, 165 | model4: Model, 166 | model5: Model, 167 | selector: ( 168 | state1: State1, 169 | state2: State2, 170 | state3: State3, 171 | state4: State4, 172 | state5: State5, 173 | ) => T, 174 | algorithm?: Algorithm, 175 | ): T; 176 | 177 | export function useModel(): any { 178 | const args = toArgs(arguments); 179 | let algorithm: Algorithm | false = 180 | args.length > 1 && isString(args[args.length - 1]) && args.pop(); 181 | const selector: Function | false = 182 | args.length > 1 && isFunction(args[args.length - 1]) && args.pop(); 183 | const models: Model[] = args; 184 | const modelsLength = models.length; 185 | const onlyOneModel = modelsLength === 1; 186 | 187 | if (!algorithm) { 188 | if (selector) { 189 | // 返回子集或者计算过的内容。 190 | // 如果只是从模型中获取数据且没有做转换,则大部分时间会降级为shallow或者strict。 191 | // 如果对数据做了转换,则肯定需要使用深对比。 192 | algorithm = 'deepEqual'; 193 | } else if (onlyOneModel) { 194 | // 一个model属于一个reducer,reducer已经使用了深对比来判断是否变化, 195 | algorithm = 'strictEqual'; 196 | } else { 197 | // { key => model } 集合。 198 | // 一个model属于一个reducer,reducer已经使用了深对比来判断是否变化, 199 | algorithm = 'shallowEqual'; 200 | } 201 | } 202 | 203 | // 储存了结果说明是state状态变化导致的对比计算。 204 | // 因为存在闭包,除模型外的所有参数都是旧的, 205 | // 所以我们只需要保证用到的模型数据不变即可,这样可以减少无意义的计算。 206 | let hasMemo = false, 207 | snapshot: any, 208 | prevState: Record, 209 | currentStates: object[], 210 | i: number, 211 | changed: boolean; 212 | 213 | const reducerNames: string[] = []; 214 | for (i = 0; i < modelsLength; ++i) { 215 | reducerNames.push(models[i]!.name); 216 | } 217 | 218 | return useModelSelector((state: Record) => { 219 | if (hasMemo) { 220 | changed = false; 221 | for (i = modelsLength; i-- > 0; ) { 222 | const reducerName = reducerNames[i]!; 223 | if (state[reducerName] !== prevState[reducerName]) { 224 | changed = true; 225 | break; 226 | } 227 | } 228 | 229 | if (!changed) { 230 | prevState = state; 231 | return snapshot; 232 | } 233 | } 234 | 235 | prevState = state; 236 | hasMemo = true; 237 | 238 | if (onlyOneModel) { 239 | const firstState = state[reducerNames[0]!]; 240 | return (snapshot = selector ? selector(firstState) : firstState); 241 | } 242 | 243 | if (selector) { 244 | currentStates = []; 245 | for (i = modelsLength; i-- > 0; ) { 246 | currentStates[i] = state[reducerNames[i]!]!; 247 | } 248 | return (snapshot = selector.apply(null, currentStates)); 249 | } 250 | 251 | snapshot = {}; 252 | for (i = modelsLength; i-- > 0; ) { 253 | const reducerName = reducerNames[i]!; 254 | snapshot[reducerName] = state[reducerName]; 255 | } 256 | return snapshot; 257 | }, compareFn[algorithm]); 258 | } 259 | 260 | const compareFn: Record< 261 | Algorithm, 262 | undefined | ((previous: any, next: any) => boolean) 263 | > = { 264 | deepEqual: deepEqual, 265 | shallowEqual: shallowEqual, 266 | strictEqual: void 0, 267 | }; 268 | -------------------------------------------------------------------------------- /docs/home.md: -------------------------------------------------------------------------------- 1 | # FOCA 2 | 3 | 流畅的 react 状态管理库,基于[redux](https://github.com/reduxjs/redux)和[react-redux](https://github.com/reduxjs/react-redux)。简洁、极致、高效。 4 | 5 | [![npm peer react version](https://img.shields.io/npm/dependency-version/foca/peer/react?logo=react)](https://github.com/facebook/react) 6 | [![npm peer typescript version](https://img.shields.io/npm/dependency-version/foca/peer/typescript?logo=typescript)](https://github.com/microsoft/TypeScript) 7 | [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/foca-js/foca/test.yml?branch=master&label=test&logo=vitest)](https://github.com/foca-js/foca/actions) 8 | [![Codecov](https://img.shields.io/codecov/c/github/foca-js/foca?logo=codecov)](https://codecov.io/gh/foca-js/foca) 9 | [![npm](https://img.shields.io/npm/v/foca?logo=npm)](https://www.npmjs.com/package/foca) 10 | [![npm](https://img.shields.io/npm/dt/foca?logo=codeforces)](https://www.npmjs.com/package/foca) 11 | [![npm bundle size (version)](https://img.shields.io/bundlephobia/minzip/foca?label=bundle+size&cacheSeconds=3600&logo=esbuild)](https://bundlephobia.com/package/foca@latest) 12 | [![License](https://img.shields.io/github/license/foca-js/foca?logo=open-source-initiative)](https://github.com/foca-js/foca/blob/master/LICENSE) 13 | [![Code Style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?logo=prettier)](https://github.com/prettier/prettier) 14 | 15 |
16 | 17 | ![mind map](./mindMap.svg) 18 | 19 | # 使用环境 20 | 21 | - Browser 22 | - React Native 23 | - Taro 24 | - Electron 25 | 26 | # 特性 27 | 28 | #### 模块化开发,导出即可使用 29 | 30 | 一个模型包含了状态操作的所有方法,基础代码全部剥离,不再像原生 redux 一般拆分成 action/type/reducer 三个文件,既不利于管理,又难以在提示上关联起来。 31 | 32 | 定义完模型,导出即可在组件中使用,不用再怕忘记注册或者嫌麻烦了。 33 | 34 | #### 专注 TS 极致体验,超强的类型自动推导 35 | 36 | 无 TS 不编程,foca 提供 **100%** 的基础类型提示,强大的自动推导能力让你产生一种提示上瘾的快感,而你只需关注业务中的类型。 37 | 38 | #### 内置 [immer](https://github.com/immerjs/immer) 响应式修改数据 39 | 40 | 可以说加入 immer 是非常有必要的,当 reducer 数据多层嵌套时,你不必再忍受更改里层的数据而不断使用 rest/spread(...)扩展符的烦恼,相反地,直接赋值就好了,其他的交给 immer 搞定。 41 | 42 | #### 支持计算属性,自动收集依赖,可传参数 43 | 44 | 现在,redux 家族不需要再羡慕`vue`或者`mobx`等响应式框架,咱也能支持计算属性并且自动收集依赖,而且是时候把[reselect](https://github.com/reduxjs/reselect)**扔进垃圾桶**了。 45 | 46 | #### 自动管理异步函数的 loading 状态 47 | 48 | 我们总是想知道某个异步方法(或者请求)正在执行,然后在页面上渲染出`loading...`字样,幸运地是框架自动(按需)为你记录了执行状态。 49 | 50 | #### 可定制的多引擎数据持久化 51 | 52 | 某些数据在一个时间段内可能是不变的,比如登录凭证 token。所以你想着先把数据存到本地,下次自动恢复到模型中,这样用户就不需要频繁登录了。 53 | 54 | #### 支持局部模型,用完即扔 55 | 56 | 利用全局模型派生出局部模型,并跟随组件`挂载/卸载`,状态与外界隔离,组件卸载后状态自动删除,严格控制内存使用量 57 | 58 | #### 支持私有方法 59 | 60 | 一个前置下划线(`_`)就能让方法变成私有的,外部使用时 TS 不会提示私有方法和私有变量,简单好记又省心。 61 | 62 | # 生态 63 | 64 | #### 网络请求 65 | 66 | | 仓库 | 版本 | 描述 | 67 | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------- | 68 | | [axios](https://github.com/axios/axios) | [![npm](https://img.shields.io/npm/v/axios)](https://www.npmjs.com/package/axios) | 当下最流行的请求库 | 69 | | [foca-axios](https://github.com/foca-js/foca-axios) | [![npm](https://img.shields.io/npm/v/foca-axios)](https://www.npmjs.com/package/foca-axios) | axios++ 支持 节流、缓存、重试 | 70 | | [foca-openapi](https://github.com/foca-js/foca-openapi) | [![npm](https://img.shields.io/npm/v/foca-openapi)](https://www.npmjs.com/package/foca-openapi) | 使用openapi文档生成请求服务 | 71 | 72 | #### 持久化存储引擎 73 | 74 | | 仓库 | 版本 | 描述 | 平台 | 75 | | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -------- | 76 | | [react-native-async-storage](https://github.com/react-native-async-storage/async-storage) | [![npm](https://img.shields.io/npm/v/@react-native-async-storage/async-storage)](https://www.npmjs.com/package/@react-native-async-storage/async-storage) | React-Native 持久化引擎 | RN | 77 | | [foca-taro-storage](https://github.com/foca-js/foca-taro-storage) | [![npm](https://img.shields.io/npm/v/foca-taro-storage)](https://www.npmjs.com/package/foca-taro-storage) | Taro 持久化引擎 | Taro | 78 | | [localforage](https://github.com/localForage/localForage) | [![npm](https://img.shields.io/npm/v/localforage)](https://www.npmjs.com/package/localforage) | 浏览器端持久化引擎,支持 IndexedDB, WebSQL | Web | 79 | | [foca-electron-storage](https://github.com/foca-js/foca-electron-storage) | [![npm](https://img.shields.io/npm/v/foca-electron-storage)](https://www.npmjs.com/package/foca-electron-storage) | Electron 持久化引擎 | Electron | 80 | | [foca-mmkv-storage](https://github.com/foca-js/foca-mmkv-storage) | [![npm](https://img.shields.io/npm/v/foca-mmkv-storage)](https://www.npmjs.com/package/foca-mmkv-storage) | 基于 mmkv 的持久化引擎 | RN | 81 | | [foca-cookie-storage](https://github.com/foca-js/foca-cookie-storage) | [![npm](https://img.shields.io/npm/v/foca-cookie-storage)](https://www.npmjs.com/package/foca-cookie-storage) | Cookie 持久化引擎 | Web | 82 | 83 | | 仓库 | 版本 | 描述 | 平台 | 84 | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------- | 85 | | [@redux-devtools/extension](https://github.com/reduxjs/redux-devtools) | [![npm](https://img.shields.io/npm/v/@redux-devtools/extension)](https://www.npmjs.com/package/@redux-devtools/extension) | 浏览器日志插件 | Web, RN | 86 | | [react-native-debugger](https://github.com/jhen0409/react-native-debugger) | [![npm](https://img.shields.io/npm/v/react-native-debugger)](https://www.npmjs.com/package/react-native-debugger) | 日志应用程序 | RN | 87 | | [redux-logger](https://github.com/LogRocket/redux-logger) | [![npm](https://img.shields.io/npm/v/redux-logger)](https://www.npmjs.com/package/redux-logger) | 控制台输出日志 | Web, RN, Taro | 88 | 89 | # 缺陷 90 | 91 | - [不支持 SSR](/troubleshooting?id=为什么不支持-ssr) 92 | 93 | # 例子 94 | 95 | React 案例仓库:https://github.com/foca-js/foca-demo-web 96 |
97 | RN 案例仓库:https://github.com/foca-js/foca-demo-react-native 98 |
99 | Taro 案例仓库:https://github.com/foca-js/foca-demo-taro 100 |
101 | 102 | # 在线试玩 103 | 104 | 110 | -------------------------------------------------------------------------------- /src/persist/persist-item.ts: -------------------------------------------------------------------------------- 1 | import type { StorageEngine } from '../engines/storage-engine'; 2 | import type { 3 | GetInitialState, 4 | InternalModel, 5 | Model, 6 | ModelPersist, 7 | } from '../model/types'; 8 | import { isObject, isPlainObject, isString } from '../utils/is-type'; 9 | import { toPromise } from '../utils/to-promise'; 10 | import { parseState, stringifyState } from '../utils/serialize'; 11 | 12 | export interface PersistSchema { 13 | /** 14 | * 版本 15 | */ 16 | v: number | string; 17 | /** 18 | * 数据 19 | */ 20 | d: { 21 | [key: string]: PersistItemSchema; 22 | }; 23 | } 24 | 25 | export interface PersistItemSchema { 26 | /** 27 | * 版本 28 | */ 29 | v: number | string; 30 | /** 31 | * 数据 32 | */ 33 | d: string; 34 | } 35 | 36 | export type PersistMergeMode = 'replace' | 'merge' | 'deep-merge'; 37 | 38 | export interface PersistOptions { 39 | /** 40 | * 存储唯一标识名称 41 | */ 42 | key: string; 43 | /** 44 | * 存储名称前缀,默认值:`@@foca.persist:` 45 | */ 46 | keyPrefix?: string; 47 | /** 48 | * 持久化数据与初始数据的合并方式。默认值:`merge` 49 | * 50 | * - replace - 覆盖模式。数据从存储引擎取出后直接覆盖初始数据 51 | * - merge - 合并模式。数据从存储引擎取出后,与初始数据多余部分进行合并,可以理解为`Object.assign()`操作 52 | * - deep-merge - 二级合并模式。在合并模式的基础上,如果某个key的值为对象,则该对象也会执行合并操作 53 | * 54 | * 注意:当数据为数组格式时该配置无效。 55 | * @since 3.0.0 56 | */ 57 | merge?: PersistMergeMode; 58 | /** 59 | * 版本号 60 | */ 61 | version: string | number; 62 | /** 63 | * 存储引擎 64 | */ 65 | engine: StorageEngine; 66 | /** 67 | * 允许持久化的模型列表 68 | */ 69 | models: Model[]; 70 | } 71 | 72 | type CustomModelPersistOptions = Required> & { 73 | ctx: GetInitialState; 74 | }; 75 | 76 | const defaultDumpOrLoadFn = (value: any) => value; 77 | 78 | interface PersistRecord { 79 | model: Model; 80 | /** 81 | * 模型的persist参数 82 | */ 83 | opts: CustomModelPersistOptions; 84 | /** 85 | * 已经存储的模型内容,data是字符串。 86 | * 存储时,如果各项属性符合条件,则会当作最终值,从而省去了系列化的过程。 87 | */ 88 | schema?: PersistItemSchema; 89 | /** 90 | * 已经存储的模型内容,data是对象。 91 | * 主要用于和store变化后的state对比。 92 | */ 93 | prev?: object; 94 | } 95 | 96 | export class PersistItem { 97 | readonly key: string; 98 | 99 | protected readonly records: Record = {}; 100 | 101 | constructor(protected readonly options: PersistOptions) { 102 | const { 103 | models, 104 | keyPrefix = '@@foca.persist:', 105 | key, 106 | merge = 'merge' satisfies PersistMergeMode, 107 | } = options; 108 | 109 | this.key = keyPrefix + key; 110 | 111 | for (let i = models.length; i-- > 0; ) { 112 | const model = models[i]!; 113 | const { 114 | load = defaultDumpOrLoadFn, 115 | dump = defaultDumpOrLoadFn, 116 | version: customVersion = 0, 117 | merge: customMerge = merge, 118 | } = (model as unknown as InternalModel)._$opts.persist || {}; 119 | 120 | this.records[model.name] = { 121 | model, 122 | opts: { 123 | version: customVersion, 124 | merge: customMerge, 125 | load, 126 | dump, 127 | ctx: (model as unknown as InternalModel)._$persistCtx, 128 | }, 129 | }; 130 | } 131 | } 132 | 133 | init(): Promise { 134 | return toPromise(() => this.options.engine.getItem(this.key)).then( 135 | (data) => { 136 | if (!data) { 137 | this.loadMissingState(); 138 | return this.dump(); 139 | } 140 | 141 | try { 142 | const schema = JSON.parse(data); 143 | 144 | if (!this.validateSchema(schema)) { 145 | this.loadMissingState(); 146 | return this.dump(); 147 | } 148 | 149 | const schemaKeys = Object.keys(schema.d); 150 | for (let i = schemaKeys.length; i-- > 0; ) { 151 | const key = schemaKeys[i]!; 152 | const record = this.records[key]; 153 | 154 | if (record) { 155 | const { opts } = record; 156 | const itemSchema = schema.d[key]!; 157 | if (this.validateItemSchema(itemSchema, opts)) { 158 | const dumpData = parseState(itemSchema.d); 159 | record.prev = this.merge( 160 | opts.load.call(opts.ctx, dumpData), 161 | opts.ctx.initialState, 162 | opts.merge, 163 | ); 164 | record.schema = itemSchema; 165 | } 166 | } 167 | } 168 | 169 | this.loadMissingState(); 170 | return this.dump(); 171 | } catch (e) { 172 | this.dump(); 173 | throw e; 174 | } 175 | }, 176 | ); 177 | } 178 | 179 | loadMissingState() { 180 | this.loop((record) => { 181 | const { prev, opts, schema } = record; 182 | if (!schema || !prev) { 183 | const dumpData = opts.dump.call(null, opts.ctx.initialState); 184 | record.prev = this.merge( 185 | opts.load.call(opts.ctx, dumpData), 186 | opts.ctx.initialState, 187 | opts.merge, 188 | ); 189 | record.schema = { 190 | v: opts.version, 191 | d: stringifyState(dumpData), 192 | }; 193 | } 194 | }); 195 | } 196 | 197 | merge(persistState: any, initialState: any, mode: PersistMergeMode) { 198 | const isStateArray = Array.isArray(persistState); 199 | const isInitialStateArray = Array.isArray(initialState); 200 | if (isStateArray && isInitialStateArray) return persistState; 201 | if (isStateArray || isInitialStateArray) return initialState; 202 | 203 | if (mode === 'replace') return persistState; 204 | 205 | const state = Object.assign({}, initialState, persistState); 206 | 207 | if (mode === 'deep-merge') { 208 | const keys = Object.keys(persistState); 209 | for (let i = 0; i < keys.length; ++i) { 210 | const key = keys[i]!; 211 | if ( 212 | Object.prototype.hasOwnProperty.call(initialState, key) && 213 | isPlainObject(state[key]) && 214 | isPlainObject(initialState[key]) 215 | ) { 216 | state[key] = Object.assign({}, initialState[key], state[key]); 217 | } 218 | } 219 | } 220 | 221 | return state; 222 | } 223 | 224 | collect(): Record { 225 | const stateMaps: Record = {}; 226 | 227 | this.loop(({ prev: state }, key) => { 228 | state && (stateMaps[key] = state); 229 | }); 230 | 231 | return stateMaps; 232 | } 233 | 234 | update(nextState: Record) { 235 | let changed = false; 236 | 237 | this.loop((record) => { 238 | const { model, prev, opts, schema } = record; 239 | const nextStateForKey = nextState[model.name]!; 240 | 241 | // 状态不变的情况下,即使过期了也无所谓,下次初始化时会自动剔除。 242 | // 版本号改动的话一定会触发页面刷新。 243 | if (nextStateForKey !== prev) { 244 | record.prev = nextStateForKey; 245 | const nextSchema: PersistItemSchema = { 246 | v: opts.version, 247 | d: stringifyState(opts.dump.call(null, nextStateForKey)), 248 | }; 249 | 250 | if (!schema || nextSchema.d !== schema.d) { 251 | record.schema = nextSchema; 252 | changed ||= true; 253 | } 254 | } 255 | }); 256 | 257 | changed && this.dump(); 258 | } 259 | 260 | protected loop(callback: (record: PersistRecord, key: string) => void) { 261 | const records = this.records; 262 | const recordKeys = Object.keys(records); 263 | for (let i = recordKeys.length; i-- > 0; ) { 264 | const key = recordKeys[i]!; 265 | callback(records[key]!, key); 266 | } 267 | } 268 | 269 | protected dump() { 270 | this.options.engine.setItem(this.key, JSON.stringify(this.toJSON())); 271 | } 272 | 273 | protected validateSchema(schema: any): schema is PersistSchema { 274 | return ( 275 | isObject(schema) && 276 | isObject(schema.d) && 277 | schema.v === this.options.version 278 | ); 279 | } 280 | 281 | protected validateItemSchema( 282 | schema: PersistItemSchema | undefined, 283 | options: CustomModelPersistOptions, 284 | ) { 285 | return schema && schema.v === options.version && isString(schema.d); 286 | } 287 | 288 | protected toJSON(): PersistSchema { 289 | const states: PersistSchema['d'] = {}; 290 | 291 | this.loop(({ schema }, key) => { 292 | schema && (states[key] = schema); 293 | }); 294 | 295 | return { v: this.options.version, d: states }; 296 | } 297 | } 298 | --------------------------------------------------------------------------------