├── .gitignore ├── docs ├── 1.gif ├── 2.gif ├── 3.png ├── 4.gif └── 5.gif ├── jest.config.js ├── static ├── type.jpg ├── logo_2.png ├── redux_devtool.jpg └── index.html ├── src ├── vendor.d.ts ├── index.ts ├── error.ts ├── createStore.ts ├── types.d.ts ├── test │ ├── action.test.ts │ └── imdux.test.ts └── createAction.ts ├── .travis.yml ├── imdux.code-workspace ├── babel.config.js ├── tsconfig.json ├── webpack.dev.config.js ├── webpack.dist.config.js ├── package.json ├── tslint.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dev 2 | dist 3 | node_modules 4 | coverage 5 | -------------------------------------------------------------------------------- /docs/1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lake2/imdux/HEAD/docs/1.gif -------------------------------------------------------------------------------- /docs/2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lake2/imdux/HEAD/docs/2.gif -------------------------------------------------------------------------------- /docs/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lake2/imdux/HEAD/docs/3.png -------------------------------------------------------------------------------- /docs/4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lake2/imdux/HEAD/docs/4.gif -------------------------------------------------------------------------------- /docs/5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lake2/imdux/HEAD/docs/5.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["./src/test/"], 3 | }; 4 | -------------------------------------------------------------------------------- /static/type.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lake2/imdux/HEAD/static/type.jpg -------------------------------------------------------------------------------- /static/logo_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lake2/imdux/HEAD/static/logo_2.png -------------------------------------------------------------------------------- /static/redux_devtool.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lake2/imdux/HEAD/static/redux_devtool.jpg -------------------------------------------------------------------------------- /src/vendor.d.ts: -------------------------------------------------------------------------------- 1 | declare module "is-primitive" { 2 | export default function isPrimitiveType(params: any): boolean 3 | } 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from "./createAction"; 2 | import { createStore } from "./createStore"; 3 | 4 | export { createAction, createStore }; 5 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Title 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "12" 4 | - node 5 | 6 | script: echo "Running tests against $(node -v)..." 7 | 8 | jobs: 9 | include: 10 | - stage: Produce Coverage 11 | node_js: node 12 | script: jest --coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage 13 | -------------------------------------------------------------------------------- /imdux.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "editor.formatOnSave": true, 9 | "typescript.tsdk": "node_modules\\typescript\\lib", 10 | "window.title": "${rootName}${separator}${dirty}${activeEditorShort}${separator}${appName}", 11 | "editor.foldingStrategy": "indentation" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const presets = [ 2 | [ 3 | "@babel/preset-env", 4 | { 5 | "targets": { 6 | "chrome": "30", 7 | "ie": "10", 8 | } 9 | } 10 | ], 11 | "@babel/preset-typescript" 12 | ]; 13 | 14 | const plugins = [ 15 | "@babel/plugin-transform-runtime", 16 | ]; 17 | 18 | module.exports = { presets, plugins }; 19 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | export const notInitialized = "[Imdux] Do not call Action.query or Action.dispatch before initializing it using createStore."; 2 | export const wrongModify = "[Imdux] Do not modify value of store directly, use dispatch instead."; 3 | export const payloadNotValid = "[Imdux] Payload should always be a plain object or a primitive type value, other value will be replace as undefined. " + 4 | "\nTo disable this warn, please set payloadNotValidWarn = false on createStore. See more information: http://www.baidu.com"; 5 | export const moreThanOneDot = "[Imdux] There should be only one dot in action name or reducer name."; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "es6", 5 | "sourceMap": false, 6 | "outDir": "dev/js", 7 | "declaration": true, 8 | "pretty": true, 9 | "downlevelIteration": true, 10 | "noUnusedLocals": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "strictNullChecks": true, 15 | "strictBindCallApply": true, 16 | "jsx": "react", 17 | "moduleResolution": "node", 18 | "experimentalDecorators": true, 19 | "esModuleInterop": true, 20 | "skipLibCheck": true, 21 | "typeRoots": [ 22 | "./node_modules/@types" 23 | ] 24 | }, 25 | "include": [ 26 | "src/**/*", 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | mode: "development", 6 | devtool: "eval-cheap-source-map", 7 | entry: path.resolve(__dirname, "./src/index.ts"), 8 | output: { 9 | filename: "app.js", 10 | path: path.resolve(__dirname, './dev/'), 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.ts$/, 16 | loader: ["babel-loader"], 17 | }, 18 | ] 19 | }, 20 | plugins: [ 21 | new HtmlWebpackPlugin({ template: './dev/index.html' }), 22 | ], 23 | resolve: { 24 | extensions: [".js", ".ts"], 25 | }, 26 | devServer: { 27 | contentBase: "./dev/", 28 | host: "0.0.0.0", 29 | port: "9000", 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /webpack.dist.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 3 | const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); 4 | 5 | module.exports = { 6 | mode: "production", 7 | devtool: 'hidden-source-map', 8 | entry: path.resolve(__dirname, "./src/index.ts"), 9 | output: { 10 | filename: "imdux.js", 11 | path: path.resolve(__dirname, `./dist/`), 12 | library: "imdux", 13 | libraryTarget: 'commonjs2', 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.ts$/, 19 | loader: ["babel-loader"], 20 | }, 21 | ] 22 | }, 23 | plugins: [ 24 | new BundleAnalyzerPlugin({ 25 | analyzerMode: "static", 26 | reportFilename: 'report.html', 27 | defaultSizes: "stat", 28 | generateStatsFile: true, 29 | statsFilename: 'report.json' 30 | }), 31 | ], 32 | resolve: { 33 | extensions: [".js", ".ts"], 34 | }, 35 | externals: { 36 | 'immer': 'commonjs immer', 37 | 'redux': 'commonjs redux', 38 | }, 39 | optimization: { 40 | minimizer: [ 41 | new UglifyJsPlugin({ 42 | sourceMap: true, 43 | uglifyOptions: { 44 | output: { 45 | comments: false, 46 | }, 47 | }, 48 | }), 49 | ], 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /src/createStore.ts: -------------------------------------------------------------------------------- 1 | import * as Redux from "redux"; 2 | 3 | import { Imdux } from "./types"; 4 | import { wrongModify } from "./error"; 5 | 6 | export function createStore(actions: T, opt?: Partial): Imdux.Store { 7 | return new class Store implements Imdux.Store { 8 | Dispatch: any; 9 | Query: any; 10 | redux: Redux.Store; 11 | 12 | constructor() { 13 | this.Dispatch = {}; 14 | this.Query = {}; 15 | const options: Imdux.createStoreOptions = { devtool: false, payloadNotValidWarn: true, ...(opt || {}) }; 16 | 17 | const reducers: any = {}; 18 | Object.keys(actions).forEach(name => { 19 | const action = actions[name] as any; 20 | reducers[name] = action.reducer; 21 | }); 22 | const rootReducer = Redux.combineReducers(reducers); 23 | this.redux = Redux.createStore( 24 | rootReducer, 25 | options?.devtool ? (window as any).__REDUX_DEVTOOLS_EXTENSION__?.({ trace: true, name: options.name }) : undefined 26 | ); 27 | 28 | Object.keys(actions).forEach(name => { 29 | const action = actions[name] as any; 30 | action.options = options; 31 | action.redux = this.redux; 32 | action.namespace = name; 33 | this.Dispatch[name] = actions[name].dispatch; 34 | Object.defineProperty(this.Query, name, { 35 | get() { 36 | return actions[name].query; 37 | }, 38 | set() { 39 | throw new Error(wrongModify); 40 | }, 41 | }); 42 | }); 43 | } 44 | }(); 45 | } 46 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import * as Redux from "redux"; 2 | 3 | export namespace Imdux { 4 | 5 | export interface Action { 6 | dispatch: InferActionDispath, 7 | query: S 8 | } 9 | 10 | export interface Actions { 11 | [key: string]: Imdux.Action; 12 | } 13 | 14 | export interface Store { 15 | Dispatch: InferDispatch, 16 | Query: InferState, 17 | redux: Redux.Store; 18 | } 19 | 20 | export type InferActionDispathFunction = T extends (...arg: infer A) => any ? A extends [any] ? () => void : T extends (draft: any, payload: infer R) => any ? (payload: R) => void : never : never; 21 | 22 | export type InferActionDispath = { 23 | [K in keyof T]: T[K] extends (...arg: any) => any ? InferActionDispathFunction : InferActionDispath 24 | } 25 | 26 | export type InferDispatch = { 27 | [K in keyof T]: T[K] extends Action ? InferActionDispath : unknown 28 | } 29 | 30 | export type InferState = { 31 | [K in keyof T]: T[K] extends Action ? S : unknown 32 | } 33 | 34 | export type Draft = (draft: T, payload: any) => T | void; 35 | 36 | export interface Reducers { 37 | [key: string]: Draft | Reducers, 38 | } 39 | 40 | export interface CreateActionParams { 41 | initialState: T, 42 | reducers: Reducers 43 | } 44 | 45 | export interface createStoreOptions { 46 | devtool: boolean // false 47 | payloadNotValidWarn: boolean // true 48 | name?: string 49 | } 50 | } 51 | 52 | export declare function createStore(actions: T, options?: Partial): Imdux.Store; 53 | 54 | export declare function createAction(params: Imdux.CreateActionParams): Imdux.Action; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imdux", 3 | "version": "0.2.1", 4 | "description": "A redux helper for react & hooks & typescript developers.", 5 | "main": "dist/imdux.js", 6 | "types": "src/types.d.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "cover": "jest --coverage --coverageReporters=text-lcov | coveralls", 10 | "dev": "webpack-dev-server --config webpack.dev.config", 11 | "pack": "webpack --config webpack.dist.config", 12 | "pub": "npm publish --registry http://registry.npmjs.org", 13 | "lint": "node_modules/.bin/tslint -p tsconfig.json --fix" 14 | }, 15 | "files": [ 16 | "dist/imdux.js", 17 | "dist/imdux.js.map", 18 | "src/**/*" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/lake2/imdux.git" 23 | }, 24 | "keywords": [ 25 | "react", 26 | "react-redux", 27 | "immer", 28 | "hook", 29 | "store" 30 | ], 31 | "author": "lake2", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/lake2/imdux/issues" 35 | }, 36 | "homepage": "https://github.com/lake2/imdux#readme", 37 | "devDependencies": { 38 | "@babel/core": "^7.10.3", 39 | "@babel/plugin-transform-runtime": "^7.10.3", 40 | "@babel/preset-env": "^7.10.3", 41 | "@babel/preset-typescript": "^7.10.1", 42 | "@babel/runtime": "^7.10.3", 43 | "@types/jest": "^25.2.3", 44 | "babel-loader": "^8.1.0", 45 | "coveralls": "^3.1.0", 46 | "html-webpack-plugin": "^3.2.0", 47 | "jest": "^25.5.4", 48 | "tslint": "^6.1.2", 49 | "typescript": "^3.9.5", 50 | "uglifyjs-webpack-plugin": "^2.2.0", 51 | "webpack": "^4.43.0", 52 | "webpack-bundle-analyzer": "^3.8.0", 53 | "webpack-cli": "^3.3.12", 54 | "webpack-dev-server": "^3.11.0" 55 | }, 56 | "dependencies": { 57 | "immer": "^5.3.6", 58 | "is-plain-object": "^3.0.0", 59 | "is-primitive": "^3.0.1", 60 | "redux": "^4.0.5" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/test/action.test.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createStore } from "../"; 2 | // import { Imdux } from "../types"; 3 | 4 | describe("change action name", () => { 5 | type State = typeof initialState; 6 | type Reducers = typeof reducers; 7 | 8 | const initialState = { 9 | bracket: false, 10 | bar: false, 11 | jerry: false, 12 | bar2: false, 13 | jerry2: false, 14 | count: 0 15 | }; 16 | 17 | const reducers = { 18 | ["bracket/set"](draft: State) { 19 | draft.bracket = true; 20 | }, 21 | foo: { 22 | bar(draft: State) { 23 | draft.bar = true; 24 | }, 25 | tom: { 26 | jerry(draft: State) { 27 | draft.jerry = true 28 | } 29 | } 30 | }, 31 | foo2: { 32 | bar(draft: State) { 33 | draft.bar2 = true; 34 | }, 35 | jerry(draft: State) { 36 | draft.jerry2 = true; 37 | } 38 | }, 39 | add1(draft: State) { 40 | draft.count++; 41 | }, 42 | add2(draft: State) { 43 | draft.count++; 44 | } 45 | }; 46 | 47 | it("createStore", () => { 48 | let flag: number; 49 | 50 | const home = createAction({ initialState, reducers }); 51 | const actions = { home }; 52 | const { Dispatch, Query, redux } = createStore(actions); 53 | 54 | flag = 0; 55 | redux.subscribe(() => flag = 1); 56 | Dispatch.home["bracket/set"](); 57 | expect(Query.home.bracket).toBe(true); 58 | expect(flag).toBe(1); 59 | 60 | redux.subscribe(() => flag = 2); 61 | Dispatch.home.foo.bar(); 62 | expect(Query.home.bar).toBe(true); 63 | expect(flag).toBe(2); 64 | 65 | redux.subscribe(() => flag = 3); 66 | Dispatch.home.foo.tom.jerry(); 67 | expect(Query.home.jerry).toBe(true); 68 | 69 | redux.subscribe(() => flag = 4); 70 | Dispatch.home.foo2.bar(); 71 | expect(Query.home.bar2).toBe(true); 72 | 73 | redux.subscribe(() => flag = 5); 74 | Dispatch.home.foo2.jerry(); 75 | expect(Query.home.jerry2).toBe(true); 76 | 77 | redux.subscribe(() => flag = 6); 78 | Dispatch.home.add1(); 79 | expect(Query.home.count).toBe(1); 80 | 81 | redux.subscribe(() => flag = 7); 82 | Dispatch.home.add2(); 83 | expect(Query.home.count).toBe(2); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "eofline": true, 9 | "function-constructor": true, 10 | "interface-name": false, 11 | "type-literal-delimiter": false, 12 | "arrow-return-shorthand": true, 13 | "max-line-length": false, 14 | "static-this": true, 15 | "member-access": false, 16 | "variable-name": false, 17 | "object-literal-sort-keys": false, 18 | "member-ordering": false, 19 | "max-classes-per-file": false, 20 | "no-empty-interface": false, 21 | "no-console": false, 22 | "no-bitwise": false, 23 | "radix": false, 24 | "no-empty": false, 25 | "no-unused-expression": [ 26 | true, 27 | "allow-fast-null-checks", 28 | "allow-new" 29 | ], 30 | "ordered-imports": [ 31 | true, 32 | { 33 | "grouped-imports": true, 34 | "groups": [ 35 | { 36 | "name": "pkga imports", 37 | "match": "^\\w", 38 | "order": 10 39 | }, 40 | { 41 | "name": "style", 42 | "match": ".*.(less|css|js)", 43 | "order": 40 44 | }, 45 | { 46 | "name": "local", 47 | "match": "^[\\.]", 48 | "order": 20 49 | } 50 | ], 51 | "import-sources-order": "any", 52 | "named-imports-order": "lowercase-first" 53 | } 54 | ], 55 | "arrow-parens": [ 56 | true, 57 | "ban-single-arg-parens" 58 | ], 59 | "semicolon": [ 60 | true, 61 | "ignore-interfaces" 62 | ], 63 | "indent": [ 64 | true, 65 | "spaces", 66 | 4 67 | ], 68 | "trailing-comma": [ 69 | true, 70 | { 71 | "multiline": { 72 | "objects": "always", 73 | "arrays": "always", 74 | "functions": "never", 75 | "typeLiterals": "always" 76 | }, 77 | "esSpecCompliant": true 78 | } 79 | ], 80 | "array-type": [ 81 | true, 82 | "generic" 83 | ] 84 | }, 85 | "rulesDirectory": [] 86 | } 87 | -------------------------------------------------------------------------------- /src/createAction.ts: -------------------------------------------------------------------------------- 1 | import * as Redux from "redux"; 2 | import produce from "immer"; 3 | import isPrimitiveType from "is-primitive"; 4 | import isPlainObject from "is-plain-object"; 5 | 6 | import { Imdux } from "./types"; 7 | import { notInitialized, payloadNotValid, wrongModify } from "./error"; 8 | 9 | export function createAction(params: Imdux.CreateActionParams): Imdux.Action { 10 | return new class Action implements Imdux.Action{ 11 | namespace: string; 12 | redux: Redux.Store; 13 | dispatch: any; 14 | reducers: any; 15 | reducer: any; 16 | options: Imdux.createStoreOptions; 17 | 18 | constructor() { 19 | this.dispatch = {}; 20 | this.reducers = {}; 21 | this.reducer = (state: any, action: any) => { 22 | const split: Array = action.type.split("/"); 23 | const namespace = split[0]; 24 | const type = "/" + split.slice(1).join("/"); 25 | if (state === undefined) { 26 | return params.initialState; 27 | } else if (namespace !== this.namespace) { 28 | return state; 29 | } else if (split.length >= 2 && this.reducers[type]) { 30 | return this.reducers[type](state, action.payload); 31 | } else { 32 | return state; 33 | } 34 | }; 35 | 36 | const init = (map: Imdux.Reducers, namespace: string, dispatch: any) => { 37 | Object.keys(map).forEach(name => { 38 | let reducer: any; 39 | let path = namespace + "/" + name; 40 | if (typeof map[name] === "function") { 41 | reducer = map[name]; 42 | this.reducers[path] = (state: any, payload: any) => produce(state, (draft: any) => reducer(draft, payload)); 43 | dispatch[name] = (payload: any) => { 44 | if (!this.redux) { 45 | throw new Error(notInitialized); 46 | } else { 47 | if (!isPlainObject(payload) && !isPrimitiveType(payload) && !Array.isArray(payload)) { 48 | this.options.payloadNotValidWarn && console.warn(payloadNotValid); 49 | } 50 | this.redux.dispatch({ type: this.namespace + path, payload }); 51 | } 52 | }; 53 | } else { 54 | if (!dispatch[name]) { dispatch[name] = {} }; 55 | init(map[name] as any, path, dispatch[name]); 56 | } 57 | }); 58 | } 59 | 60 | init(params.reducers, "", this.dispatch); 61 | } 62 | 63 | get query() { 64 | if (!this.redux) { 65 | throw new Error(notInitialized); 66 | } else { 67 | return Object.freeze(this.redux.getState()[this.namespace]); 68 | } 69 | } 70 | 71 | set query(value: any) { 72 | throw new Error(wrongModify); 73 | } 74 | }(); 75 | } 76 | -------------------------------------------------------------------------------- /src/test/imdux.test.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createStore } from "../"; 2 | // import { Imdux } from "../types"; 3 | 4 | describe("imdux object state", () => { 5 | type State = typeof initialState; 6 | type Reducers = typeof reducers; 7 | 8 | const initialState = { 9 | name: "", 10 | count: 1, 11 | show: false, 12 | array: [1], 13 | }; 14 | 15 | const reducers = { 16 | increase(draft: State, payload: { inc: number }) { 17 | draft.count += payload.inc; 18 | }, 19 | decrease(draft: State, payload: number) { 20 | draft.count -= payload; 21 | }, 22 | replace(draft: State, payload: State): State { 23 | return payload; 24 | }, 25 | show(draft: State) { 26 | draft.show = true; 27 | }, 28 | hide(draft: State, payload: "1" | "2" | "3" | null | undefined) { 29 | draft.show = false; 30 | }, 31 | array(draft: State, payload: Array) { 32 | draft.array = payload; 33 | }, 34 | optional(draft: State, payload?: number) { 35 | draft.count -= 1; 36 | }, 37 | }; 38 | 39 | const reducers2 = { 40 | increase(draft: State, payload: { inc: number }) { 41 | draft.count += payload.inc; 42 | }, 43 | }; 44 | 45 | it("createStore", () => { 46 | const home = createAction({ initialState, reducers }); 47 | const actions = { home }; 48 | const { Dispatch, Query, redux } = createStore(actions); 49 | expect(Query.home.name).toBe(""); 50 | expect(Query.home.count).toBe(1); 51 | 52 | let flag = 0; 53 | redux.subscribe(() => flag = 1); 54 | Dispatch.home.increase({ inc: 1 }); 55 | expect(Query.home.count).toBe(2); 56 | expect(flag).toBe(1); 57 | 58 | redux.subscribe(() => flag = 2); 59 | Dispatch.home.decrease(2); 60 | expect(Query.home.count).toBe(0); 61 | expect(flag).toBe(2); 62 | 63 | redux.subscribe(() => flag = 3); 64 | Dispatch.home.replace({ count: 3, name: "t", show: false, array: [1] }); 65 | expect(Query.home.name).toBe("t"); 66 | expect(Query.home.count).toBe(3); 67 | expect(flag).toBe(3); 68 | 69 | redux.subscribe(() => flag = 4); 70 | Dispatch.home.array([]); 71 | expect(Query.home.array).toEqual([]); 72 | expect(flag).toBe(4); 73 | 74 | expect(() => { Query.home = { count: 0, name: "", show: false, array: [1] }; }).toThrowError("modify"); 75 | expect(() => { Query.home.count = 0; }).toThrowError("Cannot assign"); 76 | expect(() => { Query.home.name = ""; }).toThrowError("Cannot assign"); 77 | 78 | const home2 = createAction({ initialState, reducers: reducers2 }); 79 | const actions2 = { home, home2 }; 80 | const { Dispatch: Dispatch2, Query: Query2 } = createStore(actions2); 81 | 82 | Dispatch2.home.increase({ inc: 1 }); 83 | expect(Query2.home.count).toBe(2); 84 | expect(Query2.home2.count).toBe(1); 85 | }); 86 | 87 | it("createAction", () => { 88 | const home = createAction({ initialState, reducers }); 89 | const actions = { home }; 90 | 91 | expect(() => { home.dispatch.show(); }).toThrowError("call"); 92 | expect(() => home.query.name).toThrowError("call"); 93 | expect(() => home.query.count).toThrowError("call"); 94 | 95 | const { redux } = createStore(actions); 96 | expect(home.query.name).toBe(""); 97 | expect(home.query.count).toBe(1); 98 | 99 | let flag = 0; 100 | redux.subscribe(() => flag = 1); 101 | home.dispatch.increase({ inc: 1 }); 102 | expect(home.query.count).toBe(2); 103 | expect(flag).toBe(1); 104 | 105 | redux.subscribe(() => flag = 2); 106 | home.dispatch.decrease(2); 107 | expect(home.query.count).toBe(0); 108 | expect(flag).toBe(2); 109 | 110 | redux.subscribe(() => flag = 3); 111 | home.dispatch.replace({ count: 3, name: "t", show: false, array: [1] }); 112 | expect(home.query.name).toBe("t"); 113 | expect(home.query.count).toBe(3); 114 | expect(flag).toBe(3); 115 | 116 | redux.subscribe(() => flag = 4); 117 | home.dispatch.array([]); 118 | expect(home.query.array).toEqual([]); 119 | expect(flag).toBe(4); 120 | 121 | redux.subscribe(() => flag = 4); 122 | 123 | home.dispatch.show(); 124 | expect(home.query.show).toBe(true); 125 | expect(flag).toBe(4); 126 | 127 | 128 | expect(() => { home.query = { count: 0, name: "", show: false, array: [1] }; }).toThrowError("modify"); 129 | expect(() => { home.query.count = 0; }).toThrowError("Cannot assign"); 130 | expect(() => { home.query.name = ""; }).toThrowError("Cannot assign"); 131 | 132 | const home2 = createAction({ initialState, reducers: reducers2 }); 133 | const actions2 = { home, home2 }; 134 | createStore(actions2); 135 | 136 | home.dispatch.increase({ inc: 1 }); 137 | expect(home.query.count).toBe(2); 138 | expect(home2.query.count).toBe(1); 139 | }); 140 | 141 | it("types", () => { 142 | const home = createAction({ initialState, reducers }); 143 | const actions = { home }; 144 | const { Dispatch } = createStore(actions); 145 | Dispatch.home.hide("1"); 146 | Dispatch.home.hide("2"); 147 | Dispatch.home.hide("3"); 148 | Dispatch.home.hide(null); 149 | (a: "1" | "2" | null) => Dispatch.home.hide(a); 150 | Dispatch.home.optional(1); 151 | Dispatch.home.optional(undefined); 152 | }); 153 | }); 154 | 155 | describe("imdux value state", () => { 156 | type State = typeof initialState; 157 | type Reducers = typeof reducers; 158 | 159 | const initialState = 1 as number; 160 | 161 | const reducers = { 162 | increase(draft: State, payload: { inc: number }): State { 163 | return draft + payload.inc; 164 | }, 165 | decrease(draft: State, payload: number): State { 166 | return draft - payload; 167 | }, 168 | replace(draft: State, payload: { count: number }): State { 169 | return payload.count; 170 | }, 171 | show(draft: State): State { 172 | return 10; 173 | }, 174 | }; 175 | 176 | it("createStore", () => { 177 | const home = createAction({ initialState, reducers }); 178 | const actions = { home }; 179 | const { Dispatch, Query, redux } = createStore(actions); 180 | expect(Query.home).toBe(1); 181 | 182 | let flag = 0; 183 | redux.subscribe(() => flag = 1); 184 | Dispatch.home.increase({ inc: 1 }); 185 | expect(Query.home).toBe(2); 186 | expect(flag).toBe(1); 187 | 188 | redux.subscribe(() => flag = 2); 189 | Dispatch.home.decrease(2); 190 | expect(Query.home).toBe(0); 191 | expect(flag).toBe(2); 192 | 193 | redux.subscribe(() => flag = 3); 194 | Dispatch.home.replace({ count: 3 }); 195 | expect(Query.home).toBe(3); 196 | expect(flag).toBe(3); 197 | 198 | expect(() => { Query.home = 0; }).toThrowError("modify"); 199 | }); 200 | 201 | it("createAction", () => { 202 | const home = createAction({ initialState, reducers }); 203 | const actions = { home }; 204 | 205 | expect(() => { home.dispatch.show(); }).toThrowError("call"); 206 | expect(() => home.query).toThrowError("call"); 207 | 208 | const { redux } = createStore(actions); 209 | expect(home.query).toBe(1); 210 | 211 | let flag = 0; 212 | redux.subscribe(() => flag = 1); 213 | home.dispatch.increase({ inc: 1 }); 214 | expect(home.query).toBe(2); 215 | expect(flag).toBe(1); 216 | 217 | redux.subscribe(() => flag = 2); 218 | home.dispatch.decrease(2); 219 | expect(home.query).toBe(0); 220 | expect(flag).toBe(2); 221 | 222 | redux.subscribe(() => flag = 3); 223 | home.dispatch.replace({ count: 3 }); 224 | expect(home.query).toBe(3); 225 | expect(flag).toBe(3); 226 | 227 | redux.subscribe(() => flag = 4); 228 | home.dispatch.show(); 229 | expect(home.query).toBe(10); 230 | expect(flag).toBe(4); 231 | 232 | expect(() => { home.query = 0; }).toThrowError("modify"); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

imdux logo

2 |

Imdux

3 |

🌈 A redux helper for react & hooks & typescript developers.

4 |

5 | CI Status 6 | Coverage Status 7 | Version 8 | Download 9 | Mini size 10 | MIT License 11 |

12 | 13 | ### 特点 14 | 15 | - 🚀简单高效:完全去除了redux冗余低效的样板代码,提供一把全自动的生产力工具。 16 | - :shaved_ice: 类型安全:面向typescript用户,100%类型安全,同时摒弃interface类型预定义,改用infer,实现了state和dispatch类型推导,极大地减少了类型定义代码。 17 | - ✈️目前未来:拥抱react hooks,便于typescript的类型检查和代码复用。 18 | - :cocktail:最佳实践:Imdux的目的不仅仅是提供一个库,更希望的是提供一个解决方案,探索一种react hooks的最佳实践。 19 | 20 | #### 开始 21 | 22 | 首先,创建一个react项目: 23 | 24 | ```shell 25 | npx create-react-app imdux-demo 26 | ``` 27 | 28 | 安装imdux,imdux依赖于 immer,redux,react-redux : 29 | 30 | ```shell 31 | yarn add imdux immer redux react-redux 32 | ``` 33 | 34 | 创建一个简单的项目结构: 35 | 36 | ```js 37 | ├── package.json 38 | ├── public 39 | │   ├── favicon.ico 40 | │   ├── index.html 41 | │   └── robots.txt 42 | ├── src 43 | │   ├── App.js 44 | │   ├── index.js 45 | │   └── store 46 | │   ├── counter.reducers.js 47 | │   └── index.js 48 | └── yarn.lock 49 | ``` 50 | 51 | 52 | 打开`src/store/counter.reducers.js`,输入代码: 53 | 54 | 55 | ```js 56 | import { createAction } from "imdux"; 57 | 58 | const initialState = { 59 | value: 0 60 | }; 61 | 62 | const reducers = { 63 | increase(draft, payload) { 64 | draft.value += payload; 65 | }, 66 | decrease(draft, payload) { 67 | draft.value -= payload; 68 | } 69 | }; 70 | 71 | export const counter = createAction({ initialState, reducers }); 72 | ``` 73 | 74 | 75 | 打开`src/store/index.js`,创建一个store: 76 | 77 | 78 | ```js 79 | import { createStore } from "imdux"; 80 | 81 | import { counter } from "./counter.reducers"; 82 | 83 | export const store = createStore({ counter }, { devtool: true }); 84 | export const { Dispatch, Query } = store; 85 | ``` 86 | 87 | 88 | 打开`src/App.js`,创建一个`App`: 89 | 90 | 91 | ```js 92 | import React from "react"; 93 | import { useSelector } from "react-redux"; 94 | 95 | import { Dispatch } from "./store"; 96 | 97 | export function App() { 98 | // 取出counter中的值,如果这个值改变,那么组件会自动更新 99 | const value = useSelector(store => store.counter.value); 100 | return ( 101 |
102 |

{value}

103 | {/* 通过Dispatch触发状态更新 */} 104 | 105 |
106 | ); 107 | } 108 | ``` 109 | 110 | 111 | 最后,打开`src/index.js`,注入redux的store: 112 | 113 | 114 | ```js 115 | import React from "react"; 116 | import ReactDOM from "react-dom"; 117 | import { Provider } from "react-redux"; 118 | 119 | import { App } from "./App"; 120 | import { store } from "./store"; 121 | 122 | ReactDOM.render( 123 | {/* 注入 */} 124 | 125 | , 126 | document.getElementById("root") 127 | ); 128 | ``` 129 | 130 | enjoy it~ 很简单,对不对? 131 | 132 | 你可以在浏览器中打开这个例子: [javascript](https://codesandbox.io/s/imdux-start-javascript-3049f?fontsize=14&hidenavigation=1&theme=dark) [typescript](https://codesandbox.io/s/imdux-start-typescript-7wz5u?fontsize=14&hidenavigation=1&theme=dark) 133 | 134 | 打开redux的devtool,通过点击`increase`和`decrease`button,我们可以看到状态变更的历史记录: 135 | 136 | ![redux_devtool](https://user-images.githubusercontent.com/6293752/86553981-e9f2a800-bf7e-11ea-97d4-3511ea30a37a.gif) 137 | 138 | ### 命名空间 139 | 140 | 上面的例子中,如果有多个counter,可以在`reducers`中用命名空间隔离: 141 | 142 | ```js 143 | import { createAction } from "imdux"; 144 | 145 | const initialState = { 146 | first: 0, 147 | last: 0, 148 | }; 149 | 150 | const reducers = { 151 | first: { 152 | increase(draft, payload) { 153 | draft.first += payload; 154 | }, 155 | decrease(draft, payload) { 156 | draft.first -= payload; 157 | } 158 | }, 159 | last: { 160 | increase(draft, payload) { 161 | draft.last += payload; 162 | }, 163 | decrease(draft, payload) { 164 | draft.last -= payload; 165 | } 166 | } 167 | }; 168 | 169 | export const counter = createAction({ initialState, reducers }); 170 | 171 | ``` 172 | 173 | ```js 174 | export function App() { 175 | const first = useSelector(store => store.counter.first); 176 | const last = useSelector(store => store.counter.last); 177 | return ( 178 |
179 |

{first}

180 | 181 | 182 | 183 |

{last}

184 | 185 | 186 |
187 | ); 188 | } 189 | ``` 190 | 191 | ![redux_devtool](https://user-images.githubusercontent.com/6293752/86555496-1ad4dc00-bf83-11ea-8622-f74d7dda75f3.gif) 192 | 193 | 命名空间是可以多级嵌套,应当根据项目情况自由组织,推荐把相关的状态变更放在一个命名空间下。 194 | 195 | ### getState() 196 | 197 | 在redux中,某些情况下需要`同步`获得状态的最新值,[redux提供了getState()接口来实现](https://redux.js.org/basics/store)。 198 | 199 | 在imdux中,`createStore`导出的`Query`内置了getter,可以达到和`getState()`一样的效果。 200 | 201 | 例如: 202 | 203 | ```js 204 | import { createStore } from "imdux"; 205 | 206 | import { counter } from "./counter.reducers"; 207 | 208 | export const store = createStore({ counter }, { devtool: true }); 209 | export const { Dispatch, Query } = store; 210 | 211 | console.log(store.redux.getState().counter); // { value: 0 } 212 | console.log(Query.counter); // { value: 0 } 213 | 214 | console.log(store.redux.getState().counter === Query.counter); // true 215 | 216 | ``` 217 | 218 | ### Typescript 219 | 220 | 在redux中实现100%的类型检查是imdux的初衷。对于typescript用户,推荐在`counter.reducers.ts`中带上类型: 221 | 222 | ```ts 223 | import { createAction } from "imdux"; 224 | 225 | type State = typeof initialState; // 获得类型 226 | type Reducers = typeof reducers; // 获得类型 227 | 228 | const initialState = { 229 | value: 0 230 | }; 231 | 232 | const reducers = { 233 | increase(draft: State, payload: number) { // draft的类型为State 234 | draft.value += payload; 235 | }, 236 | decrease(draft: State, payload: number) { // draft的类型为State 237 | draft.value -= payload; 238 | } 239 | }; 240 | 241 | export const counter = createAction({ initialState, reducers }); // 注入类型 242 | ``` 243 | 244 | 在`src/store/index.ts`中,导出`Query`的类型,改写`useSelector`的函数定义: 245 | 246 | ```ts 247 | import { createStore } from "imdux"; 248 | import { useSelector as useReduxSelector } from "react-redux"; 249 | 250 | import { counter } from "./counter.reducers"; 251 | 252 | export const store = createStore({ counter }, { devtool: true }); 253 | export const { Dispatch, Query } = store; 254 | 255 | export type Store = typeof Query; 256 | 257 | export function useSelector( 258 | selector: (state: Store) => TSelected, 259 | equalityFn?: (left: TSelected, right: TSelected) => boolean 260 | ) { 261 | return useReduxSelector(selector, equalityFn); 262 | } 263 | ``` 264 | 265 | 在`src/App.tsx`中,使用改写后的`useSelector`,这样就可以很轻松地获得typescript的类型检查和代码提示: 266 | 267 | ![type](https://user-images.githubusercontent.com/6293752/86553310-f6760100-bf7c-11ea-8f7b-096a80656c4c.gif) 268 | 269 | 你可以在浏览器中打开这个例子: [javascript](https://codesandbox.io/s/imdux-start-javascript-3049f?fontsize=14&hidenavigation=1&theme=dark) [typescript](https://codesandbox.io/s/imdux-start-typescript-7wz5u?fontsize=14&hidenavigation=1&theme=dark) 270 | 271 | 按住`ctrl`键,鼠标左键点击`increase`,可以准确跳转到`reducers.increase`: 272 | 273 | ![navigate](https://user-images.githubusercontent.com/6293752/87135651-ea67a780-c2cc-11ea-8dbe-bb66a42bf4d7.gif) 274 | 275 | 276 | ### 和immer的关系 277 | 278 | immer是一个强大的immutable库,它可以非常直观、高效地创建immutable数据: 279 | 280 | ```ts 281 | import produce from "immer"; 282 | 283 | const user = { 284 | name: "Jack", 285 | friends: [{ name: "Tom" }, { name: "Jerry" }] 286 | }; 287 | 288 | const user2 = produce(user, draft => { 289 | draft.name = "James"; 290 | }); 291 | 292 | console.log(user2.friends === user.friends); // true 293 | 294 | const user3 = produce(user, draft => { 295 | draft.friends.push({ name: "Vesper" }); 296 | }); 297 | 298 | console.log(user3.friends === user.friends); // false 299 | console.log(user3.friends[0] === user.friends[0]); // true 300 | ``` 301 | 302 | 他的原理如图: 303 | 304 | ![immer](https://user-images.githubusercontent.com/6293752/76953831-530bcc80-694a-11ea-93ec-069d99bb67b0.gif) 305 | 306 | 相对于通过扩展运算符...,Array.slice等方式来创建immutable对象,immer通过一个参数为draft的函数来修改原对象,然后将修改的过程打包生成一个新对象,原对象不变,符合人的思维直觉。 307 | 308 | 详情请参考immer文档:https://immerjs.github.io/immer/docs/introduction 309 | 310 | 其实,从名字你就可以看出端倪:imdux = im + dux = immer + redux 311 | 312 | imdux做的事情其实很简单,就是将redux中的reducer,和immer中的draft函数合二为一: 313 | 314 | 1. 利用修改draft不会影响原来对象的特性,在reducer内直接读取和修改draft 315 | 2. 利用immer中的produce函数,来生成下一个immutable状态,然后提交给redux,触发状态更新 316 | 317 | 基于以上原理,imdux中的reducer必须是**同步的**。 318 | 319 | ### 异步请求 320 | 321 | imdux推荐两种异步操作解决办法: 322 | 1. 基于hooks的异步方案 323 | 2. 基于全局函数的异步方案 324 | 325 | #### 基于hooks的异步方案 326 | 327 | 一个常见的滚动翻页代码如下: 328 | 329 | ```js 330 | // 定义一个名称为news的action,并使用createStore初始化 331 | 332 | import { createAction } from "imdux"; 333 | 334 | const initialState = { 335 | list: [], 336 | page: 0, 337 | isLoading: false, 338 | isEndOfList: false 339 | }; 340 | 341 | const reducers = { 342 | addNews(draft, list) { 343 | draft.list.push(...list); 344 | }, 345 | addPage(draft) { 346 | if (draft.isEndOfList || draft.isLoading) return; 347 | draft.page++; 348 | }, 349 | startLoading(draft) { 350 | draft.isLoading = true; 351 | }, 352 | stopLoading(draft) { 353 | draft.isLoading = false; 354 | }, 355 | reachEndOfList(draft) { 356 | draft.isEndOfList = true; 357 | } 358 | }; 359 | 360 | export const news = createAction({ initialState, reducers }); 361 | 362 | ``` 363 | 364 | ```js 365 | // 使用Dispatch.news.addPage更新news.page,触发request异步操作 366 | 367 | import * as React from "react"; 368 | import { useSelector } from "react-redux"; 369 | 370 | import { Dispatch, Store } from "./store"; 371 | 372 | export default function App() { 373 | const news = useSelector(p => p.news); 374 | 375 | React.useEffect(() => { 376 | request(news.page); 377 | }, [news.page]); 378 | 379 | const request = async (page) => { 380 | Dispatch.news.startLoading(); 381 | try { 382 | const response = await Api.getNewsList(page); 383 | if (!Api.isError(response)) { 384 | if (response.data.list.length === 0) { 385 | Dispatch.news.reachEndOfList(); 386 | } else { 387 | Dispatch.news.addNews(response.data.list); 388 | } 389 | } else { 390 | alert(response.message); 391 | } 392 | } catch (e) { 393 | alert(e.message); 394 | } finally { 395 | Dispatch.news.stopLoading(); 396 | } 397 | }; 398 | 399 | return ( 400 |
401 | {news.list.map(item => 402 |

{item.title}

403 | )} 404 | {news.isLoading ? "加载中" : news.isEndOfList ? "加载完毕" : ""} 405 |
406 | ); 407 | } 408 | ``` 409 | 410 | #### 基于全局函数的异步方案 411 | 当一个异步方法需要在多个component中复用的时候,可以定义一个全局函数,在函数内使用`Dispatch`触发状态更新,使用`Query`获得状态的最新值,然后在需要的component中import这个函数即可。 412 | 413 | 需要注意的是,这种方案非常简单,但是会造成全局变量污染问题,请酌情使用。 414 | 415 | ### 最佳实践 416 | 417 | // TODO 418 | 419 | ### License 420 | 421 | MIT 422 | --------------------------------------------------------------------------------