├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmignore ├── .npmrc ├── .prettierrc ├── .travis.yml ├── .umirc.ts ├── .vscode └── launch.json ├── CHANGELOG.md ├── LATESTLOG.md ├── LICENSE ├── README.md ├── README.zh-CN.md ├── __tests__ ├── effect.spec.tsx ├── get-effects.spec.ts ├── get-reducers.spec.ts ├── get-state.spec.ts ├── helper │ ├── CountClassComponent.tsx │ ├── CountFunctionComponent.tsx │ ├── MultiCountClassComponent.tsx │ ├── createHook.tsx │ ├── model.ts │ ├── ref.ts │ ├── shared.ts │ └── store.ts ├── index.spec.ts ├── model.spec.ts ├── multiple.spec.ts ├── provider.spec.tsx ├── reducer.spec.ts ├── store.spec.ts └── utils.spec.ts ├── api-extractor.json ├── commitlint.config.js ├── docs ├── api │ └── index.md ├── demos │ ├── counter │ │ ├── class │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ └── model.ts │ │ ├── hooks │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ └── model.ts │ │ └── hooks2 │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ ├── model.ts │ │ │ └── store.ts │ └── todo-list │ │ ├── Header │ │ ├── index.tsx │ │ └── model.ts │ │ ├── UndoList │ │ ├── index.tsx │ │ └── model.ts │ │ ├── index.less │ │ ├── index.tsx │ │ ├── models.ts │ │ └── store.ts ├── guide │ ├── best-practice.md │ ├── devtools.md │ ├── examples.md │ ├── faq.md │ ├── getting-started.md │ └── index.md └── index.md ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── public ├── dobux-flow.png ├── logo.png ├── simple-logo.png ├── style.css ├── time-travel-counter.gif └── time-travel-todo-list.gif ├── rollup.config.js ├── scripts ├── build.js ├── dev.js ├── release.js ├── setupJestEnv.ts └── utils.js ├── src ├── common │ ├── const.ts │ └── env.ts ├── core │ ├── Container.ts │ ├── Model.tsx │ ├── Store.tsx │ └── createProvider.tsx ├── default.ts ├── global.d.ts ├── index.ts ├── types.ts └── utils │ ├── func.ts │ ├── invariant.ts │ ├── shallowEqual.ts │ └── type.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | charset = utf-8 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # javascripts 2 | lib/ 3 | scripts/ 4 | 5 | # docs 6 | docs/ 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": ["plugin:@typescript-eslint/recommended", "prettier"], 5 | "rules": { 6 | "@typescript-eslint/member-delimiter-style": 0, 7 | "@typescript-eslint/no-explicit-any": 0, 8 | "@typescript-eslint/no-empty-function": 0, 9 | "@typescript-eslint/no-this-alias": 0, 10 | "@typescript-eslint/ban-ts-ignore": 0, 11 | "@typescript-eslint/ban-ts-comment": 0, 12 | "@typescript-eslint/no-var-requires": 0 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: '🐛 Bug Report' 3 | about: Report a reproducible bug or regression. 4 | title: 'Bug: ' 5 | labels: 'bug' 6 | --- 7 | 8 | ## Bug Report 9 | 10 | 15 | 16 | ## Steps To Reproduce 17 | 18 | 1. 19 | 2. 20 | 21 | 26 | 27 | Link to code example: 28 | 29 | 36 | 37 | ## Current behavior 38 | 39 | ## Expected behavior 40 | 41 | ## Environment 42 | 43 | - Browser: [e.g. chrome, safari] 44 | - Version: [e.g. v1.0.0] 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: '🚀 Feature Request' 3 | about: Suggest an idea for dox-test 4 | title: 'Feature: ' 5 | labels: 'enhancement' 6 | --- 7 | 8 | ### Problem Description 9 | 10 | 11 | 12 | ### Proposed Solution 13 | 14 | 15 | 16 | ### Alternatives Considered 17 | 18 | 19 | 20 | ### Additional Information 21 | 22 | 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # systems 2 | .DS_Store 3 | .idea/ 4 | 5 | # temps 6 | *.log 7 | *.cache 8 | *.diff 9 | *.patch 10 | *.tmp 11 | 12 | # coverages 13 | coverage/ 14 | *.lcov 15 | 16 | # dependencies 17 | node_modules/ 18 | 19 | # customers 20 | dist/ 21 | cjs/ 22 | esm/ 23 | umd/ 24 | lib/ 25 | .umi/ 26 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package 2 | 3 | # tests 4 | test/ 5 | coverage/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # linters 11 | .eslintrc 12 | .eslintignore 13 | 14 | # settings 15 | .editorconfig 16 | .prettierrc 17 | .npmrc 18 | tsconfig.json 19 | 20 | # systems 21 | .DS_Store 22 | .idea/ 23 | *.log.com 24 | .vscode/ 25 | 26 | # customers 27 | src/ 28 | scripts/ 29 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry="https://registry.npmjs.org/" 2 | strict-peer-dependencies=false 3 | public-hoist-pattern[]=*jest* 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "semi": false, 6 | "trailingComma": "es5", 7 | "bracketSpacing": true, 8 | "jsxBracketSameLine": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | cache: npm 5 | env: 6 | - CI=true 7 | script: 8 | - npm run test:once 9 | - npm run docs:build 10 | after_success: 11 | - npm run coverage 12 | deploy: 13 | provider: pages 14 | skip_cleanup: true 15 | github_token: $GITHUB_TOKEN 16 | local_dir: ./dist 17 | on: 18 | branch: master 19 | -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | base: '/dobux', 3 | publicPath: '/dobux/', 4 | locales: [['zh-CN', '中文'], ['en-US', 'English']], 5 | exportStatic: {}, 6 | nodeModulesTransform: { 7 | type: 'none', 8 | exclude: [], 9 | }, 10 | mode: 'site', 11 | title: 'Dobux', 12 | favicon: '/dobux/simple-logo.png', 13 | logo: '/dobux/logo.png', 14 | dynamicImport: {}, 15 | manifest: {}, 16 | hash: true, 17 | links: [ 18 | { rel: 'manifest', href: '/dobux/asset-manifest.json' }, 19 | { rel: 'stylesheet', href: '/dobux/style.css' }, 20 | ], 21 | navs: [ 22 | { title: '指南', path: '/guide' }, 23 | { title: 'API', path: '/api' }, 24 | { title: 'GitHub', path: 'https://github.com/kcfe/dobux' }, 25 | { title: '更新日志', path: 'https://github.com/kcfe/dobux/releases' }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Current File", 8 | "program": "${workspaceRoot}/node_modules/ts-node/dist/bin.js", 9 | "windows": { 10 | "program": "${workspaceFolder}/node_modules/ts-node/bin/ts-node" 11 | }, 12 | "args": ["-O", "{\"module\": \"commonjs\"}", "--files", "${file}"], 13 | "cwd": "${workspaceRoot}", 14 | "console": "integratedTerminal" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "Jest Current File", 20 | "program": "${workspaceFolder}/node_modules/.bin/jest", 21 | "windows": { 22 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 23 | }, 24 | "runtimeExecutable": "sh", 25 | "args": ["${fileBasenameNoExtension}", "--config", "jest.config.js"], 26 | "console": "integratedTerminal", 27 | "internalConsoleOptions": "neverOpen" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | ## [1.5.1](https://github.com/kcfe/dobux/compare/v1.5.0...v1.5.1) (2022-07-13) 4 | 5 | 6 | ### 🐛 Bug Fixes 7 | 8 | * auto reset ([df1ab53](https://github.com/kcfe/dobux/commit/df1ab53ba91e2aacce1fbe7410214bda5b15182a)) 9 | 10 | 11 | 12 | 13 | 14 | # [1.5.0](https://github.com/kcfe/dobux/compare/v1.4.3...v1.5.0) (2022-04-25) 15 | 16 | 17 | ### ✨ Features 18 | 19 | * support setValue reducer use immer ([73d10a0](https://github.com/kcfe/dobux/commit/73d10a050e0101481a21718a392a491f61e0d53c)) 20 | 21 | 22 | ### 🐛 Bug Fixes 23 | 24 | * build in reducer ts type error ([692b110](https://github.com/kcfe/dobux/commit/692b1108942bd93adf234e031a1bb0a74ceff7d8)) 25 | 26 | 27 | 28 | 29 | 30 | ## [1.4.3](https://github.com/kcfe/dobux/compare/v1.4.2...v1.4.3) (2022-04-21) 31 | 32 | 33 | ### 🐛 Bug Fixes 34 | 35 | * build in set value reducer pass function ([c7cb196](https://github.com/kcfe/dobux/commit/c7cb196f569b1759e4f2c1801c90228e564b39d8)) 36 | 37 | 38 | 39 | 40 | 41 | ## [1.4.2](https://github.com/kcfe/dobux/compare/v1.4.2-beta.0...v1.4.2) (2021-09-09) 42 | 43 | **Note:** Version bump only for package dobux 44 | 45 | ## [1.4.2-beta.0](https://github.com/kcfe/dobux/compare/v1.4.1...v1.4.2-beta.0) (2021-09-08) 46 | 47 | 48 | ### 🐛 Bug Fixes 49 | 50 | * delete effect subscribe error ([b5ff249](https://github.com/kcfe/dobux/commit/b5ff249fa8ed137b07e24999a09815a961291794)) 51 | 52 | 53 | 54 | 55 | 56 | ## [1.4.1](https://github.com/kcfe/dobux/compare/v1.4.0...v1.4.1) (2021-09-06) 57 | 58 | 59 | ### 🐛 Bug Fixes 60 | 61 | * provider rerender ([88ddfef](https://github.com/kcfe/dobux/commit/88ddfefc7e5cb1bd2eb8b2b1d6664e8c86b9312c)) 62 | 63 | 64 | 65 | 66 | 67 | # [1.4.0](https://github.com/kcfe/dobux/compare/v1.3.1...v1.4.0) (2021-09-02) 68 | 69 | 70 | ### ✨ Features 71 | 72 | * add get effects ([d1378b1](https://github.com/kcfe/dobux/commit/d1378b1b4e4d7545fbbffbc77f4246599b3a3c84)) 73 | * add get reducers ([a41502f](https://github.com/kcfe/dobux/commit/a41502fc0985fdead603076e3cf37d446c2b61cb)) 74 | 75 | 76 | 77 | 78 | 79 | ## [1.3.1](https://github.com/kcfe/dobux/compare/v1.3.1-beta.0...v1.3.1) (2021-08-05) 80 | 81 | 82 | 83 | 84 | 85 | ## [1.3.1-beta.0](https://github.com/kcfe/dobux/compare/v1.3.0...v1.3.1-beta.0) (2021-07-27) 86 | 87 | 88 | ### 🐛 Bug Fixes 89 | 90 | * modify state ([76c214f](https://github.com/kcfe/dobux/commit/76c214feb7d55e86f587e0c49fb30a40a8c58cd4)) 91 | 92 | 93 | 94 | 95 | 96 | # [1.3.0](https://github.com/kcfe/dobux/compare/v1.2.0...v1.3.0) (2021-07-12) 97 | 98 | 99 | ### ✨ Features 100 | 101 | * support setValue & setValues pass function ([7877f83](https://github.com/kcfe/dobux/commit/7877f83c9495ba49ff9c11727253859635e5ca03)) 102 | 103 | 104 | 105 | 106 | 107 | # [1.2.0](https://github.com/kcfe/dobux/compare/v1.1.0...v1.2.0) (2021-07-02) 108 | 109 | 110 | ### ✨ Features 111 | 112 | * add store.withModels to support multi models in Class component ([0915dc9](https://github.com/kcfe/dobux/commit/0915dc9076ee8eed147aa29f5cce9f5864bf1ce9)) 113 | * store.withModel support specific [contextName] ([3b1158e](https://github.com/kcfe/dobux/commit/3b1158e1ee24e6d1c5dc7ff44c89cada351d62e5)) 114 | 115 | 116 | 117 | 118 | 119 | # [1.1.0](https://github.com/kcfe/dobux/compare/v1.0.6...v1.1.0) (2021-06-04) 120 | 121 | 122 | ### ⚡ Performance Improvements 123 | 124 | * add hocs display name ([e96ddf3](https://github.com/kcfe/dobux/commit/e96ddf3487ae6c68ebc6d0c0d5e08d82b0ced35a)) 125 | 126 | 127 | ### ✨ Features 128 | 129 | * support with provider forward ref, closes [#4](https://github.com/kcfe/dobux/issues/4) ([08d8bb0](https://github.com/kcfe/dobux/commit/08d8bb0ce40dfc94db577303e9105c01c76e3b35)) 130 | 131 | 132 | ### 🐛 Bug Fixes 133 | 134 | * create model ts type ([118add3](https://github.com/kcfe/dobux/commit/118add310cecccccf677dff4e720cbf00e2a6992)) 135 | * with provider generic ([bdaf332](https://github.com/kcfe/dobux/commit/bdaf3321628ebfdc9825c0245de3aea7807d2115)) 136 | 137 | 138 | 139 | 140 | 141 | ## [1.0.6](https://github.com/kcfe/dobux/compare/v1.0.5...v1.0.6) (2021-04-14) 142 | 143 | 144 | ### 🐛 Bug Fixes 145 | 146 | * immer return value ([f3b3005](https://github.com/kcfe/dobux/commit/f3b3005ef4eb1c47c3d483fa38ef6f3bbc60f460)) 147 | 148 | 149 | 150 | 151 | 152 | ## [1.0.5](https://github.com/kcfe/dobux/compare/v1.0.5-beta.0...v1.0.5) (2021-04-09) 153 | 154 | 155 | 156 | 157 | 158 | ## [1.0.5-beta.0](https://github.com/kcfe/dobux/compare/v1.0.4...v1.0.5-beta.0) (2021-04-01) 159 | 160 | 161 | 162 | 163 | 164 | ## [1.0.4](https://github.com/kcfe/dobux/compare/v1.0.3...v1.0.4) (2021-03-31) 165 | 166 | 167 | ### 🐛 Bug Fixes 168 | 169 | * peer dependency ([978f1b4](https://github.com/kcfe/dobux/commit/978f1b4012bea884ae4e438e9f85aebe59925494)) 170 | 171 | 172 | 173 | 174 | 175 | ## [1.0.3](https://github.com/kcfe/dobux/compare/v1.0.2...v1.0.3) (2021-02-04) 176 | 177 | 178 | ### 🐛 Bug Fixes 179 | 180 | * redux devtool ([9559dea](https://github.com/kcfe/dobux/commit/9559dea790022bb1714a06b5d357919ad591e07b)) 181 | * ts overload ([81b5eef](https://github.com/kcfe/dobux/commit/81b5eef14ecc59aaaa575599c75063c4f00835a7)) 182 | * ts overload ([66a6b42](https://github.com/kcfe/dobux/commit/66a6b42f753e4e81dc6579ca7053a2d1ee21f1a8)) 183 | 184 | 185 | 186 | 187 | 188 | ## [1.0.2](https://github.com/kcfe/dobux/compare/v1.0.1...v1.0.2) (2021-01-15) 189 | 190 | 191 | ### 🐛 Bug Fixes 192 | 193 | * support strict mode ([9a82fda](https://github.com/kcfe/dobux/commit/9a82fda6c8c2e3cbf5c40bebc0f34a0d106f7ad5)) 194 | 195 | 196 | 197 | 198 | 199 | ## [1.0.1](https://github.com/kcfe/dobux/compare/v1.0.0...v1.0.1) (2021-01-02) 200 | 201 | 202 | 203 | 204 | 205 | # [1.0.0](https://github.com/kwai-efe/dobux/compare/v0.0.1...v1.0.0) (2021-01-01) 206 | 207 | 208 | ### ⚡ Performance Improvements 209 | 210 | * only update when component depend effect loading ([cd2b6e0](https://github.com/kwai-efe/dobux/commit/cd2b6e0bba586180a4376e74ed374d61e3cb9705)) 211 | 212 | 213 | ### ✅ Tests 214 | 215 | * getState ([52d5db8](https://github.com/kwai-efe/dobux/commit/52d5db861ebb029e6c669a3f2ec84b4ab48efa8e)) 216 | * increase coverage ([0339d5e](https://github.com/kwai-efe/dobux/commit/0339d5e20715fd6bea0f8ebdbae02fd9f5f3509f)) 217 | * multiple ([7f3eda0](https://github.com/kwai-efe/dobux/commit/7f3eda0865de792f03df7fdfe7c793702f5bd3fb)) 218 | * provider & index ([c4c6bea](https://github.com/kwai-efe/dobux/commit/c4c6bea16de1dd3303f0ceef0714c2225c0d6c15)) 219 | * reducer & effect ([c1c70b5](https://github.com/kwai-efe/dobux/commit/c1c70b53bc9803b095fd95b20ba11649e655cc18)) 220 | * store & model ([2daa59b](https://github.com/kwai-efe/dobux/commit/2daa59b8e4de9d1871e5cda3b57326f24ec1e654)) 221 | * utils ([6f87f2a](https://github.com/kwai-efe/dobux/commit/6f87f2a250bab27884b8a31a03d4690e093acd25)) 222 | 223 | 224 | ### ✨ Features 225 | 226 | * add Container ([5e95d11](https://github.com/kwai-efe/dobux/commit/5e95d11b8455057f52a8307138416f668de8e57b)) 227 | * add createProvider ([e0381f5](https://github.com/kwai-efe/dobux/commit/e0381f57763ebe04bdaa4cc9ff381668e0a3ec87)) 228 | * add Provider & Consumer ([79ee803](https://github.com/kwai-efe/dobux/commit/79ee803edee50301be291a7edf676b623f18ca8b)) 229 | * add Store & Model ([e35e611](https://github.com/kwai-efe/dobux/commit/e35e6117312391c13be4b448681f0207f96c3e8c)) 230 | * add types ([dc06836](https://github.com/kwai-efe/dobux/commit/dc06836372704ad92e28d4ccf3385210cb2c658d)) 231 | * add utils ([026a26b](https://github.com/kwai-efe/dobux/commit/026a26b518fda377d0c7abd3f74de32b5f5b73c0)) 232 | * parse model ([f81dfa8](https://github.com/kwai-efe/dobux/commit/f81dfa8133158b7a26dfadafe47aa67ede250a0e)) 233 | * support auto reset when unmount ([339b650](https://github.com/kwai-efe/dobux/commit/339b65080fa4eb694d5230c455fe052a1bd05687)) 234 | * support getState ([794aca5](https://github.com/kwai-efe/dobux/commit/794aca5c7fcd8ce4ea56f9add02a39d5b76e73f5)) 235 | * support time travel ([9774e4b](https://github.com/kwai-efe/dobux/commit/9774e4bce23e71e0e71b0ee1e57e8b4a1bcbdde0)) 236 | 237 | 238 | 239 | 240 | 241 | ## 0.0.1 (2020-11-18) 242 | 243 | 244 | ### ✨ Features 245 | 246 | * init project ([7dce606](https://github.com/kwai-ad-fe/dobux/commit/7dce606e8a6be184e6004aba4340fac8ead83160)) -------------------------------------------------------------------------------- /LATESTLOG.md: -------------------------------------------------------------------------------- 1 | ## [Changes](https://github.com/kcfe/dobux/compare/v1.5.0...v1.5.1) (2022-07-13) 2 | 3 | 4 | ### 🐛 Bug Fixes 5 | 6 | * auto reset ([df1ab53](https://github.com/kcfe/dobux/commit/df1ab53ba91e2aacce1fbe7410214bda5b15182a)) 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 kwai-ad-fe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Dobux logo

2 | 3 |

Dobux

4 | 5 |

6 | Lightweight responsive state management solution based on React Context and React Hooks. 7 |

8 | Build Status 9 | Coverage Status 10 | Version 11 | Downloads 12 | Bundle Size 13 | Vulnerabilities 14 |
15 | Peer React 16 | Peer React Dom 17 |

18 | 19 | English | [简体中文](./README.zh-CN.md) 20 | 21 | ## ✨ Features 22 | 23 | - **🎉 Simplify**:Only 3 core APIs, no additional learning cost, easy to get started with the knowledge of `React Hooks`. 24 | - **🚀 Immutable**:Interact with view by simply modifying it while keeping all the benefits of immutable data. 25 | - **🌲 Flexible Usage**:Support global and local data sources, manage the state of the entire application more elegantly. 26 | - **🍳 Friendly Asynchronous Processing**:Record the loading status of asynchronous operations, simplify the presentation logic in the view layer. 27 | - **🍬 TypeScript Support**:Complete `TypeScript` type definition, complete type checking and type inference can be obtained in the editor. 28 | 29 | ## 📦 Installation 30 | 31 | ```bash 32 | // use npm 33 | $ npm i dobux --save 34 | 35 | // use yarn 36 | $ yarn add dobux 37 | ``` 38 | 39 | ## 🔨 Documents 40 | 41 | - [Introduction](https://kcfe.github.io/dobux/guide) 42 | - [Get Started](https://kcfe.github.io/dobux/guide/getting-started) 43 | - [Best Practices](https://kcfe.github.io/dobux/guide/best-practice) 44 | - [API](https://kcfe.github.io/dobux/api) 45 | - [FAQ](https://kcfe.github.io/dobux/guide/faq) 46 | 47 | ## 🔗 Examples 48 | 49 | - [Simple counter](https://kcfe.github.io/dobux/guide/examples#简单的计数器) 50 | - [Todo List](https://kcfe.github.io/dobux/guide/examples#待办事项清单) 51 | 52 | ## 🖥 Version dependency 53 | 54 | - React >= 16.8.0 55 | - ReactDOM >= 16.8.0 56 | 57 | ## 📄 LICENSE 58 | 59 | [MIT](https://github.com/kcfe/dobux/blob/master/LICENSE) 60 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 |

Dobux logo

2 | 3 |

Dobux

4 | 5 |

6 | 基于 React Context 和 React Hooks 的轻量级响应式状态管理方案。 7 |

8 | Build Status 9 | Coverage Status 10 | Version 11 | Downloads 12 | Bundle Size 13 | Vulnerabilities 14 |
15 | Peer React 16 | Peer React Dom 17 |

18 | 19 | [English](./README.md) | 简体中文 20 | 21 | ## ✨ 特性 22 | 23 | - **🎉 简单易用**:仅有 3 个核心 API,无需额外的学习成本,只需要了解 `React Hooks` 24 | - **🚀 不可变数据**:通过简单地修改数据与视图交互,同时保留不可变数据的特性 25 | - **🌲 灵活的使用方式**:支持全局和局部数据源,更优雅的管理整个应用的状态 26 | - **🍳 友好的异步处理**:记录异步操作的加载状态,简化了视图层中的呈现逻辑 27 | - **🍬 TypeScript 支持**:完整的 `TypeScript` 类型定义,在编辑器中能获得完整的类型检查和类型推断 28 | 29 | ## 📦 安装 30 | 31 | ```bash 32 | // 使用 npm 33 | $ npm i dobux --save 34 | 35 | // 使用 yarn 36 | $ yarn add dobux 37 | ``` 38 | 39 | ## 🔨 文档 40 | 41 | - [介绍](https://kcfe.github.io/dobux/guide) 42 | - [快速上手](https://kcfe.github.io/dobux/guide/getting-started) 43 | - [最佳实践](https://kcfe.github.io/dobux/guide/best-practice) 44 | - [API](https://kcfe.github.io/dobux/api) 45 | - [FAQ](https://kcfe.github.io/dobux/guide/faq) 46 | 47 | ## 🔗 示例 48 | 49 | - [简单计数器](https://kcfe.github.io/dobux/guide/examples#简单的计数器) 50 | - [待办事项清单](https://kcfe.github.io/dobux/guide/examples#待办事项清单) 51 | 52 | ## 🖥 版本依赖 53 | 54 | - React >= 16.8.0 55 | - ReactDOM >= 16.8.0 56 | 57 | ## 📄 LICENSE 58 | 59 | [MIT](https://github.com/kcfe/dobux/blob/master/LICENSE) 60 | -------------------------------------------------------------------------------- /__tests__/effect.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createStore } from '../src/index' 3 | import { counter } from './helper/model' 4 | import { createHook } from './helper/createHook' 5 | import { Counter, Counter1 } from './helper/CountFunctionComponent' 6 | import { act, fireEvent, render } from '@testing-library/react' 7 | import { store } from './helper/store' 8 | 9 | console.error = jest.fn() 10 | 11 | jest.setTimeout(10000) 12 | 13 | describe('effect test', () => { 14 | it('effect should be async function', () => { 15 | const store = createStore({ 16 | counter, 17 | }) 18 | const { Provider, useModel } = store 19 | 20 | const { result } = createHook(Provider, useModel, 'counter') 21 | 22 | const increaseAsync = result.current.effects.increaseAsync 23 | 24 | expect(typeof increaseAsync().then).toBe('function') 25 | }) 26 | 27 | it('effect should have expected field', () => { 28 | const store = createStore({ 29 | counter, 30 | }) 31 | const { Provider, useModel } = store 32 | 33 | const { result } = createHook(Provider, useModel, 'counter') 34 | 35 | const increaseAsync = result.current.effects.increaseAsync 36 | 37 | expect(increaseAsync.loading).toBe(false) 38 | expect(increaseAsync.identifier).toBe(0) 39 | }) 40 | 41 | it('effect should throw error when has error', () => { 42 | const store = createStore({ 43 | counter, 44 | }) 45 | const { Provider, useModel } = store 46 | 47 | const { result } = createHook(Provider, useModel, 'counter') 48 | 49 | const fetchError = result.current.effects.fetchError 50 | 51 | return fetchError().catch((err: any) => { 52 | expect(err).toBe('customer error') 53 | }) 54 | }) 55 | 56 | it('should set loading to true when the effect is executed, and after execution, it should be set to false', async () => { 57 | const store = createStore({ 58 | counter, 59 | }) 60 | const { Provider, useModel } = store 61 | 62 | const { result, waitForNextUpdate } = createHook(Provider, useModel, 'counter') 63 | 64 | const increaseAsync = result.current.effects.increaseAsync 65 | 66 | expect(increaseAsync.loading).toBe(false) 67 | increaseAsync() 68 | expect(increaseAsync.loading).toBe(true) 69 | 70 | await waitForNextUpdate() 71 | 72 | expect(increaseAsync.loading).toBe(false) 73 | expect(result.current.state.count).toBe(1) 74 | }) 75 | 76 | it('should only rerender when Component depend effect loading', done => { 77 | const CounterRender = jest.fn() 78 | const Counter1Render = jest.fn() 79 | 80 | // https://spectrum.chat/testing-library/help/is-there-a-way-to-count-the-number-of-times-a-component-gets-rendered~8b8b3f8f-775d-49cc-80fd-baaf40fa37eb 81 | const { getByTestId, queryByText } = render( 82 | 83 | 84 | 85 | 86 | ) 87 | 88 | expect(CounterRender).toBeCalledTimes(1) 89 | expect(Counter1Render).toBeCalledTimes(1) 90 | 91 | expect(queryByText('Loading ...')).not.toBeInTheDocument() 92 | 93 | act(() => { 94 | fireEvent.click(getByTestId('increaseAsync')) 95 | }) 96 | 97 | expect(CounterRender).toBeCalledTimes(2) 98 | expect(Counter1Render).toBeCalledTimes(1) 99 | 100 | expect(queryByText('Loading ...')).toBeInTheDocument() 101 | 102 | setTimeout(() => { 103 | expect(CounterRender).toBeCalledTimes(4) 104 | expect(Counter1Render).toBeCalledTimes(2) 105 | done() 106 | }, 1000) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /__tests__/get-effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react-hooks' 2 | import { createStore } from '../src/index' 3 | import { counter } from './helper/model' 4 | import { createHook } from './helper/createHook' 5 | 6 | describe('getEffects test', () => { 7 | it('call getEffects with no parameter should return all model effects', async () => { 8 | const store = createStore({ 9 | counter, 10 | }) 11 | const { Provider, useModel } = store 12 | 13 | const { result } = createHook(Provider, useModel, 'counter') 14 | 15 | const effects = store.getEffects() 16 | 17 | expect(effects).toHaveProperty('counter') 18 | 19 | await act(async () => { 20 | await effects.counter.increaseAsync() 21 | }) 22 | 23 | expect(result.current.state.count).toBe(1) 24 | 25 | await act(async () => { 26 | await effects.counter.decreaseAsync() 27 | }) 28 | expect(result.current.state.count).toBe(0) 29 | }) 30 | 31 | it('call getEffects with parameter should return specific model state', async () => { 32 | const store = createStore({ 33 | counter, 34 | }) 35 | const { Provider, useModel } = store 36 | 37 | const { result } = createHook(Provider, useModel, 'counter') 38 | 39 | const effects = store.getEffects('counter') 40 | 41 | expect(typeof effects.increaseAsync === 'function').toBeTruthy() 42 | expect(typeof effects.decreaseAsync === 'function').toBeTruthy() 43 | 44 | await act(async () => { 45 | await effects.increaseAsync() 46 | }) 47 | expect(result.current.state.count).toBe(1) 48 | 49 | await act(async () => { 50 | await effects.decreaseAsync() 51 | }) 52 | expect(result.current.state.count).toBe(0) 53 | }) 54 | 55 | it('call getEffects with not exist parameter should throw error', () => { 56 | const store = createStore({ 57 | counter, 58 | }) 59 | 60 | // @ts-ignore 61 | expect(() => store.getEffects('counter1')).toThrow( 62 | 'Invariant Failed: [store.getEffects] Expected the modelName to be one of counter, but got counter1' 63 | ) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /__tests__/get-reducers.spec.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react-hooks' 2 | import { createStore } from '../src/index' 3 | import { counter } from './helper/model' 4 | import { createHook } from './helper/createHook' 5 | 6 | describe('getReducers test', () => { 7 | it('call getReducers with no parameter should return all model reducers', () => { 8 | const store = createStore({ 9 | counter, 10 | }) 11 | const { Provider, useModel } = store 12 | 13 | const { result } = createHook(Provider, useModel, 'counter') 14 | 15 | const reducers = store.getReducers() 16 | 17 | expect(reducers).toHaveProperty('counter') 18 | 19 | act(() => { 20 | reducers.counter.increase() 21 | }) 22 | expect(result.current.state.count).toBe(1) 23 | 24 | act(() => { 25 | reducers.counter.decrease() 26 | }) 27 | expect(result.current.state.count).toBe(0) 28 | }) 29 | 30 | it('call getReducers with parameter should return specific model state', () => { 31 | const store = createStore({ 32 | counter, 33 | }) 34 | const { Provider, useModel } = store 35 | 36 | const { result } = createHook(Provider, useModel, 'counter') 37 | 38 | const reducers = store.getReducers('counter') 39 | 40 | expect(typeof reducers.increase === 'function').toBeTruthy() 41 | expect(typeof reducers.decrease === 'function').toBeTruthy() 42 | expect(typeof reducers.setValue === 'function').toBeTruthy() 43 | expect(typeof reducers.setValues === 'function').toBeTruthy() 44 | expect(typeof reducers.reset === 'function').toBeTruthy() 45 | 46 | act(() => { 47 | reducers.increase() 48 | }) 49 | expect(result.current.state.count).toBe(1) 50 | 51 | act(() => { 52 | reducers.decrease() 53 | }) 54 | expect(result.current.state.count).toBe(0) 55 | }) 56 | 57 | it('call getReducers with not exist parameter should throw error', () => { 58 | const store = createStore({ 59 | counter, 60 | }) 61 | 62 | // @ts-ignore 63 | expect(() => store.getReducers('counter1')).toThrow( 64 | 'Invariant Failed: [store.getReducers] Expected the modelName to be one of counter, but got counter1' 65 | ) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /__tests__/get-state.spec.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react-hooks' 2 | import { createStore } from '../src/index' 3 | import { counter } from './helper/model' 4 | import { createHook } from './helper/createHook' 5 | 6 | describe('getState test', () => { 7 | it('call getState with no parameter should return all model state', () => { 8 | const store = createStore({ 9 | counter, 10 | }) 11 | const { Provider, useModel } = store 12 | 13 | const { result } = createHook(Provider, useModel, 'counter') 14 | 15 | const increase = result.current.reducers.increase 16 | const decrease = result.current.reducers.decrease 17 | 18 | expect(store.getState()).toEqual({ 19 | counter: { 20 | count: 0, 21 | data: { 22 | a: 1, 23 | b: '2', 24 | }, 25 | }, 26 | }) 27 | 28 | act(() => { 29 | increase() 30 | }) 31 | expect(result.current.state.count).toBe(1) 32 | 33 | expect(store.getState()).toEqual({ 34 | counter: { 35 | count: 1, 36 | data: { 37 | a: 1, 38 | b: '2', 39 | }, 40 | }, 41 | }) 42 | 43 | act(() => { 44 | decrease() 45 | }) 46 | expect(store.getState()).toEqual({ 47 | counter: { 48 | count: 0, 49 | data: { 50 | a: 1, 51 | b: '2', 52 | }, 53 | }, 54 | }) 55 | }) 56 | 57 | it('call getState with parameter should return specific model state', () => { 58 | const store = createStore({ 59 | counter, 60 | }) 61 | const { Provider, useModel } = store 62 | 63 | const { result } = createHook(Provider, useModel, 'counter') 64 | 65 | const increase = result.current.reducers.increase 66 | const decrease = result.current.reducers.decrease 67 | 68 | expect(store.getState('counter')).toEqual({ 69 | count: 0, 70 | data: { 71 | a: 1, 72 | b: '2', 73 | }, 74 | }) 75 | 76 | act(() => { 77 | increase() 78 | }) 79 | expect(result.current.state.count).toBe(1) 80 | 81 | expect(store.getState('counter')).toEqual({ 82 | count: 1, 83 | data: { 84 | a: 1, 85 | b: '2', 86 | }, 87 | }) 88 | 89 | act(() => { 90 | decrease() 91 | }) 92 | expect(store.getState('counter')).toEqual({ 93 | count: 0, 94 | data: { 95 | a: 1, 96 | b: '2', 97 | }, 98 | }) 99 | }) 100 | 101 | it('call getState with not exist parameter should throw error', () => { 102 | const store = createStore({ 103 | counter, 104 | }) 105 | 106 | // @ts-ignore 107 | expect(() => store.getState('counter1')).toThrow( 108 | 'Invariant Failed: [store.getState] Expected the modelName to be one of counter, but got counter1' 109 | ) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /__tests__/helper/CountClassComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { RootModel } from './store' 3 | 4 | interface CounterProps { 5 | state: RootModel['counter']['state'] 6 | reducers: RootModel['counter']['reducers'] 7 | effects: RootModel['counter']['effects'] 8 | } 9 | 10 | export class Counter extends React.Component { 11 | render() { 12 | const { state, reducers, effects } = this.props 13 | 14 | return ( 15 |
16 |
{state.count}
17 |
18 |
19 |
20 |
21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /__tests__/helper/CountFunctionComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useImperativeHandle } from 'react' 2 | import { store } from './store' 3 | 4 | export interface CounterProps { 5 | onRender?: () => void 6 | } 7 | 8 | export const Counter: React.FC = ({ onRender }) => { 9 | const { state, reducers, effects } = store.useModel('counter') 10 | 11 | if (onRender) { 12 | onRender() 13 | } 14 | 15 | if (effects.increaseAsync.loading) { 16 | return
Loading ...
17 | } 18 | 19 | return ( 20 |
21 |
{state.count}
22 |
23 |
24 |
25 |
26 | ) 27 | } 28 | 29 | export const Counter1: React.FC = ({ onRender }) => { 30 | const { state, reducers, effects } = store.useModel('counter') 31 | 32 | if (onRender) { 33 | onRender() 34 | } 35 | 36 | return ( 37 |
38 |
{state.count}
39 |
40 |
41 |
42 |
43 | ) 44 | } 45 | 46 | export const CounterWithRef = store.withProviderForwardRef( 47 | React.forwardRef((props, ref) => { 48 | useImperativeHandle(ref, () => { 49 | return { 50 | methodFromUseImperativeHandle: () => true, 51 | } 52 | }) 53 | 54 | return <> 55 | }) 56 | ) 57 | -------------------------------------------------------------------------------- /__tests__/helper/MultiCountClassComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { RootModel } from './store' 3 | 4 | interface CounterProps { 5 | [c: string]: RootModel 6 | } 7 | 8 | export class CounterWithContextName extends React.Component { 9 | render() { 10 | const { 11 | forDobux: { 12 | counter, 13 | counter2 14 | } 15 | } = this.props 16 | 17 | return ( 18 |
19 |
{counter.state.count}
20 |
21 |
22 |
23 | 24 |
{counter2.state.count}
25 |
26 |
27 |
28 |
29 | ) 30 | } 31 | } 32 | 33 | export class CounterWithDefault extends React.Component { 34 | render() { 35 | const { 36 | models: { 37 | counter, 38 | counter2 39 | } 40 | } = this.props 41 | 42 | return ( 43 |
44 |
{counter.state.count}
45 |
46 |
47 |
48 | 49 |
{counter2.state.count}
50 |
51 |
52 |
53 |
54 | ) 55 | } 56 | } 57 | 58 | export class CounterWithSameContextName extends React.Component<{ 59 | models: string, 60 | myModel: any 61 | }> { 62 | render() { 63 | return
{this.props.models}
64 | } 65 | } 66 | 67 | export class CounterWithOtherContextName extends React.Component<{ 68 | myProp: string, 69 | myModel: any 70 | }> { 71 | render() { 72 | return
73 |
{this.props.myProp}
74 |
{this.props.myModel.state.count}
75 |
76 | } 77 | } 78 | -------------------------------------------------------------------------------- /__tests__/helper/createHook.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { renderHook } from '@testing-library/react-hooks' 3 | import { MapStateToModel } from '../../src/types' 4 | 5 | export function createHook( 6 | Provider: React.FC, 7 | hook: any, 8 | namespace?: string, 9 | mapStateToModel?: MapStateToModel 10 | ) { 11 | // https://react-hooks-testing-library.com/usage/advanced-hooks#context 12 | return renderHook(() => hook(namespace, mapStateToModel), { 13 | wrapper: props => {props.children}, 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /__tests__/helper/model.ts: -------------------------------------------------------------------------------- 1 | import { createModel } from '../../src' 2 | import { RootModel } from './store' 3 | import { config } from './shared' 4 | 5 | export const counter = createModel()(config) 6 | 7 | export const counter2 = createModel()(config) 8 | -------------------------------------------------------------------------------- /__tests__/helper/ref.ts: -------------------------------------------------------------------------------- 1 | export const unsafeGetTestingRef = (tree, component) => { 2 | // Unsafe way to get access to a ref property. Uses internal _fiber property of a ReactTestingInstance since AFAIK react-test-renderer does not expose refs in any other way 3 | const node = tree.root.findByType(component) 4 | 5 | expect(node).not.toBeNull() 6 | expect(node._fiber).not.toBeNull() 7 | expect(node._fiber).not.toBeUndefined() 8 | 9 | const ref = node._fiber.ref 10 | 11 | expect(ref).not.toBeNull() 12 | expect(ref).not.toBeUndefined() 13 | 14 | return ref 15 | } 16 | -------------------------------------------------------------------------------- /__tests__/helper/shared.ts: -------------------------------------------------------------------------------- 1 | export function wait(ms: number) { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, ms) 4 | }) 5 | } 6 | 7 | export const defaultStoreOptions = { 8 | autoReset: false, 9 | devtools: true, 10 | name: 'dobuxStore', 11 | } 12 | 13 | export const config = { 14 | state: { 15 | count: 0, 16 | data: { 17 | a: 1, 18 | b: '2', 19 | }, 20 | }, 21 | reducers: { 22 | increase(state: any) { 23 | state.count++ 24 | }, 25 | decrease(state: any) { 26 | state.count-- 27 | }, 28 | }, 29 | effects: (store: any, rootStore: any) => ({ 30 | async increaseAsync() { 31 | await wait(10) 32 | store.reducers.increase() 33 | }, 34 | 35 | async decreaseAsync() { 36 | await wait(10) 37 | store.reducers.decrease() 38 | }, 39 | 40 | async fetchError() { 41 | return new Promise((_, reject) => { 42 | reject('customer error') 43 | }) 44 | }, 45 | }), 46 | } 47 | 48 | export const defaultModelOptions = { 49 | storeName: 'dobuxStore', 50 | name: 'counter', 51 | config: { 52 | ...config, 53 | effects: {}, 54 | }, 55 | rootModel: Object.create(null), 56 | autoReset: false, 57 | devTools: true, 58 | } 59 | -------------------------------------------------------------------------------- /__tests__/helper/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, Models } from '../../src' 2 | import * as models from './model' 3 | 4 | export type RootModel = Models 5 | 6 | export const store = createStore({ ...models }) 7 | -------------------------------------------------------------------------------- /__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { createStore, createModel } from '../src' 2 | import { Store } from '../src/core/Store' 3 | import { config, defaultStoreOptions } from './helper/shared' 4 | 5 | describe('entry test', () => { 6 | it('createStore should be defined', () => { 7 | expect(createStore).toBeDefined() 8 | }) 9 | 10 | it('should return the instance of Store when call createStore', () => { 11 | const store = createStore( 12 | { 13 | counter: config, 14 | }, 15 | defaultStoreOptions 16 | ) 17 | 18 | expect(store).toBeInstanceOf(Store) 19 | }) 20 | 21 | it('should pass boolean to autoReset', () => { 22 | const store = createStore( 23 | { 24 | counter: config, 25 | }, 26 | { 27 | autoReset: true, 28 | } 29 | ) 30 | 31 | expect(store).toBeInstanceOf(Store) 32 | }) 33 | 34 | it('should pass array to autoReset', () => { 35 | const store = createStore( 36 | { 37 | counter: config, 38 | }, 39 | { 40 | autoReset: ['counter'], 41 | } 42 | ) 43 | 44 | expect(store).toBeInstanceOf(Store) 45 | }) 46 | 47 | it('should pass boolean to devtools', () => { 48 | const store = createStore( 49 | { 50 | counter: config, 51 | }, 52 | { 53 | devtools: true, 54 | } 55 | ) 56 | 57 | expect(store).toBeInstanceOf(Store) 58 | }) 59 | 60 | it('should pass array to devtools', () => { 61 | const store = createStore( 62 | { 63 | counter: config, 64 | }, 65 | { 66 | devtools: ['counter'], 67 | } 68 | ) 69 | 70 | expect(store).toBeInstanceOf(Store) 71 | }) 72 | 73 | it('createModel should be defined', () => { 74 | expect(createModel).toBeDefined() 75 | }) 76 | 77 | it('should return the default value when config is invalid', () => { 78 | const store = createModel()({ 79 | state: { 80 | count: 0, 81 | }, 82 | }) 83 | 84 | expect(store.state).toEqual({ count: 0 }) 85 | expect(store.reducers).toEqual({}) 86 | expect(typeof store.effects).toBe('function') 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /__tests__/model.spec.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../src/core/Model' 2 | 3 | describe('Model test', () => { 4 | it('Model should be defined', () => { 5 | expect(Model).toBeDefined() 6 | expect(Model.prototype.constructor).toBe(Model) 7 | }) 8 | 9 | it('should have valid api', () => { 10 | expect(Object.keys(Model)).toEqual(['instances']) 11 | expect(typeof Model.prototype.useModel).toBe('function') 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /__tests__/multiple.spec.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react-hooks' 2 | import { createStore } from '../src/index' 3 | import { counter } from './helper/model' 4 | import { createHook } from './helper/createHook' 5 | 6 | describe('multiple stores test', () => { 7 | it('should multiple stores to be isolated', () => { 8 | const { Provider: Provider1, useModel: useModel1 } = createStore({ 9 | counter, 10 | }) 11 | const { Provider: Provider2, useModel: useModel2 } = createStore({ 12 | counter, 13 | }) 14 | 15 | const { result: result1 } = createHook(Provider1, useModel1, 'counter') 16 | const { result: result2 } = createHook(Provider2, useModel2, 'counter') 17 | 18 | expect(result1.current.state.count).toBe(0) 19 | expect(result2.current.state.count).toBe(0) 20 | }) 21 | 22 | it('should be able to exec specific stores', () => { 23 | const { Provider: Provider1, useModel: useModel1 } = createStore({ 24 | counter, 25 | }) 26 | const { Provider: Provider2, useModel: useModel2 } = createStore({ 27 | counter, 28 | }) 29 | 30 | const { result: result1 } = createHook(Provider1, useModel1, 'counter') 31 | const { result: result2 } = createHook(Provider2, useModel2, 'counter') 32 | 33 | act(() => { 34 | result1.current.reducers.increase() 35 | result2.current.reducers.decrease() 36 | }) 37 | 38 | expect(result1.current.state.count).toBe(1) 39 | expect(result2.current.state.count).toBe(-1) 40 | }) 41 | 42 | it('should share state when use same model', async () => { 43 | const { Provider, useModel } = createStore({ 44 | counter, 45 | }) 46 | 47 | const { result: result1, waitForNextUpdate } = createHook(Provider, useModel, 'counter') 48 | const { result: result2 } = createHook(Provider, useModel, 'counter') 49 | 50 | expect(result1.current.state.count).toBe(0) 51 | expect(result2.current.state.count).toBe(0) 52 | 53 | act(() => { 54 | result1.current.reducers.increase() 55 | }) 56 | 57 | expect(result1.current.state.count).toBe(1) 58 | expect(result2.current.state.count).toBe(1) 59 | 60 | act(() => { 61 | result2.current.reducers.decrease() 62 | }) 63 | 64 | expect(result1.current.state.count).toBe(0) 65 | expect(result2.current.state.count).toBe(0) 66 | 67 | expect(result1.current.effects.increaseAsync.loading).toBeFalsy() 68 | expect(result2.current.effects.increaseAsync.loading).toBeFalsy() 69 | 70 | act(() => { 71 | result1.current.effects.increaseAsync() 72 | }) 73 | expect(result1.current.effects.increaseAsync.loading).toBeTruthy() 74 | expect(result2.current.effects.increaseAsync.loading).toBeTruthy() 75 | 76 | await waitForNextUpdate() 77 | 78 | expect(result1.current.effects.increaseAsync.loading).toBeFalsy() 79 | expect(result2.current.effects.increaseAsync.loading).toBeFalsy() 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /__tests__/provider.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from '@testing-library/react' 3 | import { act } from '@testing-library/react-hooks' 4 | import { createStore } from '../src/index' 5 | import { counter, counter2 } from './helper/model' 6 | import { createHook } from './helper/createHook' 7 | import { CounterWithRef } from './helper/CountFunctionComponent' 8 | import { Counter } from './helper/CountClassComponent' 9 | import { 10 | CounterWithContextName, 11 | CounterWithDefault, 12 | CounterWithSameContextName, 13 | CounterWithOtherContextName, 14 | } from './helper/MultiCountClassComponent' 15 | 16 | describe('Provider test', () => { 17 | it('should render correct when use provider in component', () => { 18 | const store = createStore({ 19 | counter, 20 | }) 21 | const { Provider, withProvider } = store 22 | 23 | const Component: React.FC = () =>
24 | 25 | expect(() => { 26 | render( 27 | 28 | 29 | 30 | ) 31 | }).not.toThrow() 32 | 33 | const WithProvider = withProvider(Component) 34 | 35 | expect(() => { 36 | render() 37 | }).not.toThrow() 38 | }) 39 | 40 | it('should throw error when call useModel without Provider wrapper', () => { 41 | const originalError = console.error 42 | console.error = jest.fn() 43 | 44 | const store = createStore({ 45 | counter, 46 | }) 47 | const { useModel } = store 48 | 49 | const ErrorCounter: React.FC = () => { 50 | const { state } = useModel('counter') 51 | return
{state.count}
52 | } 53 | 54 | expect(() => render()).toThrow() 55 | 56 | console.error = originalError 57 | }) 58 | 59 | it('support pass ref use withProviderForwardRef', () => { 60 | let ref: any 61 | render( (ref = r)} />) 62 | 63 | expect(ref.methodFromUseImperativeHandle).toBeDefined() 64 | }) 65 | 66 | it('should add the store to function component context', () => { 67 | const store = createStore({ 68 | counter, 69 | }) 70 | const { Provider, useModel } = store 71 | 72 | const { 73 | result: { current }, 74 | } = createHook(Provider, useModel, 'counter') 75 | 76 | expect(current.state.count).toBe(0) 77 | expect(current.reducers).toBeDefined() 78 | expect(current.effects).toBeDefined() 79 | }) 80 | 81 | it('should add the store to class component context', () => { 82 | const originalWarn = console.warn 83 | console.warn = jest.fn() 84 | 85 | const store = createStore({ 86 | counter, 87 | }) 88 | const { Provider, withModel } = store 89 | 90 | const Component = withModel('counter')(Counter) 91 | 92 | const wrapper = render( 93 | 94 | 95 | 96 | ) 97 | 98 | expect(wrapper.getByTestId('count').innerHTML).toBe('0') 99 | 100 | const Component2 = withModel('counter', undefined, 'models')(CounterWithSameContextName) 101 | const wrapper2 = render( 102 | 103 | {/* @ts-ignore */} 104 | 105 | 106 | ) 107 | expect(console.warn).toHaveBeenCalledWith( 108 | 'IMPORT MODEL FAILED: The component wrapped by [withModel] already has "models" in its props.' 109 | ) 110 | expect(wrapper2.getByTestId('show-models').innerHTML).toBe('what ever') 111 | 112 | const Component3 = withModel('counter')(Counter) 113 | 114 | render( 115 | 116 | {/* @ts-ignore */} 117 | 118 | 119 | ) 120 | expect(console.warn).toHaveBeenCalledWith( 121 | 'IMPORT MODEL FAILED: The component wrapped by [withModel] already has "state" in its props.' 122 | ) 123 | expect(console.warn).toHaveBeenCalledWith( 124 | 'IMPORT MODEL FAILED: The component wrapped by [withModel] already has "reducers" in its props.' 125 | ) 126 | expect(console.warn).toHaveBeenCalledWith( 127 | 'IMPORT MODEL FAILED: The component wrapped by [withModel] already has "effects" in its props.' 128 | ) 129 | 130 | const Component4 = withModel('counter', undefined, 'myModel')(CounterWithOtherContextName) 131 | const wrapper4 = render( 132 | 133 | {/* @ts-ignore */} 134 | 135 | 136 | ) 137 | expect(wrapper4.getByTestId('show-myProp').innerHTML).toBe('what ever') 138 | expect(wrapper4.getByTestId('show-myModel').innerHTML).toBe('0') 139 | 140 | console.warn = originalWarn 141 | }) 142 | 143 | it('should warning when props override', () => { 144 | const originalWarn = console.warn 145 | console.warn = jest.fn() 146 | 147 | const store = createStore({ 148 | counter, 149 | }) 150 | const { Provider, withModel } = store 151 | 152 | const Component = withModel('counter')(Counter) 153 | 154 | const wrapper = render( 155 | 156 | 157 | 158 | ) 159 | 160 | expect(wrapper.getByTestId('count').innerHTML).toBe('0') 161 | 162 | const Component2 = withModel('counter', undefined, 'models')(CounterWithSameContextName) 163 | const wrapper2 = render( 164 | 165 | {/* @ts-ignore */} 166 | 167 | 168 | ) 169 | expect(console.warn).toHaveBeenCalledWith( 170 | 'IMPORT MODEL FAILED: The component wrapped by [withModel] already has "models" in its props.' 171 | ) 172 | expect(wrapper2.getByTestId('show-models').innerHTML).toBe('what ever') 173 | 174 | const Component3 = withModel('counter', undefined, 'myModel')(CounterWithOtherContextName) 175 | const wrapper3 = render( 176 | 177 | {/* @ts-ignore */} 178 | 179 | 180 | ) 181 | expect(wrapper3.getByTestId('show-myProp').innerHTML).toBe('what ever') 182 | expect(wrapper3.getByTestId('show-myModel').innerHTML).toBe('0') 183 | 184 | console.warn = originalWarn 185 | }) 186 | 187 | it('should add specific stores to class component context', () => { 188 | const store = createStore({ 189 | counter, 190 | counter2, 191 | }) 192 | const { Provider, withModels } = store 193 | 194 | const Component2 = withModels(['counter', 'counter2'])(CounterWithDefault) 195 | 196 | const wrapper2 = render( 197 | 198 | 199 | 200 | ) 201 | expect(wrapper2.getByTestId('count-1').innerHTML).toBe('0') 202 | expect(wrapper2.getByTestId('count-2').innerHTML).toBe('0') 203 | }) 204 | 205 | it('should add specific stores to class component context with custom property', () => { 206 | const originalWarn = console.warn 207 | console.warn = jest.fn() 208 | 209 | const store = createStore({ 210 | counter, 211 | counter2, 212 | }) 213 | const { Provider, withModels } = store 214 | 215 | const Component = withModels( 216 | ['counter', 'counter2'], 217 | { 218 | counter: state => ({ 219 | count: state.count, 220 | }), 221 | }, 222 | 'forDobux' 223 | )(CounterWithContextName) 224 | 225 | const wrapper = render( 226 | 227 | 228 | 229 | ) 230 | expect(wrapper.getByTestId('count-1').innerHTML).toBe('0') 231 | expect(wrapper.getByTestId('count-2').innerHTML).toBe('0') 232 | 233 | const Component2 = withModels(['counter', 'counter2'])(CounterWithSameContextName) 234 | 235 | const wrapper2 = render( 236 | 237 | {/* @ts-ignore */} 238 | 239 | 240 | ) 241 | 242 | expect(console.warn).toHaveBeenCalledWith( 243 | 'IMPORT MODELS FAILED: The component wrapped by [withModels] already has "models" in its props.' 244 | ) 245 | expect(wrapper2.getByTestId('show-models').innerHTML).toBe('correct answer') 246 | 247 | console.warn = originalWarn 248 | }) 249 | 250 | it('should not reset store when component unmount', async () => { 251 | const store = createStore({ 252 | counter, 253 | }) 254 | const { Provider, useModel } = store 255 | const { result, unmount } = createHook(Provider, useModel, 'counter') 256 | 257 | act(() => { 258 | expect(result.current.state.count).toBe(0) 259 | result.current.reducers.increase() 260 | }) 261 | 262 | expect(result.current.state.count).toBe(1) 263 | 264 | unmount() 265 | 266 | const { 267 | result: { current }, 268 | } = createHook(Provider, useModel, 'counter') 269 | 270 | expect(current.state.count).toBe(1) 271 | }) 272 | 273 | it('setting autoReset to true, model should be reset when the component unmount', async () => { 274 | const store = createStore( 275 | { 276 | counter, 277 | }, 278 | { 279 | autoReset: true, 280 | } 281 | ) 282 | const { Provider, useModel } = store 283 | const { result, unmount } = createHook(Provider, useModel, 'counter') 284 | 285 | act(() => { 286 | expect(result.current.state.count).toBe(0) 287 | result.current.reducers.increase() 288 | }) 289 | 290 | expect(result.current.state.count).toBe(1) 291 | 292 | unmount() 293 | 294 | const { 295 | result: { current }, 296 | } = createHook(Provider, useModel, 'counter') 297 | 298 | expect(current.state.count).toBe(0) 299 | }) 300 | 301 | it('setting autoReset to specify model, should be reset when the component unmount', async () => { 302 | const store = createStore( 303 | { 304 | counter, 305 | }, 306 | { 307 | autoReset: ['counter'], 308 | } 309 | ) 310 | const { Provider, useModel } = store 311 | const { result, unmount } = createHook(Provider, useModel, 'counter') 312 | 313 | act(() => { 314 | expect(result.current.state.count).toBe(0) 315 | result.current.reducers.increase() 316 | }) 317 | 318 | expect(result.current.state.count).toBe(1) 319 | 320 | unmount() 321 | 322 | const hook = createHook(Provider, useModel, 'counter') 323 | 324 | expect(hook.result.current.state.count).toBe(0) 325 | }) 326 | }) 327 | -------------------------------------------------------------------------------- /__tests__/reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react-hooks' 2 | import { createStore } from '../src/index' 3 | import { counter } from './helper/model' 4 | import { createHook } from './helper/createHook' 5 | 6 | describe('reducer test', () => { 7 | it('customize reducers execution results should be as expected', () => { 8 | const store = createStore({ 9 | counter, 10 | }) 11 | const { Provider, useModel } = store 12 | 13 | const { result } = createHook(Provider, useModel, 'counter') 14 | 15 | const increase = result.current.reducers.increase 16 | const decrease = result.current.reducers.decrease 17 | 18 | expect(increase.length).toBe(0) 19 | 20 | act(() => { 21 | increase() 22 | }) 23 | expect(result.current.state.count).toBe(1) 24 | 25 | act(() => { 26 | decrease() 27 | }) 28 | expect(result.current.state.count).toBe(0) 29 | }) 30 | 31 | it('should not rerender when the value of mapStateToProps returned not modified', () => { 32 | const store = createStore({ 33 | counter, 34 | }) 35 | const { Provider, useModel } = store 36 | 37 | const { result } = createHook(Provider, useModel, 'counter', (state: any) => { 38 | return { 39 | count: state.count, 40 | } 41 | }) 42 | 43 | const increase = result.current.reducers.increase 44 | const decrease = result.current.reducers.decrease 45 | 46 | expect(result.current.state.count).toBe(0) 47 | expect(result.current.state.data).toBeUndefined() 48 | expect(increase.length).toBe(0) 49 | 50 | act(() => { 51 | increase() 52 | }) 53 | expect(result.current.state.count).toBe(1) 54 | 55 | act(() => { 56 | decrease() 57 | }) 58 | expect(result.current.state.count).toBe(0) 59 | 60 | act(() => { 61 | result.current.reducers.setValue('data', 1) 62 | }) 63 | 64 | expect(result.current.state.data).toBeUndefined() 65 | }) 66 | 67 | it('should provider build-in reducers when no customize passed', () => { 68 | const store = createStore({ 69 | counter, 70 | }) 71 | const { Provider, useModel } = store 72 | 73 | const { result } = createHook(Provider, useModel, 'counter') 74 | 75 | const setValue = result.current.reducers.setValue 76 | const setValues = result.current.reducers.setValues 77 | const reset = result.current.reducers.reset 78 | 79 | expect(setValue.length).toBe(2) 80 | act(() => { 81 | setValue('count', 1) 82 | }) 83 | expect(result.current.state.count).toBe(1) 84 | 85 | act(() => { 86 | setValue('count', (prevState: number) => { 87 | return prevState - 1 88 | }) 89 | }) 90 | expect(result.current.state.count).toBe(0) 91 | 92 | act(() => { 93 | setValue('data', (draft: any) => { 94 | draft.a = 2 95 | draft.b = '3' 96 | }) 97 | }) 98 | expect(result.current.state.data).toEqual({ 99 | a: 2, 100 | b: '3', 101 | }) 102 | 103 | expect(setValues.length).toBe(1) 104 | act(() => { 105 | setValues({ 106 | count: 10, 107 | }) 108 | }) 109 | expect(result.current.state.count).toBe(10) 110 | 111 | act(() => { 112 | setValues((prevState: Record) => { 113 | prevState.count = prevState.count - 10 114 | }) 115 | }) 116 | expect(result.current.state.count).toBe(0) 117 | 118 | act(() => { 119 | setValues((prevState: Record) => { 120 | return { 121 | count: prevState.count + 1, 122 | } 123 | }) 124 | }) 125 | expect(result.current.state.count).toBe(1) 126 | 127 | expect(reset.length).toBe(1) 128 | act(() => { 129 | reset('count') 130 | }) 131 | expect(result.current.state.count).toBe(0) 132 | 133 | act(() => { 134 | setValues({ 135 | data: 1, 136 | count: 10, 137 | }) 138 | }) 139 | 140 | expect(result.current.state.count).toBe(10) 141 | expect(result.current.state.data).toBe(1) 142 | 143 | act(() => { 144 | reset() 145 | }) 146 | 147 | expect(result.current.state.count).toBe(0) 148 | expect(result.current.state.data).toEqual({ 149 | a: 1, 150 | b: '2', 151 | }) 152 | }) 153 | 154 | it('should provider build-in reducers when no customize passed', () => { 155 | const store = createStore({ 156 | counter, 157 | }) 158 | const { Provider, useModel } = store 159 | 160 | const { result } = createHook(Provider, useModel, 'counter') 161 | 162 | const setValue = result.current.reducers.setValue 163 | const setValues = result.current.reducers.setValues 164 | const reset = result.current.reducers.reset 165 | 166 | expect(setValue.length).toBe(2) 167 | act(() => { 168 | setValue('count', 1) 169 | }) 170 | expect(result.current.state.count).toBe(1) 171 | 172 | act(() => { 173 | setValue('count', (prevState: number) => { 174 | return prevState - 1 175 | }) 176 | }) 177 | expect(result.current.state.count).toBe(0) 178 | 179 | expect(setValues.length).toBe(1) 180 | act(() => { 181 | setValues({ 182 | count: 10, 183 | }) 184 | }) 185 | expect(result.current.state.count).toBe(10) 186 | 187 | act(() => { 188 | setValues((prevState: Record) => { 189 | prevState.count = prevState.count - 10 190 | }) 191 | }) 192 | expect(result.current.state.count).toBe(0) 193 | 194 | expect(reset.length).toBe(1) 195 | act(() => { 196 | reset('count') 197 | }) 198 | expect(result.current.state.count).toBe(0) 199 | 200 | act(() => { 201 | setValues({ 202 | data: 1, 203 | count: 10, 204 | }) 205 | }) 206 | 207 | expect(result.current.state.count).toBe(10) 208 | expect(result.current.state.data).toBe(1) 209 | 210 | act(() => { 211 | reset() 212 | }) 213 | 214 | expect(result.current.state.count).toBe(0) 215 | expect(result.current.state.data).toEqual({ 216 | a: 1, 217 | b: '2', 218 | }) 219 | }) 220 | 221 | it('should overwrite build-in reducers when customize passed', () => { 222 | const store = createStore({ 223 | counter: { 224 | state: { 225 | count: 10, 226 | }, 227 | reducers: { 228 | setValue(state, payload) { 229 | state.count = payload + 1 230 | }, 231 | 232 | setValues(state, partialState) { 233 | Object.keys(partialState).forEach(key => { 234 | state[key] = partialState[key] + 1 235 | }) 236 | }, 237 | 238 | reset(state) { 239 | state.count = 10 240 | }, 241 | }, 242 | effects: () => ({}), 243 | }, 244 | }) 245 | const { Provider, useModel } = store 246 | 247 | const { result } = createHook(Provider, useModel, 'counter') 248 | 249 | const setValue = result.current.reducers.setValue 250 | const setValues = result.current.reducers.setValues 251 | const reset = result.current.reducers.reset 252 | 253 | act(() => { 254 | setValue(1) 255 | }) 256 | expect(result.current.state.count).toBe(2) 257 | 258 | act(() => { 259 | setValues({ 260 | count: 10, 261 | }) 262 | }) 263 | expect(result.current.state.count).toBe(11) 264 | 265 | act(() => { 266 | reset() 267 | }) 268 | expect(result.current.state.count).toBe(10) 269 | }) 270 | }) 271 | -------------------------------------------------------------------------------- /__tests__/store.spec.ts: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react-hooks' 2 | import { createModel } from '../src' 3 | import { Store } from '../src/core/Store' 4 | import { createHook } from './helper/createHook' 5 | import { defaultStoreOptions } from './helper/shared' 6 | 7 | const store = new Store( 8 | { 9 | test: createModel()({ 10 | state: { 11 | count: 1, 12 | }, 13 | }), 14 | }, 15 | defaultStoreOptions 16 | ) 17 | 18 | describe('Store test', () => { 19 | it('Store should be defined', () => { 20 | expect(Store).toBeDefined() 21 | expect(Store.prototype.constructor).toBe(Store) 22 | }) 23 | 24 | it('should have valid api', () => { 25 | const methods = Object.keys(store) 26 | 27 | expect(methods).toContain('Provider') 28 | expect(methods).toContain('withProvider') 29 | expect(methods).toContain('withProviderForwardRef') 30 | expect(methods).toContain('useModel') 31 | expect(methods).toContain('withModel') 32 | expect(methods).toContain('withModels') 33 | expect(methods).toContain('getState') 34 | expect(methods).toContain('getReducers') 35 | }) 36 | 37 | it('state can be primitive value', () => { 38 | const { Provider, useModel } = new Store( 39 | { 40 | counter: { 41 | state: 1, 42 | reducers: { 43 | increase() { 44 | return 2 45 | }, 46 | }, 47 | effects: () => ({}), 48 | }, 49 | }, 50 | defaultStoreOptions 51 | ) 52 | 53 | const { result } = createHook(Provider, useModel, 'counter') 54 | 55 | expect(result.current.state).toBe(1) 56 | 57 | act(() => { 58 | result.current.reducers.increase() 59 | }) 60 | 61 | expect(result.current.state).toBe(2) 62 | }) 63 | 64 | it('state can be array', () => { 65 | const { Provider, useModel } = new Store( 66 | { 67 | counter: { 68 | state: [1, 2, 3], 69 | reducers: { 70 | increase() { 71 | return 2 72 | }, 73 | }, 74 | effects: () => ({}), 75 | }, 76 | }, 77 | defaultStoreOptions 78 | ) 79 | 80 | const { result } = createHook(Provider, useModel, 'counter') 81 | 82 | expect(result.current.state).toEqual([1, 2, 3]) 83 | }) 84 | 85 | it('state can be object', () => { 86 | const { Provider, useModel } = new Store( 87 | { 88 | counter: { 89 | state: { 90 | count: 1, 91 | }, 92 | reducers: { 93 | increase(state) { 94 | state.count += 1 95 | }, 96 | }, 97 | effects: () => ({}), 98 | }, 99 | }, 100 | defaultStoreOptions 101 | ) 102 | 103 | const { result } = createHook(Provider, useModel, 'counter') 104 | 105 | expect(result.current.state.count).toBe(1) 106 | 107 | act(() => { 108 | result.current.reducers.increase() 109 | }) 110 | 111 | expect(result.current.state.count).toBe(2) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { STORE_NAME_PREFIX } from '../src/common/const' 2 | import { invariant } from '../src/utils/invariant' 3 | import { shallowEqual } from '../src/utils/shallowEqual' 4 | import * as type from '../src/utils/type' 5 | import * as func from '../src/utils/func' 6 | 7 | describe('util test', () => { 8 | beforeEach(() => { 9 | jest.resetModules() 10 | }) 11 | 12 | it('should isDev return true when NODE_ENV is development', () => { 13 | process.env.NODE_ENV = 'development' 14 | // eslint-disable-next-line @typescript-eslint/no-var-requires 15 | const env = require('../src/common/env') 16 | 17 | expect(env.isDev).toBeTruthy() 18 | expect(env.isProd).toBeFalsy() 19 | }) 20 | 21 | it('should isProd return true when NODE_ENV is production', () => { 22 | process.env.NODE_ENV = 'production' 23 | // eslint-disable-next-line @typescript-eslint/no-var-requires 24 | const env = require('../src/common/env') 25 | 26 | expect(env.isDev).toBeFalsy() 27 | expect(env.isProd).toBeTruthy() 28 | }) 29 | 30 | it('should noop func return object', () => { 31 | expect(func.noop()).toEqual({}) 32 | }) 33 | 34 | it('should identify func return the same value with input', () => { 35 | const state = { 36 | a: 1, 37 | b: { 38 | c: 2, 39 | }, 40 | } 41 | expect(func.identify(state)).toEqual(state) 42 | }) 43 | 44 | it('should return correct store name using getStoreName', () => { 45 | let count = 1 46 | expect(func.getStoreName()).toBe(`${STORE_NAME_PREFIX}/${count}`) 47 | count++ 48 | expect(func.getStoreName()).toBe(`${STORE_NAME_PREFIX}/${count}`) 49 | count++ 50 | }) 51 | 52 | it('should not throw error when condition is true', () => { 53 | expect(() => invariant(true, 'this is error')).not.toThrow() 54 | }) 55 | 56 | it('should throw error when condition is false', () => { 57 | expect(() => invariant(false, 'this is error')).toThrow('Invariant Failed: this is error') 58 | 59 | jest.isolateModules(() => { 60 | process.env.NODE_ENV = 'production' 61 | 62 | // eslint-disable-next-line @typescript-eslint/no-var-requires 63 | expect(() => require('../src/utils/invariant').invariant(false, 'this is error')).toThrow( 64 | 'Invariant Failed' 65 | ) 66 | }) 67 | }) 68 | 69 | it('should return correct result using shallowEqual', () => { 70 | expect(shallowEqual(1, 2)).toBeFalsy() 71 | expect(shallowEqual(1, 1)).toBeTruthy() 72 | 73 | const o1 = { 74 | b: { 75 | c: 2, 76 | }, 77 | } 78 | 79 | const o2 = { 80 | b: { 81 | c: 2, 82 | }, 83 | } 84 | 85 | expect(shallowEqual(o1, o1)).toBeTruthy() 86 | expect(shallowEqual(o1, o2)).toBeFalsy() 87 | 88 | const o3 = { 89 | a: 1, 90 | b: 2, 91 | } 92 | 93 | expect(shallowEqual(o1, o3)).toBeFalsy() 94 | 95 | const o4 = { 96 | a: 1, 97 | b: 2, 98 | } 99 | 100 | expect(shallowEqual(o3, o4)).toBeTruthy() 101 | 102 | const o5 = { 103 | a: NaN, 104 | } 105 | 106 | expect(shallowEqual(o5, { a: NaN })).toBeTruthy() 107 | expect(shallowEqual(+0, -0)).toBeFalsy() 108 | }) 109 | 110 | it('should judge correct type using type', () => { 111 | let a 112 | expect(type.isUndefined(a)).toBeTruthy() 113 | 114 | const b = null 115 | expect(type.isNull(b)).toBeTruthy() 116 | 117 | expect(type.isFunction(() => {})).toBeTruthy() 118 | expect(type.isString('dobux')).toBeTruthy() 119 | 120 | async function c() {} 121 | 122 | expect(type.isFunction(c)).toBeFalsy() 123 | expect(type.isAsyncFunc(c)).toBeTruthy() 124 | expect(type.isPromise(c())).toBeTruthy() 125 | expect(type.isObject({})).toBeTruthy() 126 | expect(type.isArray([])).toBeTruthy() 127 | }) 128 | }) 129 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | // https://api-extractor.com/pages/configs/api-extractor_json/ 2 | { 3 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 4 | 5 | "mainEntryPointFilePath": "./dist/src/index.d.ts", 6 | 7 | "apiReport": { 8 | "enabled": false 9 | }, 10 | 11 | "docModel": { 12 | "enabled": false 13 | }, 14 | 15 | "dtsRollup": { 16 | "enabled": true, 17 | "untrimmedFilePath": "", 18 | "publicTrimmedFilePath": "./dist/.d.ts" 19 | }, 20 | 21 | "tsdocMetadata": { 22 | "enabled": false 23 | }, 24 | 25 | "messages": { 26 | "compilerMessageReporting": { 27 | "default": { 28 | "logLevel": "warning" 29 | } 30 | }, 31 | 32 | "extractorMessageReporting": { 33 | "default": { 34 | "logLevel": "warning", 35 | "addToApiReportFile": true 36 | }, 37 | 38 | "ae-missing-release-tag": { 39 | "logLevel": "none" 40 | }, 41 | 42 | "ae-wrong-input-file-type": { 43 | "logLevel": "none" 44 | } 45 | }, 46 | 47 | "tsdocMessageReporting": { 48 | "default": { 49 | "logLevel": "warning" 50 | }, 51 | 52 | "tsdoc-undefined-tag": { 53 | "logLevel": "none" 54 | }, 55 | 56 | "tsdoc-escape-greater-than": { 57 | "logLevel": "none" 58 | }, 59 | 60 | "tsdoc-malformed-inline-tag": { 61 | "logLevel": "none" 62 | }, 63 | 64 | "tsdoc-escape-right-brace": { 65 | "logLevel": "none" 66 | }, 67 | 68 | "tsdoc-unnecessary-backslash": { 69 | "logLevel": "none" 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional 2 | module.exports = { 3 | extends: ['@commitlint/config-conventional'], 4 | rules: { 5 | 'type-enum': [ 6 | 2, 7 | 'always', 8 | [ 9 | 'build', 10 | 'release', 11 | 'chore', 12 | 'ci', 13 | 'docs', 14 | 'feat', 15 | 'fix', 16 | 'perf', 17 | 'refactor', 18 | 'revert', 19 | 'style', 20 | 'test', 21 | ], 22 | ], 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://d.umijs.org/zh-CN/config/frontmatter 3 | order: 1 4 | nav: 5 | order: 2 6 | title: API 7 | toc: menu 8 | --- 9 | 10 | # API 11 | 12 | ## `createModel()(model)` 13 | 14 | 创建一个 `Dobux` 模型,它是一个 **高阶函数**,调用时没有入参,存在两个范型参数: 15 | 16 | - `type RootModel`:整个 `store` 的根模型,通过 `dobux` 提供的 `Models` 类型推导出,[示例代码](https://github.com/kcfe/dobux/blob/master/docs/demos/counter/hooks/index.tsx#L7) 17 | - `type modelName: string`:当前模型的名称,是一个 `store` 下会包含多个 `Model` 之一,通常传入当前定义的 `Model`,[示例代码](https://github.com/kcfe/dobux/blob/master/docs/demos/counter/hooks/model.ts#L10) 18 | 19 | 执行之后返回一个函数,调用这个函数会创建一个模型,入参为 `model`,包含以下三个属性: 20 | 21 | ### `model.state: any` 22 | 23 | 当前模型的初始状态,通常为一个 JavaScript 对象,必传 24 | 25 | ```tsx | pure 26 | import { createModel } from 'dobux' 27 | import { RootModel } from 'path/to/type' 28 | 29 | const counter = createModel()({ 30 | state: { 31 | count: 0, 32 | }, 33 | }) 34 | ``` 35 | 36 | ### `model.reducers?: { [reducerName: string]: (state, ...payload: any[]) => void }` 37 | 38 | 修改模型状态的同步方法,非必传。当用户执行该函数时会默认传入以下参数: 39 | 40 | - `state`:当前模型最新的状态,直接修改会生成一个新的对象并触发对应的组件更新 41 | - `payload`:调用该 `reducer` 时传入的参数,支持传入多个 42 | 43 | ```ts 44 | import { createModel } from 'dobux' 45 | import { RootModel } from 'path/to/type' 46 | 47 | const counter = createModel()({ 48 | state: { 49 | count: 0, 50 | }, 51 | reducers: { 52 | increase(state, payload: number) { 53 | state.count += 1 54 | }, 55 | decrease(state, a: number, b?: number) { 56 | state.count -= 1 57 | }, 58 | }, 59 | }) 60 | ``` 61 | 62 | `Dobux` 提供了三个内置的 `reducer`,可以很方便的进行状态更新,比如更新(重置)表单的字段 63 | 64 | `reducers.setValue(key: K extends keyof State, value: State[K] | ((prevState: S[K]) => S[K]))` 65 | 66 | 更新指定的状态 67 | 68 | - `key`:需要更新的字段名称,属于 `Object.keys(state)` 其中的一个,必传 69 | - `value`:修改后的字面量或一个更新函数(类似调用 React.setState 时传入的回调函数),必传 70 | 71 | ```ts 72 | // 字面量形式 73 | reducers.setValue('count', 1) 74 | // 回调函数形式 75 | reducers.setValue('count', prevState => { 76 | return prevState - 1 77 | }) 78 | 79 | // 字面量形式 80 | reducers.setValue('userInfo', { 81 | name: 'dobux', 82 | age: 0, 83 | }) 84 | // 回调函数形式 85 | reducers.setValue('userInfo', prevState => { 86 | return { 87 | ...prevState, 88 | age: 1, 89 | } 90 | }) 91 | ``` 92 | 93 | > 注:如果需要拿到 **最新状态** 需要使用回调函数的形式 94 | 95 | `reducers.setValues(state: Partial | ((prevState: S) => S))` 96 | 97 | 批量状态更新 98 | 99 | - `state`:修改后的字面量或一个更新函数(类似调用 React.setState 时传入的回调函数),必传 100 | 101 | ```ts 102 | // 字面量形式 103 | reducers.setValues({ 104 | count: 1, 105 | }) 106 | // 回调函数形式 107 | reducers.setValues(prevState => { 108 | return { 109 | count: 1, 110 | } 111 | }) 112 | 113 | // 字面量形式 114 | reducers.setValues({ 115 | count: 5, 116 | userInfo: { 117 | name: 'dobux', 118 | age: 1, 119 | }, 120 | }) 121 | // 回调函数形式 122 | reducers.setValues(prevState => { 123 | return { 124 | count: 1, 125 | userInfo: { 126 | ...prevState.userInfo, 127 | name: 'dobux', 128 | }, 129 | } 130 | }) 131 | ``` 132 | 133 | > 注:框架内部只会批量更新第一层数据,如果需要更新深层的数据,需要手动合并 134 | 135 | `reducers.reset(key?: K extends keyof State)` 136 | 137 | 重置状态为初始值 138 | 139 | - `key`:如果没有传入则重置整个 `state`,如果传入则重置指定的字段,非必传 140 | 141 | ```ts 142 | // reset total state 143 | reducers.reset() 144 | 145 | // reset specific state 146 | reducers.reset('count') 147 | ``` 148 | 149 | ### `model.effects?: (model: Model, rootModel: RootModel) => { [effectName: string]: (...payload: any) => Promise }` 150 | 151 | 所有的副作用函数都需要在 `effects` 中处理,非必传,当用户执行该函数时会默认传入以下参数: 152 | 153 | - `model`:当前模型实例 154 | - `model.state`:当前模型的状态 155 | - `model.reducers`:当前组件可用于修改模型状态的方法 156 | - `model.effects`:当前组件可用于执行副作用的方法 157 | - `rootModel`:整个 `store` 的根模型实例,保存了同一个 `store` 下所有的 `model`,可以通过这个对象操作其他的模型,[查看示例](/guide/examples#待办事项清单) 158 | - `{ [modelName: string]: Model }` 159 | - `payload`:调用该 `effect` 时传入的参数,支持传入多个 160 | 161 | ```ts 162 | import { createModel } from 'dobux' 163 | import { RootModel } from 'path/to/type' 164 | 165 | const counter = createModel()({ 166 | state: { 167 | count: 0, 168 | }, 169 | reducers: { 170 | increase(state) { 171 | state.count += 1 172 | }, 173 | }, 174 | effects: (model, rootModel) => ({ 175 | async increaseAsync(payload: number) { 176 | await wait(1000) 177 | model.reducers.increase() 178 | }, 179 | }), 180 | }) 181 | ``` 182 | 183 | ## `store = createStore(models, options)` 184 | 185 | 用于创建一个 `Dobux` 的 `Store` 实例 186 | 187 | - `models`: 通过 [createModel](#createmodelrootmodel-modelnamemodel) 创建的多个 `model` 组成的一个对象,其中对象的 `key` 值为模型的名称,在组件内调用 `useModel(key)` 消费数据时传入 188 | 189 | - `options.name?: string`:指定 `store` 的名称,该名称会显示在 [redux devtools](/guide/devtools) 的面板上,非必传,默认为 `dobux/${number}` 190 | 191 | - `options.autoReset?: boolean | Array`:组件内部通过 `useModel` 消费数据源时,在组件卸载的时候是否需要自动重置为初始的数据,非必传,默认为 `false`,如果传入 `true` 表示当前 `store` 对应的多个 `model` 在组件卸载的时候都会自动卸载数据;如果传入数组可以指定某些 `model` 执行卸载操作 192 | - `options.autoReset` 配置项已经弃用,会在下一个大版本删除,如果需要重置模型状态,可以通过 `reducers.reset` 方法 193 | 194 | ```tsx | pure 195 | import React, { FC, useEffect } from 'react' 196 | import store from './store' 197 | 198 | const Counter: FC = () => { 199 | const { 200 | state, 201 | reducers: { reset }, 202 | effects, 203 | } = store.useModel('counter') 204 | 205 | useEffect(() => { 206 | return () => reset() 207 | }, []) 208 | 209 | if (effects.increaseAsync.loading) { 210 | return
loading ...
211 | } 212 | 213 | return
Count: {state.count}
214 | } 215 | ``` 216 | 217 | - `options.devtools?: boolean | Array`:在开发环境下模型是否支持连接 `redux devtools`,非必传,默认为 `true`,如果传入 `false` 表示当前 `store` 下的所有 `model` 都不支持连接 `devtools`,传入数组可以指定某些 `model` 不连接 `devtool` 218 | 219 | #### 基本使用 220 | 221 | ```ts 222 | import { createModel, createStore } from 'dobux' 223 | 224 | const counter = createModel()({ 225 | state: { 226 | count: 0, 227 | }, 228 | reducers: { 229 | increase(state, payload: number) { 230 | state.count += 1 231 | }, 232 | decrease(state, payload: number) { 233 | state.count -= 1 234 | }, 235 | }, 236 | effects: (model, rootModel) => ({ 237 | async increaseAsync(payload: number) { 238 | await wait(1000) 239 | model.reducers.increase() 240 | }, 241 | }), 242 | }) 243 | 244 | const store = createStore({ 245 | counter, 246 | }) 247 | ``` 248 | 249 | #### 自动重置 250 | 251 | ```ts 252 | import { createModel, createStore } from 'dobux' 253 | 254 | const counter = createModel()({ 255 | state: { 256 | count: 0, 257 | }, 258 | reducers: { 259 | increase(state, payload: number) { 260 | state.count += 1 261 | }, 262 | decrease(state, payload: number) { 263 | state.count -= 1 264 | }, 265 | }, 266 | effects: (model, rootModel) => ({ 267 | async increaseAsync(payload: number) { 268 | await wait(1000) 269 | model.reducers.increase() 270 | }, 271 | }), 272 | }) 273 | 274 | const store = createStore( 275 | { 276 | counter, 277 | }, 278 | { 279 | // 当前 Store 下的所有 Model 都会自动卸载 280 | autoReset: true, 281 | } 282 | ) 283 | 284 | const store = createStore({ 285 | counter, 286 | }) 287 | ``` 288 | 289 | #### Devtools 290 | 291 | ```ts 292 | import { createModel, createStore } from 'dobux' 293 | 294 | const counter = createModel()({ 295 | state: { 296 | count: 0, 297 | }, 298 | reducers: { 299 | increase(state, payload: number) { 300 | state.count += 1 301 | }, 302 | decrease(state, payload: number) { 303 | state.count -= 1 304 | }, 305 | }, 306 | effects: (model, rootModel) => ({ 307 | async increaseAsync(payload: number) { 308 | await wait(1000) 309 | model.reducers.increase() 310 | }, 311 | }), 312 | }) 313 | 314 | const store = createStore({ 315 | counter, 316 | }) 317 | 318 | const store = createStore( 319 | { 320 | counter, 321 | }, 322 | { 323 | // 当前 Store 下的 `counter` Model 开启 Devtool 功能,其他 Model 关闭 324 | devtools: ['counter'], 325 | } 326 | ) 327 | ``` 328 | 329 | ### `store.Provider: (props: { children: React.ReactElement }) => React.ReactElement` 330 | 331 | 通过 `Provider` 将 `Store` 实例挂载到 `React` 应用,以便组件能够通过 `Hooks` 的方式获取不同的模型进行交互 332 | 333 | - `props.children`:需要消费 `store` 的子节点 334 | 335 | ```tsx | pure 336 | import React from 'react' 337 | import ReactDOM from 'react-dom' 338 | import App from './App.tsx' 339 | import store from './store' 340 | 341 | const { Provider } = store 342 | 343 | ReactDOM.render( 344 | 345 | 346 | , 347 | document.getElementById('root') 348 | ) 349 | ``` 350 | 351 | ### `store.withProvider: (component: React.FunctionComponent) => React.ReactElement` 352 | 353 | 作用同 `Provider`,区别是该组件是一个 **高阶组件**,在组件内通过 `useModel` 获取指定的模型必须要在 `Provider | withProvider` 的子组件中执行,对于不希望嵌套一层组件的需求就可以使用 `withProvider` 包裹 354 | 355 | ```tsx | pure 356 | import { Tabs } from 'antd' 357 | import store from './store' 358 | 359 | const { withProvider } = store 360 | const { TabPane } = Tabs 361 | 362 | export interface Props 363 | extends RouteComponentProps<{ 364 | type: 'list' | 'product' 365 | }> {} 366 | 367 | function App(props: Props) { 368 | const { reducers: adPositionReducers } = store.useModel('adPosition') 369 | const { reducers: productReducers } = store.useModel('product') 370 | 371 | const onChange = (activeKey: string) => { 372 | switch (activeKey) { 373 | case 'list': 374 | adPositionReducers.setValue('searchParams', adPositionInitialState.searchParams) 375 | break 376 | case 'product': 377 | productReducers.setValue('searchParams', productInitialState.searchParams) 378 | break 379 | } 380 | } 381 | 382 | return ( 383 |
384 | 385 | 386 | {type === 'list' ? : null} 387 | 388 | 389 | {type === 'product' ? : null} 390 | 391 | 392 |
393 | ) 394 | } 395 | 396 | export default withRouter(withProvider(App)) 397 | ``` 398 | 399 | ### `store.withProviderForwardRef: >(Component: React.ForwardRefExoticComponent & RefAttributes>) => React.ReactElement` 400 | 401 | 该 API 与 `withProvider` 的区别在于 `withProvider` 不能增强使用 `React.forwardRef` 包裹的组件,该 API 可以解决这个问题 402 | 403 | ```tsx | pure 404 | import React, { useImperativeHandle } from 'react' 405 | import store from './store' 406 | 407 | const { withProviderForwardRef } = store 408 | 409 | const WithProviderForwardRefDemo = React.forwardRef((props, ref) => { 410 | useImperativeHandle(ref, () => { 411 | return { 412 | say() { 413 | console.log('Hello Dobux') 414 | }, 415 | } 416 | }) 417 | 418 | return <> 419 | }) 420 | 421 | export default withProviderForwardRef(WithProviderForwardRefDemo) 422 | ``` 423 | 424 | ### `store.useModel: (modelName: string, mapStateToModel?: (state: State) => any) => { state, reducers, effects }` 425 | 426 | 通过该 API 可以在函数组件内获取对应模型的实例,接受两个参数: 427 | 428 | - `modelName`:需要消费的模型名称,即执行 `createStore(models)` 时传入对象的 `key` 值 `keyof models`,必传 429 | - `mapStateToModel`:返回一个自定义的对象作为组件真实的消费模型 `state`,表示当前组件只有在这个返回对象发生改变时才会重新触发组件的渲染,用于性能优化,阻止不必要的渲染,入参为当前模型最新的 `state`,非必传 430 | 431 | 返回结果的信息如下: 432 | 433 | - `state`:当前消费模型对应的最新状态 434 | - `reducers`:当前组件可用于修改模型状态的方法 435 | - `effects`:当前组件可用于执行副作用的方法;其中 `effects.effectName.loading` 记录了异步操作时的状态,当执行 `effects.effectName` 时会将 `effects.effectName.loading` 设置为 `true`,当对应的副作用执行完成后会将 `effects.effectName.loading` 重置为 `false`。在视图中不再需要自己定义多个 `loading` 状态,通过该属性就简化视图层逻辑 436 | 437 | #### 基本用法 438 | 439 | ```tsx | pure 440 | import React from 'react' 441 | import store from './store' 442 | 443 | const Counter: React.FC = () => { 444 | const { state, reducers, effects } = store.useModel('counter') 445 | 446 | // 当异步请求 `increaseAsync` 执行时 `loading` 会设置为 true,显示 loading 447 | if (effects.increaseAsync.loading) { 448 | return
loading ...
449 | } 450 | 451 | return
Count: {state.count}
452 | } 453 | ``` 454 | 455 | #### 性能优化 456 | 457 | 在某些组件中可能只需要依赖某一数据源的部分状态,同时只有当这部分依赖的状态变化时才会重新渲染,可以通过 `useModel` 第二个参数的 `mapStateToModel` 属性进行控制 458 | 459 | ```tsx | pure 460 | import React, { FC } from 'react' 461 | import store from './store' 462 | 463 | const Counter: FC = () => { 464 | const { state, reducers, effects } = store.useModel('counter', state => { 465 | // 只有当模型 `counter` 中的 `count` 字段改变时才会触发当前组件的 rerender 466 | return { 467 | count: state.count, 468 | } 469 | }, 470 | }) 471 | 472 | if (effects.increaseAsync.loading) { 473 | return
loading ...
474 | } 475 | 476 | return
Count: {state.count}
477 | } 478 | ``` 479 | 480 | ### `store.withModel: (modelName: string, mapStateToModel?: (state: State) => any, contextName?: string) => (Component: React.ComponentType) => React.ComponentType` 481 | 482 | 对于 Class Component 可以通过 `withModel` 高阶组件进行模型的消费,该组件接受两个参数: 483 | 484 | - `modelName`:需要消费的模型名称,即执行 `createStore(models)` 时传入对象的 `key` 值 `keyof models`,必传 485 | - `mapStateToModel`:返回一个自定义的对象作为组件真实的消费模型 `state`,表示当前组件只有在这个返回对象发生改变时才会重新触发组件的渲染,用于性能优化,阻止不必要的渲染,入参为当前模型最新的 `state`,非必传 486 | - `contextName`:在被包裹组件的 `props` 上挂载的属性名,默认为空,会在被包裹组件的 `props` 上挂载以下三个属性: 487 | - `props.state`:当前消费的模型对应的最新状态 488 | - `props.reducers`:当前组件可用于修改模型状态的方法 489 | - `props.effects`:当前组件可用于执行副作用的方法,其中 `effects.effectName.loading` 记录了异步操作时的状态,可以简化视图层逻辑 490 | 491 | 如果传入了 `contextName` 则对应 `model` 的状态和方法都会在被包裹组件的 `props[contextName][modelName]` 上暴露: 492 | 493 | - `props[contextName].state`:当前消费的模型对应的最新状态 494 | - `props[contextName].reducers`:当前组件可用于修改模型状态的方法 495 | - `props[contextName].effects`:当前组件可用于执行模型副作用的方法 496 | 497 | > 当使用的组件与已有 props 冲突时,则默认不引入 model 中对应的值。比如组件外层传递了 state 属性,那么 dobux 的 state 就不会传入 498 | 499 | #### 基本用法 500 | 501 | ```tsx | pure 502 | import store, { RootModel } from './store' 503 | 504 | const { withModel } = store 505 | 506 | export interface CounterProps { 507 | state: RootModel['counter']['state'] 508 | reducers: RootModel['counter']['reducers'] 509 | effects: RootModel['counter']['effects'] 510 | } 511 | 512 | class Counter extends React.Component { 513 | handleIncrease = () => { 514 | const { reducers } = this.props 515 | reducers.increase() 516 | } 517 | 518 | handleDecrease = () => { 519 | const { reducers } = this.props 520 | reducers.decrease() 521 | } 522 | 523 | handleIncreaseAsync = () => { 524 | const { effects } = this.props 525 | effects.increaseAsync() 526 | } 527 | 528 | render() { 529 | const { state, effects } = this.props 530 | 531 | if (effects.increaseAsync.loading) { 532 | return

loading ...

533 | } 534 | 535 | return ( 536 |
537 |

The count is: {state.count}

538 | 539 | 540 | 541 |
542 | ) 543 | } 544 | } 545 | 546 | export default withModel('counter')(Counter) 547 | ``` 548 | 549 | #### 性能优化 550 | 551 | 在某些组件中可能只需要依赖某一数据源的部分状态,同时只有当这部分依赖的状态变化时才会重新渲染,可以通过 `withModel` 第二个参数的 `mapStateToModel` 属性进行控制 552 | 553 | ```tsx | pure 554 | import store, { RootModel } from './store' 555 | 556 | const { withModel } = store 557 | 558 | export interface CounterProps { 559 | state: Pick 560 | reducers: RootModel['counter']['reducers'] 561 | effects: RootModel['counter']['effects'] 562 | } 563 | 564 | class Counter extends React.Component { 565 | handleIncrease = () => { 566 | const { reducers } = this.props 567 | reducers.increase() 568 | } 569 | 570 | handleDecrease = () => { 571 | const { reducers } = this.props 572 | reducers.decrease() 573 | } 574 | 575 | handleIncreaseAsync = () => { 576 | const { effects } = this.props 577 | effects.increaseAsync() 578 | } 579 | 580 | render() { 581 | const { state, effects } = this.props 582 | 583 | if (effects.increaseAsync.loading) { 584 | return

loading ...

585 | } 586 | 587 | return ( 588 |
589 |

The count is: {state.count}

590 | 591 | 592 | 593 |
594 | ) 595 | } 596 | } 597 | 598 | export default withModel('counter', state => { 599 | // 只有当数据源 `counter` 中的 `state.count` 改变时才会触发当前组件的 re-render 600 | return { 601 | count: state.count, 602 | } 603 | })(Count) 604 | ``` 605 | 606 | ### `store.withModels: (modelNames: string[], mapStateToModels?: { [modelName: string]: (state: State) => any) }, contextName = 'models') => (Component: React.ComponentType) => React.ComponentType` 607 | 608 | `withModel` 只支持传入一个 model,而 `withModels` 支持传入多个 models 供 Class Component 消费,该组件接受三个参数: 609 | 610 | - `modelNames`:需要消费的模型名称列表,即执行 `createStore(models)` 时传入对象的 `key` 值 `keyof models`,必传 611 | - `mapStateToModels`:返回一个自定义的对象作为组件真实的消费模型 `state`,表示当前组件只有在这个返回对象发生改变时才会重新触发组件的渲染,用于性能优化,阻止不必要的渲染,入参为当前模型最新的 `state`,非必传 612 | - `contextName`:在被包裹组件的`props`上挂载的属性名,默认为 `models`,**当使用的 `contextName` 和组件已有 props 冲突时,默认不引入 model,保留原有 `contextName` 的值** 613 | 614 | 每个 model 的状态和方法都会在被包裹组件的 `props[contextName][modelName]` 上暴露: 615 | 616 | - `props[contextName].modelA.state`:当前消费的模型`modelA`对应的最新状态 617 | - `props[contextName].modelA.reducers`:当前组件可用于修改模型`modelA`状态的方法 618 | - `props[contextName].modelA.effects`:当前组件可用于执行模型`modelA`副作用的方法,其中 `effects.effectName.loading` 记录了异步操作时的状态,可以简化视图层逻辑 619 | 620 | #### 基本用法 621 | 622 | ```tsx | pure 623 | import store, { RootModel } from './store' 624 | 625 | const { withModels } = store 626 | 627 | export interface CounterProps { 628 | forDobux: { 629 | [k: keyof RootModel]: { 630 | state: RootModel[k]['state'] 631 | reducers: RootModel[k]['reducers'] 632 | effects: RootModel[k]['effects'] 633 | } 634 | } 635 | } 636 | 637 | class Counter extends React.Component { 638 | handleIncrease = (modelName: string) => () => { 639 | this.props.forDobux[modelName].reducers.increase() 640 | } 641 | 642 | handleDecrease = (modelName: string) => () => { 643 | this.props.forDobux[modelName].reducers.decrease() 644 | } 645 | 646 | handleIncreaseAsync = (modelName: string) => () => { 647 | this.props.forDobux[modelName].effects.increaseAsync() 648 | } 649 | 650 | render() { 651 | const { 652 | forDobux: { 653 | counter1: { state: state1, effects: effects1 }, 654 | counter2: { state: state2, effects: effects2 }, 655 | }, 656 | } = this.props 657 | 658 | if (effects1.increaseAsync.loading || effects2.increaseAsync.loading) { 659 | return

loading ...

660 | } 661 | 662 | return ( 663 | <> 664 |
665 |

The count1 is: {state1.count}

666 | 667 | 668 | 669 |
670 |
671 |

The count2 is: {state2.count}

672 | 673 | 674 | 675 |
676 | 677 | ) 678 | } 679 | } 680 | 681 | export default withModels(['counter1', 'counter2'], undefined, 'forDobux')(Counter) 682 | ``` 683 | 684 | #### 性能优化 685 | 686 | 在某些组件中可能只需要依赖某一数据源的部分状态,同时只有当这部分依赖的状态变化时才会重新渲染,可以通过 `withModels` 第二个参数的 `mapStateToModel` 属性进行控制 687 | 688 | ```tsx | pure 689 | import store, { RootModel } from './store' 690 | 691 | const { withModels } = store 692 | 693 | export interface CounterProps { 694 | models: { 695 | [k: keyof RootModel]: { 696 | state: RootModel[k]['state'] 697 | reducers: RootModel[k]['reducers'] 698 | effects: RootModel[k]['effects'] 699 | } 700 | } 701 | } 702 | 703 | class Counter extends React.Component { 704 | handleIncrease = (modelName: string) => () => { 705 | this.props.models[modelName].reducers.increase() 706 | } 707 | 708 | handleDecrease = (modelName: string) => () => { 709 | this.props.models[modelName].reducers.decrease() 710 | } 711 | 712 | handleIncreaseAsync = (modelName: string) => () => { 713 | this.props.models[modelName].effects.increaseAsync() 714 | } 715 | 716 | render() { 717 | const { 718 | models: { 719 | counter1: { state: state1, effects: effects1 }, 720 | counter2: { state: state2, effects: effects2 }, 721 | }, 722 | } = this.props 723 | 724 | if (effects1.increaseAsync.loading || effects2.increaseAsync.loading) { 725 | return

loading ...

726 | } 727 | 728 | return ( 729 | <> 730 |
731 |

The count1 is: {state1.count}

732 | 733 | 734 | 735 |
736 |
737 |

The count2 is: {state2.count}

738 | 739 | 740 | 741 |
742 | 743 | ) 744 | } 745 | } 746 | 747 | export default withModels(['counter1', 'counter2'], { 748 | counter1: state => { 749 | // 只有当数据源 `counter1` 中的 `state.count` 改变时才会触发当前组件的 re-render 750 | return { 751 | count: state.count, 752 | } 753 | }, 754 | counter2: state => { 755 | return { 756 | count: state.count, 757 | } 758 | }, 759 | })(Count) 760 | ``` 761 | 762 | ### `store.getState: (modelName?: string) => ModelState` 763 | 764 | 获取指定(所有)模型的最新状态 `state`,可以在组件外部使用。可以解决在闭包中获取最新的状态值或者想要只使用状态而不订阅更新的场景 765 | 766 | - `modelName`:模型名称,非必传,如果传入会返回对应模型的 `state`,如果没传入会返回整个 `store` 对应的多个模型的 `state` 767 | 768 | ```tsx | pure 769 | import { store } from './store' 770 | 771 | const rootState = store.getState() 772 | // { header: { value: ''}, undoList: { items: [{ content: 'Learn dobux' }] } } 773 | 774 | const headerState = store.getState('header') 775 | // { value: ''} 776 | 777 | const undoListState = store.getState('undoList') 778 | // { items: [{ content: 'Learn dobux' }] } 779 | ``` 780 | 781 | ### `store.getReducers: (modelName?: string) => ModelReducers` 782 | 783 | 获取指定(所有)模型的 `reducers`,可以在组件外部使用。可以解决在闭包中获取指定的 `reducers` 或者想要只使用 `reducers` 而不订阅更新的场景 784 | 785 | - `modelName`:模型名称,非必传,如果传入会返回对应模型的 `reducers`,如果没传入会返回整个 `store` 对应的多个模型的 `reducers` 786 | 787 | ```tsx | pure 788 | import { store } from './store' 789 | 790 | const rootReducers = store.getReducers() 791 | // { header: { changeValue, setValue, setValues, reset }, undoList: { addItem, deleteItem, toggleItem, setValue, setValues, reset } } 792 | 793 | const headerReducers = store.getReducers('header') 794 | // { changeValue, setValue, setValues, reset } 795 | 796 | const undoListReducers = store.getReducers('undoList') 797 | // { addItem, deleteItem, toggleItem, setValue, setValues, reset } 798 | ``` 799 | 800 | ### `store.getEffects: (modelName?: string) => ModelEffects` 801 | 802 | 获取指定(所有)模型的 `effects`,可以在组件外部使用。可以解决在闭包中获取指定的 `effects` 或者想要只使用 `effects` 而不订阅更新的场景 803 | 804 | - `modelName`:模型名称,非必传,如果传入会返回对应模型的 `effects`,如果没传入会返回整个 `store` 对应的多个模型的 `effects` 805 | 806 | ```tsx | pure 807 | import { store } from './store' 808 | 809 | const rootEffects = store.getEffects() 810 | // { header: { addUndoItem }, undoList: { fetchUndoList } } 811 | 812 | const headerEffects = store.getEffects('header') 813 | // { addUndoItem } 814 | 815 | const undoListEffects = store.getEffects('undoList') 816 | // { fetchUndoList } 817 | ``` 818 | -------------------------------------------------------------------------------- /docs/demos/counter/class/index.less: -------------------------------------------------------------------------------- 1 | .counter { 2 | p { 3 | font-size: 20px; 4 | font-weight: bold; 5 | margin-bottom: 30px; 6 | } 7 | 8 | button { 9 | min-width: 42px; 10 | outline: none; 11 | color: #fff; 12 | background-color: #1890ff; 13 | border-color: #1890ff; 14 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12); 15 | display: inline-block; 16 | padding: 5px 10px; 17 | border: 1px solid #ccc; 18 | border-radius: 5px; 19 | font-size: 14px; 20 | line-height: 1.499; 21 | display: inline-block; 22 | font-weight: 400; 23 | white-space: nowrap; 24 | text-align: center; 25 | border: 1px solid transparent; 26 | -webkit-box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015); 27 | box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015); 28 | cursor: pointer; 29 | height: 32px; 30 | padding: 0 15px; 31 | font-size: 14px; 32 | border-color: #d9d9d9; 33 | } 34 | 35 | button + button { 36 | margin-left: 10px; 37 | } 38 | } 39 | 40 | .loading { 41 | display: flex; 42 | font-size: 20px; 43 | font-weight: bold; 44 | margin-bottom: 30px; 45 | align-items: center; 46 | justify-content: center; 47 | background-color: rgba(255, 255, 255, 0.3); 48 | z-index: 10001; 49 | } 50 | -------------------------------------------------------------------------------- /docs/demos/counter/class/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createStore, Models } from 'dobux' 3 | import * as models from './model' 4 | 5 | import './index.less' 6 | 7 | export type RootModel = Models 8 | 9 | export interface CounterProps { 10 | state: RootModel['counter']['state'] 11 | reducers: RootModel['counter']['reducers'] 12 | effects: RootModel['counter']['effects'] 13 | } 14 | 15 | // 创建 store 实例 16 | const { withProvider, withModel } = createStore(models) 17 | 18 | class Counter extends React.Component { 19 | handleIncrease = () => { 20 | const { reducers } = this.props 21 | reducers.increase() 22 | } 23 | 24 | handleDecrease = () => { 25 | const { reducers } = this.props 26 | reducers.decrease() 27 | } 28 | 29 | handleSetValue = () => { 30 | const { state ,reducers } = this.props 31 | reducers.setValue('count', state.count + 2) 32 | } 33 | 34 | handleSetValues = () => { 35 | const { state, reducers } = this.props 36 | reducers.setValues({ 37 | count: state.count - 2 38 | }) 39 | } 40 | 41 | handleAsync = () => { 42 | const { effects } = this.props 43 | effects.increaseAsync() 44 | } 45 | 46 | handleReset = () => { 47 | const { reducers } = this.props 48 | reducers.reset('count') 49 | } 50 | 51 | render() { 52 | const { state, effects } = this.props 53 | 54 | if (effects.increaseAsync.loading) { 55 | return

loading ...

56 | } 57 | 58 | return ( 59 |
60 |

The count is: {state.count}

61 | 62 | 63 | 64 | 65 | 66 | 67 |
68 | ) 69 | } 70 | } 71 | 72 | export default withProvider(withModel('counter')(Counter)) 73 | -------------------------------------------------------------------------------- /docs/demos/counter/class/model.ts: -------------------------------------------------------------------------------- 1 | import { createModel } from 'dobux' 2 | import { RootModel } from './index' 3 | 4 | function wait(ms: number) { 5 | return new Promise(resolve => { 6 | setTimeout(resolve, ms) 7 | }) 8 | } 9 | 10 | export const counter = createModel()({ 11 | state: { 12 | count: 0, 13 | }, 14 | reducers: { 15 | increase(state) { 16 | state.count += 1 17 | }, 18 | decrease(state) { 19 | state.count -= 1 20 | }, 21 | }, 22 | effects: (model, rootModel) => ({ 23 | async increaseAsync() { 24 | await wait(1000) 25 | model.reducers.increase() 26 | }, 27 | }), 28 | }) 29 | 30 | -------------------------------------------------------------------------------- /docs/demos/counter/hooks/index.less: -------------------------------------------------------------------------------- 1 | .counter { 2 | p { 3 | font-size: 20px; 4 | font-weight: bold; 5 | margin-bottom: 30px; 6 | } 7 | 8 | button { 9 | min-width: 42px; 10 | outline: none; 11 | color: #fff; 12 | background-color: #1890ff; 13 | border-color: #1890ff; 14 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12); 15 | display: inline-block; 16 | padding: 5px 10px; 17 | border: 1px solid #ccc; 18 | border-radius: 5px; 19 | font-size: 14px; 20 | line-height: 1.499; 21 | display: inline-block; 22 | font-weight: 400; 23 | white-space: nowrap; 24 | text-align: center; 25 | border: 1px solid transparent; 26 | -webkit-box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015); 27 | box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015); 28 | cursor: pointer; 29 | height: 32px; 30 | padding: 0 15px; 31 | font-size: 14px; 32 | border-color: #d9d9d9; 33 | } 34 | 35 | button + button { 36 | margin-left: 10px; 37 | } 38 | } 39 | 40 | .loading { 41 | display: flex; 42 | font-size: 20px; 43 | font-weight: bold; 44 | margin-bottom: 30px; 45 | align-items: center; 46 | justify-content: center; 47 | background-color: rgba(255, 255, 255, 0.3); 48 | z-index: 10001; 49 | } 50 | -------------------------------------------------------------------------------- /docs/demos/counter/hooks/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createStore, Models } from 'dobux' 3 | import * as models from './model' 4 | 5 | import './index.less' 6 | 7 | export type RootModel = Models 8 | 9 | // 创建 store 实例 10 | const { withProvider, useModel } = createStore(models) 11 | 12 | const Counter: React.FC = () => { 13 | const { state, reducers, effects } = useModel('counter') 14 | 15 | const handleIncrease = () => { 16 | reducers.increase() 17 | } 18 | 19 | const handleDecrease = () => { 20 | reducers.decrease() 21 | } 22 | 23 | const handleSetValue = () => { 24 | reducers.setValue('count', state.count + 2) 25 | } 26 | 27 | const handleSetValues = () => { 28 | reducers.setValues({ 29 | count: state.count - 2 30 | }) 31 | } 32 | 33 | const handleReset = () => { 34 | reducers.reset() 35 | } 36 | 37 | const handleAsync = () => { 38 | effects.increaseAsync() 39 | } 40 | 41 | if (effects.increaseAsync.loading) { 42 | return

loading ...

43 | } 44 | 45 | return ( 46 |
47 |

The count is: {state.count}

48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | ) 56 | } 57 | 58 | export default withProvider(Counter) 59 | -------------------------------------------------------------------------------- /docs/demos/counter/hooks/model.ts: -------------------------------------------------------------------------------- 1 | import { createModel } from 'dobux' 2 | import { RootModel } from './index' 3 | 4 | function wait(ms: number) { 5 | return new Promise(resolve => { 6 | setTimeout(resolve, ms) 7 | }) 8 | } 9 | 10 | export const counter = createModel()({ 11 | state: { 12 | count: 0, 13 | }, 14 | reducers: { 15 | increase(state) { 16 | state.count += 1 17 | }, 18 | decrease(state) { 19 | state.count -= 1 20 | }, 21 | }, 22 | effects: (model, rootModel) => ({ 23 | async increaseAsync() { 24 | await wait(1000) 25 | model.reducers.increase() 26 | return model.effects.decreaseAsync() as number 27 | }, 28 | 29 | async decreaseAsync() { 30 | await wait(1000) 31 | return -1 32 | }, 33 | }), 34 | }) 35 | -------------------------------------------------------------------------------- /docs/demos/counter/hooks2/index.less: -------------------------------------------------------------------------------- 1 | .counter { 2 | p { 3 | font-size: 20px; 4 | font-weight: bold; 5 | margin-bottom: 30px; 6 | } 7 | 8 | button { 9 | min-width: 42px; 10 | outline: none; 11 | color: #fff; 12 | background-color: #1890ff; 13 | border-color: #1890ff; 14 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12); 15 | display: inline-block; 16 | padding: 5px 10px; 17 | border: 1px solid #ccc; 18 | border-radius: 5px; 19 | font-size: 14px; 20 | line-height: 1.499; 21 | display: inline-block; 22 | font-weight: 400; 23 | white-space: nowrap; 24 | text-align: center; 25 | border: 1px solid transparent; 26 | -webkit-box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015); 27 | box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015); 28 | cursor: pointer; 29 | height: 32px; 30 | padding: 0 15px; 31 | font-size: 14px; 32 | border-color: #d9d9d9; 33 | } 34 | 35 | button + button { 36 | margin-left: 10px; 37 | } 38 | } 39 | 40 | .loading { 41 | display: flex; 42 | font-size: 20px; 43 | font-weight: bold; 44 | margin-bottom: 30px; 45 | align-items: center; 46 | justify-content: center; 47 | background-color: rgba(255, 255, 255, 0.3); 48 | z-index: 10001; 49 | } 50 | -------------------------------------------------------------------------------- /docs/demos/counter/hooks2/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { store } from './store' 3 | 4 | import './index.less' 5 | 6 | const { withProvider, useModel } = store 7 | 8 | const Counter: React.FC = () => { 9 | const { state, reducers, effects } = useModel('counter') 10 | 11 | const handleIncrease = () => { 12 | reducers.increase() 13 | } 14 | 15 | const handleDecrease = () => { 16 | reducers.decrease() 17 | } 18 | 19 | const handleSetValue = () => { 20 | reducers.setValue('count', state.count + 2) 21 | } 22 | 23 | const handleSetValues = () => { 24 | reducers.setValues({ 25 | count: state.count - 2 26 | }) 27 | } 28 | 29 | const handleReset = () => { 30 | reducers.reset() 31 | } 32 | 33 | const handleAsync = () => { 34 | effects.increaseAsync() 35 | } 36 | 37 | if (effects.increaseAsync.loading) { 38 | return

loading ...

39 | } 40 | 41 | return ( 42 |
43 |

The count is: {state.count}

44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | ) 52 | } 53 | 54 | export default withProvider(Counter) 55 | -------------------------------------------------------------------------------- /docs/demos/counter/hooks2/model.ts: -------------------------------------------------------------------------------- 1 | import { createModel } from 'dobux' 2 | import { RootModel } from './store' 3 | 4 | function wait(ms: number) { 5 | return new Promise(resolve => { 6 | setTimeout(resolve, ms) 7 | }) 8 | } 9 | 10 | export const counter = createModel()({ 11 | state: { 12 | count: 0, 13 | }, 14 | reducers: { 15 | increase(state) { 16 | state.count += 1 17 | }, 18 | decrease(state) { 19 | state.count -= 1 20 | }, 21 | }, 22 | effects: (model, rootModel) => ({ 23 | async increaseAsync() { 24 | await wait(1000) 25 | model.reducers.increase() 26 | }, 27 | }), 28 | }) 29 | 30 | -------------------------------------------------------------------------------- /docs/demos/counter/hooks2/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, Models } from 'dobux' 2 | import * as models from './model' 3 | 4 | export type RootModel = Models 5 | 6 | // 创建 store 实例 7 | export const store = createStore(models) 8 | -------------------------------------------------------------------------------- /docs/demos/todo-list/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, KeyboardEvent } from 'react' 2 | import { store } from '../store' 3 | 4 | function Header() { 5 | const { state, reducers, effects } = store.useModel('header') 6 | 7 | const handleChange = (e: ChangeEvent<{ value: string }>) => { 8 | reducers.changeValue(e.target.value) 9 | } 10 | 11 | const handleKeyUp = (e: KeyboardEvent) => { 12 | if (state.value && e.keyCode === 13) { 13 | effects.addUndoItem() 14 | reducers.changeValue('') 15 | } 16 | } 17 | 18 | return ( 19 |
20 |
21 | TodoList 22 | 28 |
29 |
30 | ) 31 | } 32 | 33 | export default Header 34 | -------------------------------------------------------------------------------- /docs/demos/todo-list/Header/model.ts: -------------------------------------------------------------------------------- 1 | import { createModel } from 'dobux' 2 | import { RootModel } from '../store' 3 | 4 | export const header = createModel()({ 5 | state: { 6 | value: '', 7 | }, 8 | reducers: { 9 | changeValue(state, payload: string) { 10 | state.value = payload 11 | }, 12 | }, 13 | effects: (model, rootModel) => ({ 14 | addUndoItem() { 15 | rootModel.undoList.reducers.addItem({ 16 | content: model.state.value, 17 | }) 18 | }, 19 | }), 20 | }) 21 | -------------------------------------------------------------------------------- /docs/demos/todo-list/UndoList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { store } from '../store' 3 | 4 | function UndoList() { 5 | const { state, reducers, effects } = store.useModel('undoList') 6 | 7 | const handleClick = (index: number) => { 8 | reducers.deleteItem(index) 9 | } 10 | 11 | const handleToggle = (index: number) => { 12 | reducers.toggleItem(index) 13 | } 14 | 15 | if (effects.fetchUndoList.loading) { 16 | return
loading ...
17 | } 18 | 19 | return ( 20 |
21 |

22 | 正在进行
{state.items.filter((item: any) => !item.done).length}
23 |

24 |
    25 | {state.items.map((item: any, index: number) => { 26 | return ( 27 | !item.done && ( 28 |
  • handleToggle(index)} className="item" key={index}> 29 | {item.content} 30 |
    { 32 | e.stopPropagation() 33 | handleClick(index) 34 | }} 35 | className="delete" 36 | > 37 | - 38 |
    39 |
  • 40 | ) 41 | ) 42 | })} 43 |
44 |

45 | 已经完成{' '} 46 |
47 | {state.items.filter((item: any) => item.done).length} 48 |
49 |

50 |
    51 | {state.items.map((item: any, index: number) => { 52 | return ( 53 | item.done && ( 54 |
  • handleToggle(index)} className="item" key={index}> 55 | {item.content} 56 |
    { 58 | e.stopPropagation() 59 | handleClick(index) 60 | }} 61 | className="delete" 62 | > 63 | - 64 |
    65 |
  • 66 | ) 67 | ) 68 | })} 69 |
70 |
71 | ) 72 | } 73 | 74 | export default UndoList 75 | -------------------------------------------------------------------------------- /docs/demos/todo-list/UndoList/model.ts: -------------------------------------------------------------------------------- 1 | import { createModel } from 'dobux' 2 | import { RootModel } from '../store' 3 | 4 | function fetchList(): Promise<{ data: any[] }> { 5 | return new Promise(resolve => { 6 | setTimeout(() => { 7 | resolve({ 8 | data: [ 9 | { 10 | content: 'Learn dobux', 11 | }, 12 | { 13 | content: 'Learn typescript', 14 | }, 15 | ], 16 | }) 17 | }, 1000) 18 | }) 19 | } 20 | 21 | interface Item { 22 | done?: boolean 23 | content: string 24 | } 25 | 26 | export const undoList = createModel()({ 27 | state: { 28 | items: [ 29 | { 30 | content: 'Learn dobux', 31 | }, 32 | { 33 | content: 'Learn typescript', 34 | }, 35 | ] as Item[], 36 | }, 37 | reducers: { 38 | addItem(state, item: Item) { 39 | state.items.push(item) 40 | }, 41 | 42 | deleteItem(state, index: number) { 43 | state.items.splice(index, 1) 44 | }, 45 | 46 | toggleItem(state, index: number) { 47 | state.items[index].done = !state.items[index].done 48 | }, 49 | }, 50 | effects: (model) => ({ 51 | async fetchUndoList() { 52 | const result = await fetchList() 53 | model.reducers.setValue('items', result.data as any) 54 | }, 55 | }), 56 | }) 57 | -------------------------------------------------------------------------------- /docs/demos/todo-list/index.less: -------------------------------------------------------------------------------- 1 | .todo-list { 2 | .header { 3 | line-height: 60px; 4 | background-color: #333; 5 | } 6 | 7 | .header .content { 8 | width: 600px; 9 | margin: 0 auto; 10 | font-size: 24px; 11 | color: #fff; 12 | } 13 | 14 | .header input { 15 | width: 60%; 16 | float: right; 17 | margin-top: 15px; 18 | padding: 0 10px; 19 | line-height: 24px; 20 | border-radius: 5px; 21 | outline: none; 22 | } 23 | 24 | .undo-list { 25 | width: 600px; 26 | margin: 0 auto; 27 | text-align: left; 28 | } 29 | 30 | .undo-list .title { 31 | line-height: 30px; 32 | margin: 10px 0; 33 | font-size: 24px; 34 | font-weight: bold; 35 | } 36 | 37 | .undo-list .count { 38 | float: right; 39 | width: 30px; 40 | height: 30px; 41 | line-height: 30px; 42 | border-radius: 50%; 43 | text-align: center; 44 | font-size: 12px; 45 | background-color: #e6e6e6; 46 | } 47 | 48 | .undo-list .content { 49 | list-style-type: none; 50 | } 51 | 52 | .undo-list .content .item { 53 | line-height: 32px; 54 | font-size: 16px; 55 | margin-bottom: 10px; 56 | background-color: #fff; 57 | border-left: 3px solid #629a9c; 58 | text-indent: 10px; 59 | border-radius: 3px; 60 | } 61 | 62 | .undo-list .content .delete { 63 | float: right; 64 | width: 20px; 65 | height: 20px; 66 | line-height: 20px; 67 | margin-top: 6px; 68 | margin-right: 6px; 69 | font-size: 16px; 70 | margin-bottom: 10px; 71 | background-color: #e6e6e6; 72 | text-indent: 0; 73 | border-radius: 50%; 74 | text-align: center; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /docs/demos/todo-list/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Header from './Header' 3 | import UndoList from './UndoList' 4 | import { store } from './store' 5 | 6 | import './index.less' 7 | 8 | const { Provider } = store 9 | 10 | const TodoList: React.FC = () => { 11 | return ( 12 | 13 |
14 |
15 | 16 |
17 |
18 | ) 19 | } 20 | 21 | export default TodoList 22 | -------------------------------------------------------------------------------- /docs/demos/todo-list/models.ts: -------------------------------------------------------------------------------- 1 | export { header } from './Header/model' 2 | export { undoList } from './UndoList/model' 3 | -------------------------------------------------------------------------------- /docs/demos/todo-list/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, Models } from 'dobux' 2 | import * as models from './models' 3 | 4 | export type RootModel = Models 5 | 6 | export const store = createStore(models) 7 | -------------------------------------------------------------------------------- /docs/guide/best-practice.md: -------------------------------------------------------------------------------- 1 | # 最佳实践 2 | 3 | 在使用 `React` 进行业务开发的过程中,一个页面通常会抽象为多个组件,每个组件可能会维护多个内部状态用于控制组件的表现行为。推荐将组件的状态、修改状态的行为以及  副作用处理函数,即一个组件对应的 [模型](/guide#model) 抽离到一个单独文件中使用 `Dobux` 接管,这样做有以下几方面优势: 4 | 5 | 1. 组件的模型可以集中式的统一管理,逻辑清晰,便于维护和扩展 6 | 2. `reducer` 提供了响应式的编程方式,只需要简单在函数体内修改数据就可以生成不可变数据源,避免出现引用类型的问题,保证依赖的正确性 7 | 3. 副作用的处理通过 `effects` 统一管控,页面只负责 UI 渲染,同时每个 `effect` 上都会记录异步操作的加载状态,避免声明一些冗余的 `loading` 状态,可以简化视图层中的呈现逻辑 8 | 4. 提供了完整的 `TypeScript` 类型定义,在编辑器中能获得完整的类型检查和类型推断 9 | 5. 内置了实用的 `reducer` 大大简化状态修改逻辑,提高开发效率 10 | 11 | 对于使用 `TypeScript` 的项目推荐使用下面两种方式对代码结构进行组织,这样会避免类型 [循环引用报错的问题](/guide/faq#实例化-store-时为什么要对-model-进行循环引用?) 12 | 13 | ## 单一 Model 14 | 15 | 对于简单的业务场景,如单个组件或者一个简单的表单页,通常不需要状态共享,可以采用单一 `Model` 来对视图的状态进行管理。有两种文件组织形式 16 | 17 | ### 组件 + model.ts 18 | 19 | 创建一个 `model.ts` 文件创建组件对应的模型,在组件内引入并通过该 `model` 创建 `Store`,文件组织结构如下: 20 | 21 | ``` 22 | - counter 23 | - index.less 24 | - index.tsx 25 | - model.ts 26 | ``` 27 | 28 | 29 | 30 | ### 组件 + store.ts + model.ts 31 | 32 | 创建一个 `model.ts` 文件创建组件对应的模型同时创建一个 `Store` 文件引入该 `model` 并创建 `Store`,在组件内引入 `Store` 进行消费。文件组织结构如下: 33 | 34 | ``` 35 | - counter 36 | - index.less 37 | - index.tsx 38 | - model.ts 39 | - store.ts 40 | ``` 41 | 42 | 43 | 44 | ## 多 Model 45 | 46 | 对于复杂的业务场景可以使用多 `Model` 模式,按照组件的颗粒度进行 `Model` 的拆分,在一个 `Store` 中处统一注入,多个 `Model` 之间可以通过 `effects` 的第二个参数 `rootModel` 相互调用,通过分层的设计降低单一 `Model` 的复杂度,提升项目的维护性和扩展性 47 | 48 | 文件组织结构如下: 49 | 50 | ``` 51 | - todo-list 52 | - Header 53 | - index.tsx 54 | - model.ts 55 | - UndoList 56 | - index.tsx 57 | - model.ts 58 | - index.less 59 | - index.tsx 60 | - models.ts 61 | - store.ts 62 | ``` 63 | 64 | 65 | -------------------------------------------------------------------------------- /docs/guide/devtools.md: -------------------------------------------------------------------------------- 1 | # Devtools 2 | 3 | `Dobux` 在 **开发环境** 默认集成了 [redux-devtools-extension](https://github.com/zalmoxisus/redux-devtools-extension),可以借助浏览器插件实现 **time travel** 功能。每个通过 [createModel](/api#createmodelrootmodel-modelnamemodel) 生成的 `Model` 都会和 `redux-devtools` 建立一个独立的连接生成一个 `devtools` 实例,对应的实例名称默认为 `dobux/number/modelName`,例如 `dobux/1/counter`,其中 `dobux/1` 为默认生成的 `storeName`(可以通过 [createStore](/api#store--createstoremodels) 创建 `store` 的时候传入自定义的名称),`counter` 为 `modelName`。每一个状态改变的 `action` 名称由 `modelName/reducerName` 组成,例如 `counter/increase` 4 | 5 |
6 | 7 |
8 | 9 | ## 多 Model travel 10 | 11 | `Dobux` 支持使用多 Model 注入。通过 devtools 的实例的切换面板可以根据诉求查看对应的 Model 的状态改变 12 | 13 |
14 | 15 |
16 | 17 | > 注:为了减少过多的占用 Chrome 内存,当前 Model 对应的组件卸载的时候会断开与 devtools 的连接,切换到新的 Model 后可能需要手动重新开启(先关闭再开启)开发者调试工具 18 | -------------------------------------------------------------------------------- /docs/guide/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | order: 3 3 | --- 4 | 5 | # 示例 6 | 7 | ## 简单的计数器 8 | 9 | ### React Hooks 10 | 11 | 12 | 13 | ### Class Component 14 | 15 | 16 | 17 | ## 待办事项清单 18 | 19 | 20 | -------------------------------------------------------------------------------- /docs/guide/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ### 实例化 Store 时为什么要对 Model 进行循环引用? 4 | 5 | 由于 `typescript` 的类型推断是建立在静态检查的前提下,是需要根据用户定义的字面量推断出它的结构,在使用 `createModel` 创建模型的时候,模型内部的 `reducers` 会依赖 `state`,`effects` 会依赖 `model` 和 `rootModel`,内部本身就存在一个循环引用。由于文件之间 `import` 的是静态类型而不是实际的代码,因此不会影响代码本身的执行逻辑,只是通过这种方式帮助推断出类型。如果项目不使用 `typescript` 或者不需要推断出上述的类型,可以避免使用这种类型循环引用 6 | 7 | ### 一个 Effect 中返回另一个 Effect 的执行函数类型推断失效 8 | 9 | ```ts 10 | export const counter = createModel()({ 11 | state: { 12 | count: 0, 13 | }, 14 | reducers: { 15 | increase(state) { 16 | state.count += 1 17 | }, 18 | decrease(state) { 19 | state.count -= 1 20 | }, 21 | }, 22 | effects: (model, rootModel) => ({ 23 | async increaseAsync() { 24 | await wait(1000) 25 | model.reducers.increase() 26 | // 出现循环引用,类型推断错误 27 | return model.effects.decreaseAsync() 28 | }, 29 | 30 | async decreaseAsync() { 31 | await wait(1000) 32 | return -1 33 | }, 34 | }), 35 | }) 36 | ``` 37 | 38 | 这是因为类型推断出现了 **循环引用** 的情况,解决方案是 **手动指定 effect 返回类型,打破循环依赖** 39 | 40 | ```diff 41 | export const counter = createModel()({ 42 | state: { 43 | count: 0, 44 | }, 45 | reducers: { 46 | increase(state) { 47 | state.count += 1 48 | }, 49 | decrease(state) { 50 | state.count -= 1 51 | }, 52 | }, 53 | effects: (model, rootModel) => ({ 54 | async increaseAsync() { 55 | await wait(1000) 56 | model.reducers.increase() 57 | + // 手动指定 effect 返回类型,打破循环依赖 58 | + const result: number = await model.effects.decreaseAsync() 59 | + return result 60 | - return model.effects.decreaseAsync() 61 | }, 62 | 63 | async decreaseAsync() { 64 | await wait(1000) 65 | return -1 66 | }, 67 | }), 68 | }) 69 | ``` 70 | 71 | ### 路由之间切换时 Model 的状态会一直保存,但是业务需要自动卸载? 72 | 73 | 使用 `Dobux` 创建的 `store` 实例会常驻于浏览器的中内存,默认情况下当组件卸载是不会自动重置的,如果想要在组件卸载的时候重置数据可以根据实际需求在组件卸载的时候调用 `reducers.reset` 方法重置 74 | 75 | ```tsx | pure 76 | import React, { FC, useEffect } from 'react' 77 | import store from './store' 78 | 79 | const Counter: FC = () => { 80 | const { 81 | state, 82 | reducers: { reset }, 83 | effects, 84 | } = store.useModel('counter') 85 | 86 | useEffect(() => { 87 | return () => reset() 88 | }, []) 89 | 90 | if (effects.increaseAsync.loading) { 91 | return
loading ...
92 | } 93 | 94 | return
Count: {state.count}
95 | } 96 | ``` 97 | 98 | ### 多 Model 模式下,一个 Model 的改变会影响依赖其他 Model 的组件刷新,引起不必要的渲染吗? 99 | 100 | 不会,一个 `Model` 的状态改变时,只有依赖了这个 `Model` 的组件会发生重新渲染,其他组件是无感知的。同时 `useModel` 同样提供了第二个参数 `mapStateToModel` 进行性能优化,你可以通过该函数的返回值精确的控制组件的渲染力度,[详见](/api#性能优化) 101 | 102 | ### 通过 `useModel` 获取的 `state` 是在一个 Hooks 的闭包中,如何在 `useCallback` 等闭包中获取最新的值? 103 | 104 | `Dobux` 提供了 `getState` API,提供了获取模型最新状态的能力,[详见](/api#storegetstate-modelname-string--modelstate) 105 | -------------------------------------------------------------------------------- /docs/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | order: 2 3 | --- 4 | 5 | # 快速上手 6 | 7 | Tips: 请确保你的 React 版本 >= **16.8.0** 8 | 9 | ## 安装 10 | 11 | ```bash 12 | // 使用 npm 13 | $ npm i dobux --save 14 | 15 | // 使用 yarn 16 | $ yarn add dobux 17 | ``` 18 | 19 | ## 基本使用 20 | 21 | ```jsx | pure 22 | import { createModel, createStore } from 'dobux' 23 | 24 | // 1. 创建 Model 25 | export const counter = createModel()({ 26 | state: { 27 | count: 0, 28 | }, 29 | reducers: { 30 | increase(state) { 31 | state.count += 1 32 | }, 33 | decrease(state) { 34 | state.count -= 1 35 | }, 36 | }, 37 | effects: (model, rootModel) => ({ 38 | async increaseAsync() { 39 | await wait(2000) 40 | model.reducers.increase() 41 | }, 42 | }), 43 | }) 44 | 45 | // 2. 创建 Store 46 | const store = createStore({ 47 | counter, 48 | }) 49 | 50 | // 3. 挂载模型 51 | const { Provider, useModel } = store 52 | 53 | function App() { 54 | return ( 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | // 4. 消费模型 62 | function Counter() { 63 | const { state, reducers, effects } = useModel('counter') 64 | 65 | const handelIncrease = () => { 66 | reducers.increase() 67 | } 68 | 69 | const handelDecrease = () => { 70 | reducers.decrease() 71 | } 72 | 73 | const handelIncreaseAsync = () => { 74 | effects.increaseAsync() 75 | } 76 | 77 | // 当异步请求 `increaseAsync` 执行时 `loading` 会设置为 true,显示 loading 78 | if (effects.increaseAsync.loading) { 79 | return

loading ...

80 | } 81 | 82 | return ( 83 |
84 |
The count is:{state.count}
85 | 86 | 87 | 88 |
89 | ) 90 | } 91 | ``` 92 | 93 | [点击查看 Typescript 示例](/guide/examples#简单的计数器) 94 | -------------------------------------------------------------------------------- /docs/guide/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | order: 1 3 | --- 4 | 5 | # 介绍 6 | 7 | `Dobux` 是基于 React Context 和 React Hooks 的 **轻量级响应式** 状态管理方案 8 | 9 | ## 特性 10 | 11 | - **🎉 简单易用**:仅有 3 个核心 API,无需额外的学习成本,只需要了解 `React Hooks` 12 | - **🚀 不可变数据**:通过简单地修改数据与视图交互,同时保留不可变数据的特性 13 | - **🌲 灵活的使用方式**:支持全局和局部数据源,更优雅的管理整个应用的状态 14 | - **🍳 友好的异步处理**:记录异步操作的加载状态,简化了视图层中的呈现逻辑 15 | - **🍬 TypeScript 支持**:完整的 `TypeScript` 类型定义,在编辑器中能获得完整的类型检查和类型推断 16 | 17 | ## 核心概念 18 | 19 | ### Model 20 | 21 | 对于 `React` 这种组件化的开发方式,一个页面通常会抽象为多个组件,每个组件可能会维护多个内部状态用于控制组件的表现行为。在组件内部还会存在一些副作用的调用,最常见的就是 `Ajax` 请求。在 `Dobux` 中我们将这一组内部状态、用于修改内部状态的方法以及副作用处理函数的组合称为 **Model(模型)** 22 | 23 | 在 `Dobux` 中 `Model` 是最基本的单元,下面分别介绍了 `Model` 的三个组成部分: 24 | 25 | #### State 26 | 27 | `type State = any` 28 | 29 | `State` 保存了当前模型的状态,通常表现为一个 JavaScript 对象(当然它可以是任何值);操作的时候每次都要当作不可变数据(immutable data)来对待,保证每次都是全新对象,没有引用关系,这样才能保证 `State` 的独立性以及依赖的正确性 30 | 31 | ```ts 32 | import { createModel } from 'dobux' 33 | 34 | const counter = createModel()({ 35 | state: { 36 | count: 0, 37 | }, 38 | }) 39 | ``` 40 | 41 | #### Reducer 42 | 43 | `type Reducer = (state: S, ...payload: P) => void` 44 | 45 | 在 `Dobux` 中所有模型状态的改变都必须通过 `Reducer`,它是一个同步执行的函数,在调用时会传入以下几个参数: 46 | 47 | - `state`:当前模型的最新状态 48 | - `...payload: any[]`:调用方传入的多个参数 49 | 50 | **响应式** 体现在对于每一个 `Reducer` 在函数体中可以通过简单地修改数据就能更新状态并刷新组件视图,同时生成不可变数据源,保证依赖的正确性 51 | 52 | ```ts 53 | import { createModel } from 'dobux' 54 | 55 | const counter = createModel()({ 56 | state: { 57 | count: 0, 58 | }, 59 | reducers: { 60 | increase(state) { 61 | state.count += 1 62 | }, 63 | }, 64 | }) 65 | ``` 66 | 67 | 为了简化状态修改逻辑同时避免用户重复的编写常用 `reducer` 的类型约束,`Dobux` 内置了名为 `setValue`、`setValues` 和 `reset` 的 `Reducer` 68 | 69 | ```ts 70 | // modify state specially 71 | reducers.setValue('count', 10) 72 | 73 | // batched modify state 74 | reducers.setValues({ 75 | count: 10, 76 | }) 77 | 78 | // reset whole state 79 | reducers.reset() 80 | // reset partial state 81 | reducers.reset('count') 82 | ``` 83 | 84 | #### Effect 85 | 86 | `type Effect

= (...payload: P) => Promise` 87 | 88 | `Effect` 被称为副作用,它来自于函数编程的概念,之所以叫副作用是因为它使得我们的函数变得不纯,同样的输入不一定获得同样的输出 89 | 90 | 在 `Dobux` 中副作用处理通过调用 `Effect` 执行,通常会在副作用中发送异步请求或者调用其他模型(通过 `rootModel` 可以调用其他模型) 91 | 92 | ```ts 93 | import { createModel } from 'dobux' 94 | 95 | const counter = createModel()({ 96 | state: { 97 | count: 0, 98 | }, 99 | reducers: { 100 | increase(state) { 101 | state.count += 1 102 | }, 103 | }, 104 | effects: (model, rootModel) => ({ 105 | async increaseAsync() { 106 | await wait(2000) 107 | model.reducers.increase() 108 | }, 109 | }), 110 | }) 111 | ``` 112 | 113 | `Dobux` 内置了异步操作 `loading` 态处理,在视图中通过 `effects.effectName.loading` 就可以获取当前副作用的 `loading` 状态,简化了视图逻辑处理 114 | 115 | ```tsx | pure 116 | const Counter: React.FC = () => { 117 | const { state, reducers, effects } = useModel('counter') 118 | 119 | const handleIncrease = () => { 120 | reducers.increase() 121 | } 122 | 123 | const handleDecrease = () => { 124 | reducers.decrease() 125 | } 126 | 127 | const handleIncreaseAsync = () => { 128 | reducers.increaseAsync() 129 | } 130 | 131 | if (effects.increaseAsync.loading) { 132 | return

Loading ...
133 | } 134 | 135 | return ( 136 |
137 |

The count is: {state.count}

138 | 139 | 140 | 141 |
142 | ) 143 | } 144 | ``` 145 | 146 | ### Store 147 | 148 | 在 `Dobux` 中 `Model` 不能独立的完成状态的管理和共享。`Store` 作为 `Model` 的载体可以赋予它这部分的能力。每一个 `Store` 都会包含一个或多个 `Model`,同一个 `Store` 下的一组 `Model` 之间是相互独立、互不干扰的 149 | 150 | 一个应用可以创建多个 `Store`(全局和局部数据源),它们之间也是相互独立、互不干扰的 151 | 152 | ```ts 153 | import { createModel, createStore } from 'dobux' 154 | 155 | const counter = createModel()({ 156 | state: { 157 | count: 0, 158 | }, 159 | reducers: { 160 | increase(state) { 161 | state.count += 1 162 | }, 163 | }, 164 | effects: (model, rootModel) => ({ 165 | async increaseAsync() { 166 | await wait(2000) 167 | model.reducers.increase() 168 | }, 169 | }), 170 | }) 171 | 172 | const store = createStore({ 173 | counter, 174 | }) 175 | ``` 176 | 177 | ## 数据流向 178 | 179 | 数据的改变发生通常是通过用户交互行为触发的,当此类行为触发需要对模型状态修改的时候可以直接调用 `Reducers` 改变 `State` ,如果需要执行副作用(比如异步请求)则需要先调用 `Effects`,执行完副作用后再调用 `Reducers` 改变 `State` 180 | 181 |
182 | 183 |
184 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dobux - React State Management Library 3 | # https://d.umijs.org/zh-CN/config/frontmatter#hero 4 | hero: 5 | title: Dobux 6 | desc: 🍃 轻量级响应式状态管理方案 7 | actions: 8 | - text: 快速上手 9 | link: /guide/getting-started 10 | features: 11 | - icon: https://static.yximgs.com/udata/pkg/ks-ad-fe/kcfe/dobux-feature-simple.png 12 | title: 简单易用 13 | desc: 仅有 3 个核心 API,无需额外的学习成本,只需要了解 React Hooks 14 | - icon: https://static.yximgs.com/udata/pkg/ks-ad-fe/kcfe/dobux-feature-immutable.png 15 | title: 不可变数据源 16 | desc: 通过简单地修改数据与视图进行交互,生成不可变数据源,保证依赖的正确性 17 | - icon: https://static.yximgs.com/udata/pkg/ks-ad-fe/kcfe/dobux-feature-ts.png 18 | title: TypeScript 支持 19 | desc: 提供完整的 TypeScript 类型定义,在编辑器中能获得完整的类型检查和类型推断 20 | footer: Open-source MIT Licensed | Copyright © 2020-present
Powered by [KCFe](https://github.com/kcfe) 21 | --- 22 | 23 | ## 轻松上手 24 | 25 | ```bash 26 | // 使用 npm 27 | $ npm i dobux --save 28 | 29 | // 使用 yarn 30 | $ yarn add dobux 31 | ``` 32 | 33 | 78 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // https://jestjs.io/docs/configuration 2 | module.exports = { 3 | testEnvironment: 'jsdom', 4 | preset: 'ts-jest', 5 | setupFilesAfterEnv: ['/scripts/setupJestEnv.ts'], 6 | rootDir: __dirname, 7 | globals: { 8 | __DEV__: true, 9 | __TEST__: true, 10 | // eslint-disable-next-line @typescript-eslint/no-var-requires 11 | __VERSION__: require('./package.json').version, 12 | __GLOBAL__: false, 13 | __ESM__: true, 14 | __NODE_JS__: true, 15 | 'ts-jest': { 16 | tsconfig: { 17 | // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping 18 | target: 'es2019', 19 | sourceMap: true, 20 | }, 21 | }, 22 | }, 23 | coverageDirectory: 'coverage', 24 | coverageReporters: ['html', 'lcov', 'text'], 25 | collectCoverageFrom: ['src/**/*.ts'], 26 | watchPathIgnorePatterns: ['/node_modules/', '/dist/', '/.git/'], 27 | moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], 28 | testMatch: ['/__tests__/**/*spec.[jt]s?(x)'], 29 | testPathIgnorePatterns: ['/node_modules/', '/examples/__tests__'], 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dobux", 3 | "version": "1.5.1", 4 | "description": "Lightweight responsive state management solution", 5 | "main": "dist/dobux.cjs.js", 6 | "module": "dist/dobux.esm.js", 7 | "types": "dist/dobux.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "dev": "node scripts/dev.js", 13 | "build": "node scripts/build.js", 14 | "docs:dev": "dumi dev docs", 15 | "docs:build": "dumi build docs", 16 | "lint": "eslint 'src/**/*.@(js|ts|jsx|tsx)' --fix", 17 | "format": "prettier --write 'src/**/*.@(js|ts|jsx|tsx)'", 18 | "test": "npm run test:once -- --watch", 19 | "test:once": "jest --runInBand --colors --forceExit", 20 | "coverage": "codecov", 21 | "prepare": "husky install", 22 | "release": "node scripts/release.js" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/kcfe/dobux" 27 | }, 28 | "keywords": [ 29 | "dobux", 30 | "react", 31 | "hooks", 32 | "typescript", 33 | "state-management", 34 | "immutable" 35 | ], 36 | "lint-staged": { 37 | "{src,__tests__}/**/*.{js,jsx,ts,tsx}": [ 38 | "prettier --write", 39 | "eslint --fix" 40 | ] 41 | }, 42 | "dependencies": { 43 | "hoist-non-react-statics": "^3.3.2", 44 | "immer": "^6.0.9" 45 | }, 46 | "peerDependencies": { 47 | "react": "^16.8.3 || ^17", 48 | "react-dom": "^16.8.3 || ^17" 49 | }, 50 | "devDependencies": { 51 | "@commitlint/cli": "^16.2.1", 52 | "@commitlint/config-conventional": "^16.2.1", 53 | "@eljs/release": "0.7.3", 54 | "@microsoft/api-extractor": "^7.19.4", 55 | "@rollup/plugin-commonjs": "^21.0.2", 56 | "@rollup/plugin-image": "^2.1.1", 57 | "@rollup/plugin-json": "^4.1.0", 58 | "@rollup/plugin-node-resolve": "^13.3.0", 59 | "@rollup/plugin-replace": "^4.0.0", 60 | "@testing-library/jest-dom": "^5.16.4", 61 | "@testing-library/react": "12.1.5", 62 | "@testing-library/react-hooks": "^8.0.1", 63 | "@types/fs-extra": "^9.0.3", 64 | "@types/hoist-non-react-statics": "^3.3.1", 65 | "@types/jest": "^27.4.1", 66 | "@types/node": "^17.0.21", 67 | "@types/react": "^16.14.8", 68 | "@types/react-dom": "^16.9.13", 69 | "@typescript-eslint/eslint-plugin": "^5.13.0", 70 | "@typescript-eslint/parser": "^5.13.0", 71 | "chalk": "^4.1.2", 72 | "codecov": "^3.8.3", 73 | "dumi": "^1.1.43", 74 | "eslint": "^8.10.0", 75 | "eslint-config-prettier": "^8.5.0", 76 | "execa": "^5.1.1", 77 | "fs-extra": "^10.1.0", 78 | "husky": "^8.0.1", 79 | "jest": "^27.5.1", 80 | "lint-staged": "^12.3.4", 81 | "minimist": "^1.2.5", 82 | "prettier": "^2.5.1", 83 | "react": "^16.8.0", 84 | "react-dom": "^16.8.0", 85 | "rollup": "^2.69.0", 86 | "rollup-plugin-typescript2": "^0.32.0", 87 | "ts-jest": "^27.1.3", 88 | "ts-node": "^10.6.0", 89 | "tslib": "^2.3.1", 90 | "typescript": "4.5.5" 91 | }, 92 | "author": "Ender Lee", 93 | "publishConfig": { 94 | "registry": "https://registry.npmjs.org/" 95 | }, 96 | "license": "MIT" 97 | } 98 | -------------------------------------------------------------------------------- /public/dobux-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kcfe/dobux/f8a56ab1aaf3d1a4d38eca06a6f8f12fbf596311/public/dobux-flow.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kcfe/dobux/f8a56ab1aaf3d1a4d38eca06a6f8f12fbf596311/public/logo.png -------------------------------------------------------------------------------- /public/simple-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kcfe/dobux/f8a56ab1aaf3d1a4d38eca06a6f8f12fbf596311/public/simple-logo.png -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | .__dumi-default-navbar-logo { 2 | color: transparent !important; 3 | } 4 | 5 | .__dumi-default-previewer-demo { 6 | } 7 | 8 | .__dumi-default-layout-hero { 9 | } 10 | 11 | a[title='站长统计'] { 12 | display: none; 13 | } 14 | 15 | input, 16 | button { 17 | padding: 4px; 18 | } 19 | -------------------------------------------------------------------------------- /public/time-travel-counter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kcfe/dobux/f8a56ab1aaf3d1a4d38eca06a6f8f12fbf596311/public/time-travel-counter.gif -------------------------------------------------------------------------------- /public/time-travel-todo-list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kcfe/dobux/f8a56ab1aaf3d1a4d38eca06a6f8f12fbf596311/public/time-travel-todo-list.gif -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import ts from 'rollup-plugin-typescript2' 3 | import json from '@rollup/plugin-json' 4 | import replace from '@rollup/plugin-replace' 5 | import nodeResolve from '@rollup/plugin-node-resolve' 6 | import commonjs from '@rollup/plugin-commonjs' 7 | 8 | const { resolveRoot } = require('./scripts/utils') 9 | 10 | const pkgJSONPath = resolveRoot('package.json') 11 | const pkg = require(pkgJSONPath) 12 | const name = path.basename(__dirname) 13 | 14 | // ensure TS checks only once for each build 15 | let hasTSChecked = false 16 | 17 | const outputConfigs = { 18 | cjs: { 19 | file: resolveRoot(`dist/${name}.cjs.js`), 20 | format: `cjs`, 21 | }, 22 | esm: { 23 | file: resolveRoot(`dist/${name}.esm.js`), 24 | format: `es`, 25 | }, 26 | } 27 | 28 | const defaultFormats = ['esm', 'cjs'] 29 | const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(',') 30 | const packageFormats = inlineFormats || defaultFormats 31 | const packageConfigs = process.env.PROD_ONLY 32 | ? [] 33 | : packageFormats.map(format => createConfig(format, outputConfigs[format])) 34 | 35 | export default packageConfigs 36 | 37 | function createConfig(format, output, plugins = []) { 38 | if (!output) { 39 | throw new Error(`Invalid format: "${format}"`) 40 | } 41 | 42 | const isProductionBuild = process.env.__DEV__ === 'false' 43 | const isESMBuild = format === 'esm' 44 | const isNodeBuild = format === 'cjs' 45 | 46 | output.exports = 'named' 47 | output.sourcemap = !!process.env.SOURCE_MAP 48 | output.externalLiveBindings = false 49 | 50 | const shouldEmitDeclarations = pkg.types && process.env.TYPES != null && !hasTSChecked 51 | 52 | const tsPlugin = ts({ 53 | check: process.env.NODE_ENV === 'production' && !hasTSChecked, 54 | tsconfig: resolveRoot('tsconfig.json'), 55 | cacheRoot: resolveRoot('node_modules/.rts2_cache'), 56 | tsconfigOverride: { 57 | compilerOptions: { 58 | sourceMap: output.sourcemap, 59 | declaration: shouldEmitDeclarations, 60 | declarationMap: shouldEmitDeclarations, 61 | }, 62 | exclude: ['__tests__'], 63 | }, 64 | }) 65 | // we only need to check TS and generate declarations once for each build. 66 | // it also seems to run into weird issues when checking multiple times 67 | // during a single build. 68 | hasTSChecked = true 69 | 70 | const entryFile = `src/index.ts` 71 | 72 | return { 73 | input: resolveRoot(entryFile), 74 | external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})], 75 | plugins: [ 76 | tsPlugin, 77 | json({ 78 | namedExports: false, 79 | }), 80 | createReplacePlugin(isProductionBuild, isESMBuild, isNodeBuild), 81 | nodeResolve(), 82 | commonjs(), 83 | ...plugins, 84 | ], 85 | output, 86 | onwarn: (msg, warn) => { 87 | if (!/Circular/.test(msg)) { 88 | warn(msg) 89 | } 90 | }, 91 | treeshake: { 92 | moduleSideEffects: false, 93 | }, 94 | watch: { 95 | exclude: ['node_modules/**', 'dist/**'], 96 | }, 97 | } 98 | } 99 | 100 | function createReplacePlugin(isProduction, isESMBuild, isNodeBuild) { 101 | const replacements = { 102 | __VERSION__: `"${pkg.version}"`, 103 | __DEV__: isESMBuild 104 | ? // preserve to be handled by bundlers 105 | `(process.env.NODE_ENV !== 'production')` 106 | : // hard coded dev/prod builds 107 | !isProduction, 108 | __ESM__: isESMBuild, 109 | __NODE_JS__: isNodeBuild, 110 | } 111 | 112 | // allow inline overrides like 113 | Object.keys(replacements).forEach(key => { 114 | if (key in process.env) { 115 | replacements[key] = process.env[key] 116 | } 117 | }) 118 | 119 | return replace({ 120 | values: replacements, 121 | preventAssignment: true, 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const chalk = require('chalk') 3 | const { logger } = require('@eljs/release') 4 | 5 | const { resolveRoot, bin, run } = require('./utils') 6 | 7 | const args = require('minimist')(process.argv.slice(2)) 8 | const formats = args.formats || args.f 9 | const devOnly = args.devOnly || args.d 10 | const prodOnly = !devOnly && (args.prodOnly || args.p) 11 | const sourceMap = args.sourcemap || args.s 12 | const isRelease = args.release 13 | const buildTypes = args.t || args.types || isRelease 14 | 15 | const step = msg => { 16 | logger.step(msg, 'Build') 17 | } 18 | 19 | main() 20 | 21 | async function main() { 22 | if (isRelease) { 23 | // remove build cache for release builds to avoid outdated enum values 24 | await fs.remove(resolveRoot('node_modules/.rts2_cache')) 25 | } 26 | 27 | const pkgJSONPath = resolveRoot('package.json') 28 | const pkg = require(pkgJSONPath) 29 | 30 | // if building a specific format, do not remove dist. 31 | if (!formats) { 32 | await fs.remove(resolveRoot('dist')) 33 | } 34 | 35 | const env = devOnly ? 'development' : 'production' 36 | 37 | step(`Rolling up bundles for ${chalk.cyanBright.bold(pkg.name)}`) 38 | await run(bin('rollup'), [ 39 | '-c', 40 | '--environment', 41 | [ 42 | `NODE_ENV:${env}`, 43 | formats ? `FORMATS:${formats}` : ``, 44 | buildTypes ? `TYPES:true` : ``, 45 | prodOnly ? `PROD_ONLY:true` : ``, 46 | sourceMap ? `SOURCE_MAP:true` : ``, 47 | ] 48 | .filter(Boolean) 49 | .join(','), 50 | ]) 51 | 52 | // build types 53 | if (buildTypes && pkg.types) { 54 | step(`Rolling up type definitions for ${chalk.cyanBright.bold(pkg.name)}`) 55 | console.log() 56 | 57 | const { Extractor, ExtractorConfig } = require('@microsoft/api-extractor') 58 | 59 | const extractorConfigPath = resolveRoot(`api-extractor.json`) 60 | const extractorConfig = ExtractorConfig.loadFileAndPrepare(extractorConfigPath) 61 | const extractorResult = Extractor.invoke(extractorConfig, { 62 | localBuild: true, 63 | showVerboseMessages: true, 64 | }) 65 | 66 | if (!extractorResult.succeeded) { 67 | logger.printErrorAndExit( 68 | `API Extractor completed with ${extractorResult.errorCount} errors` + 69 | ` and ${extractorResult.warningCount} warnings.` 70 | ) 71 | } 72 | 73 | await fs.remove(resolveRoot('dist/src')) 74 | } 75 | 76 | console.log() 77 | logger.done(`Compiled ${chalk.cyanBright.bold(pkg.name)} successfully.`) 78 | console.log() 79 | } 80 | -------------------------------------------------------------------------------- /scripts/dev.js: -------------------------------------------------------------------------------- 1 | const cp = require('child_process') 2 | const path = require('path') 3 | const { logger } = require('@eljs/release') 4 | 5 | const { resolveRoot, bin, run } = require('./utils') 6 | 7 | const args = require('minimist')(process.argv.slice(2)) 8 | const buildTypes = args.t || args.types 9 | 10 | main() 11 | 12 | async function main() { 13 | const pkgJSONPath = resolveRoot('package.json') 14 | const pkg = require(pkgJSONPath) 15 | 16 | if (pkg.private) { 17 | return 18 | } 19 | 20 | logger.step(`Watching ${chalk.cyanBright.bold(pkg.name)}`, 'Dev') 21 | if (buildTypes) { 22 | const watch = cp.spawn(bin('rollup'), [ 23 | '-c', 24 | '-w', 25 | '--environment', 26 | [`FORMATS:cjs`, `TYPES:true`], 27 | ]) 28 | 29 | watch.stdout.on('data', data => { 30 | console.log(data.toString()) 31 | try { 32 | doBuildTypes() 33 | } catch (err) {} 34 | }) 35 | 36 | watch.stderr.on('data', data => { 37 | console.log(data.toString()) 38 | try { 39 | doBuildTypes() 40 | } catch (err) {} 41 | }) 42 | 43 | function doBuildTypes() { 44 | if (buildTypes && pkg.types) { 45 | const { Extractor, ExtractorConfig } = require('@microsoft/api-extractor') 46 | 47 | const extractorConfigPath = path.resolve(pkgDir, `api-extractor.json`) 48 | const extractorConfig = ExtractorConfig.loadFileAndPrepare(extractorConfigPath) 49 | const extractorResult = Extractor.invoke(extractorConfig, { 50 | localBuild: true, 51 | showVerboseMessages: true, 52 | }) 53 | 54 | if (!extractorResult.succeeded) { 55 | logger.printErrorAndExit( 56 | `API Extractor completed with ${extractorResult.errorCount} errors` + 57 | ` and ${extractorResult.warningCount} warnings.` 58 | ) 59 | } 60 | 61 | removeSync(`${pkgDir}/dist/packages`) 62 | } 63 | } 64 | } else { 65 | await run(bin('rollup'), ['-c', '-w', '--environment', [`FORMATS:cjs`]]) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /scripts/release.js: -------------------------------------------------------------------------------- 1 | const { logger, release } = require('@eljs/release') 2 | 3 | const { bin, run } = require('./utils') 4 | 5 | const args = require('minimist')(process.argv.slice(2)) 6 | const skipTests = args.skipTests 7 | const skipBuild = args.skipBuild 8 | 9 | main().catch(err => { 10 | console.error(err) 11 | process.exit(1) 12 | }) 13 | 14 | async function main() { 15 | const { stdout } = await run('git', ['status', '--porcelain'], { 16 | stdio: 'pipe', 17 | }) 18 | 19 | if (stdout) { 20 | logger.printErrorAndExit('Your git status is not clean. Aborting.') 21 | } 22 | 23 | // run tests before release 24 | logger.step('Running tests ...') 25 | if (!skipTests) { 26 | await run(bin('jest'), ['--clearCache']) 27 | await run('pnpm', ['test:once', '--bail', '--passWithNoTests']) 28 | } else { 29 | console.log(`(skipped)`) 30 | } 31 | 32 | // build package with types 33 | logger.step('Building package ...') 34 | if (!skipBuild) { 35 | await run('pnpm', ['build', '--release']) 36 | } else { 37 | console.log(`(skipped)`) 38 | } 39 | 40 | release({ 41 | checkGitStatus: false, 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /scripts/setupJestEnv.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect' 6 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const execa = require('execa') 3 | 4 | function resolveRoot(p) { 5 | return path.resolve(__dirname, '..', p) 6 | } 7 | 8 | function bin(name) { 9 | return path.resolve(__dirname, '../node_modules/.bin/' + name) 10 | } 11 | 12 | function run(bin, args, opts = {}) { 13 | return execa(bin, args, { stdio: 'inherit', ...opts }) 14 | } 15 | 16 | module.exports = { 17 | resolveRoot, 18 | bin, 19 | run, 20 | } 21 | -------------------------------------------------------------------------------- /src/common/const.ts: -------------------------------------------------------------------------------- 1 | export const STORE_NAME_PREFIX = 'dobux' 2 | -------------------------------------------------------------------------------- /src/common/env.ts: -------------------------------------------------------------------------------- 1 | const NODE_ENV = process.env.NODE_ENV 2 | 3 | export const isDev = NODE_ENV === 'development' 4 | export const isProd = NODE_ENV === 'production' 5 | -------------------------------------------------------------------------------- /src/core/Container.ts: -------------------------------------------------------------------------------- 1 | import { shallowEqual } from '../utils/shallowEqual' 2 | import { StateSubscriber, EffectSubscriber } from '../types' 3 | 4 | type SubscribeType = 'state' | 'effect' 5 | 6 | export class Container { 7 | private stateSubscribers: StateSubscriber[] = [] 8 | private effectSubscribers: EffectSubscriber[] = [] 9 | 10 | public subscribe(type: 'state', payload: StateSubscriber): void 11 | public subscribe(type: 'effect', payload: EffectSubscriber): void 12 | public subscribe(type: SubscribeType, payload: any): void { 13 | if (type === 'state') { 14 | const stateSubscriber = payload 15 | 16 | /* istanbul ignore else */ 17 | if (this.stateSubscribers.indexOf(stateSubscriber) === -1) { 18 | this.stateSubscribers.push(stateSubscriber) 19 | } 20 | } /* istanbul ignore else */ else if (type === 'effect') { 21 | const effectSubscriber = payload 22 | /* istanbul ignore else */ 23 | if (this.effectSubscribers.indexOf(effectSubscriber) === -1) { 24 | this.effectSubscribers.push(effectSubscriber) 25 | } 26 | } 27 | } 28 | 29 | public notify(payload?: T): void { 30 | if (payload) { 31 | for (let i = 0; i < this.stateSubscribers.length; i++) { 32 | const { dispatcher, mapStateToModel, prevState } = this.stateSubscribers[i] 33 | 34 | const newState = mapStateToModel(payload) 35 | 36 | this.stateSubscribers[i].prevState = newState 37 | 38 | if (!shallowEqual(prevState, newState)) { 39 | dispatcher(Object.create(null)) 40 | } 41 | } 42 | } else { 43 | for (let i = 0; i < this.effectSubscribers.length; i++) { 44 | const dispatcher = this.effectSubscribers[i] 45 | dispatcher(Object.create(null)) 46 | } 47 | } 48 | } 49 | 50 | public unsubscribe(type: 'state', payload: StateSubscriber): void 51 | public unsubscribe(type: 'effect', payload: EffectSubscriber): void 52 | public unsubscribe(type: SubscribeType, payload: any): void { 53 | if (type === 'state') { 54 | const index = this.stateSubscribers.indexOf(payload) 55 | 56 | if (index !== -1) { 57 | this.stateSubscribers.splice(index, 1) 58 | } 59 | } /* istanbul ignore else */ else if (type === 'effect') { 60 | const index = this.effectSubscribers.indexOf(payload) 61 | 62 | if (index !== -1) { 63 | this.effectSubscribers.splice(index, 1) 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/core/Model.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect, Dispatch } from 'react' 2 | import { unstable_batchedUpdates as batchedUpdates } from 'react-dom' 3 | import produce from 'immer' 4 | import { 5 | ConfigReducer, 6 | ContextPropsModel, 7 | MapStateToModel, 8 | ModelConfig, 9 | ModelConfigEffect, 10 | ModelContextProps, 11 | Noop, 12 | StateSubscriber, 13 | } from '../types' 14 | import { invariant } from '../utils/invariant' 15 | import { isFunction, isObject } from '../utils/type' 16 | import { createProvider } from './createProvider' 17 | import { Container } from './Container' 18 | import { noop } from '../utils/func' 19 | import { isDev } from '../common/env' 20 | 21 | interface ModelOptions { 22 | storeName: string 23 | name: string 24 | config: C 25 | rootModel: Record 26 | autoReset: boolean 27 | devtools: boolean 28 | } 29 | interface ModelInstance { 30 | [key: string]: number 31 | } 32 | 33 | /* istanbul ignore next */ 34 | const devtoolExtension = 35 | isDev && typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ 36 | 37 | export class Model { 38 | static instances: ModelInstance = Object.create(null) 39 | 40 | private model: ContextPropsModel 41 | private initialState: C['state'] 42 | 43 | private container = new Container() 44 | private currentDispatcher: Dispatch = noop 45 | private isInternalUpdate = false 46 | 47 | private instanceName: string 48 | private devtoolInstance?: DevtoolInstance 49 | private unsubscribeDevtool?: Noop 50 | private isTimeTravel = false 51 | 52 | public Provider: React.FC 53 | private useContext: () => ModelContextProps 54 | 55 | constructor(private options: ModelOptions) { 56 | const { storeName, name, config, rootModel } = options 57 | 58 | this.instanceName = `${storeName}/${name}` 59 | 60 | /* istanbul ignore else */ 61 | if (!Model.instances[this.instanceName]) { 62 | Model.instances[this.instanceName] = 0 63 | } 64 | 65 | this.initialState = config.state 66 | this.model = this.initModel(config) 67 | 68 | rootModel[name] = this.model 69 | 70 | const [Provider, useContext] = createProvider(this.model) 71 | 72 | this.Provider = Provider 73 | this.useContext = useContext 74 | } 75 | 76 | public useModel(mapStateToModel: MapStateToModel): any { 77 | const [, dispatcher] = useState() 78 | const { model } = this.useContext() 79 | 80 | this.currentDispatcher = dispatcher 81 | 82 | const subscriberRef = useRef>() 83 | 84 | // make sure only subscribe once 85 | if (!subscriberRef.current) { 86 | const subscriber = { 87 | dispatcher, 88 | mapStateToModel, 89 | prevState: mapStateToModel(this.model.state), 90 | } 91 | 92 | subscriberRef.current = subscriber 93 | this.container.subscribe('state', subscriber) 94 | } 95 | 96 | useEffect(() => { 97 | /* istanbul ignore else */ 98 | if (this.options.devtools) { 99 | // a Model only creates one devtool instance 100 | if (Model.instances[this.instanceName] === 0) { 101 | this.initDevtools() 102 | } 103 | 104 | Model.instances[this.instanceName]++ 105 | } 106 | 107 | return (): void => { 108 | if (this.options.autoReset) { 109 | this.model.state = this.initialState 110 | } 111 | 112 | // unsubscribe when component unmount 113 | this.container.unsubscribe('state', subscriberRef.current as StateSubscriber) 114 | this.container.unsubscribe('effect', dispatcher) 115 | 116 | /* istanbul ignore next */ 117 | if (isFunction(this.unsubscribeDevtool)) { 118 | Model.instances[this.instanceName]-- 119 | 120 | // disconnect after all dependent components are destroyed 121 | if (Model.instances[this.instanceName] <= 0) { 122 | this.unsubscribeDevtool() 123 | devtoolExtension && devtoolExtension.disconnect?.() 124 | } 125 | } 126 | } 127 | }, []) 128 | 129 | invariant( 130 | isObject(model), 131 | '[store.useModel] You should add or withProvider() in the upper layer when calling useModel' 132 | ) 133 | 134 | const state = mapStateToModel(model.state) 135 | // model.state = state 136 | 137 | return { 138 | state, 139 | reducers: model.reducers, 140 | effects: model.effects, 141 | } 142 | } 143 | 144 | private produceState(state: C['state'], reducer: ConfigReducer, payload: any = []): C['state'] { 145 | let newState 146 | 147 | if (typeof state === 'object') { 148 | newState = produce(state, draft => { 149 | return reducer(draft, ...payload) 150 | }) 151 | } else { 152 | newState = reducer(state, ...payload) 153 | } 154 | 155 | return newState 156 | } 157 | 158 | private notify(name: string, state: C['state']): void { 159 | /* istanbul ignore next */ 160 | if (this.devtoolInstance) { 161 | this.devtoolInstance.send?.(`${this.options.name}/${name}`, state) 162 | } 163 | 164 | batchedUpdates(this.container.notify.bind(this.container, state)) 165 | } 166 | 167 | private getReducers(config: C): ContextPropsModel['reducers'] { 168 | const reducers: ContextPropsModel['reducers'] = Object.keys(config.reducers).reduce( 169 | (reducers, name) => { 170 | const originReducer = config.reducers[name] 171 | 172 | const reducer = (...payload: any[]): void => { 173 | const newState = this.produceState(this.model.state, originReducer, payload) 174 | 175 | this.model.state = newState 176 | this.notify(name, newState) 177 | } 178 | 179 | reducers[name] = reducer 180 | return reducers 181 | }, 182 | Object.create(null) 183 | ) 184 | 185 | // internal reducer setValue 186 | if (!reducers.setValue) { 187 | reducers.setValue = (key: string, value: any): void => { 188 | let newState 189 | 190 | if (typeof value === 'function') { 191 | newState = this.produceState(this.model.state, draft => { 192 | draft[key] = this.produceState(this.model.state[key], value) 193 | }) 194 | } else { 195 | newState = this.produceState(this.model.state, draft => { 196 | draft[key] = value 197 | }) 198 | } 199 | 200 | this.model.state = newState 201 | this.notify('setValue', newState) 202 | } 203 | } 204 | 205 | // internal reducer setValues 206 | if (!reducers.setValues) { 207 | reducers.setValues = (partialState: any): void => { 208 | let newState 209 | 210 | if (typeof partialState === 'function') { 211 | newState = this.produceState(this.model.state, partialState) 212 | } else { 213 | newState = this.produceState(this.model.state, draft => { 214 | Object.keys(partialState).forEach(key => { 215 | draft[key] = partialState[key] 216 | }) 217 | }) 218 | } 219 | 220 | this.model.state = newState 221 | 222 | /* istanbul ignore next */ 223 | if (this.isTimeTravel) { 224 | this.container.notify(newState) 225 | } else { 226 | this.notify('setValues', newState) 227 | } 228 | } 229 | } 230 | 231 | // internal reducer reset 232 | if (!reducers.reset) { 233 | reducers.reset = (key): void => { 234 | const newState = this.produceState(this.model.state, draft => { 235 | if (key) { 236 | draft[key] = this.initialState[key] 237 | } else { 238 | Object.keys(this.initialState).forEach(key => { 239 | draft[key] = this.initialState[key] 240 | }) 241 | } 242 | }) 243 | 244 | this.model.state = newState 245 | this.notify('reset', newState) 246 | } 247 | } 248 | 249 | return reducers 250 | } 251 | 252 | private getEffects(config: C): ContextPropsModel['effects'] { 253 | return Object.keys(config.effects).reduce((effects, name) => { 254 | const originEffect = config.effects[name] 255 | 256 | const effect: ModelConfigEffect = async ( 257 | ...payload: any[] 258 | ): Promise => { 259 | try { 260 | effect.identifier++ 261 | 262 | this.isInternalUpdate = true 263 | effect.loading = true 264 | this.isInternalUpdate = false 265 | 266 | const result = await originEffect(...payload) 267 | return result 268 | } catch (error) { 269 | throw error 270 | } finally { 271 | effect.identifier-- 272 | 273 | /* istanbul ignore else */ 274 | if (effect.identifier === 0) { 275 | this.isInternalUpdate = true 276 | effect.loading = false 277 | this.isInternalUpdate = false 278 | } 279 | } 280 | } 281 | 282 | effect.loading = false 283 | effect.identifier = 0 284 | 285 | let value = false 286 | const that = this 287 | 288 | Object.defineProperty(effect, 'loading', { 289 | configurable: false, 290 | enumerable: true, 291 | 292 | get() { 293 | that.container.subscribe('effect', that.currentDispatcher) 294 | return value 295 | }, 296 | 297 | set(newValue) { 298 | // avoid modify effect loading out of internal 299 | /* istanbul ignore else */ 300 | if (newValue !== value && that.isInternalUpdate) { 301 | value = newValue 302 | that.container.notify() 303 | } 304 | }, 305 | }) 306 | 307 | effects[name] = effect 308 | 309 | return effects 310 | }, Object.create(null)) 311 | } 312 | 313 | private initModel(config: C): ContextPropsModel { 314 | // @ts-ignore 315 | config.reducers = this.getReducers(config) 316 | // @ts-ignore 317 | config.effects = this.getEffects(config) 318 | 319 | // @ts-ignore 320 | return config 321 | } 322 | 323 | private initDevtools(): void { 324 | /* istanbul ignore next */ 325 | if (devtoolExtension && isFunction(devtoolExtension.connect)) { 326 | // https://github.com/zalmoxisus/redux-devtools-extension/blob/master/docs/API/Arguments.md#name 327 | this.devtoolInstance = devtoolExtension.connect({ 328 | name: this.instanceName, 329 | }) 330 | 331 | if (isFunction(this.devtoolInstance?.subscribe) && isFunction(this.devtoolInstance?.init)) { 332 | this.unsubscribeDevtool = this.devtoolInstance.subscribe( 333 | /* istanbul ignore next */ message => { 334 | if (message.type === 'DISPATCH' && message.state) { 335 | this.isTimeTravel = true 336 | this.model.reducers.setValues(JSON.parse(message.state)) 337 | this.isTimeTravel = false 338 | } 339 | } 340 | ) 341 | 342 | this.devtoolInstance.init(this.initialState) 343 | } 344 | } 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/core/Store.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithoutRef, RefAttributes } from 'react' 2 | import hoistNonReactStatics from 'hoist-non-react-statics' 3 | import { Model } from './Model' 4 | import { isObject, isArray, isUndefined, isNull, isFunction } from '../utils/type' 5 | import { invariant } from '../utils/invariant' 6 | import { isDev } from '../common/env' 7 | 8 | import { 9 | Configs, 10 | StoreOptions, 11 | ModelConfig, 12 | StoreProvider, 13 | MapStateToModel, 14 | Models, 15 | HOC, 16 | ModelsState, 17 | ModelsReducers, 18 | ModelsEffects, 19 | } from '../types' 20 | 21 | type StoreModels = { 22 | [K in keyof C]: Model> 23 | } 24 | 25 | export class Store { 26 | private models: StoreModels 27 | private rootModel = Object.create(null) 28 | 29 | constructor(private configs: C, options: Required>) { 30 | if (options.autoReset && isDev) { 31 | console.error( 32 | `[dobux] \`autoReset\` is deprecated, please check https://kcfe.github.io/dobux/api#store--createstoremodels-options` 33 | ) 34 | } 35 | 36 | this.models = this.initModels(configs, options) 37 | 38 | this.getState = this.getState.bind(this) 39 | this.getReducers = this.getReducers.bind(this) 40 | this.getEffects = this.getEffects.bind(this) 41 | } 42 | 43 | public Provider: StoreProvider = ({ children }): React.ReactElement => { 44 | Object.keys(this.models).forEach(namespace => { 45 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 46 | const { Provider } = this.models[namespace] 47 | children = {children} 48 | }) 49 | 50 | return <>{children} 51 | } 52 | 53 | public withProvider =

>(Component: React.ComponentType

) => { 54 | const WithProvider: React.FC

= props => { 55 | return ( 56 | 57 | 58 | 59 | ) 60 | } 61 | 62 | const displayName = Component.displayName || Component.name 63 | 64 | WithProvider.displayName = `${displayName}-with-provider` 65 | 66 | hoistNonReactStatics(WithProvider, Component) 67 | 68 | return WithProvider 69 | } 70 | 71 | // https://stackoverflow.com/questions/61743517/what-is-the-right-way-to-use-forwardref-with-withrouter 72 | public withProviderForwardRef = ( 73 | Component: React.ForwardRefExoticComponent & RefAttributes> 74 | ) => { 75 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 76 | const WithProvider: React.FC = ({ forwardedRef, ...props }) => { 77 | return ( 78 | 79 | 80 | 81 | ) 82 | } 83 | 84 | const WithProviderForwardRef = React.forwardRef((props, ref) => ( 85 | 86 | )) 87 | 88 | const displayName = Component.displayName || Component.name 89 | 90 | WithProviderForwardRef.displayName = `${displayName}-with-provider-forwardRef` 91 | 92 | hoistNonReactStatics(WithProviderForwardRef, Component) 93 | 94 | return WithProviderForwardRef 95 | } 96 | 97 | public useModel = ( 98 | modelName: K, 99 | mapStateToModel: MapStateToModel[K], S> = (state: S): S => state 100 | ): Models[K] => { 101 | invariant(!isUndefined(modelName), `[store.useModel] Expected the modelName not to be empty.`) 102 | 103 | const modelNames = Object.keys(this.configs) 104 | 105 | invariant( 106 | modelNames.indexOf(modelName as string) > -1, 107 | `[store.useModel] Expected the modelName to be one of ${modelNames}, but got ${ 108 | modelName as string 109 | }.` 110 | ) 111 | 112 | invariant( 113 | isUndefined(mapStateToModel) || isFunction(mapStateToModel), 114 | `[store.useModel] Expected the mapStateToModel to be function or undefined, but got ${typeof mapStateToModel}.` 115 | ) 116 | 117 | return this.models[modelName].useModel(mapStateToModel) 118 | } 119 | 120 | public withModel = ( 121 | modelName: K, 122 | mapStateToModel?: MapStateToModel[K], S>, 123 | contextName?: string 124 | ): HOC => { 125 | return Component => { 126 | const WithModel: React.FC = props => { 127 | const store = this.useModel(modelName, mapStateToModel) 128 | 129 | if (contextName && typeof contextName === 'string') { 130 | if (props.hasOwnProperty(contextName)) { 131 | console.warn( 132 | `IMPORT MODEL FAILED: The component wrapped by [withModel] already has "${contextName}" in its props.` 133 | ) 134 | return 135 | } else { 136 | return 137 | } 138 | } else { 139 | if (props.hasOwnProperty('state')) { 140 | console.warn( 141 | `IMPORT MODEL FAILED: The component wrapped by [withModel] already has "state" in its props.` 142 | ) 143 | Reflect.deleteProperty(store, 'state') 144 | } 145 | 146 | if (props.hasOwnProperty('reducers')) { 147 | console.warn( 148 | `IMPORT MODEL FAILED: The component wrapped by [withModel] already has "reducers" in its props.` 149 | ) 150 | Reflect.deleteProperty(store, 'reducers') 151 | } 152 | 153 | if (props.hasOwnProperty('effects')) { 154 | console.warn( 155 | `IMPORT MODEL FAILED: The component wrapped by [withModel] already has "effects" in its props.` 156 | ) 157 | Reflect.deleteProperty(store, 'effects') 158 | } 159 | 160 | return 161 | } 162 | } 163 | 164 | const displayName = Component.displayName || Component.name 165 | 166 | WithModel.displayName = `${displayName}-with-model` 167 | 168 | hoistNonReactStatics(WithModel, Component) 169 | 170 | return WithModel 171 | } 172 | } 173 | 174 | public withModels = ( 175 | modelNames: K[], 176 | mapStateToModels?: { 177 | [p in keyof C]?: MapStateToModel[p], S> 178 | }, 179 | contextName = 'models' 180 | ): HOC => { 181 | return Component => { 182 | const WithModels: React.FC = props => { 183 | if (props.hasOwnProperty(contextName)) { 184 | console.warn( 185 | `IMPORT MODELS FAILED: The component wrapped by [withModels] already has "${contextName}" in its props.` 186 | ) 187 | return 188 | } 189 | 190 | const store = { 191 | [contextName]: modelNames.reduce((s, modelName) => { 192 | s[modelName] = this.useModel(modelName, mapStateToModels?.[modelName]) 193 | return s 194 | }, Object.create(null)), 195 | } 196 | 197 | return 198 | } 199 | 200 | const displayName = Component.displayName || Component.name 201 | 202 | WithModels.displayName = `${displayName}-with-models` 203 | 204 | hoistNonReactStatics(WithModels, Component) 205 | 206 | return WithModels 207 | } 208 | } 209 | 210 | public getState(): ModelsState 211 | public getState(modelName: K): ModelsState[K] 212 | public getState(modelName?: K) { 213 | if (modelName) { 214 | const modelNames = Object.keys(this.configs) 215 | 216 | invariant( 217 | modelNames.indexOf(modelName as string) > -1, 218 | `[store.getState] Expected the modelName to be one of ${modelNames}, but got ${ 219 | modelName as string 220 | }.` 221 | ) 222 | 223 | return this.rootModel[modelName].state 224 | } else { 225 | const state = Object.keys(this.rootModel).reduce((state, modelName) => { 226 | state[modelName] = this.rootModel[modelName].state 227 | return state 228 | }, Object.create(null)) 229 | 230 | return state 231 | } 232 | } 233 | 234 | public getReducers(): ModelsReducers 235 | public getReducers(modelName: K): ModelsReducers[K] 236 | public getReducers(modelName?: K) { 237 | if (modelName) { 238 | const modelNames = Object.keys(this.configs) 239 | 240 | invariant( 241 | modelNames.indexOf(modelName as string) > -1, 242 | `[store.getReducers] Expected the modelName to be one of ${modelNames}, but got ${ 243 | modelName as string 244 | }.` 245 | ) 246 | 247 | return this.rootModel[modelName].reducers 248 | } else { 249 | const reducers = Object.keys(this.rootModel).reduce((reducers, modelName) => { 250 | reducers[modelName] = this.rootModel[modelName].reducers 251 | return reducers 252 | }, Object.create(null)) 253 | 254 | return reducers 255 | } 256 | } 257 | 258 | public getEffects(): ModelsEffects 259 | public getEffects(modelName: K): ModelsEffects[K] 260 | public getEffects(modelName?: K) { 261 | if (modelName) { 262 | const modelNames = Object.keys(this.configs) 263 | 264 | invariant( 265 | modelNames.indexOf(modelName as string) > -1, 266 | `[store.getEffects] Expected the modelName to be one of ${modelNames}, but got ${ 267 | modelName as string 268 | }.` 269 | ) 270 | 271 | return this.rootModel[modelName].effects 272 | } else { 273 | const effects = Object.keys(this.rootModel).reduce((effects, modelName) => { 274 | effects[modelName] = this.rootModel[modelName].effects 275 | return effects 276 | }, Object.create(null)) 277 | 278 | return effects 279 | } 280 | } 281 | 282 | private initModels(configs: C, options: Required>): StoreModels { 283 | const { name: storeName, autoReset, devtools } = options 284 | const modelNames = Object.keys(configs) 285 | 286 | invariant(modelNames.length > 0, `createStore requires at least one configuration.`) 287 | 288 | return modelNames.reduce((models, name) => { 289 | const { state, reducers, effects } = configs[name] 290 | 291 | invariant( 292 | !isUndefined(state) && !isNull(state), 293 | `[createStore] Expected the state of ${name} not to be undefined.` 294 | ) 295 | 296 | const config = Object.create(null) 297 | 298 | config.state = isObject(state) ? { ...state } : isArray(state) ? [...state] : state 299 | config.reducers = { ...reducers } 300 | config.effects = effects(config, this.rootModel) 301 | 302 | models[name] = new Model({ 303 | storeName, 304 | name, 305 | config, 306 | rootModel: this.rootModel, 307 | autoReset: isArray(autoReset) ? (autoReset as any[]).indexOf(name) > -1 : autoReset, 308 | devtools: isArray(devtools) ? (devtools as any[]).indexOf(name) > -1 : devtools, 309 | }) 310 | 311 | return models 312 | }, Object.create(null)) 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/core/createProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, Context } from 'react' 2 | import { ContextPropsModel, ModelContextProps } from '../types' 3 | 4 | type ReturnType = [React.FC, () => ModelContextProps] 5 | 6 | const NO_PROVIDER: any = '__NP__' 7 | 8 | function createUseContext(context: Context) { 9 | return (): ModelContextProps => { 10 | const value = useContext(context) 11 | return value 12 | } 13 | } 14 | 15 | export function createProvider(model: ContextPropsModel): ReturnType { 16 | const Context = createContext(NO_PROVIDER) 17 | const value = { 18 | model, 19 | } 20 | 21 | const Provider: React.FC = props => { 22 | return {props.children} 23 | } 24 | 25 | return [Provider, createUseContext(Context)] 26 | } 27 | -------------------------------------------------------------------------------- /src/default.ts: -------------------------------------------------------------------------------- 1 | export const defaultOptions = { 2 | autoReset: false, 3 | devtools: true, 4 | } 5 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import { Noop } from './types' 2 | import '@testing-library/jest-dom' 3 | 4 | declare global { 5 | interface DevtoolExtension { 6 | connect: (options: { name?: string }) => DevtoolInstance 7 | disconnect: Noop 8 | } 9 | 10 | interface DevtoolInstance { 11 | subscribe: (cb: (message: { type: string; state: any }) => void) => Noop 12 | send: (actionType: string, payload: Record) => void 13 | init: (state: any) => void 14 | } 15 | 16 | interface Window { 17 | __REDUX_DEVTOOLS_EXTENSION__: DevtoolExtension 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Store } from './core/Store' 2 | import { defaultOptions } from './default' 3 | import { getStoreName } from './utils/func' 4 | 5 | import { Configs, ConfigReducers, ConfigEffects, Noop, StoreOptions } from './types' 6 | 7 | export { Store } 8 | export * from './types' 9 | 10 | export function createStore(configs: C, options?: StoreOptions): Store { 11 | const opts = Object.assign( 12 | { 13 | name: getStoreName(), 14 | }, 15 | defaultOptions, 16 | options 17 | ) 18 | 19 | return new Store(configs, opts) 20 | } 21 | 22 | export const createModel: , N extends keyof RM>() => < 23 | S, 24 | R extends ConfigReducers, 25 | E extends ConfigEffects 26 | >(model: { 27 | state: S 28 | reducers?: R 29 | effects?: E 30 | }) => { 31 | state: S 32 | reducers: R extends ConfigReducers ? R : Record 33 | effects: E extends ConfigEffects ? E : Noop> 34 | } = 35 | () => 36 | (model): any => { 37 | const { state, reducers = {}, effects = (): Record => ({}) } = model 38 | 39 | return { 40 | state, 41 | reducers, 42 | effects, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from 'react' 2 | import { Draft } from 'immer' 3 | import { Nothing } from 'immer/dist/internal' 4 | 5 | export type Push = ((r: any, ...x: L) => void) extends (...x: infer L2) => void 6 | ? { [K in keyof L2]-?: K extends keyof L ? L[K] : T } 7 | : never 8 | 9 | // convert a union to an intersection: X | Y | Z ==> X & Y & Z 10 | export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( 11 | k: infer I 12 | ) => void 13 | ? I 14 | : never 15 | 16 | // convert a union to an overloaded function X | Y ==> ((x: X)=>void) & ((y:Y)=>void) 17 | export type UnionToOvlds = UnionToIntersection void : never> 18 | 19 | // convert a union to a tuple X | Y => [X, Y] 20 | // a union of too many elements will become an array instead 21 | export type UnionToTuple = UTT0 extends infer T 22 | ? T extends any[] 23 | ? Exclude extends never 24 | ? T 25 | : U[] 26 | : never 27 | : never 28 | 29 | // each type function below pulls the last element off the union and 30 | // pushes it onto the list it builds 31 | export type UTT0 = UnionToOvlds extends (a: infer A) => void 32 | ? Push>, A> 33 | : [] 34 | export type UTT1 = UnionToOvlds extends (a: infer A) => void 35 | ? Push>, A> 36 | : [] 37 | export type UTT2 = UnionToOvlds extends (a: infer A) => void 38 | ? Push>, A> 39 | : [] 40 | export type UTT3 = UnionToOvlds extends (a: infer A) => void 41 | ? Push>, A> 42 | : [] 43 | export type UTT4 = UnionToOvlds extends (a: infer A) => void 44 | ? Push>, A> 45 | : [] 46 | export type UTT5 = UnionToOvlds extends (a: infer A) => void 47 | ? Push>, A> 48 | : [] 49 | export type UTTX = [] 50 | 51 | export interface Noop { 52 | (...args: any[]): R 53 | } 54 | 55 | export interface ConfigReducer { 56 | (state: S, ...payload: any): void 57 | } 58 | 59 | export interface ConfigReducers { 60 | [name: string]: ConfigReducer 61 | } 62 | 63 | export interface ConfigEffect { 64 | (...payload: any[]): any 65 | } 66 | 67 | export interface ConfigEffects { 68 | (model: M, rootModel: RM): { [key: string]: ConfigEffect } 69 | } 70 | 71 | export interface Config { 72 | state: S 73 | reducers: ConfigReducers 74 | effects: ConfigEffects 75 | } 76 | 77 | export interface Configs { 78 | [key: string]: Config 79 | } 80 | 81 | export interface ModelEffectState { 82 | readonly loading: boolean 83 | } 84 | 85 | export type ValidRecipeReturnType = 86 | | State 87 | | void 88 | | undefined 89 | | (State extends undefined ? Nothing : never) 90 | 91 | export interface Recipe { 92 | (draft: Draft): ValidRecipeReturnType> 93 | } 94 | 95 | export interface BuildInReducerSetValue { 96 | (key: K, value: S[K]): void 97 | (key: K, recipe: Recipe): void 98 | } 99 | 100 | export interface BuildInReducerSetValues { 101 | (recipe: Recipe): void 102 | (prevState: Partial): void 103 | } 104 | 105 | export interface BuildInReducers { 106 | setValue: BuildInReducerSetValue 107 | setValues: BuildInReducerSetValues 108 | reset: (key?: K) => void 109 | } 110 | 111 | export type ModelReducer = MR extends ( 112 | state: any, 113 | ...payload: infer P 114 | ) => void 115 | ? (...payload: P) => void 116 | : any 117 | 118 | export type ModelReducers = { 119 | [K in keyof R]: ModelReducer 120 | } & BuildInReducers 121 | 122 | export type ModelEffect = (E extends (...payload: infer P) => infer R 123 | ? (...payload: P) => R 124 | : any) & 125 | ModelEffectState 126 | 127 | export type ModelEffects = { 128 | [K in keyof C]: ModelEffect 129 | } 130 | 131 | export interface Model { 132 | state: S extends undefined ? C['state'] : S 133 | reducers: R extends undefined 134 | ? C['reducers'] extends ConfigReducers 135 | ? ModelReducers 136 | : BuildInReducers 137 | : R 138 | effects: E extends undefined 139 | ? C['effects'] extends ConfigEffects 140 | ? ModelEffects> 141 | : Record 142 | : E 143 | } 144 | 145 | export type Models = { 146 | [K in keyof C]: { 147 | state: Model['state'] 148 | reducers: Model['reducers'] 149 | effects: Model['effects'] 150 | } 151 | } 152 | 153 | export type ModelsState = { 154 | [K in keyof C]: Model['state'] 155 | } 156 | 157 | export type ModelsReducers = { 158 | [K in keyof C]: Model['reducers'] 159 | } 160 | 161 | export type ModelsEffects = { 162 | [K in keyof C]: Model['effects'] 163 | } 164 | 165 | export interface ModelConfig { 166 | state: S 167 | reducers: ConfigReducers 168 | effects: { [key: string]: ConfigEffect } 169 | } 170 | 171 | export type ModelConfigEffect = (E extends (...payload: infer P) => infer R 172 | ? (...payload: P) => R 173 | : any) & { 174 | loading: boolean 175 | identifier: number 176 | } 177 | 178 | export interface ContextPropsModel { 179 | state: C['state'] 180 | reducers: C['reducers'] extends ConfigReducers 181 | ? ModelReducers 182 | : BuildInReducers 183 | effects: C['effects'] extends ConfigEffects 184 | ? ModelEffects> 185 | : Record 186 | } 187 | 188 | export interface ModelProviderOptions { 189 | /** 190 | * @deprecated 191 | * https://kcfe.github.io/dobux/api#store--createstoremodels-options 192 | */ 193 | autoReset?: boolean 194 | devtools?: boolean 195 | } 196 | 197 | export interface ModelContextProps { 198 | model: ContextPropsModel 199 | } 200 | 201 | export type StoreProvider = React.FC> 202 | 203 | export interface StoreOptions { 204 | name?: string 205 | /** 206 | * @deprecated 207 | * https://kcfe.github.io/dobux/api#store--createstoremodels-options 208 | */ 209 | autoReset?: boolean | UnionToTuple 210 | devtools?: boolean | UnionToTuple 211 | } 212 | 213 | export type HOC =

( 214 | Component: React.ComponentType

215 | ) => React.ComponentType

216 | 217 | export type Optionality = Omit 218 | 219 | export interface StateSubscriber { 220 | mapStateToModel: MapStateToModel 221 | prevState: T 222 | dispatcher: Dispatch 223 | } 224 | 225 | export type EffectSubscriber = Dispatch 226 | 227 | export interface MapStateToModel, S = any> { 228 | (state: M['state']): S 229 | } 230 | -------------------------------------------------------------------------------- /src/utils/func.ts: -------------------------------------------------------------------------------- 1 | import { STORE_NAME_PREFIX } from '../common/const' 2 | 3 | export function noop(...args: any[]): any { 4 | return {} 5 | } 6 | 7 | export function identify(state: any): any { 8 | return state 9 | } 10 | 11 | let count = 0 12 | 13 | export function getStoreName(): string { 14 | count++ 15 | return `${STORE_NAME_PREFIX}/${count}` 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/invariant.ts: -------------------------------------------------------------------------------- 1 | import { isProd } from '../common/env' 2 | 3 | const prefix = 'Invariant Failed' 4 | 5 | export function invariant(condition: any, message: string): never | undefined { 6 | if (condition) { 7 | return 8 | } 9 | 10 | if (isProd) { 11 | throw new Error(prefix) 12 | } 13 | 14 | throw new Error(`${prefix}: ${message}`) 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/shallowEqual.ts: -------------------------------------------------------------------------------- 1 | function is(a: any, b: any): boolean { 2 | if (a === b) { 3 | // 0 !== -0 4 | return a !== 0 || b !== 0 || 1 / a === 1 / b 5 | } else { 6 | // NaN !== NaN 7 | return a !== a && b !== b 8 | } 9 | } 10 | 11 | const hasOwn = Object.prototype.hasOwnProperty 12 | 13 | export function shallowEqual(objA: any, objB: any): boolean { 14 | if (is(objA, objB)) { 15 | return true 16 | } 17 | 18 | if (typeof objA !== 'object' || typeof objB !== 'object' || !objA || !objB) { 19 | return false 20 | } 21 | 22 | const keysA = Object.keys(objA) 23 | const keysB = Object.keys(objB) 24 | 25 | if (keysA.length !== keysB.length) { 26 | return false 27 | } 28 | 29 | for (let i = 0; i < keysA.length; i++) { 30 | const key = keysA[i] 31 | 32 | if (!hasOwn.call(objB, key) || !is(objA[key], objB[key])) { 33 | return false 34 | } 35 | } 36 | 37 | return true 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/type.ts: -------------------------------------------------------------------------------- 1 | import { Noop } from '../types' 2 | 3 | /* istanbul ignore next */ 4 | function isTypeof(target: any, type: string): boolean { 5 | if (!type) { 6 | return false 7 | } 8 | 9 | try { 10 | type = type.toLocaleLowerCase() 11 | 12 | if (target === undefined) { 13 | return type === 'undefined' 14 | } 15 | 16 | if (target === null) { 17 | return type === 'null' 18 | } 19 | 20 | return Object.prototype.toString.call(target).toLocaleLowerCase() === `[object ${type}]` 21 | } catch (err) { 22 | return false 23 | } 24 | } 25 | 26 | /** 27 | * 是否是 Undefined 28 | * @param target 29 | */ 30 | export function isUndefined(target: any): target is undefined { 31 | return isTypeof(target, 'undefined') 32 | } 33 | 34 | /** 35 | * 是否是 Null 36 | * @param target 37 | */ 38 | export function isNull(target: any): target is null { 39 | return isTypeof(target, 'null') 40 | } 41 | 42 | /** 43 | * 是否是 String 44 | * @param target 45 | */ 46 | export function isString(target: any): target is string { 47 | return isTypeof(target, 'string') 48 | } 49 | 50 | /** 51 | * 是否是普通函数 52 | * @param target 53 | */ 54 | export function isFunction(target: any): target is Noop { 55 | return isTypeof(target, 'function') 56 | } 57 | 58 | /** 59 | * 是否是 Async 函数 60 | * @param target 61 | */ 62 | export function isAsyncFunc(target: unknown): target is AsyncGeneratorFunction { 63 | return typeof target === 'function' && target.constructor.name === 'AsyncFunction' 64 | } 65 | 66 | /** 67 | * 是否是 Object 68 | * @param target 69 | */ 70 | export function isObject(target: any): target is Record { 71 | return isTypeof(target, 'object') 72 | } 73 | 74 | /** 75 | * 是否是数组 76 | * @param target 77 | */ 78 | export function isArray(target: any): target is Array { 79 | return isTypeof(target, 'array') 80 | } 81 | 82 | /** 83 | * 是否是 Promise 84 | * @param target 85 | */ 86 | export function isPromise(target: any): target is Promise { 87 | return target && typeof target.then === 'function' 88 | } 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "rootDir": "./", 5 | "outDir": "dist", 6 | "sourceMap": false, 7 | "target": "es2016", 8 | "useDefineForClassFields": false, 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "allowJs": false, 12 | "strict": true, 13 | "noUnusedLocals": true, 14 | "experimentalDecorators": true, 15 | "resolveJsonModule": true, 16 | "esModuleInterop": true, 17 | "removeComments": false, 18 | "jsx": "react", 19 | "lib": ["esnext", "dom"] 20 | }, 21 | "include": ["src"], 22 | "exclude": ["node_modules"] 23 | } 24 | --------------------------------------------------------------------------------