├── example-react ├── src │ ├── vite-env.d.ts │ ├── types │ │ └── indexData.ts │ ├── main.tsx │ ├── store │ │ ├── str.ts │ │ ├── testAsync.ts │ │ ├── num.ts │ │ ├── testObject.ts │ │ ├── indexData.ts │ │ └── arr.ts │ ├── api │ │ └── index.ts │ ├── mock │ │ └── mockPromiseArray.ts │ ├── App.css │ ├── components │ │ └── Button.tsx │ ├── index.css │ ├── assets │ │ └── react.svg │ └── App.tsx ├── vite.config.ts ├── tsconfig.node.json ├── index.html ├── tsconfig.json ├── package.json ├── README.md └── public │ └── vite.svg ├── src ├── utils │ ├── env.ts │ ├── local.ts │ ├── __test__ │ │ ├── getBooleanValue.test.ts │ │ ├── local.test.ts │ │ └── getChangeType.test.ts │ └── getChangeValue.ts ├── __test__ │ ├── __snapshots__ │ │ ├── react.test.tsx.snap │ │ └── index.test.tsx.snap │ ├── react.test.tsx │ └── index.test.tsx ├── type.ts └── index.ts ├── .travis.yml ├── pnpm-workspace.yaml ├── .gitignore ├── scripts └── jest.setup.ts ├── jest.config.js ├── tsconfig.json ├── .npmignore ├── example-node ├── package.json ├── src │ └── index.js └── pnpm-lock.yaml ├── .eslintrc.cjs ├── package.json ├── CHANGELOG.md ├── .vscode └── settings.json └── README.md /example-react/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/utils/env.ts: -------------------------------------------------------------------------------- 1 | export type Environment = 'web' | 'ReactNative'; 2 | -------------------------------------------------------------------------------- /example-react/src/types/indexData.ts: -------------------------------------------------------------------------------- 1 | export type IndexDataType = Record; 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | script: 5 | - npm run codecov 6 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'src/**' 3 | - 'example-node/**' 4 | - 'example-react/**' 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | exmaple-node/node_modules/ 4 | example-react/node_modules/ 5 | example-vue/ 6 | coverage/ -------------------------------------------------------------------------------- /example-react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /example-react/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App.tsx'; 4 | import './index.css'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | ); 9 | -------------------------------------------------------------------------------- /scripts/jest.setup.ts: -------------------------------------------------------------------------------- 1 | const noop = () => {}; 2 | // TODO spy on console.error 3 | /* eslint-disable no-console */ 4 | console.debug = noop; 5 | console.warn = noop; 6 | console.error = noop; 7 | console.groupCollapsed = noop; 8 | console.groupEnd = noop; 9 | /* eslint-enable no-console */ 10 | -------------------------------------------------------------------------------- /example-react/src/store/str.ts: -------------------------------------------------------------------------------- 1 | import {createMapperHooksStore} from '@extremelyjs/store/src/index'; 2 | 3 | const strStore = createMapperHooksStore('', {withLocalStorage: 'str-test'}); 4 | 5 | export const useStr = strStore.useStoreValue; 6 | 7 | export const setStr = strStore.setStoreValue; 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // require.resolve 会报错 3 | preset: './node_modules/@reskript/config-jest/config/jest-react.js', 4 | testMatch: ['/src/**/__test__/**/*.test.{js,jsx,ts,tsx}'], 5 | setupFiles: [ 6 | '/scripts/jest.setup.ts', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /example-react/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /example-react/src/store/testAsync.ts: -------------------------------------------------------------------------------- 1 | import {createMapperHooksStore} from '@extremelyjs/store/src/index'; 2 | 3 | const testAsyncStore = createMapperHooksStore('', {strategy: 'acceptSequenced'}); 4 | 5 | export const useTestAsync = testAsyncStore.useStoreValue; 6 | 7 | export const loadTestAsync = testAsyncStore.load; 8 | -------------------------------------------------------------------------------- /example-react/src/store/num.ts: -------------------------------------------------------------------------------- 1 | import {createMapperHooksStore} from '@extremelyjs/store/src/index'; 2 | 3 | const numStore = createMapperHooksStore(0, {withLocalStorage: 'num'}); 4 | 5 | export const useNum = numStore.useStoreValue; 6 | 7 | export const setNum = numStore.setStoreValue; 8 | 9 | export const resetNum = numStore.reset; 10 | -------------------------------------------------------------------------------- /example-react/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import {IndexDataType} from '../types/indexData'; 2 | 3 | const baseUrl = 'http://localhost:3000'; 4 | 5 | async function getIndex(params: string): Promise { 6 | const res = await fetch(baseUrl + '?params=' + params); 7 | return res.json(); 8 | } 9 | 10 | 11 | export { 12 | getIndex, 13 | }; 14 | -------------------------------------------------------------------------------- /example-react/src/mock/mockPromiseArray.ts: -------------------------------------------------------------------------------- 1 | function mockPromiseArray() { 2 | // 模拟十个异步任务,返回数字 3 | const arr: Array> = []; 4 | for (let i = 0; i < 10; i++) { 5 | arr.push(new Promise(resolve => { 6 | setTimeout(() => resolve(String(i)), 1000 * i); 7 | })); 8 | } 9 | return arr; 10 | } 11 | 12 | export { 13 | mockPromiseArray, 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "strict": true, 5 | "declaration": true, 6 | "jsx": "react", 7 | "target": "es5", 8 | "moduleResolution": "node", 9 | "lib": ["esnext", "dom"], 10 | "skipLibCheck": true, 11 | "esModuleInterop": true 12 | }, 13 | "include": [ 14 | "./src/*","./src/**/*" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /example-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | react-test/ 3 | vue-test/ 4 | src/ 5 | pnpm-lock.yaml 6 | README.md 7 | package.json 8 | prod.config.js 9 | base.config.js 10 | dev.config.js 11 | global.style.tsx 12 | vue.dev.config.js 13 | webpack.config.js 14 | rollup.config.js 15 | index.html 16 | tsconfig.json 17 | example-react/ 18 | example-vue/ 19 | example-node/ 20 | .vscode 21 | pnpm-workspace.yaml 22 | .eslintrc.cjs 23 | jest.config.js 24 | .travis.yml 25 | scripts 26 | coverage -------------------------------------------------------------------------------- /example-node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extremelyjs/example-node", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "dev": "nodemon ./src/index.js" 9 | }, 10 | "type": "module", 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "cors": "^2.8.5", 16 | "express": "^4.19.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/local.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * 获取本地对象 4 | * 5 | * @param local 自定义本地存储对象,如果传入null或undefined则使用localStorage 6 | * @returns 返回本地对象,如果local不为null或undefined则返回local,否则返回localStorage 7 | */ 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | export function getLocalObject(local?: any) { 10 | if (local) { 11 | return local; 12 | } 13 | if (typeof localStorage !== 'undefined') { 14 | return localStorage; 15 | } 16 | return null; 17 | } 18 | -------------------------------------------------------------------------------- /example-react/src/store/testObject.ts: -------------------------------------------------------------------------------- 1 | import {createMapperHooksStore} from '@extremelyjs/store/src/index'; 2 | 3 | interface MyInfo { 4 | id: string; 5 | name: string; 6 | age: number; 7 | address: string; 8 | } 9 | 10 | const testObjectStore = createMapperHooksStore({ 11 | id: '1', 12 | name: 'zhangsan', 13 | age: 18, 14 | address: 'beijing', 15 | }); 16 | 17 | export const useTestObject = testObjectStore.useStoreValue; 18 | export const setTestObject = testObjectStore.setStoreValue; 19 | -------------------------------------------------------------------------------- /src/__test__/__snapshots__/react.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`react Subscribe to num value 1`] = `[Function]`; 4 | 5 | exports[`react getLoading 1`] = `[Function]`; 6 | 7 | exports[`react load 1`] = `[Function]`; 8 | 9 | exports[`react loadQueue 1`] = `[Function]`; 10 | 11 | exports[`react loading 1`] = `[Function]`; 12 | 13 | exports[`react reset 1`] = `[Function]`; 14 | 15 | exports[`react useStoreLoading 1`] = `[Function]`; 16 | 17 | exports[`react useStoreValue 1`] = `[Function]`; 18 | -------------------------------------------------------------------------------- /example-react/src/store/indexData.ts: -------------------------------------------------------------------------------- 1 | import {createMapperHooksStore} from '@extremelyjs/store/src/index'; 2 | import {getIndex} from '../api'; 3 | 4 | const indexDataStore = createMapperHooksStore, string>( 5 | {value: ''}, 6 | {withLocalStorage: 'index'} 7 | ); 8 | 9 | export const useIndexData = indexDataStore.useStoreValue; 10 | 11 | export const loadIndexData = indexDataStore.loadStoreValue( 12 | params => params, 13 | getIndex 14 | ); 15 | 16 | export const useIndexDataLoading = indexDataStore.useStoreLoading; 17 | -------------------------------------------------------------------------------- /example-react/src/store/arr.ts: -------------------------------------------------------------------------------- 1 | import {createMapperHooksStore} from '@extremelyjs/store/src/index'; 2 | 3 | interface TestArrType { 4 | test: string; 5 | test2: string; 6 | test3: number; 7 | } 8 | 9 | const testArrStore = createMapperHooksStore([ 10 | {test: 'test', test2: 'test2', test3: 1}, 11 | {test: 'test', test2: 'test2', test3: 1}, 12 | ]); 13 | 14 | export const useTestArr = testArrStore.useStoreValue; 15 | 16 | export const setTestArr = testArrStore.setStoreValue; 17 | 18 | export const resetTestArr = testArrStore.reset; 19 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | require('@reskript/config-lint/patch'); 2 | 3 | module.exports = { 4 | extends: require.resolve('@reskript/config-lint/config/eslint'), 5 | rules: { 6 | // close some rules 7 | 'camelcase': 'off', 8 | 'max-len': 'off', 9 | 'max-statements': 'off', 10 | 'no-negated-condition': 'off', 11 | 'prefer-promise-reject-errors': 'off', 12 | '@typescript-eslint/init-declarations': 'off', 13 | '@typescript-eslint/prefer-ts-expect-error': 'error', 14 | '@typescript-eslint/no-explicit-any': 'error', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/__test__/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`export api after loadStoreValue 1`] = ` 4 |
5 | 0 6 |
7 | `; 8 | 9 | exports[`export api after set 1`] = ` 10 |
11 | 0 12 |
13 | `; 14 | 15 | exports[`export api after setStoreValue 1`] = ` 16 |
17 | 19 18 |
19 | `; 20 | 21 | exports[`export api use selector 1`] = ` 22 |
23 | 18 24 |
25 | `; 26 | 27 | exports[`export api useStoreLoading 1`] = `
`; 28 | 29 | exports[`export api useStoreValue 1`] = ` 30 |
31 | 0 32 |
33 | `; 34 | 35 | exports[`export api useStoreValue handles selector error 1`] = ` 36 |
37 | 0 38 |
39 | `; 40 | -------------------------------------------------------------------------------- /example-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/__test__/getBooleanValue.test.ts: -------------------------------------------------------------------------------- 1 | import {getBooleanValue} from '../getChangeValue'; 2 | 3 | describe('getChangeValue', () => { 4 | test('getBooleanValue should return true when input is "true"', () => { 5 | expect(getBooleanValue('true')).toBe(true); 6 | }); 7 | 8 | test('getBooleanValue should return false when input is "false"', () => { 9 | expect(getBooleanValue('false')).toBe(false); 10 | }); 11 | 12 | test('getBooleanValue should throw an error when input is not "true" or "false"', () => { 13 | // @ts-expect-error ts-migrate(2345) FIXME: Argument of type '""' is not assignable to parameter of typ... 14 | expect(() => getBooleanValue('')).toThrow('Invalid boolean value'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /example-react/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /example-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extremelyjs/example-react", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@extremelyjs/store": "workspace:*", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.66", 19 | "@types/react-dom": "^18.2.22", 20 | "@typescript-eslint/eslint-plugin": "^7.2.0", 21 | "@typescript-eslint/parser": "^7.2.0", 22 | "@vitejs/plugin-react-swc": "^3.5.0", 23 | "eslint": "^8.57.0", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.4.6", 26 | "typescript": "^5.2.2", 27 | "vite": "^5.2.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example-react/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback} from 'react'; 2 | import {resetNum, setNum} from '../store/num'; 3 | 4 | function Button() { 5 | const handleAdd = useCallback( 6 | () => { 7 | setNum(num => num + 10); 8 | }, 9 | [] 10 | ); 11 | const handleSetValue = useCallback( 12 | () => { 13 | setNum(1); 14 | }, 15 | [] 16 | ); 17 | const handleReset = useCallback( 18 | () => { 19 | resetNum(); 20 | }, 21 | [] 22 | ); 23 | return ( 24 | <> 25 | 28 | 31 | 34 | 35 | ); 36 | } 37 | 38 | export default Button; 39 | -------------------------------------------------------------------------------- /src/utils/__test__/local.test.ts: -------------------------------------------------------------------------------- 1 | // local.test.ts 2 | import {getLocalObject} from '../local'; 3 | 4 | describe('getLocalObject function', () => { 5 | test('should return AsyncStorage when env is ReactNative', () => { 6 | const result = getLocalObject(localStorage); 7 | expect(result).toBe(localStorage); 8 | }); 9 | 10 | test('should return localStorage when env is not ReactNative', () => { 11 | const result = getLocalObject(); 12 | expect(result).toBe(localStorage); 13 | }); 14 | 15 | test('should return null when env is not ReactNative and local is undefined', () => { 16 | const result = getLocalObject({local: undefined}); 17 | // 期望的应该是{local: undefined} 18 | expect(result).toEqual({local: undefined}); 19 | }); 20 | 21 | test('should return null', () => { 22 | // @ts-expect-error 23 | // eslint-disable-next-line no-global-assign 24 | delete globalThis.localStorage; 25 | const result = getLocalObject(); 26 | expect(result).toEqual(null); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/utils/getChangeValue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 根据传入的字符串值获取布尔值 3 | * 4 | * @param value 字符串值,必须为 'true' 或 'false' 5 | * @returns 返回对应的布尔值,true 或 false 6 | * @throws 当传入的字符串值不是 'true' 或 'false' 时,抛出错误 'Invalid boolean value' 7 | */ 8 | export function getBooleanValue(value: 'true' | 'false') { 9 | if (value === 'true') { 10 | return true; 11 | } 12 | else if (value === 'false') { 13 | return false; 14 | } 15 | throw new Error('Invalid boolean value'); 16 | } 17 | 18 | /** 19 | * 获取值的类型变化 20 | * 21 | * @param currentType 当前值的类型 22 | * @param value 值 23 | * @returns 返回转换后的值 24 | */ 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | export const getChangeValue = (currentType: string | undefined, value: any) => { 27 | switch (currentType) { 28 | case 'string': 29 | return String(value); 30 | case 'number': 31 | return Number(value); 32 | case 'boolean': 33 | return getBooleanValue(value); 34 | case 'object': 35 | return JSON.parse(value); 36 | default: 37 | return undefined; 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /example-react/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /example-react/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example-react/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/utils/__test__/getChangeType.test.ts: -------------------------------------------------------------------------------- 1 | import {getChangeValue} from '../getChangeValue'; 2 | 3 | // 接下来是 Jest 单元测试 4 | describe('getChangeValue', () => { 5 | test('should return string when currentType is "string"', () => { 6 | expect(getChangeValue('string', 123)).toBe('123'); 7 | expect(getChangeValue('string', true)).toBe('true'); 8 | expect(getChangeValue('string', 'hello')).toBe('hello'); 9 | }); 10 | 11 | test('should return number when currentType is "number"', () => { 12 | expect(getChangeValue('number', '123')).toBe(123); 13 | expect(getChangeValue('number', '123.45')).toBe(123.45); 14 | expect(getChangeValue('number', 'invalid')).toBeNaN(); // 注意:NaN 与任何值都不相等,包括它自己 15 | }); 16 | 17 | test('should return boolean when currentType is "boolean"', () => { 18 | expect(getChangeValue('boolean', 'true')).toBe(true); 19 | expect(getChangeValue('boolean', 'false')).toBe(false); 20 | }); 21 | 22 | test('should return parsed object when currentType is "object"', () => { 23 | expect(getChangeValue('object', '{"name": "John"}')).toEqual({name: 'John'}); 24 | expect(getChangeValue('object', '[{"name": "John"},{"name": "John"}]')).toEqual([ 25 | {name: 'John'}, 26 | {name: 'John'}, 27 | ]); 28 | }); 29 | 30 | test('should return undefined when currentType is undefined or not recognized', () => { 31 | expect(getChangeValue(typeof undefined, 'undefined')).toBeUndefined(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extremelyjs/store", 3 | "version": "1.4.1", 4 | "description": "a store for react", 5 | "main": "lib/index.js", 6 | "private": false, 7 | "scripts": { 8 | "serve": "cd ./example-node && pnpm run dev", 9 | "dev": "cd ./example-react && pnpm run dev", 10 | "build": "rm -rf ./lib && tsc", 11 | "lint": "skr lint", 12 | "test": "skr test -- --coverage", 13 | "release": "standard-version" 14 | }, 15 | "repository": "github:extremelyjs/store", 16 | "license": "MIT", 17 | "author": "liuyuyang <2281487673@qq.com>", 18 | "devDependencies": { 19 | "@babel/core": "^7.23.6", 20 | "@babel/preset-env": "^7.23.6", 21 | "@babel/preset-react": "^7.23.3", 22 | "@reskript/cli": "^5.7.4", 23 | "@reskript/cli-lint": "^5.7.4", 24 | "@reskript/cli-test": "^6.2.1", 25 | "@reskript/config-jest": "^6.2.1", 26 | "@reskript/config-lint": "^5.7.4", 27 | "@types/aria-query": "5.0.4", 28 | "@types/jest": "^29.5.13", 29 | "@types/react": "^18.2.46", 30 | "@types/react-dom": "^18.2.18", 31 | "@types/react-test-renderer": "^18.0.7", 32 | "@types/use-sync-external-store": "^0.0.6", 33 | "babel-loader": "^9.1.3", 34 | "babel-preset-react": "^6.24.1", 35 | "core-js": "^3.38.1", 36 | "react": "^18.2.0", 37 | "react-dom": "^18.2.0", 38 | "react-test-renderer": "^18.2.0", 39 | "standard-version": "^9.5.0", 40 | "tslib": "^2.6.2", 41 | "typescript": "^5.4.5" 42 | }, 43 | "dependencies": { 44 | "use-sync-external-store": "^1.2.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /example-node/src/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cors from 'cors'; 3 | 4 | const app = express(); 5 | 6 | app.use(express.json()); 7 | app.use(cors()); 8 | 9 | const delay = ms => new Promise(resolve => { 10 | setTimeout(resolve, ms); 11 | }); 12 | 13 | app.get('/', async (req, res) => { 14 | await delay(5000); 15 | res.send(req.query); 16 | }); 17 | 18 | app.get('/music', async (req, res) => { 19 | await delay(5000); 20 | res.send({ 21 | id: 2, 22 | url: 'https://music.163.com/song/media/outer/url?id=2.mp3', 23 | title: '3', 24 | artist: '艺术家', 25 | } 26 | ); 27 | }); 28 | 29 | app.get('/test', (req, res) => { 30 | res.send(` 31 | 32 | 33 | 34 | 35 | Login Form 36 | 37 | 38 | 账号:
39 | 密码:
40 | 41 | 42 | 61 | 62 | `); 63 | }); 64 | 65 | app.post('/login', (req, res) => { 66 | const {username, password} = req.body; 67 | if (username === 'admin' && password === '123456') { 68 | res.send({ 69 | code: 200, 70 | msg: '登录成功', 71 | data: { 72 | token: '', 73 | }, 74 | }); 75 | } 76 | res.send({ 77 | code: 403, 78 | msg: '登录失败', 79 | }); 80 | }); 81 | 82 | app.listen(3000, () => { 83 | console.info('Example app listening on port 3000!'); 84 | }); 85 | -------------------------------------------------------------------------------- /src/type.ts: -------------------------------------------------------------------------------- 1 | export type Func = (...args: T[]) => V; 2 | 3 | export type Action = (...args: T[]) => V; 4 | 5 | export type FuncPromise = (data: T) => Promise; 6 | 7 | export interface EffectEvent { 8 | // eslint-disable-next-line @typescript-eslint/ban-types 9 | beforeEvent?: Function; 10 | // eslint-disable-next-line @typescript-eslint/ban-types 11 | afterEvent?: Function; 12 | } 13 | 14 | export interface StoreType { 15 | subscribe: (id: symbol, callback: Func) => void; 16 | dispatch: (action?: unknown) => void; 17 | dispatchState: (state: T | Func) => void; 18 | getState: () => T | undefined; 19 | unSubscribe: (id: symbol) => void; 20 | getIsDispatching: () => boolean; 21 | setIsDispatching: (e: boolean) => void; 22 | dispatchSlice: (slice: Func) => void; 23 | } 24 | export interface HooksStoreType { 25 | useStoreValue: { 26 | (): T; 27 | (selector?: (value: T) => ResultType): ResultType; 28 | }; 29 | setStoreValue: { 30 | (value: T): void; 31 | // eslint-disable-next-line @typescript-eslint/unified-signatures 32 | (func: Func): void; 33 | }; 34 | // eslint-disable-next-line @typescript-eslint/ban-types 35 | loadStoreValue: (params: Func, func: Action>, event?: EffectEvent) => FuncPromise; 36 | getStoreValue: () => T; 37 | useStoreLoading: () => boolean; 38 | getStoreLoading: () => boolean; 39 | reset: () => void; 40 | load: (params: Array>) => Promise; 41 | } 42 | 43 | export interface HooksStorePureType extends Omit, 'loadStoreValue' | 'setStoreValue' | 'useStoreValue' | 'getStoreValue'> { 44 | useStoreValue: { 45 | (): T | undefined; 46 | (selector?: (value: T | undefined) => ResultType | undefined): ResultType | undefined; 47 | }; 48 | getStoreValue: () => T | undefined; 49 | setStoreValue: { 50 | (value: T | undefined): void; 51 | // eslint-disable-next-line @typescript-eslint/unified-signatures 52 | (func: Func): void; 53 | }; 54 | // eslint-disable-next-line @typescript-eslint/ban-types 55 | loadStoreValue: (params: Func, func: Action>, event?: EffectEvent) => FuncPromise; 56 | } 57 | 58 | type Strategy = 'acceptFirst' | 'acceptLatest' | 'acceptEvery' | 'acceptSequenced'; 59 | 60 | export interface Options { 61 | withLocalStorage?: string; 62 | strategy?: Strategy; 63 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 64 | local?: any; 65 | } 66 | 67 | export interface Ref { 68 | loading: boolean; 69 | value: Result | undefined; 70 | promiseQueue: Map>>; 71 | error: Map; 72 | listeners: Map; 73 | params?: Params; 74 | } 75 | 76 | export type Listener = () => void; 77 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## 1.4.0 (2025-05-21) 6 | 7 | 8 | ### Features 9 | 10 | * 测试用例修复 ([c6f8c54](https://github.com/extremelyjs/store/commit/c6f8c548431bb7281df059df5a738ae9df8dbd7e)) 11 | * 更新了文档增加了对于支持load加载执行异步任务,及四种异步策略的教程及案例。 ([6eb13fb](https://github.com/extremelyjs/store/commit/6eb13fbf8c20f76759561746f1fcbe96cc537057)) 12 | * 更新文档内容 ([cdda8de](https://github.com/extremelyjs/store/commit/cdda8deffee667c7477c44e888ee8a716688b3cd)) 13 | * 减少了打包体积 ([5fb7200](https://github.com/extremelyjs/store/commit/5fb7200607b3001c32b37ca240ea1b96d96b644b)) 14 | * 局部更新问题 [#3](https://github.com/extremelyjs/store/issues/3) ([6f3790f](https://github.com/extremelyjs/store/commit/6f3790f1d4595da837d4d21e965ea141ad6a45bb)) 15 | * 完善测试用例 ([6bdac76](https://github.com/extremelyjs/store/commit/6bdac76cd8e1d1be48bf162b7852783a33d841d3)) 16 | * 完善了测试用例覆盖率 ([c41625f](https://github.com/extremelyjs/store/commit/c41625fa5fc9b4295054d44737a6cee09c9932a5)) 17 | * 增加对于loading态的处理,防止冲突 ([e52f5d7](https://github.com/extremelyjs/store/commit/e52f5d7693fedc139212b7ea52e2239f34d6ba7d)) 18 | * 增加函数注解 ([b6785d6](https://github.com/extremelyjs/store/commit/b6785d6cf91285a238e44a132ec1974b73fc580a)) 19 | * 增加了loadStoreValue执行前后的可选钩子 ([b696979](https://github.com/extremelyjs/store/commit/b6969794581e49c459c8c271bb4a14c36fa4da48)) 20 | * 增加文档内容 ([7cf807c](https://github.com/extremelyjs/store/commit/7cf807caf7ab85e262c0cce56043eb03e7cb8ab5)) 21 | * 增加与竞品store库的对比 ([745a6ba](https://github.com/extremelyjs/store/commit/745a6ba5f6c23915fa5d361a942d197eaa1de407)) 22 | * 增加lint规则 ([ac24e98](https://github.com/extremelyjs/store/commit/ac24e98191bce1c8c30c13df70f4e6ae4e47cec2)) 23 | * 增强了对于React 18.3以下的兼容性 ([1b22903](https://github.com/extremelyjs/store/commit/1b22903bb14258452c5aaee8bc05ea520d0e12ac)) 24 | * 支持了load加载执行异步任务,及四种异步 策略。 ([67a3c44](https://github.com/extremelyjs/store/commit/67a3c44f5187b314f1e10b15c556fc1f0a93bed8)) 25 | * 支持了load加载执行异步任务,及四种异步策略。 ([3c6a1a5](https://github.com/extremelyjs/store/commit/3c6a1a5b898ab8cb7fcd6efbd2af59cd8d2a2c68)) 26 | * **README:** 修改文档内容 ([45a6217](https://github.com/extremelyjs/store/commit/45a6217874252a3dd2ebbedc2d7099fbd41b3c8d)) 27 | * useStoreValue类型推断不友好 [#2](https://github.com/extremelyjs/store/issues/2) ([69d9bf8](https://github.com/extremelyjs/store/commit/69d9bf8fc1631fc6cc77e4503bd73e05bc1b40e5)) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * 修复了新架构下loading态更新的问题 ([7fd7a51](https://github.com/extremelyjs/store/commit/7fd7a512315ada5d3296cd9db73128f9faab4807)) 33 | * 修复了boolean在loacl中的转换问题,完善了了测试用例覆盖率 ([bc1a187](https://github.com/extremelyjs/store/commit/bc1a1877855c4e89e57b099e22b5ad1703d9624a)) 34 | * 修复了localStorage能力中value可能存在类型前缀标识符重复的问题 ([ac45096](https://github.com/extremelyjs/store/commit/ac450965515407da6e43dd4990b662afbadeacf5)) 35 | * 修复了localStorage能力中value可能存在类型前缀标识符重复的问题 ([da2da83](https://github.com/extremelyjs/store/commit/da2da8306846f653de4413b702bca4383e6deb72)) 36 | * 修复issues:[#1](https://github.com/extremelyjs/store/issues/1)-库体积太大 ([e03ee47](https://github.com/extremelyjs/store/commit/e03ee47a6a34b1503718d37a1599bfbf0faf9cb7)) 37 | * 修复localStorage中string类型引号问题 ([5408aa5](https://github.com/extremelyjs/store/commit/5408aa5cef22fb739a735a56f43d02a0070a2ed0)) 38 | -------------------------------------------------------------------------------- /example-react/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example-react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useState} from 'react'; 2 | import {createMapperHooksStore} from '../../src'; 3 | import * as api from '../../src'; 4 | import Button from './components/Button'; 5 | import {useNum} from './store/num'; 6 | import {loadTestAsync, useTestAsync} from './store/testAsync'; 7 | import {mockPromiseArray} from './mock/mockPromiseArray'; 8 | import { 9 | loadIndexData, 10 | useIndexData, 11 | useIndexDataLoading, 12 | } from './store/indexData'; 13 | import {setStr, useStr} from './store/str'; 14 | import {setTestArr, useTestArr} from './store/arr'; 15 | import {setTestObject, useTestObject} from './store/testObject'; 16 | 17 | interface IUser { 18 | age: number; 19 | name: string; 20 | address: string; 21 | } 22 | 23 | // function App() { 24 | // const num = useNum(); 25 | // const testAsync = useTestAsync(); 26 | // const indexValue = useIndexData(); 27 | // const indexValueLoading = useIndexDataLoading(); 28 | // const testArr = useTestArr(); 29 | // console.log(testArr); 30 | // const userInfo = useTestObject((value) => value?.id); 31 | // console.log("--userInfo--") 32 | // console.log(userInfo); 33 | // useEffect(() => { 34 | // loadTestAsync(mockPromiseArray()); 35 | // loadIndexData("Hello"); 36 | // console.log("--testArr--") 37 | // setTestObject({ 38 | // id: '1', 39 | // name: "zhangsan", 40 | // age: 18, 41 | // address: "beijing" 42 | // }) 43 | // }, []) 44 | 45 | // console.log("indexLoading", indexValueLoading) 46 | // const str = useStr(); 47 | 48 | // const handleChange = useCallback(() => { 49 | // setStr(v => v+"xxx"); 50 | // },[]) 51 | 52 | // const handleSetTestArray = useCallback(() => { 53 | // setTestArr([ 54 | // { 55 | // test: "123", 56 | // test2: "123", 57 | // test3: 123 58 | // } 59 | // ]); 60 | // setTestArr(v => [...v, { 61 | // test: "123", 62 | // test2: "123", 63 | // test3: 123 64 | // }]); 65 | // },[]) 66 | 67 | // const handleSetUserInfo = useCallback(() => { 68 | // setTestObject(v => ( 69 | // { 70 | // ...v, 71 | // name: "lisi" 72 | // } 73 | // )) 74 | // },[]); 75 | 76 | // const store = createMapperHooksStore({ 77 | // age: 18, 78 | // name: 'red', 79 | // address: 'ccc', 80 | // }); 81 | // const value = store.useStoreValue((value) => value?.age); 82 | // useEffect(() => { 83 | // store.setStoreValue(v => ({...v,age: v?.age + 1})); 84 | // }, [store]); 85 | 86 | // return ( 87 | //
88 | //

demo

89 | // 92 | // 95 | //

{num}

96 | // 97 | //

98 | // load: 99 | // { 100 | // testAsync 101 | // } 102 | //

103 | //

104 | // request: 105 | // { 106 | // indexValueLoading === true ? "loading" : JSON.stringify(indexValue) 107 | // } 108 | //

109 | // {str} 110 | // 111 | // {value} 112 | //
113 | // ) 114 | // } 115 | 116 | // function App() { 117 | // const userInfo = useTestObject((value) => value?.id); 118 | 119 | // const handleClick = useCallback(() => { 120 | // setTestObject(v => ({...v, id: v?.id + 1})) 121 | // }, []) 122 | 123 | // useEffect(() => { 124 | // setTestObject(v => ({...v, id: v?.id + 1})) 125 | // }, []) 126 | 127 | // console.log(userInfo); 128 | // return ( 129 | //
130 | // 131 | //
132 | // ) 133 | // } 134 | 135 | function App() { 136 | const store = api.createMapperHooksStore({ 137 | age: 18, 138 | name: 'red', 139 | address: 'ccc', 140 | }); 141 | const value = store.useStoreValue(value => value?.age); 142 | useEffect( 143 | () => { 144 | store.setStoreValue(v => ({...v, age: v.age + 1})); 145 | }, 146 | [store] 147 | ); 148 | const handleClick = useCallback( 149 | () => { 150 | store.setStoreValue(v => ({...v, age: v.age + 1})); 151 | }, 152 | [store] 153 | ); 154 | return ( 155 |
156 |
{value}
157 | 158 |
159 | ); 160 | } 161 | 162 | export default App; 163 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescriptreact]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | // 当保存的时候,eslint自动帮我们修复错误 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll": "explicit" 8 | }, 9 | // 保存代码,不自动格式化 10 | "editor.formatOnSave": false, 11 | "baidu.comate.langSuggestion": { 12 | "all": true, 13 | "cpp": true, 14 | "css": true, 15 | "go": true, 16 | "html": true, 17 | "java": true, 18 | "javascript": true, 19 | "less": true, 20 | "perl": true, 21 | "php": true, 22 | "python": true, 23 | "ruby": true, 24 | "shell": true, 25 | "swift": true, 26 | "typescript": true, 27 | "others": true, 28 | "vue": true, 29 | "san": true, 30 | "sass": true, 31 | "scss": true, 32 | "vhdl": true, 33 | "lua": true, 34 | "mermaid": true, 35 | "pug": true, 36 | "swan": true, 37 | "stylus": true, 38 | "rust": true, 39 | "kotlin": true, 40 | "graphql": true, 41 | "objectivec": true, 42 | "ada": true, 43 | "agda": true, 44 | "alloy": true, 45 | "antlr": true, 46 | "applescript": true, 47 | "assembly": true, 48 | "augeas": true, 49 | "awk": true, 50 | "batchfile": true, 51 | "bluespec": true, 52 | "csharp": true, 53 | "clojure": true, 54 | "cmake": true, 55 | "coffeescript": true, 56 | "commonlisp": true, 57 | "cuda": true, 58 | "dart": true, 59 | "dockerfile": true, 60 | "elixir": true, 61 | "elm": true, 62 | "emacslisp": true, 63 | "erlang": true, 64 | "fsharp": true, 65 | "fortran": true, 66 | "glsl": true, 67 | "groovy": true, 68 | "haskell": true, 69 | "idris": true, 70 | "isabelle": true, 71 | "javaserverpages": true, 72 | "json": true, 73 | "julia": true, 74 | "lean": true, 75 | "literateagda": true, 76 | "literatecoffeescript": true, 77 | "literatehaskell": true, 78 | "makefile": true, 79 | "maple": true, 80 | "markdown": true, 81 | "mathematica": true, 82 | "matlab": true, 83 | "ocaml": true, 84 | "pascal": true, 85 | "powershell": true, 86 | "prolog": true, 87 | "protocolbuffer": true, 88 | "r": true, 89 | "racket": true, 90 | "restructuredtext": true, 91 | "rmarkdown": true, 92 | "sas": true, 93 | "scala": true, 94 | "scheme": true, 95 | "smalltalk": true, 96 | "solidity": true, 97 | "sparql": true, 98 | "sql": true, 99 | "stan": true, 100 | "standardml": true, 101 | "stata": true, 102 | "systemverilog": true, 103 | "tcl": true, 104 | "tcsh": true, 105 | "tex": true, 106 | "thrift": true, 107 | "verilog": true, 108 | "visualbasic": true, 109 | "xslt": true, 110 | "yacc": true, 111 | "yaml": true, 112 | "zig": true, 113 | "jupyter": true, 114 | "xml": true 115 | }, 116 | "cSpell.userWords": [ 117 | "afterremoveitem", 118 | "aiip", 119 | "antd", 120 | "AUDITSTATUS", 121 | "badcase", 122 | "codemirror", 123 | "huse", 124 | "Icafe", 125 | "jetbrains", 126 | "jsondb", 127 | "KEYSTATUS", 128 | "myapp", 129 | "osui", 130 | "pageview", 131 | "persistor", 132 | "Popconfirm", 133 | "Reass", 134 | "reduxjs", 135 | "timestampe", 136 | "Todos", 137 | "userid", 138 | "Vectordb", 139 | "whoareyou" 140 | ], 141 | "editor.accessibilitySupport": "off", 142 | "files.autoSave": "onWindowChange", 143 | "workbench.colorTheme": "Dracula", 144 | "workbench.iconTheme": "vscode-icons", 145 | "terminal.integrated.env.osx": { 146 | "FIG_NEW_SESSION": "1" 147 | }, 148 | "vsicons.dontShowNewVersionMessage": true, 149 | "[html]": { 150 | "editor.defaultFormatter": "vscode.html-language-features" 151 | }, 152 | "baidu.comate.inlineSuggestionMode": "extremeAccurate", 153 | "editor.minimap.enabled": false, 154 | "[json]": { 155 | "editor.defaultFormatter": "esbenp.prettier-vscode" 156 | }, 157 | "debug.onTaskErrors": "debugAnyway", 158 | "[vue]": { 159 | "editor.defaultFormatter": "esbenp.prettier-vscode" 160 | }, 161 | "diffEditor.hideUnchangedRegions.enabled": true, 162 | "baidu.comate.license": "b0c39cbd-7158-4481-aad6-12c30609f974", 163 | "[javascript]": { 164 | "editor.defaultFormatter": "vscode.typescript-language-features" 165 | }, 166 | "baidu.comate.username": "liuyuyang03", 167 | "baidu.comate.enableSecurityEnhancement": true, 168 | "security.workspace.trust.untrustedFiles": "open", 169 | "baidu.comate.linePreferMode": "multiLine", 170 | "baidu.comate.enableCommentEnhancement": false, 171 | "baidu.comate.enableInlineChat": true, 172 | "[typescript]": { 173 | "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" 174 | }, 175 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 176 | } -------------------------------------------------------------------------------- /src/__test__/react.test.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react'; 2 | import * as api from '..'; 3 | 4 | export async function mockLoad() { 5 | await new Promise(resolve => setTimeout(resolve, 10)); 6 | return 1; 7 | } 8 | 9 | export function mockPromiseArray() { 10 | // 模拟十个异步任务,返回数字 11 | const arr: Array> = []; 12 | for (let i = 0; i < 10; i++) { 13 | arr.push(new Promise(resolve => { 14 | setTimeout(() => resolve(i), 1000 * i); 15 | })); 16 | } 17 | return arr; 18 | } 19 | 20 | const numStore = api.createMapperHooksStore(0); 21 | 22 | export const useNum = numStore.useStoreValue; 23 | 24 | export const setNum = numStore.setStoreValue; 25 | 26 | export const resetNum = numStore.reset; 27 | 28 | export const useNumLoading = numStore.useStoreLoading; 29 | 30 | export const loadNum = numStore.loadStoreValue( 31 | params => params, 32 | mockLoad 33 | ); 34 | 35 | export const getNumLoading = numStore.getStoreLoading; 36 | 37 | export const loadAsyncQueueNum = numStore.load; 38 | 39 | 40 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 41 | export const connect = () => (Component: any) => { 42 | return () => { 43 | const loading = useNumLoading(); 44 | const value = useNum(); 45 | return ; 46 | }; 47 | }; 48 | 49 | describe('react', () => { 50 | test('connect', () => { 51 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 52 | const Component = connect()(React.memo(({value, loading}: any) => { 53 | return
{loading ? 'loading' : value}
; 54 | })); 55 | expect(Component).toBeTruthy(); 56 | }, 10000); 57 | 58 | test('Subscribe to num value', () => { 59 | const Component = connect()(React.memo(() => { 60 | const value = useNum(); 61 | expect(value).toBe(0); 62 | setNum(1); 63 | expect(value).toBe(1); 64 | setNum(v => v + 1); 65 | expect(value).toBe(2); 66 | return
{value}
; 67 | })); 68 | expect(Component).toMatchSnapshot(); 69 | }); 70 | 71 | test('reset', () => { 72 | const Component = connect()(React.memo(() => { 73 | setNum(1); 74 | const value = useNum(); 75 | expect(value).toBe(1); 76 | resetNum(); 77 | expect(value).toBe(0); 78 | return
{value}
; 79 | })); 80 | expect(Component).toMatchSnapshot(); 81 | }); 82 | 83 | test('loading', () => { 84 | const Component = connect()(React.memo(() => { 85 | const loading = useNumLoading(); 86 | useEffect( 87 | () => { 88 | loadNum(); 89 | }, 90 | [] 91 | ); 92 | expect(loading).toBeTruthy(); 93 | return
{loading ? 'loading' : 'loaded'}
; 94 | })); 95 | expect(Component).toMatchSnapshot(); 96 | }, 1000); 97 | 98 | test('loadQueue', () => { 99 | const Component = connect()(React.memo(() => { 100 | useEffect( 101 | () => { 102 | loadAsyncQueueNum(mockPromiseArray()); 103 | }, 104 | [] 105 | ); 106 | return
loaded
; 107 | })); 108 | expect(Component).toMatchSnapshot(); 109 | }); 110 | 111 | test('load', () => { 112 | const Component = connect()(React.memo(() => { 113 | const value = useNum(); 114 | useEffect( 115 | () => { 116 | loadNum(); 117 | }, 118 | [] 119 | ); 120 | return
{value}
; 121 | })); 122 | expect(Component).toMatchSnapshot(); 123 | }); 124 | 125 | test('getLoading', () => { 126 | const Component = connect()(React.memo(() => { 127 | const loading = useNumLoading(); 128 | expect(getNumLoading()).toBeTruthy(); 129 | return
{loading ? 'loading' : 'loaded'}
; 130 | })); 131 | expect(Component).toMatchSnapshot(); 132 | }); 133 | 134 | test('useStoreValue', () => { 135 | const Component = connect()(React.memo(() => { 136 | const store = api.createMapperHooksStore(0); 137 | const useStoreValue = store.useStoreValue; 138 | const value = useStoreValue(); 139 | return
{value}
; 140 | })); 141 | expect(Component).toMatchSnapshot(); 142 | }); 143 | 144 | test('useStoreLoading', () => { 145 | const Component = connect()(React.memo(() => { 146 | const store = api.createMapperHooksStore(0); 147 | const useStoreLoading = store.useStoreLoading; 148 | const loading = useStoreLoading(); 149 | return
{loading}
; 150 | })); 151 | expect(Component).toMatchSnapshot(); 152 | }); 153 | 154 | // 测试 setNum 在加载状态下的行为 155 | test('setNum during loading throws error', async () => { 156 | function delay(time: number) { 157 | return new Promise(resolve => setTimeout(resolve, time)); 158 | } 159 | loadNum(); 160 | await delay(0); // 等待 queueMicrotask 执行 161 | expect(() => setNum(1)).toThrow('当前处于加载状态,请等待加载完成。'); 162 | }); 163 | 164 | // 测试本地存储功能 165 | test('withLocalStorage', () => { 166 | const key = 'test_key'; 167 | const store = api.createMapperHooksStore(0, {withLocalStorage: key}); 168 | store.setStoreValue(1); 169 | const storedValue = JSON.parse(localStorage.getItem(key) || '{}'); 170 | expect(storedValue.value).toBe('1'); 171 | expect(storedValue.type).toBe('number'); 172 | }); 173 | 174 | // 测试边界条件:空值 175 | test('handle undefined value', () => { 176 | const store = api.createMapperHooksStore(); 177 | expect(store.getStoreValue()).toBeUndefined(); 178 | store.setStoreValue(undefined); 179 | expect(store.getStoreValue()).toBeUndefined(); 180 | }); 181 | 182 | }); 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 一种全新的store库 2 | **思路来自:https://github.com/regionjs/region-core** 3 | 4 | **之前叫@apiknight/store,现已更名** 5 | 6 | 采用了发布订阅的设计模式,同时提供了大量React hooks,以一种新思路来管理状态,不再需要维护type和reducer,可以实现类似useState,setState式的使用方式。 7 | 8 | 同时具备了异步更新能力和持久化存储能力,同时采用ts开发,具备了完善的类型提示。 9 | 10 | ## 优势 11 | 12 | - 相比其他状态管理库,采用了类useState,setState的思想。同时天然支持了良好的异步更新,持久化存储等能力。 13 | 14 | - 写法上更加自由,心智负担更小,同时可以结合异步请求,异步事件,loading订阅和值订阅。来进一步优化代码结构,建立起统一的代码规范。 15 | 16 | ### 对比其他竞品状态管理库 17 | 1. Redux 18 | Redux 是一个流行的 JavaScript 状态管理库,它采用单向数据流和可预测的状态更新机制。然而,与 @extremelyjs/store 相比,Redux 在某些方面可能显得更为复杂: 19 | 20 | - 概念复杂度:Redux 引入了 action、reducer 和 store 等概念,需要开发者理解和遵循一定的规范。相比之下,@extremelyjs/store 提供了更简洁的 API,减少了概念上的负担。 21 | 22 | - 异步处理:Redux 本身并不直接支持异步操作,通常需要结合额外的中间件(如 redux-thunk 或 redux-saga)来处理。而 @extremelyjs/store 内置了异步更新能力,通过 loadStoreValue 等 API 可以方便地处理异步操作。 23 | 24 | - 类型安全:虽然 Redux 可以与 TypeScript 结合使用以提供类型安全,但 @extremelyjs/store 本身采用 TypeScript 开发,提供了更完善的类型提示和类型安全。 25 | 26 | - 持久化存储:Redux 需要额外的配置和库支持才能实现状态的持久化存储。@extremelyjs/store 则内置了持久化能力,只需简单配置即可使用。 27 | 28 | 2. Zustand 29 | Zustand 是一个轻量级且灵活的状态管理库,它允许你在单个 store 中存储多个独立的状态片段。与 @extremelyjs/store 相比Zustand: 30 | 31 | - 轻量级与灵活性:Zustand 非常轻量级,并且提供了灵活的 API 来管理状态。然而,@extremelyjs/store 也致力于提供简洁和灵活的 API,同时在异步处理和持久化方面提供了更多内置功能。 32 | 33 | - 异步与持久化:与 Redux 类似,Zustand 本身不直接支持异步操作和持久化存储。虽然可以通过扩展来实现这些功能,但 @extremelyjs/store 已经内置了这些能力。 34 | 35 | - 类型安全:Zustand 可以与 TypeScript 结合使用,但 @extremelyjs/store 在类型安全方面可能更为出色,因为它本身采用 TypeScript 开发。 36 | 37 | 3. MobX 38 | MobX 是另一个流行的状态管理库,它使用可观察对象和自动跟踪机制来简化状态管理。与 @extremelyjs/store 相比: 39 | 40 | - 概念与复杂性:MobX 引入了可观察对象、观察者和动作等概念。虽然这些概念使得状态管理变得直观和强大,但也增加了一定的学习成本。@extremelyjs/store 则通过更简洁的 API 和发布订阅模式来降低复杂性。 41 | 42 | - 异步与持久化:MobX 本身不直接处理异步操作和持久化存储,需要开发者自行实现或结合其他库。而 @extremelyjs/store 提供了内置的异步更新和持久化能力。 43 | 44 | - 类型安全:虽然 MobX 可以与 TypeScript 结合使用以提供类型安全,但如前所述,@extremelyjs/store 在这方面可能更为出色。 45 | 46 | 综上所述,@extremelyjs/store 在提供简洁灵活的 API、内置异步更新与持久化能力以及出色的类型安全方面表现出色。这使得它在与其他竞品状态管理库的比较中具有一定的优势。 47 | 48 | ## 文档 49 | 50 | 下面是React Hooks的使用文档。class组件的使用暂没有良好的支持。 51 | 52 | ### 安装 53 | 54 | ```bash 55 | 56 | npm install @extremelyjs/store --save 57 | 58 | ``` 59 | 60 | ### 使用方式 61 | 要求: 62 | - React Hooks版本 63 | - 如果需要本地持久化存储,需要在有localStorage或者ReactNative的环境下使用 64 | 65 | #### 入门 66 | 67 | 使用上类似React Hooks中的useState。可以使用use订阅信息,使用set修改信息。其中,set可以传递数值或者回调 68 | 69 | ```tsx 70 | // 创建num.ts这个store 71 | import { createMapperHooksStore } from '@extremelyjs/store' 72 | 73 | const numStore = createMapperHooksStore(0) 74 | 75 | export const useNum = numStore.useStoreValue // 监听state变化 76 | 77 | export const setNum = numStore.setStoreValue // 修改state,支持value或者callback 78 | 79 | export const resetNum = numStore.reset // 重置state 80 | 81 | ``` 82 | 83 | 使用这个store 84 | 85 | ```tsx 86 | function App() { 87 | const num = useNum(); //订阅num状态 88 | const handleClick = useCallback(() => { 89 | setNum(value => value + 1); 90 | },[]) 91 | const handleChangeValue = useCallback( 92 | () => { 93 | setNum(10); 94 | },[] 95 | ) 96 | return ( 97 |
98 | {num} 99 | 104 | 107 |
108 | ) 109 | } 110 | 111 | ``` 112 | 113 | #### 局部更新能力 114 | 用户在订阅一个大对象的时候,一些场景下只关注对象的一个属性,全量更新下会带来性能问题。 115 | 116 | 所以支持了局部更新能力。 117 | 118 | ```tsx 119 | 120 | import { createMapperHooksStore } from "@extremelyjs/store/src/index"; 121 | 122 | interface MyInfo { 123 | id: string; 124 | name: string; 125 | age: number; 126 | address: string; 127 | } 128 | 129 | const testObjectStore = createMapperHooksStore({ 130 | id: '1', 131 | name: "zhangsan", 132 | age: 18, 133 | address: "beijing" 134 | }); 135 | 136 | export const useTestObject = testObjectStore.useStoreValue; 137 | 138 | export const setTestObject = testObjectStore.setStoreValue; 139 | 140 | ``` 141 | 142 | 创建上还是正常的创建方式。 143 | 144 | 使用上我们可以传递类型和selctor函数来实现只订阅局部更新的变化。 145 | 146 | ```tsx 147 | const userInfo = useTestObject((value) => value?.id); // 只订阅id的变化 148 | 149 | ``` 150 | 151 | #### 持久化 152 | 153 | 对于支持localStorage的环境,可以使用持久化能力。 154 | 155 | ```tsx 156 | const num = createMapperHooksStore(0, {withLocalStorage: 'keyName'}) // keyName为自定义id 157 | ``` 158 | 159 | rn环境下,需要加上是rn的标志 160 | 161 | ```tsx 162 | import { AsyncStorage } from '@react-native-async-storage/async-storage'; 163 | const num = createMapperHooksStore(0, {withLocalStorage: 'keyName',local: AsyncStorage}) // keyName为自定义id 164 | ``` 165 | 166 | #### 异步更新能力 167 | 168 | 对于异步更新,可以使用异步更新能力。 169 | ```tsx 170 | import { createMapperHooksStore } from "@extremelyjs/store"; 171 | import fetchCurrentPageContent from "../api/fetchCurrentPageContent"; 172 | import { PageDataParams } from "../type/params"; 173 | 174 | const pageDataStore = createMapperHooksStore('', { withLocalStorage: 'page-data-new' }); 175 | 176 | export const usePageData = pageDataStore.useStoreValue; // 订阅state变化 177 | 178 | export const usePageDataLoading = pageDataStore.useStoreLoading; // 订阅Loding状态 179 | 180 | // 异步更新,支持传入参数 181 | export const loadPageData = pageDataStore.loadStoreValue( 182 | params => params, 183 | fetchCurrentPageContent 184 | ); 185 | 186 | ``` 187 | 188 | #### loading订阅 189 | 190 | 对于异步请求更新,可以使用loading订阅。 191 | 192 | 我们可以写一个纯粹的请求函数,然后使用loadStoreValue来自动更新状态,后续的更新会在内部完成。 193 | 194 | 同时我们也可以订阅他的loading态,无需额外的代码。 195 | 196 | ```tsx 197 | 198 | import { createMapperHooksStore } from "@extremelyjs/store"; 199 | import fetchCurrentPageContent from "../api/fetchCurrentPageContent"; 200 | import { PageDataParams } from "../type/params"; 201 | 202 | export interface PageData { 203 | id: number; 204 | title: string; 205 | content: string; 206 | } 207 | 208 | const pageDataStore = createMapperHooksStore('', { withLocalStorage: 'page-data-new' }); 209 | 210 | export const usePageData = pageDataStore.useStoreValue; 211 | // 订阅Loding状态 212 | export const usePageDataLoading = pageDataStore.useStoreLoading; 213 | 214 | export const loadPageData = pageDataStore.loadStoreValue( 215 | params => params, 216 | fetchCurrentPageContent 217 | ); 218 | 219 | // 使用 220 | const loading = usePageDataLoading(); 221 | 222 | ``` 223 | 224 | #### 异步任务订阅 225 | 226 | 我们还提供了`load`的api来订阅异步任务。 227 | 228 | ```tsx 229 | function mockPromiseArray() { 230 | // 模拟十个异步任务,返回数字 231 | const arr: Promise[] = [] 232 | for (let i = 0; i < 10; i++) { 233 | arr.push(new Promise((resolve) => { 234 | setTimeout(() => resolve(String(i)), 1000 * i); 235 | })) 236 | } 237 | return arr; 238 | } 239 | // store文件 240 | import { createMapperHooksStore } from "@extremelyjs/store/src/index"; 241 | 242 | const testAsyncStore = createMapperHooksStore("",{strategy: "acceptSequenced"}); 243 | 244 | export const useTestAsync = testAsyncStore.useStoreValue; 245 | 246 | export const loadTestAsync = testAsyncStore.load; 247 | 248 | // react组件 249 | const testAsync = useTestAsync(); 250 | 251 | ``` 252 | 253 | 同时我们还支持四种异步更新策略 254 | 255 | 你可以使用 `strategy` 配置异步策略,目前提供了四种异步策略: 256 | 257 | | 策略 | 描述 | 258 | | --- | --- | 259 | | `acceptFirst` | 在多个异步任务同时发出的情况下,只接受第一个成功的结果。如果已经有成功的返回,则后续请求不再发出。 | 260 | | `acceptLatest` | 在多个异步任务同时发出的情况下,只接受最后一个发出的任务的结果,成功或失败。 | 261 | | `acceptEvery` | 在多个异步任务同时发出的情况下,接受所有的返回,按照到达的顺序处理。由于到达的顺序可能是乱序,你需要处理乱序导致的问题。 | 262 | | `acceptSequenced` | 在多个异步任务同时发出的情况下,按照任务发出的顺序,接受结果,当中间的任务到达时,则不再接受此任务之前发起的任务的结果,但依旧等待后续发出的结果。 | 263 | 264 | 默认使用 `acceptSequenced` 的策略,这个策略满足绝大多数情况,在你需要特别的优化的时候,你可以选择其他的策略。 265 | 266 | #### 直接取值 267 | 268 | 我们还提供了`getStoreValue`和`getStoreLoading`来直接取值。 269 | 270 | ### Todo 271 | 272 | - 完善文档 273 | 274 | - 更好的错误提示 275 | 276 | - 测试用例的完善 277 | 278 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {useMemo} from 'react'; 2 | import {useSyncExternalStore} from 'use-sync-external-store/shim'; 3 | import {Options, Ref, HooksStoreType, Func, Action, Listener, HooksStorePureType, EffectEvent} from './type'; 4 | import {getLocalObject} from './utils/local'; 5 | import {getChangeValue} from './utils/getChangeValue'; 6 | 7 | 8 | /** 9 | * 创建一个用于存储和管理映射关系的 HooksStore 10 | * 11 | * @param initValue 初始值 12 | * @param options 配置选项 13 | * @returns 返回 HooksStoreType 类型的对象,包含多个用于操作数据的函数和方法 14 | */ 15 | export function createMapperHooksStore ( 16 | initValue?: undefined, 17 | options?: Options 18 | ): HooksStorePureType; 19 | export function createMapperHooksStore ( 20 | // eslint-disable-next-line @typescript-eslint/unified-signatures 21 | initValue?: Result, 22 | options?: Options 23 | ): HooksStoreType; 24 | export function createMapperHooksStore( 25 | initValue?: void | undefined | Result, 26 | options?: Options 27 | ): HooksStoreType | HooksStorePureType { 28 | const withLocalStorage = options?.withLocalStorage ?? ''; 29 | let curValue = initValue; 30 | const local = getLocalObject(options?.local); 31 | if (withLocalStorage !== '' && local != null) { 32 | const token = !local?.getItem(withLocalStorage) ? '{"value": "","type": "other"}' : local?.getItem(withLocalStorage); 33 | try { 34 | const obj = JSON.parse(token as string); 35 | curValue = getChangeValue(obj.type, obj.value) ?? undefined; 36 | } catch { 37 | curValue = initValue as Result; 38 | } 39 | } 40 | const ref: Ref = { 41 | // loading后续可以自定义 42 | loading: false, 43 | // 后续可以换成Map结构,减少数据冗余。 44 | value: curValue as Result ?? initValue as Result, 45 | promiseQueue: new Map>>(), 46 | error: new Map(), 47 | listeners: new Map(), 48 | }; 49 | const strategy = options?.strategy ?? 'acceptSequenced'; 50 | 51 | /** 52 | * 获取存储的值 53 | * 54 | * @returns 返回存储的值 55 | */ 56 | function getStoreValue() { 57 | return ref.value; 58 | } 59 | 60 | /** 61 | * 获取存储加载状态 62 | * 63 | * @returns 返回存储加载状态 64 | */ 65 | function getStoreLoading() { 66 | return ref.loading; 67 | } 68 | 69 | /** 70 | * 订阅事件 71 | * 72 | * @param 添加一个callback到队列里 73 | * @returns 返回一个函数,用于取消 74 | * @throws 当callback不是函数时,抛出错误 75 | */ 76 | function private_subscribe(callback: Func) { 77 | if (typeof callback === 'function') { 78 | const key = Symbol('id'); 79 | ref.listeners.set(key, callback); 80 | 81 | return () => { 82 | ref.listeners.delete(key); 83 | }; 84 | } 85 | else { 86 | throw new Error('callback应该是个函数。'); 87 | } 88 | } 89 | 90 | /** 91 | * 参考useSyncExternalStore里的入参 92 | * 93 | * @returns 返回一个包含getCurrentValue和subscribeFunc的对象,用于useSyncExternalStore里的参数 94 | */ 95 | const useValueSelectorSubscription = (selector?: (value: Result | undefined) => TResult) => { 96 | const subscription = useMemo( 97 | () => ( 98 | { 99 | getCurrentValue: () => { 100 | const currentValue = getStoreValue(); 101 | if (!selector) { 102 | return currentValue; 103 | } 104 | try { 105 | return selector(currentValue); 106 | } 107 | catch (e) { 108 | console.error(e); 109 | console.error('Above error occurs in selector.'); 110 | return currentValue; 111 | } 112 | }, 113 | subscribeFunc: (listener: Listener) => private_subscribe(listener), 114 | } 115 | ), 116 | [selector] 117 | ); 118 | 119 | return subscription; 120 | }; 121 | 122 | /** 123 | * 参考useSyncExternalStore里的入参 124 | * 125 | * @returns 返回一个包含getCurrentLoading和subscribeFunc的对象,用于useSyncExternalStore里的参数 126 | */ 127 | const useLoadingSelectorSubscription = () => { 128 | const subscription = useMemo( 129 | () => ( 130 | { 131 | getCurrentLoading: () => getStoreLoading(), 132 | subscribeFunc: (listener: Listener) => private_subscribe(listener), 133 | } 134 | ), 135 | [] 136 | ); 137 | 138 | return subscription; 139 | }; 140 | 141 | /** 142 | * 触发事件,执行注册的所有回调函数 143 | */ 144 | function private_emit() { 145 | ref?.listeners?.forEach(callback => { 146 | try { 147 | callback(); 148 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 149 | } catch (error: any) { 150 | throw new Error(error); 151 | } 152 | }); 153 | } 154 | 155 | /** 156 | * 设置存储值 157 | * 158 | * @param value 可以传value或者回调 159 | * @throws 当当前处于加载状态时,抛出错误提示 160 | */ 161 | function setStoreValue(value: Result | undefined): void; 162 | function setStoreValue(func: Func): void; 163 | function setStoreValue(value: Func | Result | undefined) { 164 | if (ref.loading) { 165 | throw new Error('当前处于加载状态,请等待加载完成。'); 166 | } 167 | if (typeof value === 'function') { 168 | try { 169 | ref.value = (value as Func)(ref.value as Result); 170 | } 171 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 172 | catch (error: any) { 173 | throw new Error(error); 174 | } 175 | } 176 | else { 177 | try { 178 | ref.value = value; 179 | } 180 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 181 | catch (error: any) { 182 | throw new Error(error); 183 | } 184 | } 185 | if (withLocalStorage !== '' && local) { 186 | const obj = { 187 | value: typeof ref.value === 'string' ? ref.value : JSON.stringify(ref.value), 188 | type: typeof ref.value, 189 | }; 190 | local?.setItem(`${withLocalStorage}`, JSON.stringify(obj)); 191 | } 192 | private_emit(); 193 | } 194 | 195 | /** 196 | * 订阅store value 197 | * 198 | * @returns 返回同步后的store值 199 | */ 200 | function useStoreValue(selector?: (value: Result | undefined) => ReturnResult | undefined) { 201 | const subscription = useValueSelectorSubscription(selector); 202 | const result = useSyncExternalStore(subscription.subscribeFunc, subscription.getCurrentValue); 203 | return result as ReturnResult | Result | undefined; 204 | } 205 | 206 | /** 207 | * 订阅异步请求更新加载状态 208 | * 209 | * @returns 返回加载状态的同步外部存储 210 | */ 211 | function useStoreLoading() { 212 | const subscription = useLoadingSelectorSubscription(); 213 | return useSyncExternalStore(subscription.subscribeFunc, subscription.getCurrentLoading); 214 | } 215 | 216 | /** 217 | * 加载存储值 218 | * 219 | * @param params 函数类型,参数为Params类型,返回值为Result类型 220 | * @param func 函数类型,参数为Result类型,返回值为Promise类型 221 | * @returns 返回一个异步函数,参数为Params类型,无返回值 222 | */ 223 | // eslint-disable-next-line @typescript-eslint/ban-types 224 | function loadStoreValue(params: Func, func: Action>, event?: EffectEvent) { 225 | // eslint-disable-next-line no-underscore-dangle 226 | async function _loadStoreValue(data: Params) { 227 | await event?.beforeEvent?.(); 228 | try { 229 | queueMicrotask(() => { 230 | // 设置loading 231 | ref.loading = true; 232 | }); 233 | const value = await func(params(data)); 234 | ref.loading = false; 235 | setStoreValue(value); 236 | } 237 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 238 | catch (error: any) { 239 | throw new Error(error); 240 | } 241 | finally { 242 | ref.loading = false; 243 | } 244 | await event?.afterEvent?.(); 245 | } 246 | 247 | return _loadStoreValue; 248 | } 249 | 250 | /** 251 | * 加载Promise队列并处理结果 252 | * 253 | * @param promiseQueue Promise队列 254 | * @returns 无返回值 255 | */ 256 | async function load(promiseQueue: Array>) { 257 | if (strategy === 'acceptEvery') { 258 | const result = await Promise.all(promiseQueue); 259 | result?.map( 260 | item => setStoreValue(item) 261 | ); 262 | } 263 | 264 | else if (strategy === 'acceptFirst') { 265 | const result = await Promise.race(promiseQueue); 266 | setStoreValue(result); 267 | } 268 | 269 | else if (strategy === 'acceptLatest') { 270 | const result = await Promise.allSettled(promiseQueue); 271 | const lastResult = result.pop(); 272 | if (lastResult?.status === 'fulfilled') { 273 | setStoreValue(lastResult.value); 274 | } 275 | else { 276 | throw new Error('最后一个收到的结果为rejected'); 277 | } 278 | } 279 | 280 | else { 281 | // 修复 acceptSequenced 策略实现 282 | for (const promise of promiseQueue) { 283 | try { 284 | const value = await promise; 285 | setStoreValue(value); 286 | } catch (error) { 287 | throw error; 288 | } 289 | } 290 | } 291 | } 292 | 293 | /** 294 | * 重置函数 295 | * 296 | * @description 将 ref 的值重置为初始值 297 | */ 298 | function reset() { 299 | setStoreValue(initValue as Result); 300 | } 301 | 302 | return { 303 | useStoreValue, 304 | setStoreValue, 305 | loadStoreValue, 306 | useStoreLoading, 307 | getStoreLoading, 308 | getStoreValue, 309 | load, 310 | reset, 311 | }; 312 | } 313 | -------------------------------------------------------------------------------- /example-node/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | dependencies: 8 | cors: 9 | specifier: ^2.8.5 10 | version: 2.8.5 11 | express: 12 | specifier: ^4.19.2 13 | version: 4.19.2 14 | 15 | packages: 16 | 17 | /accepts@1.3.8: 18 | resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} 19 | engines: {node: '>= 0.6'} 20 | dependencies: 21 | mime-types: 2.1.35 22 | negotiator: 0.6.3 23 | dev: false 24 | 25 | /array-flatten@1.1.1: 26 | resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} 27 | dev: false 28 | 29 | /body-parser@1.20.2: 30 | resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} 31 | engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} 32 | dependencies: 33 | bytes: 3.1.2 34 | content-type: 1.0.5 35 | debug: 2.6.9 36 | depd: 2.0.0 37 | destroy: 1.2.0 38 | http-errors: 2.0.0 39 | iconv-lite: 0.4.24 40 | on-finished: 2.4.1 41 | qs: 6.11.0 42 | raw-body: 2.5.2 43 | type-is: 1.6.18 44 | unpipe: 1.0.0 45 | transitivePeerDependencies: 46 | - supports-color 47 | dev: false 48 | 49 | /bytes@3.1.2: 50 | resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} 51 | engines: {node: '>= 0.8'} 52 | dev: false 53 | 54 | /call-bind@1.0.7: 55 | resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} 56 | engines: {node: '>= 0.4'} 57 | dependencies: 58 | es-define-property: 1.0.0 59 | es-errors: 1.3.0 60 | function-bind: 1.1.2 61 | get-intrinsic: 1.2.4 62 | set-function-length: 1.2.2 63 | dev: false 64 | 65 | /content-disposition@0.5.4: 66 | resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} 67 | engines: {node: '>= 0.6'} 68 | dependencies: 69 | safe-buffer: 5.2.1 70 | dev: false 71 | 72 | /content-type@1.0.5: 73 | resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} 74 | engines: {node: '>= 0.6'} 75 | dev: false 76 | 77 | /cookie-signature@1.0.6: 78 | resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} 79 | dev: false 80 | 81 | /cookie@0.6.0: 82 | resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} 83 | engines: {node: '>= 0.6'} 84 | dev: false 85 | 86 | /cors@2.8.5: 87 | resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} 88 | engines: {node: '>= 0.10'} 89 | dependencies: 90 | object-assign: 4.1.1 91 | vary: 1.1.2 92 | dev: false 93 | 94 | /debug@2.6.9: 95 | resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} 96 | peerDependencies: 97 | supports-color: '*' 98 | peerDependenciesMeta: 99 | supports-color: 100 | optional: true 101 | dependencies: 102 | ms: 2.0.0 103 | dev: false 104 | 105 | /define-data-property@1.1.4: 106 | resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} 107 | engines: {node: '>= 0.4'} 108 | dependencies: 109 | es-define-property: 1.0.0 110 | es-errors: 1.3.0 111 | gopd: 1.0.1 112 | dev: false 113 | 114 | /depd@2.0.0: 115 | resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} 116 | engines: {node: '>= 0.8'} 117 | dev: false 118 | 119 | /destroy@1.2.0: 120 | resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} 121 | engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} 122 | dev: false 123 | 124 | /ee-first@1.1.1: 125 | resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} 126 | dev: false 127 | 128 | /encodeurl@1.0.2: 129 | resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} 130 | engines: {node: '>= 0.8'} 131 | dev: false 132 | 133 | /es-define-property@1.0.0: 134 | resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} 135 | engines: {node: '>= 0.4'} 136 | dependencies: 137 | get-intrinsic: 1.2.4 138 | dev: false 139 | 140 | /es-errors@1.3.0: 141 | resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} 142 | engines: {node: '>= 0.4'} 143 | dev: false 144 | 145 | /escape-html@1.0.3: 146 | resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} 147 | dev: false 148 | 149 | /etag@1.8.1: 150 | resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} 151 | engines: {node: '>= 0.6'} 152 | dev: false 153 | 154 | /express@4.19.2: 155 | resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} 156 | engines: {node: '>= 0.10.0'} 157 | dependencies: 158 | accepts: 1.3.8 159 | array-flatten: 1.1.1 160 | body-parser: 1.20.2 161 | content-disposition: 0.5.4 162 | content-type: 1.0.5 163 | cookie: 0.6.0 164 | cookie-signature: 1.0.6 165 | debug: 2.6.9 166 | depd: 2.0.0 167 | encodeurl: 1.0.2 168 | escape-html: 1.0.3 169 | etag: 1.8.1 170 | finalhandler: 1.2.0 171 | fresh: 0.5.2 172 | http-errors: 2.0.0 173 | merge-descriptors: 1.0.1 174 | methods: 1.1.2 175 | on-finished: 2.4.1 176 | parseurl: 1.3.3 177 | path-to-regexp: 0.1.7 178 | proxy-addr: 2.0.7 179 | qs: 6.11.0 180 | range-parser: 1.2.1 181 | safe-buffer: 5.2.1 182 | send: 0.18.0 183 | serve-static: 1.15.0 184 | setprototypeof: 1.2.0 185 | statuses: 2.0.1 186 | type-is: 1.6.18 187 | utils-merge: 1.0.1 188 | vary: 1.1.2 189 | transitivePeerDependencies: 190 | - supports-color 191 | dev: false 192 | 193 | /finalhandler@1.2.0: 194 | resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} 195 | engines: {node: '>= 0.8'} 196 | dependencies: 197 | debug: 2.6.9 198 | encodeurl: 1.0.2 199 | escape-html: 1.0.3 200 | on-finished: 2.4.1 201 | parseurl: 1.3.3 202 | statuses: 2.0.1 203 | unpipe: 1.0.0 204 | transitivePeerDependencies: 205 | - supports-color 206 | dev: false 207 | 208 | /forwarded@0.2.0: 209 | resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} 210 | engines: {node: '>= 0.6'} 211 | dev: false 212 | 213 | /fresh@0.5.2: 214 | resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} 215 | engines: {node: '>= 0.6'} 216 | dev: false 217 | 218 | /function-bind@1.1.2: 219 | resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} 220 | dev: false 221 | 222 | /get-intrinsic@1.2.4: 223 | resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} 224 | engines: {node: '>= 0.4'} 225 | dependencies: 226 | es-errors: 1.3.0 227 | function-bind: 1.1.2 228 | has-proto: 1.0.3 229 | has-symbols: 1.0.3 230 | hasown: 2.0.2 231 | dev: false 232 | 233 | /gopd@1.0.1: 234 | resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} 235 | dependencies: 236 | get-intrinsic: 1.2.4 237 | dev: false 238 | 239 | /has-property-descriptors@1.0.2: 240 | resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} 241 | dependencies: 242 | es-define-property: 1.0.0 243 | dev: false 244 | 245 | /has-proto@1.0.3: 246 | resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} 247 | engines: {node: '>= 0.4'} 248 | dev: false 249 | 250 | /has-symbols@1.0.3: 251 | resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} 252 | engines: {node: '>= 0.4'} 253 | dev: false 254 | 255 | /hasown@2.0.2: 256 | resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} 257 | engines: {node: '>= 0.4'} 258 | dependencies: 259 | function-bind: 1.1.2 260 | dev: false 261 | 262 | /http-errors@2.0.0: 263 | resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} 264 | engines: {node: '>= 0.8'} 265 | dependencies: 266 | depd: 2.0.0 267 | inherits: 2.0.4 268 | setprototypeof: 1.2.0 269 | statuses: 2.0.1 270 | toidentifier: 1.0.1 271 | dev: false 272 | 273 | /iconv-lite@0.4.24: 274 | resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} 275 | engines: {node: '>=0.10.0'} 276 | dependencies: 277 | safer-buffer: 2.1.2 278 | dev: false 279 | 280 | /inherits@2.0.4: 281 | resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} 282 | dev: false 283 | 284 | /ipaddr.js@1.9.1: 285 | resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} 286 | engines: {node: '>= 0.10'} 287 | dev: false 288 | 289 | /media-typer@0.3.0: 290 | resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} 291 | engines: {node: '>= 0.6'} 292 | dev: false 293 | 294 | /merge-descriptors@1.0.1: 295 | resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} 296 | dev: false 297 | 298 | /methods@1.1.2: 299 | resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} 300 | engines: {node: '>= 0.6'} 301 | dev: false 302 | 303 | /mime-db@1.52.0: 304 | resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} 305 | engines: {node: '>= 0.6'} 306 | dev: false 307 | 308 | /mime-types@2.1.35: 309 | resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} 310 | engines: {node: '>= 0.6'} 311 | dependencies: 312 | mime-db: 1.52.0 313 | dev: false 314 | 315 | /mime@1.6.0: 316 | resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} 317 | engines: {node: '>=4'} 318 | hasBin: true 319 | dev: false 320 | 321 | /ms@2.0.0: 322 | resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} 323 | dev: false 324 | 325 | /ms@2.1.3: 326 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 327 | dev: false 328 | 329 | /negotiator@0.6.3: 330 | resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} 331 | engines: {node: '>= 0.6'} 332 | dev: false 333 | 334 | /object-assign@4.1.1: 335 | resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} 336 | engines: {node: '>=0.10.0'} 337 | dev: false 338 | 339 | /object-inspect@1.13.1: 340 | resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} 341 | dev: false 342 | 343 | /on-finished@2.4.1: 344 | resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} 345 | engines: {node: '>= 0.8'} 346 | dependencies: 347 | ee-first: 1.1.1 348 | dev: false 349 | 350 | /parseurl@1.3.3: 351 | resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} 352 | engines: {node: '>= 0.8'} 353 | dev: false 354 | 355 | /path-to-regexp@0.1.7: 356 | resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} 357 | dev: false 358 | 359 | /proxy-addr@2.0.7: 360 | resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} 361 | engines: {node: '>= 0.10'} 362 | dependencies: 363 | forwarded: 0.2.0 364 | ipaddr.js: 1.9.1 365 | dev: false 366 | 367 | /qs@6.11.0: 368 | resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} 369 | engines: {node: '>=0.6'} 370 | dependencies: 371 | side-channel: 1.0.6 372 | dev: false 373 | 374 | /range-parser@1.2.1: 375 | resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} 376 | engines: {node: '>= 0.6'} 377 | dev: false 378 | 379 | /raw-body@2.5.2: 380 | resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} 381 | engines: {node: '>= 0.8'} 382 | dependencies: 383 | bytes: 3.1.2 384 | http-errors: 2.0.0 385 | iconv-lite: 0.4.24 386 | unpipe: 1.0.0 387 | dev: false 388 | 389 | /safe-buffer@5.2.1: 390 | resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 391 | dev: false 392 | 393 | /safer-buffer@2.1.2: 394 | resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} 395 | dev: false 396 | 397 | /send@0.18.0: 398 | resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} 399 | engines: {node: '>= 0.8.0'} 400 | dependencies: 401 | debug: 2.6.9 402 | depd: 2.0.0 403 | destroy: 1.2.0 404 | encodeurl: 1.0.2 405 | escape-html: 1.0.3 406 | etag: 1.8.1 407 | fresh: 0.5.2 408 | http-errors: 2.0.0 409 | mime: 1.6.0 410 | ms: 2.1.3 411 | on-finished: 2.4.1 412 | range-parser: 1.2.1 413 | statuses: 2.0.1 414 | transitivePeerDependencies: 415 | - supports-color 416 | dev: false 417 | 418 | /serve-static@1.15.0: 419 | resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} 420 | engines: {node: '>= 0.8.0'} 421 | dependencies: 422 | encodeurl: 1.0.2 423 | escape-html: 1.0.3 424 | parseurl: 1.3.3 425 | send: 0.18.0 426 | transitivePeerDependencies: 427 | - supports-color 428 | dev: false 429 | 430 | /set-function-length@1.2.2: 431 | resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} 432 | engines: {node: '>= 0.4'} 433 | dependencies: 434 | define-data-property: 1.1.4 435 | es-errors: 1.3.0 436 | function-bind: 1.1.2 437 | get-intrinsic: 1.2.4 438 | gopd: 1.0.1 439 | has-property-descriptors: 1.0.2 440 | dev: false 441 | 442 | /setprototypeof@1.2.0: 443 | resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} 444 | dev: false 445 | 446 | /side-channel@1.0.6: 447 | resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} 448 | engines: {node: '>= 0.4'} 449 | dependencies: 450 | call-bind: 1.0.7 451 | es-errors: 1.3.0 452 | get-intrinsic: 1.2.4 453 | object-inspect: 1.13.1 454 | dev: false 455 | 456 | /statuses@2.0.1: 457 | resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} 458 | engines: {node: '>= 0.8'} 459 | dev: false 460 | 461 | /toidentifier@1.0.1: 462 | resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} 463 | engines: {node: '>=0.6'} 464 | dev: false 465 | 466 | /type-is@1.6.18: 467 | resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} 468 | engines: {node: '>= 0.6'} 469 | dependencies: 470 | media-typer: 0.3.0 471 | mime-types: 2.1.35 472 | dev: false 473 | 474 | /unpipe@1.0.0: 475 | resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} 476 | engines: {node: '>= 0.8'} 477 | dev: false 478 | 479 | /utils-merge@1.0.1: 480 | resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} 481 | engines: {node: '>= 0.4.0'} 482 | dev: false 483 | 484 | /vary@1.1.2: 485 | resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} 486 | engines: {node: '>= 0.8'} 487 | dev: false 488 | -------------------------------------------------------------------------------- /src/__test__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as reactTestRenderer from 'react-test-renderer'; 3 | import * as api from '..'; 4 | 5 | 6 | type TestObject = Record; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | const connect = () => (Component: any) => { 10 | return () => { 11 | return ; 12 | }; 13 | }; 14 | 15 | // // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | // const connectWith = (key: any, Component: any) => connect(key)(Component); 17 | 18 | function delay(ms: number) { 19 | return new Promise(resolve => setTimeout(resolve, ms)); 20 | } 21 | 22 | export async function mockLoad() { 23 | await new Promise(resolve => setTimeout(resolve, 10)); 24 | return 1; 25 | } 26 | 27 | export function mockPromiseArray() { 28 | // 模拟十个异步任务,返回数字 29 | const arr: Array> = []; 30 | for (let i = 0; i < 10; i++) { 31 | arr.push(new Promise(resolve => { 32 | setTimeout(() => resolve(i), 1000 * i); 33 | })); 34 | } 35 | return arr; 36 | } 37 | 38 | describe('export api', () => { 39 | test( 40 | 'should export api', 41 | () => { 42 | const { 43 | createMapperHooksStore, 44 | } = api; 45 | expect(createMapperHooksStore).toBeDefined(); 46 | expect(typeof createMapperHooksStore).toBe('function'); 47 | } 48 | ); 49 | 50 | test('check store return', () => { 51 | const store = api.createMapperHooksStore(); 52 | const { 53 | useStoreValue, 54 | setStoreValue, 55 | loadStoreValue, 56 | useStoreLoading, 57 | getStoreLoading, 58 | getStoreValue, 59 | load, 60 | reset, 61 | } = store; 62 | expect(store).toBeDefined(); 63 | expect(useStoreValue).toBeDefined(); 64 | expect(setStoreValue).toBeDefined(); 65 | expect(loadStoreValue).toBeDefined(); 66 | expect(useStoreLoading).toBeDefined(); 67 | expect(getStoreLoading).toBeDefined(); 68 | expect(getStoreValue).toBeDefined(); 69 | expect(load).toBeDefined(); 70 | expect(reset).toBeDefined(); 71 | }); 72 | 73 | test('reset', () => { 74 | const store = api.createMapperHooksStore(5); 75 | store.setStoreValue(1); 76 | expect(store.getStoreValue()).toBe(1); 77 | store.reset(); 78 | expect(store.getStoreValue()).toBe(5); 79 | }); 80 | 81 | test('setStoreValue', () => { 82 | const store = api.createMapperHooksStore(); 83 | store.setStoreValue({a: 1}); 84 | expect(store.getStoreValue()).toEqual({a: 1}); 85 | store.setStoreValue({b: 2}); 86 | expect(store.getStoreValue()).toEqual({b: 2}); 87 | store.setStoreValue(v => ({...v, c: 3})); 88 | expect(store.getStoreValue()).toEqual({b: 2, c: 3}); 89 | store.setStoreValue(v => ({...v, b: 4})); 90 | expect(store.getStoreValue()).toEqual({b: 4, c: 3}); 91 | expect(store.setStoreValue({})).toBeUndefined(); 92 | }); 93 | 94 | test('getStoreValue', () => { 95 | const store = api.createMapperHooksStore(); 96 | expect(store.getStoreValue()).toBeUndefined(); 97 | store.setStoreValue({a: 1}); 98 | expect(store.getStoreValue()).toEqual({a: 1}); 99 | }); 100 | 101 | test('reset', () => { 102 | const store = api.createMapperHooksStore(0); 103 | store.setStoreValue(1); 104 | expect(store.getStoreValue()).toEqual(1); 105 | store.reset(); 106 | expect(store.getStoreValue()).toBe(0); 107 | }); 108 | 109 | test('loadStoreValue', async () => { 110 | const store = api.createMapperHooksStore(-1); 111 | store.load(mockPromiseArray()); 112 | await delay(100); 113 | expect(store.getStoreValue()).not.toBe(-1); 114 | }); 115 | 116 | test('load', async () => { 117 | const store = api.createMapperHooksStore(-1); 118 | const loadNum = store.loadStoreValue( 119 | params => params, 120 | mockLoad 121 | ); 122 | loadNum(); 123 | await delay(1000); 124 | expect(store.getStoreValue()).not.toBe(-1); 125 | }); 126 | 127 | test('getLoading', async () => { 128 | const store = api.createMapperHooksStore(-1); 129 | expect(store.getStoreLoading()).toBeFalsy(); 130 | }); 131 | 132 | test('useStoreValue', () => { 133 | const User = () => { 134 | const store = api.createMapperHooksStore(0); 135 | const value = store.useStoreValue(); 136 | return ( 137 |
138 | {value} 139 |
140 | ); 141 | 142 | }; 143 | const Component = connect()(React.memo(User)); 144 | expect(reactTestRenderer.create().toJSON()).toMatchSnapshot(); 145 | expect(reactTestRenderer.create().toJSON()).toEqual({type: 'div', props: {}, children: ['0']}); 146 | }); 147 | 148 | test('after set', async () => { 149 | const User = () => { 150 | const store = api.createMapperHooksStore(0); 151 | const value = store.useStoreValue(); 152 | React.useEffect(() => { 153 | store.setStoreValue(2); 154 | }, [store]); 155 | return ( 156 |
157 | {value} 158 |
159 | ); 160 | 161 | }; 162 | const Component = connect()(React.memo(User)); 163 | expect(reactTestRenderer.create().toJSON()).toMatchSnapshot(); 164 | expect(reactTestRenderer.create().toJSON()).toEqual({type: 'div', props: {}, children: ['2']}); 165 | }); 166 | 167 | test('after loadStoreValue', async () => { 168 | const User = () => { 169 | const store = api.createMapperHooksStore(0); 170 | const value = store.useStoreValue(); 171 | const load = store.loadStoreValue( 172 | params => params, 173 | mockLoad 174 | ); 175 | React.useEffect(() => { 176 | load(); 177 | }, [load]); 178 | return ( 179 |
180 | {value} 181 |
182 | ); 183 | 184 | }; 185 | const Component = connect()(React.memo(User)); 186 | const render = reactTestRenderer.create(); 187 | expect(render.toJSON()).toMatchSnapshot(); 188 | expect(render.toJSON()).toEqual({type: 'div', props: {}, children: ['0']}); 189 | await delay(100); 190 | expect(render.toJSON()).toEqual({type: 'div', props: {}, children: ['1']}); 191 | }); 192 | 193 | test('use selector', async () => { 194 | interface IUser { 195 | age: number; 196 | name: string; 197 | address: string; 198 | } 199 | const User = () => { 200 | const store = api.createMapperHooksStore({ 201 | age: 18, 202 | name: 'red', 203 | address: 'ccc', 204 | }); 205 | const value = store.useStoreValue(value => value?.age); 206 | React.useEffect(() => { 207 | store.setStoreValue(v => ( 208 | { 209 | ...v, 210 | name: 'yellow', 211 | } 212 | )); 213 | }, [store]); 214 | return ( 215 |
216 | {value} 217 |
218 | ); 219 | 220 | }; 221 | const Component = connect()(React.memo(User)); 222 | const render = reactTestRenderer.create(); 223 | expect(render.toJSON()).toMatchSnapshot(); 224 | expect(render.toJSON()).toEqual({type: 'div', props: {}, children: ['18']}); 225 | }); 226 | 227 | test('after setStoreValue', async () => { 228 | interface IUser { 229 | age: number; 230 | name: string; 231 | address: string; 232 | } 233 | const store = api.createMapperHooksStore({ 234 | age: 18, 235 | name: 'red', 236 | address: 'ccc', 237 | }); 238 | const useStoreValue = store.useStoreValue; 239 | const setStoreValue = store.setStoreValue; 240 | const User = () => { 241 | const value = useStoreValue(value => value?.age); 242 | React.useEffect( 243 | () => { 244 | setStoreValue(v => ({...v, age: v?.age + 1})); 245 | }, 246 | [] 247 | ); 248 | return ( 249 |
250 | {value} 251 |
252 | ); 253 | 254 | }; 255 | const Component = connect()(React.memo(User)); 256 | const render = reactTestRenderer.create(); 257 | await delay(2000); 258 | expect(render.toJSON()).toMatchSnapshot(); 259 | expect(render.toJSON()).toEqual({type: 'div', props: {}, children: ['19']}); 260 | }); 261 | 262 | test('useStoreLoading', () => { 263 | const User = () => { 264 | const store = api.createMapperHooksStore(0); 265 | const isLoading = store.useStoreLoading(); 266 | return ( 267 |
268 | {isLoading} 269 |
270 | ); 271 | }; 272 | const Component = connect()(React.memo(User)); 273 | expect(reactTestRenderer.create().toJSON()).toMatchSnapshot(); 274 | }); 275 | 276 | // 测试 setStoreValue 在加载状态下的行为 277 | test('setStoreValue during loading throws error', async () => { 278 | const store = api.createMapperHooksStore(0); 279 | const loadNum = store.loadStoreValue( 280 | params => params, 281 | mockLoad 282 | ); 283 | loadNum(); 284 | await delay(0); // 等待 queueMicrotask 执行 285 | expect(() => store.setStoreValue(1)).toThrow('当前处于加载状态,请等待加载完成。'); 286 | }); 287 | 288 | // // 测试 setStoreValue 类型检查 289 | // test('setStoreValue throws error when value is not function or value', () => { 290 | // const store = api.createMapperHooksStore(0); 291 | // // @ts-expect-error 测试传入非函数非值参数 292 | // expect(() => store.setStoreValue(null)).toThrow(); 293 | // }); 294 | 295 | // 测试 setStoreValue 中的错误处理 296 | test('setStoreValue throws error when value function throws', () => { 297 | const store = api.createMapperHooksStore(0); 298 | expect(() => store.setStoreValue(() => { 299 | throw new Error('test error'); 300 | })).toThrow('test error'); 301 | }); 302 | 303 | // 测试 useStoreValue 中的错误处理 304 | test('useStoreValue handles selector error', () => { 305 | const User = () => { 306 | const store = api.createMapperHooksStore(0); 307 | const value = store.useStoreValue(() => { 308 | throw new Error('test error'); 309 | }); 310 | return
{value}
; 311 | }; 312 | const Component = connect()(React.memo(User)); 313 | const render = reactTestRenderer.create(); 314 | expect(render.toJSON()).toMatchSnapshot(); 315 | }); 316 | 317 | // 测试 useStoreValue selector 错误处理 318 | test('useStoreValue logs selector error', () => { 319 | const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); 320 | const User = () => { 321 | const store = api.createMapperHooksStore(0); 322 | store.useStoreValue(() => { 323 | throw new Error('selector error'); 324 | }); 325 | return
test
; 326 | }; 327 | const Component = connect()(React.memo(User)); 328 | reactTestRenderer.create(); 329 | expect(consoleError).toHaveBeenCalledWith(expect.any(Error)); 330 | expect(consoleError).toHaveBeenCalledWith('Above error occurs in selector.'); 331 | consoleError.mockRestore(); 332 | }); 333 | 334 | // 测试 loadStoreValue 中的错误处理 335 | test('loadStoreValue throws error when async function throws', async () => { 336 | const store = api.createMapperHooksStore(0); 337 | const loadNum = store.loadStoreValue( 338 | params => params, 339 | async () => { 340 | throw new Error('async error'); 341 | } 342 | ); 343 | await expect(loadNum()).rejects.toThrow('async error'); 344 | }); 345 | 346 | // 测试 loadStoreValue 异步错误处理 347 | test('loadStoreValue handles async error with event', async () => { 348 | const store = api.createMapperHooksStore(0); 349 | const loadNum = store.loadStoreValue( 350 | params => params, 351 | async () => { 352 | throw new Error('async error'); 353 | }, 354 | { 355 | beforeEvent: async () => {/* noop */}, 356 | afterEvent: async () => {/* noop */}, 357 | } 358 | ); 359 | await expect(loadNum()).rejects.toThrow('async error'); 360 | }); 361 | 362 | // 测试 private_subscribe 功能(通过 useStoreValue 间接测试) 363 | test('private_subscribe works through useStoreValue', async () => { 364 | const store = api.createMapperHooksStore(0); 365 | const User = () => { 366 | const value = store.useStoreValue(); 367 | return
{value}
; 368 | }; 369 | const Component = connect()(React.memo(User)); 370 | const render = reactTestRenderer.create(); 371 | 372 | // 初始渲染应为0 373 | expect(render.toJSON()).toEqual({type: 'div', props: {}, children: ['0']}); 374 | 375 | // 使用act包裹状态更新 376 | await reactTestRenderer.act(async () => { 377 | store.setStoreValue(1); 378 | // 添加微小延迟确保更新被应用 379 | await new Promise(resolve => setTimeout(resolve, 0)); 380 | }); 381 | 382 | // 检查更新后的值 383 | expect(render.toJSON()).toEqual({type: 'div', props: {}, children: ['1']}); 384 | }); 385 | 386 | // 测试 load 方法的 acceptEvery 策略 387 | test('load with acceptEvery strategy', async () => { 388 | const store = api.createMapperHooksStore(0, {strategy: 'acceptEvery'}); 389 | await store.load(mockPromiseArray()); 390 | expect(store.getStoreValue()).toBe(9); // 最后一个值 391 | }, 10000); // 增加超时时间到 10 秒 392 | 393 | // 测试 load 方法的 acceptLatest 策略 394 | test('load with acceptLatest strategy', async () => { 395 | const store = api.createMapperHooksStore(0, {strategy: 'acceptLatest'}); 396 | await store.load(mockPromiseArray()); 397 | expect(store.getStoreValue()).toBe(9); // 最后一个值 398 | }, 10000); // 增加超时时间到 10 秒 399 | 400 | // 测试 load 方法的默认策略(acceptSequenced) 401 | test('load with default strategy (acceptSequenced)', async () => { 402 | const store = api.createMapperHooksStore(0); 403 | const promises = [ 404 | new Promise(resolve => setTimeout(() => resolve(1), 100)), 405 | new Promise(resolve => setTimeout(() => resolve(2), 50)), 406 | ]; 407 | await store.load(promises); 408 | expect(store.getStoreValue()).toBe(2); // 最后一个resolve的值 409 | }); 410 | 411 | // 测试本地存储功能 412 | test('withLocalStorage', () => { 413 | const key = 'test_key'; 414 | const store = api.createMapperHooksStore(0, {withLocalStorage: key}); 415 | store.setStoreValue(1); 416 | const storedValue = JSON.parse(localStorage.getItem(key) || '{}'); 417 | expect(storedValue.value).toBe('1'); 418 | expect(storedValue.type).toBe('number'); 419 | }); 420 | 421 | // 覆盖第86行:测试 local?.getItem(withLocalStorage) 为 null 或 undefined 的情况 422 | test('handles empty localStorage content', () => { 423 | const key = 'empty_key'; 424 | localStorage.removeItem(key); 425 | const store = api.createMapperHooksStore(0, {withLocalStorage: key}); 426 | expect(store.getStoreValue()).toBe(0); // 应该回退到初始值 427 | }); 428 | 429 | // 测试边界条件:空值 430 | test('handle undefined value', () => { 431 | const store = api.createMapperHooksStore(); 432 | expect(store.getStoreValue()).toBeUndefined(); 433 | store.setStoreValue(undefined); 434 | expect(store.getStoreValue()).toBeUndefined(); 435 | }); 436 | 437 | // 测试 load 方法中的错误处理(最后一个结果为rejected) 438 | test('load throws error when last result is rejected', async () => { 439 | const store = api.createMapperHooksStore(0, {strategy: 'acceptLatest'}); 440 | const promises = [ 441 | Promise.resolve(1), 442 | Promise.reject(new Error('test error')), 443 | ]; 444 | await expect(store.load(promises)).rejects.toThrow('最后一个收到的结果为rejected'); 445 | }); 446 | 447 | // 测试 load 方法中的错误处理 448 | test('load handles promise rejection', async () => { 449 | const store = api.createMapperHooksStore(0, {strategy: 'acceptLatest'}); 450 | const promises = [ 451 | Promise.resolve(1), 452 | Promise.reject(new Error('load error')), 453 | ]; 454 | await expect(store.load(promises)).rejects.toThrow('最后一个收到的结果为rejected'); 455 | }); 456 | 457 | // 测试 useStoreValue 中的错误处理 458 | test('useStoreValue logs selector error', () => { 459 | const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); 460 | const store = api.createMapperHooksStore(0); 461 | const User = () => { 462 | store.useStoreValue(() => { 463 | throw new Error('selector error'); 464 | }); 465 | return
test
; 466 | }; 467 | const Component = connect()(React.memo(User)); 468 | reactTestRenderer.create(); 469 | expect(consoleError).toHaveBeenCalledWith(expect.any(Error)); 470 | expect(consoleError).toHaveBeenCalledWith('Above error occurs in selector.'); 471 | consoleError.mockRestore(); 472 | }); 473 | 474 | // 测试 loadStoreValue 中的错误处理 475 | test('loadStoreValue throws error when async function throws', async () => { 476 | const store = api.createMapperHooksStore(0); 477 | const loadNum = store.loadStoreValue( 478 | params => params, 479 | async () => { 480 | throw new Error('async error'); 481 | } 482 | ); 483 | await expect(loadNum()).rejects.toThrow('async error'); 484 | }); 485 | 486 | // 测试文件读取成功但非文件内容的处理 487 | test('handles invalid localStorage content', () => { 488 | const key = 'invalid_key'; 489 | localStorage.setItem(key, 'invalid json'); 490 | const store = api.createMapperHooksStore(0, {withLocalStorage: key}); 491 | expect(store.getStoreValue()).toBe(0); // 应该回退到初始值 492 | }); 493 | 494 | // 测试 load 方法中rejected结果错误处理 495 | test('load handles all rejected results', async () => { 496 | const store = api.createMapperHooksStore(0, {strategy: 'acceptLatest'}); 497 | const promises = [ 498 | Promise.reject(new Error('error1')), 499 | Promise.reject(new Error('error2')), 500 | ]; 501 | await expect(store.load(promises)).rejects.toThrow('最后一个收到的结果为rejected'); 502 | }); 503 | 504 | // 测试 setStoreValue 可以接受任何类型的值 505 | test('setStoreValue accepts any type of value', () => { 506 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 507 | const store = api.createMapperHooksStore(); 508 | 509 | // 测试基本类型 510 | store.setStoreValue(null); 511 | expect(store.getStoreValue()).toBeNull(); 512 | 513 | store.setStoreValue(undefined); 514 | expect(store.getStoreValue()).toBeUndefined(); 515 | 516 | store.setStoreValue(123); 517 | expect(store.getStoreValue()).toBe(123); 518 | 519 | store.setStoreValue('test'); 520 | expect(store.getStoreValue()).toBe('test'); 521 | 522 | store.setStoreValue(true); 523 | expect(store.getStoreValue()).toBe(true); 524 | 525 | // 测试对象和数组 526 | const obj = {a: 1}; 527 | store.setStoreValue(obj); 528 | expect(store.getStoreValue()).toEqual(obj); 529 | 530 | const arr = [1, 2, 3]; 531 | store.setStoreValue(arr); 532 | expect(store.getStoreValue()).toEqual(arr); 533 | 534 | // 测试函数 535 | store.setStoreValue(() => 'function result'); 536 | expect(store.getStoreValue()).toBe('function result'); 537 | }); 538 | 539 | // 测试 setStoreValue 在加载状态下抛出错误 540 | test('setStoreValue throws error during loading', async () => { 541 | const store = api.createMapperHooksStore(0); 542 | const loadNum = store.loadStoreValue( 543 | params => params, 544 | async () => { 545 | await new Promise(resolve => setTimeout(resolve, 100)); 546 | return 1; 547 | } 548 | ); 549 | loadNum(); 550 | await new Promise(resolve => setTimeout(resolve, 0)); // 等待微任务执行 551 | expect(() => store.setStoreValue(1)).toThrow('当前处于加载状态,请等待加载完成。'); 552 | }); 553 | 554 | // 测试 setStoreValue 函数执行出错 555 | test('setStoreValue throws error when function throws', () => { 556 | const store = api.createMapperHooksStore(0); 557 | expect(() => store.setStoreValue(() => { 558 | throw new Error('function error'); 559 | })).toThrow('function error'); 560 | }); 561 | 562 | // 测试未覆盖的86行 563 | test('loadStoreValue with event', async () => { 564 | const store = api.createMapperHooksStore(0); 565 | const loadNum = store.loadStoreValue( 566 | params => params, 567 | async () => { 568 | await new Promise(resolve => setTimeout(resolve, 100)); 569 | return 1; 570 | }, 571 | { 572 | beforeEvent: async () => {/* noop */}, 573 | afterEvent: async () => {/* noop */}, 574 | } 575 | ); 576 | await loadNum(); 577 | await new Promise(resolve => setTimeout(resolve, 0)); // 等待微任务执行 578 | expect(store.getStoreValue()).toBe(1); 579 | }); 580 | 581 | // // 测试未覆盖的150行 582 | // test('private_subscribe adds and removes listeners', () => { 583 | // const store = api.createMapperHooksStore(0); 584 | // const callback = jest.fn(); 585 | // const unsubscribe = store.private_subscribe(callback); 586 | // store.setStoreValue(1); 587 | // expect(callback).toHaveBeenCalledTimes(1); 588 | // unsubscribe(); 589 | // store.setStoreValue(2); 590 | // expect(callback).toHaveBeenCalledTimes(1); // 取消订阅后不应再调用 591 | // }); 592 | 593 | test('useValueSelectorSubscription logs selector error', () => { 594 | const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); 595 | const store = api.createMapperHooksStore(0); 596 | const User = () => { 597 | store.useStoreValue(() => { 598 | throw new Error('selector error'); 599 | }); 600 | return
test
; 601 | }; 602 | const Component = connect()(React.memo(User)); 603 | reactTestRenderer.create(); 604 | expect(consoleError).toHaveBeenCalledWith(expect.any(Error)); 605 | expect(consoleError).toHaveBeenCalledWith('Above error occurs in selector.'); 606 | consoleError.mockRestore(); 607 | }); 608 | 609 | // 覆盖第265-266行:测试 private_emit 中的异常处理逻辑 610 | test('private_emit handles callback errors', () => { 611 | const store = api.createMapperHooksStore(0); 612 | const errorCallback = () => { 613 | throw new Error('callback error'); 614 | }; 615 | // 通过 useStoreValue 间接订阅 callback 616 | const User = () => { 617 | store.useStoreValue(() => { 618 | errorCallback(); 619 | return 0; 620 | }); 621 | return
test
; 622 | }; 623 | const Component = connect()(React.memo(User)); 624 | expect(() => reactTestRenderer.create()).not.toThrow(); 625 | }); 626 | 627 | // 测试未覆盖的287行 628 | test('setStoreValue throws error when function throws', () => { 629 | const store = api.createMapperHooksStore(0); 630 | expect(() => store.setStoreValue(() => { 631 | throw new Error('function error'); 632 | })).toThrow('function error'); 633 | }); 634 | }); 635 | --------------------------------------------------------------------------------