From 52e39cd33115bc50b822158600864d0a1bcb7af1 Mon Sep 17 00:00:00 2001 From: zhanwen Date: Thu, 22 Aug 2019 17:48:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=20middleware=20&=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=AE=98=E6=96=B9=E8=B0=83=E8=AF=95=20middleware=20(#?= =?UTF-8?q?27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support middleware && add debug middleware * feat: add README of icestore-debug * feat: delete next in last middleware * fix: lint * fix: test * feat: improve test * fix: lint * feat: middleware API simplify * feat: update API according to PR review * fix: lint and test * feat: delete Proxy * fix: lint * feat: remove state diff * feat: update API according to latest PR review * chore: remove unused dependency * test: add action value * feat: split util functions to individual files * feat: support action return value from middleware * chore: remove unused util * fix: util path * feat: split util functions to individual files * feat: update README * feat: remove toJS in icestore-logger * feat: move middleware logic to store * fix: loading and error status * fix: PR review * feat: add test * fix: not throw error * feat: add test * feat: update README * feat: README fix --- .eslintrc | 26 ---- .eslintrc.js | 18 +++ README.md | 42 +++---- README.zh-CN.md | 183 ++++++++++++++++++++++++----- examples/todos/package.json | 20 ++++ examples/todos/public/index.html | 15 +++ examples/todos/src/index.js | 68 +++++++++++ examples/todos/src/stores/index.js | 16 +++ examples/todos/src/stores/todos.js | 31 +++++ middlewares/logger/README.md | 59 ++++++++++ middlewares/logger/package.json | 40 +++++++ middlewares/logger/src/index.ts | 36 ++++++ middlewares/logger/tsconfig.json | 16 +++ package.json | 12 +- src/index.ts | 35 +++++- src/interface.ts | 19 +++ src/store.ts | 83 +++++++------ src/util.ts | 42 ------- src/util/compose.ts | 27 +++++ tests/index.spec.tsx | 39 +++++- tests/store.spec.tsx | 27 +++++ tests/util.spec.tsx | 124 +++++++------------ 22 files changed, 716 insertions(+), 262 deletions(-) delete mode 100644 .eslintrc create mode 100644 .eslintrc.js create mode 100644 examples/todos/package.json create mode 100644 examples/todos/public/index.html create mode 100644 examples/todos/src/index.js create mode 100644 examples/todos/src/stores/index.js create mode 100644 examples/todos/src/stores/todos.js create mode 100644 middlewares/logger/README.md create mode 100644 middlewares/logger/package.json create mode 100644 middlewares/logger/src/index.ts create mode 100644 middlewares/logger/tsconfig.json create mode 100644 src/interface.ts delete mode 100644 src/util.ts create mode 100644 src/util/compose.ts create mode 100644 tests/store.spec.tsx diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 9a787076..00000000 --- a/.eslintrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint", - "react", - "import" - ], - "parserOptions": { - "sourceType": "module", - "project": "./tsconfig.json" - }, - "extends": [ - "airbnb-base", - "plugin:import/typescript", - "plugin:@typescript-eslint/recommended", - "plugin:react/recommended" - ], - "settings": { - "react": { - "version": "detect" - } - }, - rules: { - "@typescript-eslint/indent": ["error", 2] - } -} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..e1aa3f22 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,18 @@ +const { eslintTS, deepmerge } = require('@ice/spec'); + +module.exports = deepmerge(eslintTS, { + env: { + jest: true + }, + rules: { + "@typescript-eslint/explicit-member-accessibility": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/interface-name-prefix": 1, + }, + settings: { + "react": { + "pragma": "React", + "version": "detect" + } + }, +}); diff --git a/README.md b/README.md index 0c725b5e..09044058 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ npm install @ice/store --save `icestore` is a lightweight React state management library based on hooks. It has the following core features: -* Minimal API: Contains 3 APIs, which is easily learnable in 5 minutes. +* Minimal API: Contains 5 APIs, which is easily learnable in 5 minutes. * Predictable: Uses unidirectional data flow (similar to Redux) and allows state mutation only inside actions, allowing data flow to be traced easily. -* Optimal performance: Decreases the number of view components that rerender when the state changes by creating multiple stores. Rerendering only occurs when the current state is different from the previous state. +* Optimal performance: Decreases the number of view components that rerender when the state changes by creating multiple stores. * Built in async status: Records loading and error status of async actions, simplifying the rendering logic in the view layer. The data flow is as follows: @@ -173,6 +173,17 @@ Register store config to the global store instance. * Return value - {object} store instance +### applyMiddleware + +Apply middleware to all the store if the second parameter is not specified, +otherwise apply middleware the store by namespace. + +* Parameters + - middlewares {array} middleware array to be applied + - namespace {string} store namespace +* Return value + - void + ### useStores Hook to use multiple stores. @@ -191,34 +202,15 @@ Hook to use a single store. * Return value - {object} single store instance -### toJS +### getState -Recursively convert proxified state object to plain javaScript type. +Get the latest state of individual store by namespace. * Parameters - - value {any} value of any javaScript type + - namespace {string} store namespace * Return value - - {any} javaScript value of any type - -#### Example + - {object} the latest state of the store -```javascript -// store.js -export default { - value: { - a: 1, - b: 2, - } -}; - -// view.jsx -import IceStore, { toJS } from '@ice/store'; -const { value } = useStore('foo'); - -const a = toJS(value); -console.log(a); - -``` ## Advanced use ### async actions' executing status diff --git a/README.zh-CN.md b/README.zh-CN.md index 35d31d7d..fee92a8a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -22,10 +22,10 @@ $ npm install @ice/store --save `icestore` 是基于 React Hooks 实现的轻量级状态管理框架,有以下核心特点: -* **极简 API**:只有 3 个 API,简单上手,使用方便,不需要学习 Redux 里的各种概念。 +* **极简 API**:只有 5 个 API,简单上手,使用方便,不需要学习 Redux 里的各种概念。 * **React Hooks**:拥抱 Hooks 的使用体验,同时也是基于 React Hooks 实现。 * **集成异步状态**:记录异步 action 的执行状态,简化 view 组件中对于 loading 与 error 状态的渲染逻辑。 -* **性能优化**:通过多 store 的去中心化设计,减少单个 state 变化触发重新渲染的组件个数,同时改变 state 时做 diff,进一步减少不必要的渲染。 +* **性能优化**:通过多 store 的去中心化设计,减少单个 state 变化触发重新渲染的组件个数,从而减少不必要的渲染。 * **单向数据流**:与 Redux 一样使用单向数据流,便于状态的追踪与预测。 ### 兼容性 @@ -137,11 +137,6 @@ ReactDOM.render(, document.getElementById('root')); 完整示例展示在这个 [sandbox](https://codesandbox.io/s/icestore-hs9fe)。 -## Todos - -- [ ] 增加调试工具 -- [ ] 支持 middleware - ## 实现原理 `icestore` 数据流示意图如下: @@ -171,7 +166,7 @@ ReactDOM.render(, document.getElementById('root')); ### 不要在 action 之外直接修改 state -`icestore` 的架构设计中强制要求对state的变更只能在 action 中进行。在 action 之外的对 state的修改将直接 throw 错误。这个设计的原因是在 action 之外修改 state 将导致 state 变更逻辑散落在 view 中,变更逻辑将会难以追踪和调试。 +`icestore` 的架构设计中强制要求对 state 的变更只能在 action 中进行。在 action 之外的对 state 的修改不生效。这个设计的原因是在 action 之外修改 state 将导致 state 变更逻辑散落在 view 中,变更逻辑将会难以追踪和调试。 ```javascript // store.js @@ -214,6 +209,16 @@ ReactDOM.render(, document.getElementById('root')); * 返回值 - {object} store 实例 +### applyMiddleware + +给所有 store 或者指定 namespace 的 store 注册 middleware,如果不指定第 2 个参数,给所有 store 注册 middleware,如果指定第 2 个参数,则给指定 namespace 的 store 注册 middleware,详细用法见[注册方式](#注册方式) + +* 参数 + - middlewares {array} 待注册的 middleware 数组 + - namespace {string} store 的命名空间 +* 返回值 + - 无 + ### useStores 同时使用多个 store 的 hook。 @@ -232,34 +237,14 @@ ReactDOM.render(, document.getElementById('root')); * 返回值 - {object} store 的配置对象 -### toJS +### getState -递归将 Proxy 化的 state 对象转化成普通的 javaScript 对象 +获取单个 store 的最新 state 对象。 * 参数 - - value {any} 任意 javaScript 类型值 + - namespace {string} store 的命名空间 * 返回值 - - {any} 去 Proxy 后的 javaScript 类型 - -#### 示例 - -```javascript -// store.js -export default { - value: { - a: 1, - b: 2, - } -}; - -// view.jsx -import IceStore, { toJS } from '@ice/store'; -const { value } = useStore('foo'); - -const a = toJS(value); -console.log(a); - -``` + - {object} store 的 state 对象 ## 高级用法 @@ -319,6 +304,140 @@ return ( ); ``` +### 中间件 + +### 背景 + +如果你有使用过服务端的框架如 Express 或者 koa,应该已经熟悉了中间件的概念,在这些框架中,中间件用于在框架 `接收请求` 与 `产生响应` 间插入自定义代码,这类中间件的功能包含在请求未被响应之前对数据进行加工、鉴权,以及在请求被响应之后添加响应头、打印 log 等功能。 + + +在状态管理领域,Redux 同样实现了中间件的机制,用于在 `action 调用` 与 `到达 reducer` 之间插入自定义代码,中间件包含的功能有打印 log、提供 thunk 与 promise 异步机制、日志上报等。 + + +icestore 支持中间件的目的与 Redux 类似,也是为了在 action 调用前后增加一种扩展机制,增加诸如打印 log、埋点上报、异步请求封装等一系列能力,不同的是 icestore 已支持异步机制,因此不需要额外通过中间件方式支持。 + +### 中间件 API + +在中间件 API 的设计上,`icestore` 借鉴了 koa 的 API,见如下: + +```javascript +async (ctx, next) => { + // action 调用前逻辑 + + const result = await next(); + + // action 调用后逻辑 + + return result; +} +``` + +如果用户定义的 action 中有返回值,中间件函数必须将下一个中间件的执行结果返回,以保证中间件链式调用完成后能拿到 action 的返回值。 + +#### ctx API + +对于中间件函数的第一个 ctx 参数,从上面能拿到当前的 store 与当前调用 action 的信息,ctx 对象中包含的详细参数如下: + +* ctx.action - 当前调用的 action 对象 + * 类型:{object} + * 默认值:无 +* ctx.action.name - 当前调用的 action 方法名 + * 类型:{string} + * 默认值:无 +* ctx.action.arguments - 当前调用的 action 方法参数数组 + * 类型:{array} + * 默认值:无 +* ctx.store - 当前 store 对象 + * 类型:{object} + * 默认值:无 +* ctx.store.namespace - 当前 store 的 namespace + * 类型:{string} + * 默认值:无 +* ctx.store.getState - 获取当前 store 最新 state 的方法 + * 类型:{function} + * 参数:无 + +调用方式如下: + +```javascript +const { + action, // 当前调用的 action 对象 + store, // 当前 store 对象 +} = ctx; + +const { + name, // 当前调用的 action 方法名 + arguments, // 当前调用的 action 方法参数数组 +} = action; + +const { + namespace, // 当前 store namespace + getState, // 获取当前 store state 方法 +} = store; +``` + +### 注册方式 + +由于 `icestore` 的多 store 设计,`icestore` 支持给不同的 store 单独注册 middleware, +方式如下: + +1. 全局注册 middleware + * 全局注册的 middleware 对所有 store 生效 + + ```javascript + import Icestore from '@ice/store'; + const stores = new Icestore(); + stores.applyMiddleware([a, b]); + ``` + +2. 指定 store 注册 middleware + * store 上最终注册的 middleware 将与全局注册 middleware 做合并 + + ```javascript + stores.applyMiddleware([a, b]); + stores.applyMiddleware([c, d], 'foo'); // store foo 中间件为 [a, b, c, d] + stores.applyMiddleware([d, c], 'bar'); // store bar 中间件为 [a, b, d, c] + ``` + +## 调试 + +icestore 官方提供 logger 中间件,可以方便地跟踪触发 action 名以及 action 触发前后 state 的 diff 信息,提升问题排查效率。 + +### 使用方式 + +在注册 store 之前,使用 `applyMiddleware` 方法将 logger 中间件加入到中间件队列中 + +```javascript +import todos from './todos'; +import Icestore from '@ice/store'; +import logger from '@ice/store-logger'; + +const icestore = new Icestore(); + +const middlewares = []; + +// 线上环境不开启调试中间件 +if (process.env.NODE_ENV !== 'production') { + middlewares.push(logger); +} + +icestore.applyMiddleware(middlewares); +icestore.registerStore('todos', todos); +``` + +注册成功后,当 `store` 中的 action 被调用时,在浏览器的 DevTools 中将能看到实时的日志: + + + +日志中包含以下几个部分: + +* Store Name: 当前子 store 对应的 namespace +* Action Name: 当前触发的 action 名 +* Added / Deleted / Updated: state 变化的 diff +* Old state: 更新前的 state +* New state: 更新后的 state + + ## 测试 由于所有的 state 和 actions 都封装在一个普通的 JavaScript 对象中,可以在不 mock 的情况下很容易的给 store 写测试用例。 diff --git a/examples/todos/package.json b/examples/todos/package.json new file mode 100644 index 00000000..caaffd26 --- /dev/null +++ b/examples/todos/package.json @@ -0,0 +1,20 @@ +{ + "name": "todos", + "version": "1.0.0", + "private": true, + "main": "src/index.js", + "dependencies": { + "@ice/store": "^0.2.x", + "@ice/store-debugger": "^0.0.x", + "react": "16.8.6", + "react-dom": "16.8.6" + }, + "devDependencies": { + "ice-scripts": "^2.0.0" + }, + "scripts": { + "start": "ice-scripts dev", + "build": "ice-scripts build", + "test": "ice-scripts test" + } +} diff --git a/examples/todos/public/index.html b/examples/todos/public/index.html new file mode 100644 index 00000000..5815fc96 --- /dev/null +++ b/examples/todos/public/index.html @@ -0,0 +1,15 @@ + + + + + + + + todos app + + + +
+ + + diff --git a/examples/todos/src/index.js b/examples/todos/src/index.js new file mode 100644 index 00000000..9639943c --- /dev/null +++ b/examples/todos/src/index.js @@ -0,0 +1,68 @@ +import React, { useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import stores from './stores'; + +function Todo() { + const todos = stores.useStore('todos'); + const { dataSource, refresh, add, remove, toggle } = todos; + + useEffect(() => { + refresh(); + }, []); + + async function onAdd(name) { + const todo = await add({ name }); + console.log('Newly added todo is ', todo) + } + + function onRemove(index) { + remove(index); + } + + function onCheck(index) { + toggle(index); + } + + const noTaskView = no task; + const loadingView = loading...; + const taskView = dataSource.length ? ( +
    + {dataSource.map(({ name, done = false }, index) => ( +
  • + + +
  • + ))} +
+ ) : ( + noTaskView + ); + + return ( +
+

Todos

+ {!refresh.loading ? taskView : loadingView} +
+ { + if (event.keyCode === 13) { + onAdd(event.target.value); + event.target.value = ''; + } + }} + placeholder="Press Enter" + /> +
+
+ ); +} + +const rootElement = document.getElementById('ice-container'); +ReactDOM.render(, rootElement); diff --git a/examples/todos/src/stores/index.js b/examples/todos/src/stores/index.js new file mode 100644 index 00000000..6271c56e --- /dev/null +++ b/examples/todos/src/stores/index.js @@ -0,0 +1,16 @@ +import Icestore from '@ice/store'; +import logger from '@ice/store-logger'; +import todos from './todos'; + +const icestore = new Icestore('todos'); + +const middlewares = []; + +if (process.env.NODE_ENV !== 'production') { + middlewares.push(logger); +} + +icestore.applyMiddleware(middlewares); +icestore.registerStore('todos', todos); + +export default icestore; diff --git a/examples/todos/src/stores/todos.js b/examples/todos/src/stores/todos.js new file mode 100644 index 00000000..0712cad7 --- /dev/null +++ b/examples/todos/src/stores/todos.js @@ -0,0 +1,31 @@ +export default { + dataSource: [], + async refresh() { + this.dataSource = await new Promise(resolve => + setTimeout(() => { + resolve([ + { + name: 'react', + }, + { + name: 'vue', + done: true, + }, + { + name: 'angular', + }, + ]); + }, 1000) + ); + }, + add(todo) { + this.dataSource.push(todo); + return todo; + }, + remove(index) { + this.dataSource.splice(index, 1); + }, + toggle(index) { + this.dataSource[index].done = !this.dataSource[index].done; + }, +}; diff --git a/middlewares/logger/README.md b/middlewares/logger/README.md new file mode 100644 index 00000000..49447011 --- /dev/null +++ b/middlewares/logger/README.md @@ -0,0 +1,59 @@ +# icestore-logger + +> icestore 调试 middleware + + +## 安装 + +```bash +npm install @ice/store-logger --save +``` + +## 简介 + +`icestore-logger` 是 `icestore` 官方提供的调试中间件,使用该中间件用户可以方便地跟踪 state 的 diff、触发 action 等信息,提升问题排查效率。 + +## 快速开始 + +在注册 store 之前,使用 `applyMiddleware` 方法将 logger 中间件加入到中间件队列中 + +```javascript +import todos from './todos'; +import Icestore from '@ice/store'; +import logger from '@ice/store-logger'; + +const icestore = new Icestore(); + +const middlewares = []; + +// 线上环境不开启调试中间件 +if (process.env.NODE_ENV !== 'production') { + middlewares.push(logger); +} + +icestore.applyMiddleware(middlewares); +icestore.registerStore('todos', todos); +``` + +注册成功后,当 `store` 中的 action 被调用时,在浏览器的 DevTools 中将能看到实时的日志: + + + +日志中包含以下几个部分: + +* Store Name: 当前子 store 对应的 namespace +* Action Name: 当前触发的 action 名 +* Added / Deleted / Updated: state 变化的 diff +* Old state: 更新前的 state +* New state: 更新后的 state + +## Contributors + +欢迎反馈问题 [issue 链接](https://github.com/alibaba/ice/issues/new) +如果对 `icestore` 感兴趣,欢迎参考 [CONTRIBUTING.md](https://github.com/alibaba/ice/blob/master/.github/CONTRIBUTING.md) 学习如何贡献代码。 + +## License + +[MIT](LICENSE) + + diff --git a/middlewares/logger/package.json b/middlewares/logger/package.json new file mode 100644 index 00000000..84500bed --- /dev/null +++ b/middlewares/logger/package.json @@ -0,0 +1,40 @@ +{ + "name": "@ice/store-logger", + "version": "0.1.0", + "description": "icestore logger middleware", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/ice-lab/icestore.git" + }, + "keywords": [ + "icestore", + "debug" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/ice-lab/icestore/issues" + }, + "homepage": "https://github.com/ice-lab/icestore", + "scripts": { + "build": "rm -rf lib && tsc", + "watch": "tsc -w", + "lint": "npm run lint:nofix -- --fix", + "lint:nofix": "eslint --cache --ext .ts,.tsx ./", + "test": "NODE_ENV=unittest jest" + }, + "dependencies": { + "@ice/store": "^0.2.x", + "deep-object-diff": "^1.1.0", + "lodash.clonedeep": "^4.5.0" + }, + "devDependencies": { + "eslint": "^5.3.0", + "jest": "^24.7.1", + "typescript": "^3.4.4" + } +} diff --git a/middlewares/logger/src/index.ts b/middlewares/logger/src/index.ts new file mode 100644 index 00000000..7450c931 --- /dev/null +++ b/middlewares/logger/src/index.ts @@ -0,0 +1,36 @@ +import { detailedDiff } from 'deep-object-diff'; +import * as clone from 'lodash.clonedeep'; + +export default async (ctx, next) => { + const { namespace, getState } = ctx.store; + const { name: actionName } = ctx.action; + const preState = clone(getState()); + + const value = await next(); + + const state = clone(getState()); + const diff: any = detailedDiff(preState, state); + const hasChanges = obj => Object.keys(obj).length > 0; + + console.group('Store Name: ', namespace); + console.log('Action Name: ', actionName); + + if (hasChanges(diff.added)) { + console.log('Added\n', diff.added); + } + + if (hasChanges(diff.updated)) { + console.log('Updated\n', diff.updated); + } + + if (hasChanges(diff.deleted)) { + console.log('Deleted\n', diff.deleted); + } + + console.log('Old State\n', preState); + console.log('New State\n', state); + console.groupEnd(); + + return value; +} + diff --git a/middlewares/logger/tsconfig.json b/middlewares/logger/tsconfig.json new file mode 100644 index 00000000..88d40ea7 --- /dev/null +++ b/middlewares/logger/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es5", + "jsx": "react", + "experimentalDecorators": true, + "declaration": true, + "sourceMap": true, + "outDir": "lib" + }, + "files": [ + "src/index.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/package.json b/package.json index 92467160..4e8b7a41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ice/store", - "version": "0.2.4", + "version": "0.3.0", "description": "Lightweight React state management library based on react hooks", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -37,19 +37,15 @@ "devDependencies": { "@commitlint/cli": "^7.5.2", "@commitlint/config-conventional": "^7.5.0", + "@ice/spec": "^0.1.4", "@types/jest": "^24.0.12", "@types/node": "^12.0.0", - "@typescript-eslint/eslint-plugin": "^1.7.0", - "@typescript-eslint/parser": "^1.7.0", "codecov": "^3.3.0", "eslint": "^5.3.0", - "eslint-config-airbnb-base": "^13.1.0", - "eslint-plugin-import": "^2.17.2", - "eslint-plugin-react": "^7.13.0", "husky": "^2.2.0", "jest": "^24.7.1", - "react": "^16.7.0", - "react-dom": "^16.7.0", + "react": "^16.8.0", + "react-dom": "^16.8.0", "react-testing-library": "^7.0.0", "ts-jest": "^24.0.2", "typescript": "^3.4.4" diff --git a/src/index.ts b/src/index.ts index 5a8131b1..7325fc9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,16 @@ import Store from './store'; -import { toJS } from './util'; +import { Middleware } from './interface'; export default class Icestore { /** Stores registered */ private stores: {[namespace: string]: Store} = {}; + /** Global middlewares applied to all stores */ + private globalMiddlewares: Middleware[] = []; + + /** middleware applied to single store */ + private middlewareMap: {[namespace: string]: Middleware[]} = {}; + /** * Register and init store * @param {string} namespace - unique name of store @@ -16,10 +22,25 @@ export default class Icestore { throw new Error(`Namespace have been used: ${namespace}.`); } - this.stores[namespace] = new Store(bindings); + const storeMiddlewares = this.middlewareMap[namespace] || []; + const middlewares = this.globalMiddlewares.concat(storeMiddlewares); + this.stores[namespace] = new Store(namespace, bindings, middlewares); return this.stores[namespace]; } + /** + * Apply middleware to stores + * @param {array} middlewares - middlewares queue of store + * @param {string} namespace - unique name of store + */ + public applyMiddleware(middlewares: Middleware[], namespace: string): void { + if (namespace !== undefined) { + this.middlewareMap[namespace] = middlewares; + } else { + this.globalMiddlewares = middlewares; + } + } + /** * Find store by namespace * @param {string} namespace - unique name of store @@ -33,6 +54,15 @@ export default class Icestore { return store; } + /** + * Get state of store by namespace + * @param {string} namespace - unique name of store + * @return {object} store's state + */ + public getState(namespace: string): object { + return this.getModel(namespace).getState(); + } + /** * Hook of using store * @param {string} namespace - unique name of store @@ -52,4 +82,3 @@ export default class Icestore { } } -export { toJS }; diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 00000000..9e0fbdb4 --- /dev/null +++ b/src/interface.ts @@ -0,0 +1,19 @@ +export interface Ctx { + action: { + name: string; + arguments: any[]; + }; + store: { + namespace: string; + getState: () => object; + }; +} + +export interface Middleware { + (ctx: Ctx, next: Promise): any; +} + +export interface ComposeFunc { + (): Promise; +} + diff --git a/src/store.ts b/src/store.ts index 97e5f112..7361b7de 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,12 +1,8 @@ import * as isFunction from 'lodash.isfunction'; import * as isPromise from 'is-promise'; -import * as isObject from 'lodash.isobject'; import { useState, useEffect } from 'react'; -import { addProxy } from './util'; - -interface MethodFunc { - (): void; -} +import compose from './util/compose'; +import { ComposeFunc, Middleware } from './interface'; export default class Store { /** Store state and actions user defined */ @@ -15,49 +11,41 @@ export default class Store { /** Queue of setState method from useState hook */ private queue = []; - /** Flag of whether state changed after mutation */ - private stateChanged = false; + /** Namespace of store */ + private namespace = ''; - /** Flag of how many actions are in exection */ - private actionExecNum = 0; + /** Middleware queue of store */ + private middlewares = []; /** Flag of whether disable loading effect globally */ public disableLoading = false; - public constructor(bindings: object) { + /** + * Constuctor of Store + * @param {string} namespace - unique name of store + * @param {object} bindings - object of state and actions used to init store + * @param {array} middlewares - middlewares queue of store + */ + public constructor(namespace: string, bindings: object, middlewares: Middleware []) { + this.namespace = namespace; + this.middlewares = middlewares; + Object.keys(bindings).forEach((key) => { const value = bindings[key]; - this.bindings[key] = isFunction(value) ? this.createAction(value) : value; + this.bindings[key] = isFunction(value) ? this.createAction(value, key) : value; }); - - const handler = { - set: (target, prop, value) => { - if (!this.actionExecNum) { - console.error('Forbid modifying state directly, use action to modify instead.'); - return false; - } - if (target[prop] !== value) { - this.stateChanged = true; - } - /* eslint no-param-reassign: 0 */ - target[prop] = isObject(value) ? addProxy(value, handler) : value; - return true; - }, - }; - - this.bindings = addProxy(this.bindings, handler); } /** * Create action which will trigger state update after mutation * @param {function} func - original method user defined + * @param {string} actionName - name of action function * @return {function} action function */ - private createAction(func): MethodFunc { - const wrapper: any = async (...args) => { + private createAction(func: () => any, actionName: string): ComposeFunc { + const actionWrapper: any = async (...args) => { wrapper.loading = true; wrapper.error = null; - this.actionExecNum += 1; const disableLoading = wrapper.disableLoading !== undefined ? wrapper.disableLoading : this.disableLoading; @@ -70,16 +58,13 @@ export default class Store { const afterExec = () => { wrapper.loading = false; - this.actionExecNum -= 1; - if (enableLoading || this.stateChanged) { - this.setState(); - } - this.stateChanged = false; + this.setState(); }; try { - await result; + const value = await result; afterExec(); + return value; } catch (e) { wrapper.error = e; afterExec(); @@ -87,6 +72,21 @@ export default class Store { } }; + const actionMiddleware = async (ctx, next) => { + return await actionWrapper(...ctx.action.arguments); + }; + const ctx = { + action: { + name: actionName, + arguments: [], + }, + store: { + namespace: this.namespace, + getState: this.getState, + }, + }; + const wrapper: any = compose(this.middlewares.concat(actionMiddleware), ctx); + return wrapper; } @@ -94,7 +94,7 @@ export default class Store { * Get state from bindings * @return {object} state */ - private getState(): object { + public getState = (): object => { const { bindings } = this; const state = {}; Object.keys(bindings).forEach((key) => { @@ -111,8 +111,7 @@ export default class Store { */ private setState(): void { const state = this.getState(); - const newState = { ...state }; - this.queue.forEach(setState => setState(newState)); + this.queue.forEach(setState => setState(state)); } /** @@ -129,6 +128,6 @@ export default class Store { this.queue.splice(index, 1); }; }, []); - return this.bindings; + return { ...this.bindings }; } } diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 7e293a8e..00000000 --- a/src/util.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as isObject from 'lodash.isobject'; -import * as forEach from 'lodash.foreach'; - -/** - * Recursively add proxy to object - * @param {object} value - value of object type - * @param {object} handler - proxy handler - * @return {object} new proxy object - */ -/* eslint no-param-reassign: 0 */ -export function addProxy(value: object, handler: object): object { - if (!value || Object.isFrozen(value)) { - return value; - } - forEach(value, (item, key) => { - if (isObject(item)) { - value[key] = addProxy(item, handler); - } - }); - return new Proxy(value, handler); -} - -/** - * Convert proxied value to plain js object - * @param {any} value - js value of any type - * @return {any} plain js type - */ -export function toJS(value: any): any { - if (!value || !isObject(value)) { - return value; - } - - const newValue = Array.isArray(value) ? [] : {}; - forEach(value, (item, key) => { - if (isObject(item)) { - newValue[key] = toJS(item); - } else { - newValue[key] = item; - } - }); - return newValue; -} diff --git a/src/util/compose.ts b/src/util/compose.ts new file mode 100644 index 00000000..a40d0bb4 --- /dev/null +++ b/src/util/compose.ts @@ -0,0 +1,27 @@ +import { Ctx, Middleware, ComposeFunc } from '../interface'; + +/** + * Compose a middleware chain consisting of all the middlewares + * @param {array} middlewares - middlewares user passed + * @param {object} ctx - middleware context + * @return {function} middleware chain + */ +export default function compose(middlewares: Middleware[], ctx: Ctx): ComposeFunc { + return async (...args) => { + ctx.action.arguments = args; + + function goNext(middleware, next) { + return async () => { + return await middleware(ctx, next); + }; + } + let next = async () => { + Promise.resolve(); + }; + middlewares.slice().reverse().forEach((middleware) => { + next = goNext(middleware, next); + }); + + return await next(); + }; +} diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx index dd00e4be..893c7b9b 100644 --- a/tests/index.spec.tsx +++ b/tests/index.spec.tsx @@ -32,6 +32,43 @@ describe('#Icestore', () => { }); }); + describe('#applyMiddleware', () => { + let icestore; + const testMiddleware = async (ctx, next) => { + return next(); + }; + + beforeEach(() => { + icestore = new Icestore(); + icestore.registerStore('foo', { data: 'abc', fetchData: () => {} }); + }); + + test('should apply to global success.', () => { + icestore.applyMiddleware([testMiddleware]); + expect(icestore.globalMiddlewares).toEqual([testMiddleware]); + }); + test('should apply to single store success.', () => { + icestore.applyMiddleware([testMiddleware], 'foo'); + expect(icestore.middlewareMap.foo).toEqual([testMiddleware]); + }); + }); + + describe('#getState', () => { + let icestore; + const testMiddleware = async (ctx, next) => { + return next(); + }; + + beforeEach(() => { + icestore = new Icestore(); + icestore.registerStore('foo', { data: 'abc', fetchData: () => {} }); + }); + + test('should get state from store success.', () => { + expect(icestore.getState('foo')).toEqual({ data: 'abc' }); + }); + }); + describe('#useStore', () => { let icestore; @@ -75,7 +112,7 @@ describe('#Icestore', () => { return
{dataSource.name} -
; diff --git a/tests/store.spec.tsx b/tests/store.spec.tsx new file mode 100644 index 00000000..acd75896 --- /dev/null +++ b/tests/store.spec.tsx @@ -0,0 +1,27 @@ +import Store from '../src/store'; + +describe('#Store', () => { + test('new Class should be defined.', () => { + expect(new Store('ab', {}, [])).toBeDefined(); + }); + + describe('#Action', () => { + const store: any = new Store('foo', { + dataSource: [], + async updateData() { + this.dataSource = [1, 2, 3]; + return this.dataSource; + }, + async fetchData() { + throw new Error('bar'); + }, + }, []); + test('action excutes ok.', async () => { + const result = await store.bindings.updateData(); + expect(result).toEqual([1, 2, 3]); + }) + test('action throws ok.', async () => { + await expect(store.bindings.fetchData()).rejects.toThrow(); + }) + }); +}); diff --git a/tests/util.spec.tsx b/tests/util.spec.tsx index e64f8441..aebfc58c 100644 --- a/tests/util.spec.tsx +++ b/tests/util.spec.tsx @@ -1,4 +1,4 @@ -import { addProxy, toJS } from '../src/util'; +import compose from '../src/util/compose'; describe('#util', () => { let handler; @@ -13,90 +13,48 @@ describe('#util', () => { }; }); - describe('#addProxy', () => { - test('should null type not affected', () => { - const value = null; - expect(addProxy(value, handler)).toBe(value); - }); - - test('should frozen object not affected', () => { - const value = Object.freeze({ - a: 1, - b: 2, + describe('#compose', () => { + const arr = []; + const middlewares = []; + + function wait (ms) { + return new Promise((resolve) => setTimeout(resolve, ms || 1)) + } + test('should work', async () => { + middlewares.push(async (ctx, next) => { + arr.push(1); + await wait(1); + await next(); + await wait(1); + arr.push(6); + }); + middlewares.push(async (ctx, next) => { + arr.push(2); + await wait(1); + await next(); + await wait(1); + arr.push(5); + }); + middlewares.push(async (ctx, next) => { + arr.push(3); + await wait(1); + await next(); + await wait(1); + arr.push(4); }); - expect(addProxy(value, handler)).toBe(value); - }); - - test('should function proxy set success', () => { - const value = () => {}; - const result: any = addProxy(value, handler); - result.loading = true; - expect(result.loading).toBe('foo'); - }); - - test('should object proxy set success', () => { - const value = { - a: 1, - b: 2, - }; - const result: any = addProxy(value, handler); - result.a = 100; - expect(result.a).toBe('foo'); - }); - - test('should object proxy recursively set success', () => { - const value = { - a: [ - { c: 3 }, - ], - b: 2, - }; - const result: any = addProxy(value, handler); - result.a[0].c = 4; - expect(result.a[0].c).toBe('foo'); - }); - - test('should array proxy set success', () => { - const value = [1, 2]; - const result: any = addProxy(value, handler); - result[0] = 4; - expect(result[0]).toBe('foo'); - }); - - test('should array proxy recursively set success', () => { - const value = [ - { a: 1 }, - ]; - const result: any = addProxy(value, handler); - result[0].a = 4; - expect(result[0].a).toBe('foo'); - }); - }); - - - describe('#toJS', () => { - test('should null type convert success', () => { - const value = null; - const jsValue = toJS(value); - expect(jsValue).toBe(value); - }); - - test('should non object type convert success', () => { - const value = 1; - const jsValue = toJS(value); - expect(jsValue).toBe(value); - }); - test('should object type convert success', () => { - const value = { - a: [ - { c: 3 }, - ], - b: 2, - }; - const result: any = addProxy(value, handler); - const jsValue = toJS(result); - expect(jsValue).toEqual(value); + const ctx = { + action: { + name: '', + arguments: [], + }, + store: { + namespace: 'foo', + getState: () => { return {}; }, + }, + } + await compose(middlewares, ctx)(); + expect(arr).toEqual([1, 2, 3, 4, 5, 6]); }); }); });