()({
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 |
--------------------------------------------------------------------------------