├── .eslintrc.js ├── .gitignore ├── .npmrc ├── .travis.yml ├── LICENSE ├── README.md ├── demo ├── App.js ├── actions.js ├── api.js ├── entries │ └── index.js ├── index.css ├── reducers.js ├── selectors.js └── store.js ├── package.json ├── settings.js ├── src ├── __tests__ │ ├── entities.test.js │ ├── queries.test.js │ ├── selectors.test.js │ └── thunkCreatorFor.test.js ├── entities.ts ├── index.ts ├── interface.ts ├── queries.ts └── selectors.ts ├── tsconfig.json ├── tslint.json ├── types ├── san-update.d.ts └── vfile-message.d.ts └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: './node_modules/reskript/config/eslint.js', 3 | }; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | /cjs 61 | /es 62 | .DS_Store 63 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = https://registry.npm.taobao.org 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | script: yarn run ci 5 | after_script: 6 | - yarn run report-cov -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Baidu EFE team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # standard-redux-shape 2 | 3 | `standard-redux-shape` is a tiny utility library to help you manage an optimized redux store shape. 4 | 5 | ## Background 6 | 7 | In many applications, developers tends to consider redux store as a simple data structure represnting their views, for example, in a simple todo application, the store may look like: 8 | 9 | ```javascript 10 | store: { 11 | todoList: { 12 | pageNumber: 0, 13 | pageSize: 10, 14 | filters: { 15 | keyword: 'buy', 16 | startDate: '2017-01-01', 17 | endDate: '2017-06-30', 18 | status: 'pending' 19 | }, 20 | todos: [/* todo objects */] 21 | } 22 | } 23 | ``` 24 | 25 | This is OK, by listening events and dispatching actions to update `filters` or `todos`, with a `mapStateToProps` function you can receive an array of todo objects and display them on screen: 26 | 27 | ```javascript 28 | const TodoList = ({todos}) => ( 29 | 32 | ); 33 | 34 | const mapStateToProps = state => state.todoList.todos; 35 | 36 | export default connect(mapStateToProps)(TodoList); 37 | ``` 38 | 39 | This is how many application implements their react-redux application, however it can still introduce issues when `filters` are changed, since `TodoList` only knows `todoList.todos` property, it can receive a wrong list before actions updating `filters` are dispatched and new todo objects are loaded from remote. 40 | 41 | In order to prevent wrong data to be displayed, a simple solution is to also receive all current parameters and compare them with those corresponding to the list, so we have to change our store to contain the current and corresponding parameters: 42 | 43 | ```javascript 44 | store: { 45 | todoList: { 46 | currentParams: { 47 | pageNumber: 0, 48 | pageSize: 10, 49 | filters: { 50 | keyword: 'buy', 51 | startDate: '2017-01-01', 52 | endDate: '2017-06-30', 53 | status: 'pending' 54 | } 55 | }, 56 | response: { 57 | params: { 58 | pageNumber: 0, 59 | pageSize: 10, 60 | filters: { 61 | keyword: 'buy', 62 | startDate: '2017-01-01', 63 | endDate: '2017-06-30', 64 | status: 'pending' 65 | } 66 | }, 67 | todos: [/* todo obejcts */] 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | Then we receive all these properties and compare them to ensure the `todos` property is udpate to date: 74 | 75 | ```javascript 76 | const mapStateToProps = state => { 77 | const {currentParams, response: {params, todos}} = state; 78 | 79 | if (shallowEquals(currentParams, params)) { 80 | return todos; 81 | } 82 | 83 | return null; // To indicate current response is not returned from remote 84 | }; 85 | ``` 86 | 87 | This solves the wrong data issue "perfectly" with some shortcomings: 88 | 89 | - We have to manage a more complex store structure case by case. 90 | - The `todos` list is overridden each time `pageNumber`, `pageSize` or `filters` are changed, instant undo is missing on same view. 91 | 92 | Having our purpose to solve above issues along with receiving the correct and update to date data each time, the final approach comes with a standardized store shape with three layers. 93 | 94 | ## Standard shape of store 95 | 96 | The `standard-redux-shape` recommends a standard store shape which combines with three concepts: 97 | 98 | 1. The `entities` is for [store normalization](http://redux.js.org/docs/recipes/reducers/NormalizingStateShape.html), `standard-redux-shape` helps you to create and manage entity tables. 99 | 2. The `queries` is an area to store and retrieve query responses with certain params, consider a query response as a special kind of entity, the query params is the key of such entity, `standard-redux-shape` provides functions to serialize params and store responses. 100 | 3. The rest of the store should be a minimum state containing current params, `standard-redux-shape` also provides function to retrive responses with params. 101 | 102 | ### Store normalization 103 | 104 | Considering that in most applications entities come from the remote server, `standard-redux-shape` decided to simply binding remote API calls to entity tables, this is archived with 2 functions. 105 | 106 | First or all, the `createTableUpdater` creates a function usually called `withTableUpdate` (of type `TableUpdater`), this is a higher order function which has the signature of: 107 | 108 | ``` 109 | type StoreResolver = () => Store; 110 | type TableUpdaterCreator = ({StoreResolver} resolveStore) => TableUpdater; 111 | type EntitySelector = ({Object} response) => Obejct; 112 | type TableUpdater = ({string} tableName, {EntitySelector} selectEntites) => APIWrapper; 113 | type APIWrapper = ({Function} api) => Function; 114 | ``` 115 | 116 | Next, the `createTableUpdateReducer` is a function creating a reducer to update the table entities, simply use `combineReducers` to assign a property of store (`entities` recommended) to its return value so that `withTableUpdate` functions can work as expected. 117 | 118 | To have a real world example, suppose we have a API called `getTodos` returning a response as: 119 | 120 | ```javascript 121 | { 122 | pageNumber: 1, 123 | pageSize: 10, 124 | data: [/* todo objects */] 125 | } 126 | ``` 127 | 128 | First we need to create our store, here we need `createTableUpdateReducer` as a sub reducer, and to create and export our `withTableUpdate` function: 129 | 130 | ```javascript 131 | // store.js 132 | 133 | import {createStore, combineReducers} from 'redux'; 134 | import {createTableUpdateReducer} from 'standard-redux-shape'; 135 | import reducers from 'reducers' 136 | 137 | export const store = createStore( 138 | combineReducers({...reducers, entities: createTableUpdateReducer()}), 139 | null 140 | ); 141 | 142 | // fetch.js 143 | export const withTableUpdate = createTableUpdater(() => import('store')); 144 | ``` 145 | 146 | 147 | Then we can wrap our `getTodos` API function: 148 | 149 | ```javascript 150 | // api.js 151 | 152 | import {withTableUpdate} from 'fetch'; 153 | 154 | const getTodos = params => { 155 | // ... 156 | }; 157 | 158 | const selectTodos = ({data}) => data.reduce((todos, todo) => ({...todos, [todo.id]: todo}), {}); 159 | 160 | export const fetchTodos = withTableUpdate(selectTodos, 'todosByID')(getTodos); 161 | ``` 162 | 163 | Every thing is done, every time when you call `fetchTodos`, a todo list is fetched from remote and the `entities.todosByID` table is automatically updated. 164 | 165 | In order to enjoy the benefits of store normalization, it is highly recommended to map todos array to an array of their id in action creator: 166 | 167 | ```javascript 168 | // actions.js 169 | 170 | import {fetchTodos} from 'api'; 171 | 172 | export const requestTodos = params => async dispatch => { 173 | const response = fetchTodos(params); 174 | const todos = response.data.map(todo => todo.id); 175 | 176 | dispatch({type: 'LOAD_TODOS', todos: todos}); 177 | }; 178 | ``` 179 | 180 | `withTableUpdate` can also be nested to update multiple entity tables on one API call: 181 | 182 | ```javascript 183 | const selectTodos = ({todos}) => todos.reduce((todos, todo) => ({...todos, [todo.id]: todo}), {}); 184 | const withTodosUpdate = withTableUpdate(selectTodos, 'todosByID'); 185 | 186 | const selectMemos = ({memos}) => memos.reduce((memos, memo) => ({...memos, [memo.id]: memo}), {}); 187 | const withMemosUpdate = withTableUpdate(selectMemos, 'memosByID'); 188 | 189 | export const initializeApplication = selectMemos(selectTodos(loadIntiialData)); 190 | ``` 191 | 192 | Also you can simple make `selectEntities` function return multiple table patch: 193 | 194 | ```javascript 195 | const selectEntities = ({todos, memos}) => { 196 | return { 197 | todosByID: todos.reduce((todos, todo) => ({...todos, [todo.id]: todo}), {}), 198 | memosByID: memos.reduce((memos, memo) => ({...memos, [memo.id]: memo}), {}) 199 | }; 200 | }; 201 | 202 | export const initializeApplication = selectEntities(loadIntiialData); 203 | ``` 204 | 205 | These code are equivelent. 206 | 207 | ## Query storage 208 | 209 | `standard-redux-shape` provides action creator and reducer helpers. In our standardized shape, any query consists of three stages: 210 | 211 | 1. The **fetch** stage is when a request is sent but the response is not returned, in this stage we usually recording all pending requests so that we can show a loading indicator or to decide which response is the most fresh one. 212 | 2. The **receive** stage is when response is returned, in this stage we can simply put response to store and display it on screen via `mapStateToProps` mappings. 213 | 3. The **accept** stage is a special stage when you refuses to immediately accept and display the response, it will be stored temporarily and can be accepted later. 214 | 215 | In order to have enough information of the stage of a query and its possible responses (either accepted and pending), we designed a standard shape of query: 216 | 217 | ```javascript 218 | query: { 219 | params: {any}, // The params corresponding to this query, can be any type 220 | pendingMutex: {number}, // A number indicating the count of pending requests 221 | response: { 222 | data: {any?}, // A possible object representing the latest success response 223 | error: {Object?}, // Possible error information for failed request 224 | }, 225 | nextResponse: { // The latest unaccepted response 226 | data, 227 | error 228 | } 229 | } 230 | ``` 231 | 232 | To update a query structure, we need 2 or 3 actions which matches the fetch, receive and accept stages, `standard-redux-shape` provides several strategies to deal with new responses: 233 | 234 | - `acceptLatest({string} fetchActionType, {string} receiveActionType)`: Always accept the latest arrived response, ealier responses will be overridden, this is the mose common case. 235 | - `keepEarliest({string} fetchActionType, {string} receiveActionType, {string} acceptActionType)`: Always use the first arrived response, later responses are discarded automatically, this can be used for some statistic jobs. The latest respones are keep in `nextResponse` so that you can accept it by dispatching `acceptActionType`. 236 | - `keepEarliestSuccess({string} fetchActionType, {string} receiveActionType)`: Quite the same as `keepEarliest` but override earilier error responses. 237 | - `acceptWhenNoPending({string} fetchActionType, {string} receiveActionType)`: Accept the latest arrived response if `pendingMutext` is `0`, this prevents frequent view update when multiple requests may on the fly. 238 | 239 | Aside the above, `standard-redux-shape` also provides functions for action creators to create action payloads which can be recognized by reducers, they are: 240 | 241 | - `{Object} createQueryPayload({any} params, {any} data)` to create an action payload on success. 242 | - `{Object} createQueryErrorPayload({any} params, {any} data)` to create an action payload on error. 243 | 244 | Moreover, `standard-redux-shape` provides selectors to retrieve wanted properties from a query: 245 | 246 | - `{Function} createQuerySelector({Function} selectQuery, {Function} selectParams)`: Create a selector to get a query from query set. 247 | - `{Function} createQueryResponseSelector({Function} selectQuery, {Function} selectParams)`: Create a selector to get the `response` property of a query. 248 | - `{Function} createQueryDataSelector({Function} selectQuery, {Function} selectParams)`: Create a selector to get the `response.data` property of q query. 249 | - `{Function} createQueryErrorSelector({Function} selectQuery, {Function} selectParams)`: Create a selector to get the `response.error` property of a query. 250 | 251 | By combining these utilities we can create a simple todo list application easily, first we define our action types, each query must have a fetch and a receive type: 252 | 253 | ```javascript 254 | // actions/type.js 255 | 256 | export const FETCH_TODOS = 'FETCH_TODOS'; 257 | export const RECEIVE_TODOS = 'RECEIVE_TODOS'; 258 | ``` 259 | 260 | Then simply create an action creator which invokes the remote API and dispatches corresponding actions with correct payload: 261 | 262 | ```javascript 263 | // actions/index.js 264 | 265 | import {createQueryPayload, createQueryErrorPayload} from 'standard-redux-shape'; 266 | import {fetchTodos} from 'api'; 267 | import {FETCH_TODOS, RECEIVE_TODOS} from './type'; 268 | 269 | export const requestTodos = params => async dispatch => { 270 | dispatch({type: FETCH_TODOS, payload: params}); // Payload of fetch action must be the params 271 | 272 | try { 273 | const response = await fetchTodos(params); 274 | 275 | dispatch({type: RECEIVE_TODOS, payload: createQueryPayload(params, response)}); 276 | } 277 | catch (ex) { 278 | if (isRequestError(ex)) { 279 | dispatch({type: RECEIVE_TODOS, payload: createQueryErrorPayload(params, ex)}); 280 | } 281 | else { 282 | throw ex; 283 | } 284 | } 285 | }; 286 | ``` 287 | 288 | The reducers can be super simple: 289 | 290 | ```javascript 291 | // reducers.js 292 | 293 | import {acceptWhenNoPending} from 'standard-redux-shape'; 294 | import {combineReducers} from 'redux'; 295 | import {FETCH_TODOS, RECEIVE_TODOS} from './type'; 296 | 297 | const reducers = { 298 | todoList: acceptWhenNoPending(FETCH_TODOS, RECEIVE_TODOS) 299 | }; 300 | 301 | export default combineReducers(reducers); 302 | ``` 303 | 304 | In component we can get query state by selectors: 305 | 306 | ```javascript 307 | // TodoList.js 308 | 309 | import {createQuerySelector} from 'standard-redux-shape'; 310 | import parseQuery from 'parse-query'; 311 | import {withRouter} from 'react-router'; 312 | 313 | const selectTodoList = createQuerySelector( 314 | state => state.todoListQuery, 315 | state => { 316 | const {pageNumber, startDate, endDate} = parseQuery(props.location.query); 317 | return {pageNumber, startDate, endDate}; 318 | } 319 | ); 320 | 321 | const TodoList = props => { 322 | const query = selectTodoList(props); 323 | 324 | if (query.pendingMutex) { 325 | return ; 326 | } 327 | 328 | if (query.response.error) { 329 | return 330 | }; 331 | 332 | return ( 333 | 336 | ); 337 | }; 338 | 339 | 340 | const mapStateToProps = state => { 341 | return { 342 | todoListQuery: state.todoList.queries // Suppose we combine reducers here 343 | } 344 | }; 345 | 346 | export default withRouter(connect(mapStateToProps)(TodoList)); 347 | ``` 348 | 349 | ### Quick create thunk 350 | 351 | The `thunkCreatorFor` function helps you to quick create a simple [thunk function](https://github.com/gaearon/redux-thunk) which just dispatching 2 actions around an API call function: 352 | 353 | ```javascript 354 | {Function} thunkCreatorFor( 355 | {Function} api, 356 | {string} fetchActionType, 357 | {string} receiveActionType, 358 | {Object} options 359 | ); 360 | ``` 361 | 362 | More options can be passed via `options` parameter: 363 | 364 | - `{Function} computeParams({any} ...args)`: To compute the params for `api`. 365 | - `{boolean} once`: If set to `true`, `api` will not be invoked when there is already a response in store. 366 | - `{boolean} trustPending`: It set to `true`, thunk will immediately return if previous `api` call is still pending, in this case, the `pendingMutex` will always be `1` or `0`, it can simplify idempotent cases. 367 | - `{Function} selectQuerySet({any} state)`: When `once` is set to `true`, `selectQuerySet` must be provided to select the corresponding query set to determine whether response is already in store. 368 | 369 | ## Example 370 | 371 | You can run `npm start` to start a demo, the chart uses `keepEarliest` strategy and a friendly message is displayed to user waiting their action to accept the latest data. 372 | 373 | ## Change Log 374 | 375 | ### 1.0.0 376 | 377 | - `createTableUpdateReducer` now accepts a `customMerger` to customize entity table merging strategy. 378 | - **BREAKING** `withTableUpdate`'s parameters are reversed to `(selectEntities, tableName)`, now `tableName` is optional when `selectEntities` returns multiple entity table patch. 379 | -------------------------------------------------------------------------------- /demo/App.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file App 3 | * @author zhanglili 4 | */ 5 | 6 | import {PureComponent} from 'react'; 7 | import {connect} from 'react-redux'; 8 | import {bindActionCreators} from 'redux'; 9 | import {sortBy, get} from 'lodash'; 10 | import {bind} from 'lodash-decorators'; 11 | import withTimeout from 'react-timeout'; 12 | import ECharts from 'echarts-for-react'; 13 | import {Button, notification} from 'antd'; 14 | import {fetchSummary, acceptSummary} from './actions'; 15 | import {selectSummaryQuery} from './selectors'; 16 | import 'antd/dist/antd.min.css'; 17 | 18 | class App extends PureComponent { 19 | 20 | componentDidMount() { 21 | const {setInterval} = this.props; 22 | this.fetchSummary(); 23 | setInterval(this.fetchSummary, 10 * 1000); 24 | } 25 | 26 | componentDidUpdate(prevProps) { 27 | const nextResponse = get(this.props, 'summaryQuery.nextResponse'); 28 | const prevNextResponse = get(prevProps, 'summaryQuery.nextResponse'); 29 | 30 | if (nextResponse && nextResponse !== prevNextResponse) { 31 | const confirmButton = ; 32 | const config = { 33 | message: 'Received new data', 34 | description: 'New data is received, click "Confirm" to refresh chart', 35 | btn: confirmButton, 36 | key: 'newSummary', 37 | }; 38 | notification.open(config); 39 | } 40 | } 41 | 42 | @bind() 43 | acceptSummary() { 44 | this.props.acceptSummary(); 45 | notification.close('newSummary'); 46 | } 47 | 48 | @bind() 49 | fetchSummary() { 50 | this.props.fetchSummary(); 51 | } 52 | 53 | render() { 54 | const {summaryQuery: {response}} = this.props; 55 | 56 | if (!response) { 57 | return null; 58 | } 59 | 60 | const seriesData = [ 61 | {value: response.data.direct, name: '直接访问'}, 62 | {value: response.data.email, name: '邮件营销'}, 63 | {value: response.data.union, name: '联盟广告'}, 64 | {value: response.data.video, name: '视频广告'}, 65 | {value: response.data.search, name: '搜索引擎'}, 66 | ]; 67 | 68 | const options = { 69 | backgroundColor: '#2c343c', 70 | tooltip: { 71 | trigger: 'item', 72 | formatter: '{a}
{b} : {c} ({d}%)', 73 | }, 74 | visualMap: { 75 | show: false, 76 | min: 500, 77 | max: 5000, 78 | inRange: { 79 | colorLightness: [0, 1], 80 | }, 81 | }, 82 | series: [ 83 | { 84 | name: 'PV Source', 85 | type: 'pie', 86 | radius: '55%', 87 | center: ['50%', '50%'], 88 | data: sortBy(seriesData, 'value'), 89 | roseType: 'radius', 90 | label: { 91 | normal: { 92 | textStyle: { 93 | color: 'rgba(255, 255, 255, 0.3)', 94 | }, 95 | }, 96 | }, 97 | labelLine: { 98 | normal: { 99 | lineStyle: { 100 | color: 'rgba(255, 255, 255, 0.3)', 101 | }, 102 | smooth: 0.2, 103 | length: 10, 104 | length2: 20, 105 | }, 106 | }, 107 | itemStyle: { 108 | normal: { 109 | color: '#c23531', 110 | shadowBlur: 200, 111 | shadowColor: 'rgba(0, 0, 0, 0.5)', 112 | }, 113 | }, 114 | animationType: 'scale', 115 | animationEasing: 'elasticOut', 116 | animationDelay() { 117 | return Math.random() * 200; 118 | }, 119 | }, 120 | ], 121 | }; 122 | 123 | return ( 124 |
125 |
126 |

PV Source

127 | Data refreshes every 10 seconds. 128 | [Fetch Now] 129 |
130 | 131 |
132 | ); 133 | } 134 | } 135 | 136 | const mapStateToProps = state => { 137 | return { 138 | summaryQuery: selectSummaryQuery(state) || {}, 139 | }; 140 | }; 141 | 142 | const mapDispatchToProps = dispatch => bindActionCreators({fetchSummary, acceptSummary}, dispatch); 143 | 144 | export default connect(mapStateToProps, mapDispatchToProps)(withTimeout(App)); 145 | -------------------------------------------------------------------------------- /demo/actions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Actions 3 | * @author zhanglili 4 | */ 5 | 6 | import {thunkCreatorFor} from '../src'; 7 | import {getSummary} from './api'; 8 | 9 | export const FETCH_SUMMARY = 'FETCH_SUMMARY'; 10 | export const RECEIVE_SUMMARY = 'RECEIVE_SUMMARY'; 11 | export const ACCEPT_SUMMARY = 'ACCEPT_SUMMARY'; 12 | 13 | export const fetchSummary = thunkCreatorFor( 14 | getSummary, 15 | FETCH_SUMMARY, 16 | RECEIVE_SUMMARY, 17 | { 18 | // Since the summary is global, use a static key as param 19 | computeParams() { 20 | return 'all'; 21 | }, 22 | once: false, 23 | trustPending: true, 24 | selectQuerySet: ({queries: {summary = null}}) => summary, 25 | } 26 | ); 27 | 28 | export const acceptSummary = () => ({type: ACCEPT_SUMMARY, payload: 'all'}); 29 | -------------------------------------------------------------------------------- /demo/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file API 3 | * @author zhanglili 4 | */ 5 | 6 | import {random} from 'lodash'; 7 | 8 | const wait = time => new Promise(resolve => setTimeout(resolve, time)); 9 | 10 | export const getSummary = () => { 11 | const randomData = { 12 | search: random(1000, 3000), 13 | video: random(1000, 3000), 14 | union: random(1000, 3000), 15 | email: random(1000, 3000), 16 | direct: random(1000, 3000), 17 | }; 18 | 19 | return wait(2000).then(() => randomData); 20 | }; 21 | -------------------------------------------------------------------------------- /demo/entries/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 示例页面 3 | * @author zhanglili 4 | */ 5 | 6 | import {render} from 'react-dom'; 7 | import {Provider} from 'react-redux'; 8 | import App from '../App'; 9 | import store from '../store'; 10 | import '../index.css'; 11 | 12 | render( 13 | 14 | 15 | , 16 | document.body.appendChild(document.createElement('div')) 17 | ); 18 | -------------------------------------------------------------------------------- /demo/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | background-color: #2e343b; 4 | margin: 0; 5 | } 6 | 7 | .header { 8 | text-align: center; 9 | } 10 | 11 | .header h1 { 12 | color: #ccc; 13 | margin: 0; 14 | } 15 | 16 | .subtitle { 17 | font-size: 12px; 18 | color: #ddd; 19 | } 20 | -------------------------------------------------------------------------------- /demo/reducers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Reducers 3 | * @author zhanglili 4 | */ 5 | 6 | import {combineReducers} from 'redux'; 7 | import {createTableUpdateReducer, keepEarliest} from '../src'; 8 | import {FETCH_SUMMARY, RECEIVE_SUMMARY, ACCEPT_SUMMARY} from './actions'; 9 | 10 | const queryReducers = { 11 | summary: keepEarliest(FETCH_SUMMARY, RECEIVE_SUMMARY, ACCEPT_SUMMARY), 12 | }; 13 | 14 | export default combineReducers({queries: combineReducers(queryReducers), entities: createTableUpdateReducer()}); 15 | -------------------------------------------------------------------------------- /demo/selectors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Selectors 3 | * @author zhanglili 4 | */ 5 | 6 | import {createQuerySelector} from '../src'; 7 | 8 | export const selectSummaryQuery = createQuerySelector( 9 | state => state.queries.summary, 10 | () => 'all' 11 | ); 12 | -------------------------------------------------------------------------------- /demo/store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Store 3 | * @author zhanglili 4 | */ 5 | 6 | import {createStore, applyMiddleware, compose} from 'redux'; 7 | import thunk from 'redux-thunk'; 8 | import reducer from './reducers'; 9 | 10 | export default createStore( 11 | reducer, 12 | {}, 13 | compose( 14 | applyMiddleware(thunk), 15 | typeof window.devToolsExtension === 'function' ? window.devToolsExtension() : f => f 16 | ) 17 | ); 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "standard-redux-shape", 3 | "version": "1.0.4", 4 | "description": "A library to help standardize your redux state shape", 5 | "main": "cjs/index.js", 6 | "module": "es/index.js", 7 | "types": "es/index.d.ts", 8 | "sideEffects": false, 9 | "scripts": { 10 | "test": "skr test --target=node --coverage", 11 | "report-cov": "cat coverage/lcov.info | coveralls", 12 | "lint": "skr lint", 13 | "prepublishOnly": "yarn run ci", 14 | "ci": "yarn test && yarn run build", 15 | "start": "skr dev --src=demo", 16 | "build": "skr rollup --clean && tsc" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/ecomfe/standard-redux-shape.git" 21 | }, 22 | "keywords": [ 23 | "redux", 24 | "normalize", 25 | "thunk" 26 | ], 27 | "author": "otakustay", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/ecomfe/standard-redux-shape/issues" 31 | }, 32 | "homepage": "https://github.com/ecomfe/standard-redux-shape#readme", 33 | "files": [ 34 | "cjs", 35 | "es" 36 | ], 37 | "devDependencies": { 38 | "@types/json-stable-stringify": "^1.0.32", 39 | "@types/lodash.get": "^4.4.6", 40 | "@types/lodash.topairs": "^4.3.6", 41 | "antd": "^3.6.0", 42 | "coveralls": "^3.0.3", 43 | "echarts": "^4.1.0", 44 | "echarts-for-react": "^2.0.11", 45 | "husky": "^2.2.0", 46 | "json-stable-stringify": "^1.0.1", 47 | "less-plugin-est": "^3.0.1", 48 | "lodash": "^4.17.10", 49 | "lodash-decorators": "^6.0.1", 50 | "lodash.get": "^4.4.2", 51 | "lodash.topairs": "^4.3.0", 52 | "react": "^16.4.0", 53 | "react-dom": "^16.4.0", 54 | "react-redux": "^7.0.3", 55 | "react-timeout": "^1.1.1", 56 | "redux": "^4.0.0", 57 | "redux-thunk": "^2.3.0", 58 | "reselect": "^4.0.0", 59 | "reskript": "^0.15.1", 60 | "san-update": "^2.1.0", 61 | "webpack": "^4.10.2" 62 | }, 63 | "husky": { 64 | "hooks": { 65 | "pre-commit": "skr lint --staged && yarn run test" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /settings.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/unambiguous, import/no-commonjs, import/no-nodejs-modules */ 2 | exports.featureMatrix = { 3 | dev: {}, 4 | }; 5 | 6 | exports.build = { 7 | appTitle: 'standard-redux-shape', 8 | }; 9 | 10 | exports.devServer = { 11 | port: 9010, 12 | }; 13 | 14 | exports.rollup = { 15 | namedDependencyExports: { 16 | 'san-update': ['immutable'], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/__tests__/entities.test.js: -------------------------------------------------------------------------------- 1 | import {isFunction, noop} from 'lodash'; 2 | import {createTableUpdater, updateEntityTable, createTableUpdateReducer} from '../index.ts'; 3 | 4 | describe('createTableUpdater should', () => { 5 | test('be in signature: a -> b', () => { 6 | expect(isFunction(createTableUpdater)).toBe(true); 7 | }); 8 | 9 | test('be in signature: a -> b -> c', () => { 10 | const withTableUpdate = createTableUpdater(); 11 | expect(isFunction(withTableUpdate)).toBe(true); 12 | }); 13 | 14 | test('be in signature: a -> b -> c -> d', () => { 15 | expect(isFunction(createTableUpdater()())).toBe(true); 16 | }); 17 | 18 | test('be in signature: a -> b -> c -> d -> e', () => { 19 | expect(isFunction(createTableUpdater()()())).toBe(true); 20 | }); 21 | 22 | test('return Promise in the inner most call', () => { 23 | expect.assertions(1); 24 | 25 | const resolveStore = () => Promise.resolve({dispatch: noop}); 26 | const fetchFunction = () => Promise.resolve({}); 27 | const selectEntities = () => ({}); 28 | const tableName = 'foo'; 29 | const innerMostFunction = createTableUpdater(resolveStore)(selectEntities, tableName)(fetchFunction); 30 | return innerMostFunction().then(data => expect(data).toEqual({})); 31 | }); 32 | 33 | test('dispatches only once when TableName is exists', () => { 34 | expect.assertions(1); 35 | 36 | const dispatch = jest.fn(noop); 37 | const resolveStore = () => Promise.resolve({dispatch}); 38 | const fetchFunction = () => Promise.resolve({}); 39 | const selectEntities = () => ({}); 40 | const tableName = 'foo'; 41 | const innerMostFunction = createTableUpdater(resolveStore)(selectEntities, tableName)(fetchFunction); 42 | return innerMostFunction().then(() => { 43 | expect(dispatch).toHaveBeenCalledTimes(1); 44 | }); 45 | }); 46 | 47 | test('dispatches the same times with number of keys in selected entities when Tablename is absent', () => { 48 | expect.assertions(1); 49 | 50 | const dispatch = jest.fn(noop); 51 | const selectedEntities = {a: null, b: null, c: null}; 52 | 53 | const resolveStore = () => Promise.resolve({dispatch}); 54 | const fetchFunction = () => Promise.resolve({}); 55 | const selectEntities = () => selectedEntities; 56 | const innerMostFunction = createTableUpdater(resolveStore)(selectEntities)(fetchFunction); 57 | return innerMostFunction().then(() => { 58 | expect(dispatch).toHaveBeenCalledTimes(Object.keys(selectedEntities).length); 59 | }); 60 | }); 61 | 62 | test('dispatches with correct action when Tablename is provided', () => { 63 | expect.assertions(1); 64 | 65 | const dispatch = jest.fn(noop); 66 | const selectedEntities = {}; 67 | 68 | const resolveStore = () => Promise.resolve({dispatch}); 69 | const fetchFunction = () => Promise.resolve({}); 70 | const selectEntities = () => selectedEntities; 71 | const tableName = 'foo'; 72 | const action = { 73 | type: '@@standard-redux-shape/UPDATE_ENTITY_TABLE', 74 | payload: {tableName, entities: selectedEntities}, 75 | }; 76 | const innerMostFunction = createTableUpdater(resolveStore)(selectEntities, tableName)(fetchFunction); 77 | return innerMostFunction().then(() => { 78 | expect(dispatch).toHaveBeenCalledWith(action); 79 | }); 80 | }); 81 | 82 | test('dispatches with correct action when no Tablename is provided', () => { 83 | expect.assertions(3); 84 | 85 | const dispatch = jest.fn(noop); 86 | const selectedEntities = {a: null, b: null, c: null}; 87 | 88 | const resolveStore = () => Promise.resolve({dispatch}); 89 | const fetchFunction = () => Promise.resolve({}); 90 | const selectEntities = () => selectedEntities; 91 | const innerMostFunction = createTableUpdater(resolveStore)(selectEntities)(fetchFunction); 92 | const actions = [ 93 | { 94 | type: '@@standard-redux-shape/UPDATE_ENTITY_TABLE', 95 | payload: {tableName: 'a', entities: null}, 96 | }, 97 | { 98 | type: '@@standard-redux-shape/UPDATE_ENTITY_TABLE', 99 | payload: {tableName: 'b', entities: null}, 100 | }, 101 | { 102 | type: '@@standard-redux-shape/UPDATE_ENTITY_TABLE', 103 | payload: {tableName: 'c', entities: null}, 104 | }, 105 | ]; 106 | return innerMostFunction().then(() => { 107 | expect(dispatch).toHaveBeenNthCalledWith(1, actions[0]); 108 | expect(dispatch).toHaveBeenNthCalledWith(2, actions[1]); 109 | expect(dispatch).toHaveBeenNthCalledWith(3, actions[2]); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('createTableUpdateReducer should', () => { 115 | const defaultTables = { 116 | userByIds: { 117 | '1': {id: 1, name: 'Simon'}, 118 | '2': {id: 2, name: 'otakustay'}, 119 | }, 120 | }; 121 | 122 | const nextReducer = (state = {}, {type} = {}) => { 123 | switch (type) { 124 | default: 125 | return state; 126 | } 127 | }; 128 | 129 | test('test table data merged', () => { 130 | const updateUserTableAction = updateEntityTable('userByIds', { 131 | '1': {id: 1, age: 18, job: 'ceo'}, 132 | '2': {job: 'cto'}, 133 | }); 134 | const reducer = createTableUpdateReducer(nextReducer); 135 | expect(reducer(defaultTables, updateUserTableAction)).toEqual({ 136 | userByIds: { 137 | '1': {id: 1, name: 'Simon', age: 18, job: 'ceo'}, 138 | '2': {id: 2, name: 'otakustay', job: 'cto'}, 139 | }, 140 | }); 141 | }); 142 | 143 | test('have no diff table data merged', () => { 144 | const updateUserTableAction = updateEntityTable('userByIds', { 145 | '1': {id: 1, name: 'Simon'}, 146 | }); 147 | const table = { 148 | userByIds: { 149 | '1': {id: 1, name: 'Simon'}, 150 | }, 151 | }; 152 | const reducer = createTableUpdateReducer(nextReducer); 153 | // when without diff, table should be same reference 154 | expect(reducer(table, updateUserTableAction)).toBe(table); 155 | }); 156 | 157 | test('not UPDATE_ENTITY_TABLE excute nextReducer', () => { 158 | const otherAction = {type: 'OTHER_ACTION'}; 159 | const reducer = createTableUpdateReducer(nextReducer); 160 | expect(reducer(defaultTables, otherAction)).toEqual({ 161 | userByIds: { 162 | '1': {id: 1, name: 'Simon'}, 163 | '2': {id: 2, name: 'otakustay'}, 164 | }, 165 | }); 166 | }); 167 | 168 | test('have default nextReducer', () => { 169 | const updateUserTableAction = updateEntityTable('userByIds', { 170 | '1': {id: 1, age: 18}, 171 | }); 172 | const reducer = createTableUpdateReducer(); 173 | expect(reducer(defaultTables, updateUserTableAction)).toEqual({ 174 | userByIds: { 175 | '1': {id: 1, name: 'Simon', age: 18}, 176 | '2': {id: 2, name: 'otakustay'}, 177 | }, 178 | }); 179 | }); 180 | 181 | test('correctly init table when first time withTableUpdate', () => { 182 | const initUpdateAction = updateEntityTable('userByIds'); 183 | 184 | const reducer = createTableUpdateReducer(nextReducer); 185 | expect(reducer(undefined, initUpdateAction)).toEqual({ 186 | userByIds: {}, 187 | }); 188 | }); 189 | 190 | test('test custom merger', () => { 191 | const updateUserTableAction = updateEntityTable('userByIds', { 192 | '1': {id: 1, age: 18}, 193 | }); 194 | const customMerger = (tableName, table, entities) => { 195 | return {...table, ...entities}; 196 | }; 197 | const reducer = createTableUpdateReducer(nextReducer, customMerger); 198 | expect(reducer(defaultTables, updateUserTableAction)).toEqual({ 199 | userByIds: { 200 | '1': {id: 1, age: 18}, 201 | '2': {id: 2, name: 'otakustay'}, 202 | }, 203 | }); 204 | }); 205 | 206 | test('test diff table', () => { 207 | const updateUserTableAction = updateEntityTable('reviewByIds', { 208 | '1001': {id: 1001, diffContent: 'this is diff content'}, 209 | }); 210 | const reducer = createTableUpdateReducer(nextReducer); 211 | expect(reducer(defaultTables, updateUserTableAction)).toEqual({ 212 | userByIds: { 213 | '1': {id: 1, name: 'Simon'}, 214 | '2': {id: 2, name: 'otakustay'}, 215 | }, 216 | reviewByIds: {'1001': {id: 1001, diffContent: 'this is diff content'}}, 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /src/__tests__/queries.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | import {isFunction} from 'lodash'; 3 | import stringify from 'json-stable-stringify'; 4 | import { 5 | createQueryPayload, 6 | createQueryErrorPayload, 7 | reduceQueryBy, 8 | acceptLatest, 9 | keepEarliest, 10 | keepEarliestSuccess, 11 | acceptWhenNoPending, 12 | } from '../index.ts'; 13 | // types 14 | const FETCH = 'FETCH'; 15 | const RECEIVE = 'RECEIVE'; 16 | const ACCEPT = 'ACCEPT'; 17 | 18 | describe('createQueryPayload should', () => { 19 | test('be existed', () => { 20 | expect(createQueryPayload).not.toBeUndefined(); 21 | expect(createQueryPayload).not.toBeNull(); 22 | }); 23 | 24 | test('retun correct data', () => { 25 | const mockParams = {p: 1, q: 2}; 26 | const mockData = {a: 1, b: 2}; 27 | const now = Date.now(); 28 | Date.now = jest.fn().mockReturnValue(now); 29 | 30 | const mockResult = { 31 | arrivedAt: now, 32 | params: {p: 1, q: 2}, 33 | data: {a: 1, b: 2}, 34 | }; 35 | 36 | const result = createQueryPayload(mockParams, mockData); 37 | 38 | expect(result).toEqual(mockResult); 39 | }); 40 | }); 41 | 42 | describe('createQueryErrorPayload should', () => { 43 | test('be existed', () => { 44 | expect(createQueryErrorPayload).not.toBeUndefined(); 45 | expect(createQueryErrorPayload).not.toBeNull(); 46 | }); 47 | 48 | test('retun error data', () => { 49 | const mockParams = {p: 1, q: 2}; 50 | const mockError = {message: 'test message', a: 1, b: 2}; 51 | const now = Date.now(); 52 | Date.now = jest.fn().mockReturnValue(now); 53 | 54 | const mockResult = { 55 | arrivedAt: now, 56 | params: {p: 1, q: 2}, 57 | error: {message: 'test message', a: 1, b: 2}, 58 | }; 59 | 60 | const result = createQueryErrorPayload(mockParams, mockError); 61 | 62 | expect(result).toEqual(mockResult); 63 | }); 64 | }); 65 | 66 | describe('reduceQueryBy should', () => { 67 | test('be in signature: a -> b', () => { 68 | expect(isFunction(reduceQueryBy())).toBe(true); 69 | }); 70 | 71 | test('be in signature: a -> b -> c', () => { 72 | expect(isFunction(reduceQueryBy()())).toBe(true); 73 | }); 74 | 75 | test('use default state and default action', () => { 76 | // Actions can not be undefined, otherwise stage will be 'receive', 77 | // due to queryStageMapping: 78 | // {undefined: 'fetch', undefined: 'receive', UNIQUE: 'accept'}. 79 | // Not sure this behavior is expected 80 | const reducer = reduceQueryBy()(FETCH, RECEIVE); 81 | const newState = reducer(); 82 | expect(newState).toEqual({}); 83 | }); 84 | 85 | test('return state if stage not exist', () => { 86 | const state = {}; 87 | const reducer = reduceQueryBy()(FETCH, RECEIVE); 88 | const newState = reducer(state, {type: 'UNKNOWN'}); 89 | expect(newState).toBe(state); 90 | }); 91 | 92 | test('pending when fetch with fresh state', () => { 93 | const state = {}; 94 | 95 | const reduceState = s => s; 96 | const reducer = reduceQueryBy(reduceState)(FETCH, RECEIVE); 97 | 98 | const params = {animal: 'cat'}; 99 | const action = {type: FETCH, payload: params}; 100 | 101 | const newState = reducer(state, action); 102 | expect(newState).toEqual({ 103 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 104 | }); 105 | }); 106 | 107 | test('pending increased when fetch again', () => { 108 | const params = {animal: 'dog'}; 109 | const state = { 110 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 111 | }; 112 | 113 | const reduceState = s => s; 114 | const reducer = reduceQueryBy(reduceState)(FETCH, RECEIVE); 115 | 116 | const action = {type: FETCH, payload: params}; 117 | 118 | const newState = reducer(state, action); 119 | 120 | expect(newState).toEqual({ 121 | [stringify(params)]: {pendingMutex: 2, params, response: null, nextResponse: null}, 122 | }); 123 | }); 124 | 125 | test('accept after received', () => { 126 | const params = {animal: 'cat'}; 127 | const payload = createQueryPayload(params, {say: 'meow'}); 128 | const nextPayload = createQueryPayload(params, {say: 'woof'}); 129 | 130 | const state = { 131 | [stringify(params)]: {pendingMutex: 0, params, response: payload, nextResponse: nextPayload}, 132 | }; 133 | 134 | const reducer = keepEarliest(FETCH, RECEIVE, ACCEPT); 135 | 136 | const action = {type: ACCEPT, payload: params}; 137 | 138 | const newState = reducer(state, action); 139 | 140 | // if there is response, then always use the response 141 | expect(newState).toEqual({ 142 | [stringify(params)]: {pendingMutex: 0, params, response: nextPayload, nextResponse: null}, 143 | }); 144 | }); 145 | }); 146 | 147 | describe('acceptLatest when', () => { 148 | test('fetch', () => { 149 | const state = {}; 150 | 151 | const reducer = acceptLatest(FETCH, RECEIVE); 152 | 153 | const params = {animal: 'cat'}; 154 | const action = {type: FETCH, payload: params}; 155 | 156 | const newState = reducer(state, action); 157 | 158 | expect(newState).toEqual({ 159 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 160 | }); 161 | }); 162 | 163 | test('receive', () => { 164 | const params = {animal: 'cat'}; 165 | const state = { 166 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 167 | }; 168 | 169 | const reducer = acceptLatest(FETCH, RECEIVE); 170 | 171 | const payload = createQueryPayload(params, {say: 'meow'}); 172 | const action = {type: RECEIVE, payload}; 173 | 174 | const newState = reducer(state, action); 175 | expect(newState).toEqual({ 176 | [stringify(params)]: {pendingMutex: 0, params, response: payload, nextResponse: null}, 177 | }); 178 | }); 179 | }); 180 | 181 | describe('keepEarliest when', () => { 182 | test('fetch', () => { 183 | const state = {}; 184 | 185 | const reducer = keepEarliest(FETCH, RECEIVE); 186 | 187 | const params = {animal: 'cat'}; 188 | const action = {type: FETCH, payload: params}; 189 | 190 | const newState = reducer(state, action); 191 | expect(newState).toEqual({ 192 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 193 | }); 194 | }); 195 | 196 | test('first time receive', () => { 197 | const params = {animal: 'cat'}; 198 | 199 | const state = { 200 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 201 | }; 202 | 203 | const reducer = keepEarliest(FETCH, RECEIVE); 204 | 205 | const payload = createQueryPayload(params, {say: 'meow'}); 206 | const action = {type: RECEIVE, payload}; 207 | 208 | const newState = reducer(state, action); 209 | expect(newState).toEqual({ 210 | [stringify(params)]: {pendingMutex: 0, params, response: payload, nextResponse: null}, 211 | }); 212 | }); 213 | 214 | test('further receive', () => { 215 | const params = {animal: 'cat'}; 216 | const payload = createQueryPayload(params, {say: 'meow'}); 217 | const nextPayload = createQueryPayload(params, {say: 'woof'}); 218 | 219 | const state = { 220 | [stringify(params)]: {pendingMutex: 1, params, response: payload, nextResponse: nextPayload}, 221 | }; 222 | 223 | const reducer = keepEarliest(FETCH, RECEIVE); 224 | 225 | const action = {type: RECEIVE, payload}; 226 | 227 | const newState = reducer(state, action); 228 | // if there is response, then always use the response 229 | expect(newState).toEqual({ 230 | [stringify(params)]: {pendingMutex: 0, params, response: payload, nextResponse: payload}, 231 | }); 232 | }); 233 | }); 234 | 235 | describe('keepEarliestSuccess when', () => { 236 | test('fetch', () => { 237 | const state = {}; 238 | 239 | const reducer = keepEarliestSuccess(FETCH, RECEIVE); 240 | 241 | const params = {animal: 'cat'}; 242 | const action = {type: FETCH, payload: params}; 243 | 244 | const newState = reducer(state, action); 245 | expect(newState).toEqual({ 246 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 247 | }); 248 | }); 249 | 250 | test('receive success', () => { 251 | const params = {animal: 'cat'}; 252 | 253 | const state = { 254 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 255 | }; 256 | 257 | const reducer = keepEarliestSuccess(FETCH, RECEIVE); 258 | const payload = createQueryPayload(params, {say: 'meow'}); 259 | const action = {type: RECEIVE, payload}; 260 | 261 | const newState = reducer(state, action); 262 | expect(newState).toEqual({ 263 | [stringify(params)]: {pendingMutex: 0, params, response: payload, nextResponse: null}, 264 | }); 265 | }); 266 | 267 | test('receive success after error', () => { 268 | const params = {animal: 'cat'}; 269 | 270 | const errorResponse = createQueryErrorPayload(params, {say: 'meow'}); 271 | const state = { 272 | [stringify(params)]: {pendingMutex: 1, params, response: errorResponse, nextResponse: null}, 273 | }; 274 | 275 | const reducer = keepEarliestSuccess(FETCH, RECEIVE); 276 | 277 | const payload = createQueryPayload(params, {say: 'meow'}); 278 | const action = {type: RECEIVE, payload}; 279 | 280 | const newState = reducer(state, action); 281 | expect(newState).toEqual({ 282 | [stringify(params)]: {pendingMutex: 0, params, response: payload, nextResponse: null}, 283 | }); 284 | }); 285 | }); 286 | 287 | describe('acceptWhenNoPending', () => { 288 | test('fetch', () => { 289 | const state = {}; 290 | 291 | const reducer = acceptWhenNoPending(FETCH, RECEIVE); 292 | 293 | const params = {animal: 'cat'}; 294 | const action = {type: FETCH, payload: params}; 295 | 296 | const newState = reducer(state, action); 297 | expect(newState).toEqual({ 298 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 299 | }); 300 | }); 301 | 302 | test('should receive when no pending', () => { 303 | const params = {animal: 'cat'}; 304 | 305 | const state = { 306 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 307 | }; 308 | 309 | const reducer = acceptWhenNoPending(FETCH, RECEIVE); 310 | const payload = createQueryPayload(params, {say: 'meow'}); 311 | const action = {type: RECEIVE, payload}; 312 | 313 | const newState = reducer(state, action); 314 | expect(newState).toEqual({ 315 | [stringify(params)]: {pendingMutex: 0, params, response: payload, nextResponse: null}, 316 | }); 317 | }); 318 | 319 | test('should not receive when has pending', () => { 320 | const params = {animal: 'cat'}; 321 | 322 | const state = { 323 | [stringify(params)]: {pendingMutex: 2, params, response: null, nextResponse: null}, 324 | }; 325 | 326 | const reducer = acceptWhenNoPending(FETCH, RECEIVE); 327 | const payload = createQueryPayload(params, {say: 'meow'}); 328 | const action = {type: RECEIVE, payload}; 329 | 330 | const newState = reducer(state, action); 331 | expect(newState).toEqual({ 332 | [stringify(params)]: {pendingMutex: 1, params, response: null, nextResponse: null}, 333 | }); 334 | }); 335 | }); 336 | -------------------------------------------------------------------------------- /src/__tests__/selectors.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | import { 3 | createQuerySelector, 4 | createQueryResponseSelector, 5 | createQueryDataSelector, 6 | createQueryErrorSelector, 7 | } from '../index.ts'; 8 | 9 | describe('createQuerySelector should', () => { 10 | const query = {}; 11 | 12 | const state = { 13 | foo: { 14 | [JSON.stringify({x: 1, y: 2})]: query, 15 | }, 16 | }; 17 | 18 | test('get query by params', () => { 19 | const selector = createQuerySelector(state => state.foo, () => ({x: 1, y: 2})); 20 | 21 | expect(selector(state)).toBe(query); 22 | }); 23 | 24 | test('params with keys in different order', () => { 25 | const selector = createQuerySelector(state => state.foo, () => ({y: 2, x: 1})); 26 | 27 | expect(selector(state)).toBe(query); 28 | }); 29 | 30 | test('return undefined when not found', () => { 31 | const selector = createQuerySelector(state => state.foo, () => ({z: 3})); 32 | 33 | expect(selector(state)).toBeUndefined(); 34 | }); 35 | }); 36 | 37 | describe('createQueryResponseSelector should', () => { 38 | const response = {}; 39 | const query = {response}; 40 | 41 | const state = { 42 | foo: { 43 | [JSON.stringify({x: 1, y: 2})]: query, 44 | }, 45 | }; 46 | 47 | test('get query by params', () => { 48 | const selector = createQueryResponseSelector(state => state.foo, () => ({x: 1, y: 2})); 49 | 50 | expect(selector(state)).toBe(response); 51 | }); 52 | 53 | test('params with keys in different order', () => { 54 | const selector = createQueryResponseSelector(state => state.foo, () => ({y: 2, x: 1})); 55 | 56 | expect(selector(state)).toBe(response); 57 | }); 58 | 59 | test('return undefined when not found', () => { 60 | const selector = createQueryResponseSelector(state => state.foo, () => ({z: 3})); 61 | 62 | expect(selector(state)).toBeUndefined(); 63 | }); 64 | }); 65 | 66 | describe('createQueryDataSelector should', () => { 67 | const data = {}; 68 | const response = {data}; 69 | const query = {response}; 70 | 71 | const state = { 72 | foo: { 73 | [JSON.stringify({x: 1, y: 2})]: query, 74 | }, 75 | }; 76 | 77 | test('get query by params', () => { 78 | const selector = createQueryDataSelector(state => state.foo, () => ({x: 1, y: 2})); 79 | 80 | expect(selector(state)).toBe(data); 81 | }); 82 | 83 | test('params with keys in different order', () => { 84 | const selector = createQueryDataSelector(state => state.foo, () => ({y: 2, x: 1})); 85 | 86 | expect(selector(state)).toBe(data); 87 | }); 88 | 89 | test('return undefined when not found', () => { 90 | const selector = createQueryDataSelector(state => state.foo, () => ({z: 3})); 91 | 92 | expect(selector(state)).toBeUndefined(); 93 | }); 94 | }); 95 | 96 | describe('createQueryErrorSelector should', () => { 97 | const error = {}; 98 | const response = {error}; 99 | const query = {response}; 100 | 101 | const state = { 102 | foo: { 103 | [JSON.stringify({x: 1, y: 2})]: query, 104 | }, 105 | }; 106 | 107 | test('get query by params', () => { 108 | const selector = createQueryErrorSelector(state => state.foo, () => ({x: 1, y: 2})); 109 | 110 | expect(selector(state)).toBe(error); 111 | }); 112 | 113 | test('params with keys in different order', () => { 114 | const selector = createQueryErrorSelector(state => state.foo, () => ({y: 2, x: 1})); 115 | 116 | expect(selector(state)).toBe(error); 117 | }); 118 | 119 | test('return undefined when not found', () => { 120 | const selector = createQueryErrorSelector(state => state.foo, () => ({z: 3})); 121 | 122 | expect(selector(state)).toBeUndefined(); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /src/__tests__/thunkCreatorFor.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | import stringify from 'json-stable-stringify'; 3 | import {thunkCreatorFor, createQueryPayload, createQueryErrorPayload} from '../index.ts'; 4 | 5 | const FETCH = 'FETCH'; 6 | const RECEIVE = 'RECEIVE'; 7 | 8 | describe('thunkCreatorFor', () => { 9 | test('basic success', async () => { 10 | const api = ({animal}) => { 11 | if (animal === 'cat') { 12 | return Promise.resolve({say: 'meow'}); 13 | } 14 | if (animal === 'fox') { 15 | return Promise.reject(new Error('I dont know')); 16 | } 17 | return Promise.reject(new Error('error')); 18 | }; 19 | 20 | const params = {animal: 'cat'}; 21 | const dispatch = jest.fn(); 22 | 23 | const fetchData = thunkCreatorFor(api, FETCH, RECEIVE); 24 | const thunk = fetchData(params); 25 | // mock now 26 | jest.spyOn(Date, 'now').mockImplementation(() => 1555496661751); 27 | 28 | await thunk(dispatch, () => ({})); 29 | 30 | expect(dispatch.mock.calls.length).toBe(2); 31 | expect(dispatch.mock.calls[0][0]).toEqual({type: FETCH, payload: params}); 32 | expect(dispatch.mock.calls[1][0]).toEqual({type: RECEIVE, payload: createQueryPayload(params, {say: 'meow'})}); 33 | }); 34 | 35 | test('basic error', async () => { 36 | const api = ({animal}) => { 37 | if (animal === 'cat') { 38 | return Promise.resolve({say: 'meow'}); 39 | } 40 | if (animal === 'fox') { 41 | return Promise.reject(new Error('I dont know')); 42 | } 43 | return Promise.reject(new Error('error')); 44 | }; 45 | 46 | const params = {animal: 'fox'}; 47 | const dispatch = jest.fn(); 48 | 49 | const fetchData = thunkCreatorFor(api, FETCH, RECEIVE); 50 | const thunk = fetchData(params); 51 | // mock now 52 | jest.spyOn(Date, 'now').mockImplementation(() => 1555496661751); 53 | try { 54 | await thunk(dispatch, () => ({})); 55 | } catch { 56 | expect(dispatch.mock.calls.length).toBe(2); 57 | expect(dispatch.mock.calls[0][0]).toEqual({type: FETCH, payload: params}); 58 | expect(dispatch.mock.calls[1][0]).toEqual({ 59 | type: RECEIVE, 60 | payload: createQueryErrorPayload(params, {message: 'I dont know'}), 61 | }); 62 | } 63 | }); 64 | 65 | test('with avaiable data', async () => { 66 | const data = [{name: 'Lucy'}]; 67 | const api = jest.fn(() => Promise.resolve(data)); 68 | const params = {page: 1}; 69 | const dispatch = jest.fn(); 70 | const state = { 71 | users: { 72 | [stringify(params)]: { 73 | pendingMutex: 0, 74 | params, 75 | response: createQueryPayload(params, data), 76 | nextResponse: null, 77 | }, 78 | }, 79 | }; 80 | 81 | const fetchData = thunkCreatorFor(api, FETCH, RECEIVE, { 82 | once: true, 83 | selectQuerySet: state => state.users, 84 | }); 85 | const thunk = fetchData(params); 86 | const result = await thunk(dispatch, () => state); 87 | expect(result).toEqual(data); 88 | expect(api).not.toHaveBeenCalled(); 89 | }); 90 | 91 | test('with trust pending', async () => { 92 | const data = [{name: 'Lucy'}]; 93 | const api = jest.fn(() => Promise.resolve(data)); 94 | const params = {page: 1}; 95 | const dispatch = jest.fn(); 96 | const state = {}; 97 | 98 | const fetchData = thunkCreatorFor(api, FETCH, RECEIVE, { 99 | once: true, 100 | trustPending: true, 101 | selectQuerySet: state => state.users, 102 | }); 103 | const thunk = fetchData(params); 104 | thunk(dispatch, () => state); // call twice 105 | const result = await thunk(dispatch, () => state); 106 | expect(result).toEqual(data); 107 | expect(api.mock.calls.length).toBe(1); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/entities.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 管理Store Nomalization的相关逻辑 3 | * @author zhanglili 4 | */ 5 | 6 | import toPairs from 'lodash.topairs'; 7 | import {Reducer, Action, Dispatch} from 'redux'; 8 | import { 9 | UpdateTableActionCreator, 10 | TableActionPayload, 11 | StandardAction, 12 | AsyncStoreResolver, 13 | FetchProcessor, 14 | BasicObject, 15 | Merger, 16 | } from './interface'; 17 | 18 | const UPDATE_ENTITY_TABLE = '@@standard-redux-shape/UPDATE_ENTITY_TABLE'; 19 | 20 | export const updateEntityTable: UpdateTableActionCreator = (tableName, entities) => ({ 21 | type: UPDATE_ENTITY_TABLE, 22 | payload: {tableName, entities}, 23 | }); 24 | 25 | /** 26 | * 创建一个用于在请求结束后更新Normalized Store中的实体数据的高阶函数 27 | * 28 | * 当传递一个`store`后,会返回一个`withTableUpdate`函数,这个函数的用法如下: 29 | * 30 | * ```javascript 31 | * const apiWithTableUpdate = withTableUpdate(tableName, selectEntities)(api); 32 | * const response = await apiWithTableUpdate(args); 33 | * ``` 34 | * 35 | * 当一个用于获取后端数据的API函数被这个高阶函数包装后,会在响应返回时额外做以下行为: 36 | * 37 | * 1. 调用`selectEntities`函数,将`response`传入并得到一个对象,该对象是一系列需要更新的实体,以实体的索引属性(比如id)为键 38 | * 2. 派发一个类型为`UPDATE_ENTITY_TABLE`的Action,并通过`payload`提供`tableName`和`entity`属性 39 | * 40 | * 当reducer包含了对这个Action的处理时,会有以下逻辑: 41 | * 42 | * 1. 根据`tableName`从`state`中获取到对应的表 43 | * 2. 将当前从响应中获取的实体一一合并到表中 44 | * 45 | * 通过这种方式,可以将后端的接口与Normalized Store中的实体信息建立关联,保持所有实体更新在实体表中,其它地方通过id的方式引用保持信息同步 46 | * 47 | * 如果一个响应同时返回多个实体,也同样可以调用多次`withTableUpdate`来进行包装: 48 | * 49 | * ```javascript 50 | * import {property, keyBy} from 'lodash'; 51 | * 52 | * const withUsersTableUpdate = withTableUpdate(res => keyBy(res.users, 'username'), 'usersByName'); 53 | * const withCommitsTableUpdate = withTableUpdate(res => keyBy(res.commits, 'id'), 'commitsById'); 54 | * const apiWithTableUpdate = withUsersTableUpdate(withCommitsTableUpdate(api)); 55 | * ``` 56 | * 57 | * @param {Function} resolveStore An async function which returns the store object 58 | */ 59 | export const createTableUpdater = (resolveStore: AsyncStoreResolver) => < 60 | PayloadType, 61 | ResponseType = unknown 62 | >( 63 | selectEntities: any, 64 | tableName?: Extract 65 | ) => { 66 | const dispatchTableUpdate = (dispatch: Dispatch, responseData: ResponseType, payload: PayloadType) => { 67 | const entities = selectEntities(responseData, payload); 68 | 69 | if (tableName) { 70 | dispatch({type: UPDATE_ENTITY_TABLE, payload: {tableName, entities}}); 71 | } 72 | else { 73 | for (const pair of toPairs(entities)) { 74 | const [tableName, entities] = pair; 75 | dispatch({type: UPDATE_ENTITY_TABLE, payload: {tableName, entities}}); 76 | } 77 | } 78 | 79 | return responseData; 80 | }; 81 | 82 | const fetch: FetchProcessor = fetchFunction => (payload, extraArgument?) => { 83 | if (extraArgument) { 84 | // tslint:disable-next-line no-console 85 | console.warn('standard-redux-shape will no longer support passing multiple parameters to fetch function.'); 86 | } 87 | 88 | const loadingResponseAndStore = Promise.all([fetchFunction(payload), resolveStore()]); 89 | return loadingResponseAndStore.then(([data, {dispatch}]) => dispatchTableUpdate(dispatch, data, payload)); 90 | }; 91 | 92 | return fetch; 93 | }; 94 | 95 | const patchEntity = (entity: BasicObject, patch: BasicObject): BasicObject => { 96 | if (!entity) { 97 | return patch; 98 | } 99 | 100 | let patchedEntity: BasicObject | null = null; 101 | 102 | for (const key in patch) { 103 | /* istanbul ignore next */ 104 | if (!patch.hasOwnProperty(key)) { 105 | continue; 106 | } 107 | 108 | if (entity[key] !== patch[key]) { 109 | if (!patchedEntity) { 110 | patchedEntity = Object.assign({}, entity); 111 | } 112 | 113 | patchedEntity[key] = patch[key]; 114 | } 115 | } 116 | 117 | return patchedEntity || entity; 118 | }; 119 | 120 | const defaultCustomMerger: Merger = (tableName, table, entities, defaultMerger) => defaultMerger(); 121 | 122 | interface EntityState { 123 | [entityName: string]: TableActionPayload['entities']; 124 | } 125 | 126 | /** 127 | * 与`createTableUpdater`合作使用的reducer函数,具体参考上面的注释说明 128 | * 129 | * @param {Function} nextReducer 后续处理的reducer 130 | * @param {Function} customMerger 用户自定义的合并table的方法 131 | */ 132 | export const createTableUpdateReducer = (nextReducer: Reducer = s => s, customMerger: Merger = defaultCustomMerger) => { 133 | return (state: EntityState = {}, action: Action) => { 134 | if (action.type !== UPDATE_ENTITY_TABLE) { 135 | return nextReducer(state, action); 136 | } 137 | 138 | const {payload: {tableName, entities}} = action as StandardAction; 139 | 140 | // 在第一次调用`withTableUpdate`的时候,会触发对当前表的初始化, 141 | // 当然如果一个表对应多个API的话,初始化会触发多次,在按需加载的时候也可能在任意时刻触发(通过`replaceReducer`), 142 | // 所以当表不存在的时候才进行赋值 143 | if (!entities && !state[tableName]) { 144 | return {...state, [tableName]: {}}; 145 | } 146 | 147 | const table = state[tableName] || {}; 148 | 149 | // 因为当前的系统前后端接口并不一定会返回一个完整的实体, 150 | // 如果之前有一个比较完整的实体已经在`state`中,后来又来了一个不完整的实体,直接覆盖会导致字段丢失, 151 | // 因此默认针对每个实体,使用的是浅合并的策略,保证第一级的字段是不会丢的;更复杂场景下,由用户在 customMerger 中自行处理 152 | // 153 | // 当实体表非常大的时候,这是一个性能要求很高的函数,因此这边没有使用任何看上去优雅的函数式编程,简单粗暴地依靠遍历来解决问题w 154 | const defaultMerger = () => { 155 | let mergedTable = null; 156 | 157 | for (const key in entities) { 158 | /* istanbul ignore next */ 159 | if (!entities.hasOwnProperty(key)) { 160 | continue; 161 | } 162 | 163 | const previousEntity = table[key]; 164 | const entityPatch = entities[key]; 165 | const patchedEntity = patchEntity(previousEntity, entityPatch); 166 | 167 | if (patchedEntity !== previousEntity) { 168 | if (!mergedTable) { 169 | mergedTable = Object.assign({}, table); 170 | } 171 | 172 | mergedTable[key] = patchedEntity; 173 | } 174 | } 175 | 176 | return mergedTable || table; 177 | }; 178 | 179 | const mergedTable = customMerger(tableName, table, entities, defaultMerger); 180 | 181 | const newState = mergedTable === table ? state : {...state, [tableName]: mergedTable}; 182 | 183 | return nextReducer(newState, action); 184 | }; 185 | }; 186 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 标准化Redux结构索引 3 | * @author zhanglili 4 | */ 5 | 6 | export * from './entities'; 7 | export * from './queries'; 8 | export * from './selectors'; 9 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | import {Action, Dispatch, AnyAction, Store} from 'redux'; 2 | 3 | export type JSONLike = string | number | object | any[]; 4 | 5 | export interface BasicObject {[key: string]: any} 6 | 7 | export interface ErrorType { 8 | message: string; 9 | [key: string]: any; 10 | } 11 | 12 | export interface TableActionPayload { 13 | tableName: string; 14 | entities: {[key: string]: BasicObject}; 15 | } 16 | 17 | export interface StandardAction extends Action { 18 | payload: Payload; 19 | } 20 | 21 | export type TableActionShape = StandardAction; 22 | 23 | export type TableUpdatorDispatch = Dispatch; 24 | 25 | export type UpdateTableActionCreator = (tableName: string, entities: {[key: string]: any}) => TableActionShape; 26 | 27 | export interface QueryResponseShape { 28 | arrivedAt: number; 29 | data: DataShape; 30 | error: ErrorType; 31 | } 32 | 33 | export interface QueryResultShape { 34 | pendingMutex: number; 35 | response: QueryResponseShape | null; 36 | nextResponse: QueryResponseShape | null; 37 | } 38 | 39 | export interface BasicPayload { 40 | arrivedAt?: number; 41 | params?: ParamsType; 42 | } 43 | 44 | export interface QueryPayload extends BasicPayload { 45 | data?: DataType; 46 | } 47 | 48 | export interface ErrorPayload extends BasicPayload { 49 | error?: ErrorType; 50 | } 51 | 52 | export type UnionPayload = BasicPayload & ErrorPayload; 53 | 54 | export type BasicPayloadType = string | number | unknown[] | object; 55 | 56 | export type SetOfEntity = { 57 | [K in keyof EntitiesShapeCollection]: EntitiesShapeCollection[K] 58 | }; 59 | 60 | export type EntitySelectType< 61 | PayloadType = BasicPayloadType, 62 | ResponseType = unknown, 63 | SelectedShape = unknown 64 | > = (responseData: ResponseType, payload: PayloadType) => SelectedShape; 65 | 66 | export type AsyncStoreResolver = AnyAction> = () => Promise>; 67 | 68 | export type FetchProcessor = ( 69 | fetchFunction: (payload: PayloadType) => Promise 70 | ) => (payload: PayloadType, extraArgument?: never) => Promise; 71 | 72 | export type Merger = ( 73 | tableName: string, 74 | table: BasicObject, 75 | entities: BasicObject, 76 | defaultMerger: () => MergedTable 77 | ) => MergedTable; 78 | -------------------------------------------------------------------------------- /src/queries.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 控制标准Redux Store结构的相关辅助工具 3 | * @author zhanglili 4 | */ 5 | import get from 'lodash.get'; 6 | import stringify from 'json-stable-stringify'; 7 | import {Action} from 'redux'; 8 | import {ThunkAction} from 'redux-thunk'; 9 | import { 10 | JSONLike, 11 | StandardAction, 12 | QueryPayload, 13 | ErrorPayload, 14 | UnionPayload, 15 | QueryResultShape, 16 | ErrorType, 17 | BasicObject, 18 | } from './interface'; 19 | 20 | const UNIQUE = '@@standard-redux-shape/NONE_USED'; 21 | 22 | export const createQueryPayload = (params: JSONLike, data: unknown): QueryPayload => { 23 | return { 24 | data, 25 | params, 26 | arrivedAt: Date.now(), 27 | }; 28 | }; 29 | 30 | export const createQueryErrorPayload = (params: JSONLike, error: ErrorType): ErrorPayload => { 31 | return { 32 | params, 33 | arrivedAt: Date.now(), 34 | error: {message: error.message, ...error}, 35 | }; 36 | }; 37 | 38 | enum QueryStage { 39 | fetch = 'fetch', 40 | receive = 'receive', 41 | accept = 'accept', 42 | } 43 | 44 | export declare type AssignQueryResultShape = { 45 | response?: ResponseType; 46 | nextResponse?: ResponseType; 47 | } & QueryResultShape; 48 | 49 | export declare type ReduceStateStrategy = ( 50 | item: QueryResultShape, 51 | stage: QueryStage, 52 | response: UnionPayload 53 | ) => QueryResultShape | AssignQueryResultShape; 54 | 55 | /** 56 | * 创建一个更新查询状态的reducer创建函数 57 | * 58 | * 该函数接受一个`reduceState`函数,当接收到特定的Action时,使用`(cacheItem, stage, payload)`调用该函数获取新的查询项,参数定义如下: 59 | * 60 | * - `{Object} cacheItem`:之前的查询项,包含了`pendingMutex`、`response`和`nextResponse` 61 | * - `{string} stage`:当前对该查询项的更新状态,可选值为`"fetch"`(开始查询)和`"receive"`(获得响应) 62 | * - `{Object} payload`:收到Action时获得的payload对象 63 | * 64 | * `reduceState`在收到以上三个参数后,必须返回一个新的查询项(即对`cacheItem`的更新) 65 | * 66 | * 在调用`reduceQueryBy`后,会得到一个reducer的创建函数,创建函数接收若干个Action类型并通过这些类型控制调用`reduceState`: 67 | * 68 | * - `{string} fetchActionType`:表达`"fetch"`状态的Action类型 69 | * - `{string} receiveActionType`:表达`"receive"`状态的Action类型 70 | * - `{string} acceptActionType`:表达使用`nextResponse`覆盖`response`的Action类型,如果不需要该功能则可以不传递参数 71 | * 72 | * 在接收3个Action类型后,会进而返回一个reducer用以更新一个查询项,一个查询项是以参数为key的对象,每个参数对应的结构如下: 73 | * 74 | * - `{number} pendingMutex`:表示当前正在进行的查询数目,通常用于判断是否有并行的请求发起,也可用于thunk判断是否要发新请求 75 | * - `{Object} response`:当前正在使用的响应 76 | * - `{Object} nextResponse`:当前最新的响应,如果选择始终使用最新响应则该属性永远为`null`,否则可以在此处获得比`response`更新的响应 77 | * 78 | * 每一个响应包含以下属性: 79 | * 80 | * - `{string} arrivedAt`:响应的到达时间 81 | * - `{*} params`:对应的参数 82 | * - `{*} data`:响应成功时的内容 83 | * - `{*} error`:响应失败时的内容 84 | * 85 | * @param {Function} reduceState 根据给定的查询状态返回新的查询状态 86 | * @return {Function} 一个reducer创建函数 87 | */ 88 | export const reduceQueryBy = (reduceState: ReduceStateStrategy) => ( 89 | fetchActionType: string, 90 | receiveActionType: string, 91 | acceptActionType = UNIQUE 92 | ) => { 93 | const queryStageMapping = { 94 | [fetchActionType]: QueryStage.fetch, 95 | [receiveActionType]: QueryStage.receive, 96 | [acceptActionType]: QueryStage.accept, 97 | }; 98 | 99 | const pendingMutexAddition = { 100 | fetch: 1, 101 | receive: -1, 102 | accept: 0, 103 | }; 104 | 105 | return ( 106 | state: BasicObject = {}, 107 | action: Action= {} as Action 108 | ) => { 109 | const {type, payload} = action as StandardAction; 110 | const stage = queryStageMapping[type]; 111 | 112 | if (!stage) { 113 | return state; 114 | } 115 | 116 | const params = stage === QueryStage.receive ? payload.params : payload; 117 | const cacheKey = stringify(params); 118 | const cacheItem = state[cacheKey] || {params, pendingMutex: 0, response: null, nextResponse: null}; 119 | 120 | if (stage === QueryStage.accept) { 121 | return { 122 | ...state, 123 | [cacheKey]: { 124 | ...cacheItem, 125 | response: (cacheItem as QueryResultShape).nextResponse, 126 | nextResponse: null, 127 | }, 128 | }; 129 | } 130 | 131 | const nextPendingMutex = (cacheItem as QueryResultShape).pendingMutex + pendingMutexAddition[stage]; 132 | const newItem = 133 | nextPendingMutex === (cacheItem as QueryResultShape).pendingMutex 134 | ? cacheItem 135 | : {...cacheItem, pendingMutex: nextPendingMutex}; 136 | 137 | return { 138 | ...state, 139 | [cacheKey]: reduceState(newItem as QueryResultShape, stage, payload), 140 | }; 141 | }; 142 | }; 143 | 144 | const alwaysOverride: ReduceStateStrategy = (item, stage, response) => { 145 | if (stage === QueryStage.receive) { 146 | return {...item, response} as AssignQueryResultShape; 147 | } 148 | 149 | return item as QueryResultShape; 150 | }; 151 | 152 | /** 153 | * 一个始终使用最新响应的查询状态reducer创建函数 154 | * 155 | * @param {string} fetchActionType 表达`"fetch"`状态的Action类型 156 | * @param {string} receiveActionType 表达`"receive"`状态的Action类型 157 | * @param {string} acceptActionType 表达使用`nextResponse`覆盖`response`的Action类型,如果不需要该功能则可以不传递参数 158 | * @return {Function} 返回一个reducer函数 159 | */ 160 | export const acceptLatest = reduceQueryBy(alwaysOverride); 161 | 162 | const neverOverride: ReduceStateStrategy = (item, stage, response) => { 163 | if (stage === 'receive') { 164 | return { 165 | ...item, 166 | response: item.response ? item.response : response, 167 | nextResponse: item.response ? response : null, 168 | } as AssignQueryResultShape; 169 | } 170 | 171 | return item as QueryResultShape; 172 | }; 173 | 174 | /** 175 | * 一个始终使用最早的响应(丢弃一切后到的响应)的查询状态reducer创建函数 176 | * 177 | * @param {string} fetchActionType 表达`"fetch"`状态的Action类型 178 | * @param {string} receiveActionType 表达`"receive"`状态的Action类型 179 | * @param {string} acceptActionType 表达使用`nextResponse`覆盖`response`的Action类型,如果不需要该功能则可以不传递参数 180 | * @return {Function} 返回一个reducer函数 181 | */ 182 | export const keepEarliest = reduceQueryBy(neverOverride); 183 | 184 | const overrideOnError: ReduceStateStrategy = (item, stage, response) => { 185 | const newItem = neverOverride(item, stage, response); 186 | 187 | if (stage === QueryStage.receive) { 188 | if (newItem.response!.error && !response.error) { 189 | return { 190 | ...newItem, 191 | response, 192 | nextResponse: null, 193 | } as AssignQueryResultShape; 194 | } 195 | 196 | return newItem as QueryResultShape; 197 | } 198 | 199 | return newItem as QueryResultShape; 200 | }; 201 | 202 | /** 203 | * 一个始终使用最早的成功响应(旧响应是失败状态则用新的成功状态覆盖,否则丢弃新的)的查询状态reducer创建函数 204 | * 205 | * @param {string} fetchActionType 表达`"fetch"`状态的Action类型 206 | * @param {string} receiveActionType 表达`"receive"`状态的Action类型 207 | * @param {string} acceptActionType 表达使用`nextResponse`覆盖`response`的Action类型,如果不需要该功能则可以不传递参数 208 | * @return {Function} 返回一个reducer函数 209 | */ 210 | export const keepEarliestSuccess = reduceQueryBy(overrideOnError); 211 | 212 | const overrideOnFree: ReduceStateStrategy = (item, stage, response) => { 213 | if (item.pendingMutex === 0) { 214 | return {...item, response} as AssignQueryResultShape; 215 | } 216 | 217 | return item as QueryResultShape; 218 | }; 219 | 220 | /** 221 | * 一个当有并行查询时使用最后一个响应的查询状态reducer创建函数 222 | * 223 | * @param {string} fetchActionType 表达`"fetch"`状态的Action类型 224 | * @param {string} receiveActionType 表达`"receive"`状态的Action类型 225 | * @param {string} acceptActionType 表达使用`nextResponse`覆盖`response`的Action类型,如果不需要该功能则可以不传递参数 226 | * @return {Function} 返回一个reducer函数 227 | */ 228 | export const acceptWhenNoPending = reduceQueryBy(overrideOnFree); 229 | 230 | const head = (array: T[]) => array[0]; 231 | 232 | const getQuery = ( 233 | state: StateType, 234 | selectQuerySet: (state: StateType) => Partial, 235 | paramsKey: string 236 | ) => { 237 | const querySet = selectQuerySet(state); 238 | 239 | return querySet ? querySet[paramsKey] : null; 240 | }; 241 | 242 | export interface ActionOptions { 243 | once?: boolean; 244 | trustPending?: boolean; 245 | computeParams?: (params: unknown) => any; 246 | selectQuerySet?: (state: unknown) => Partial; 247 | } 248 | 249 | export const thunkCreatorFor = >( 250 | api: (params: JSONLike) => Promise, 251 | fetchActionType: string, 252 | receiveActionType: string, 253 | options: ActionOptions = {} 254 | ) => { 255 | const {computeParams = head, once = false, trustPending = false, selectQuerySet} = options; 256 | const cache = trustPending ? new Map() : null; 257 | 258 | // TODO: 这里也需要改成单参数 259 | return (...args: any[]): ThunkAction => (dispatch, getState) => { 260 | const params = computeParams(args); 261 | const paramsKey = stringify(params); 262 | const availableData = once && get(getQuery(getState(), selectQuerySet!, paramsKey), 'response.data', null); 263 | 264 | if (availableData) { 265 | return Promise.resolve(availableData); 266 | } 267 | 268 | if (trustPending) { 269 | const cachedPending = cache!.get(paramsKey); 270 | 271 | if (cachedPending) { 272 | return cachedPending; 273 | } 274 | } 275 | 276 | /** 277 | * ThunkDispatch的重载, 没有找到好的办法处理这个 278 | * export interface ThunkDispatch { 279 | * (action: T): T; 280 | * (asyncAction: ThunkAction): R; 281 | * } 282 | */ 283 | dispatch({type: fetchActionType, payload: params}); 284 | 285 | const removeCachedPending = () => { 286 | if (trustPending) { 287 | cache!.delete(paramsKey); 288 | } 289 | }; 290 | const handleResult = (result: unknown) => { 291 | removeCachedPending(); 292 | dispatch({type: receiveActionType, payload: createQueryPayload(params, result)}); 293 | return result; 294 | }; 295 | const handleError = (ex: ErrorType) => { 296 | removeCachedPending(); 297 | dispatch({type: receiveActionType, payload: createQueryErrorPayload(params, ex)}); 298 | throw ex; 299 | }; 300 | 301 | const pending = api(params).then(handleResult, handleError); 302 | 303 | if (trustPending) { 304 | cache!.set(paramsKey, pending); 305 | } 306 | 307 | return pending; 308 | }; 309 | }; 310 | -------------------------------------------------------------------------------- /src/selectors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 标准Redux Store结构的相关选择器 3 | * @author zhanglili 4 | */ 5 | 6 | import {createSelector, OutputSelector, Selector} from 'reselect'; 7 | import stringify from 'json-stable-stringify'; 8 | import {JSONLike} from './interface'; 9 | 10 | const get = (name: string) => (source: any) => (source == null ? undefined : (source[name] as ReturnType)); 11 | 12 | export declare type SelectorCreator = ( 13 | selectQuery: Selector, 14 | selectParams: Selector 15 | ) => OutputSelector ReturnType | undefined>; 16 | 17 | /** 18 | * 根据`selectParams`返回的参数,从`selectQuery`返回的查询集里找到对应的查询 19 | * 20 | * 一个查询包含以下属性: 21 | * 22 | * - `{number} pendingMutex`:当前有几个请求正在进行,发起请求时+1,请求响应时-1 23 | * - `{Object} response`:存储下来的响应 24 | * - `{Object} nextResponse`:如果使用的策略是不立即用新的响应替换旧的,则会在`nextResponse`中存放最新的响应 25 | * 26 | * @param {Function} selectQuery 获取到整个Query集 27 | * @param {Function} selectParams 获取参数对象 28 | */ 29 | export const createQuerySelector: SelectorCreator = (selectQuery, selectParams) => 30 | createSelector([selectQuery, selectParams], (query, params) => { 31 | const paramsKey = stringify(params); 32 | return (query as any)[paramsKey]; 33 | }); 34 | 35 | /** 36 | * 基于`createQuerySelector`再获取查询中的`response`对象 37 | * 38 | * 一个响应对象包含以下属性: 39 | * 40 | * - `{number} arrivedAt`:响应的到达时间,可用于计算过期等 41 | * - `{*} data`:如果响应是成功的,则`data`对象存放了具体的响应内容 42 | * - `{Object} error`:如果响应是失败的,则`error`对象存放了具体的错误信息 43 | * 44 | * @param {Function} selectQuery 获取到整个Query集 45 | * @param {Function} selectParams 获取参数对象 46 | */ 47 | export const createQueryResponseSelector: SelectorCreator = (selectQuery, selectParams) => 48 | createSelector([createQuerySelector(selectQuery, selectParams)], get('response')); 49 | 50 | /** 51 | * 基于`createQueryResponseSelector`再获取查询中的`data`对象 52 | * 53 | * @param {Function} selectQuery 获取到整个Query集 54 | * @param {Function} selectParams 获取参数对象 55 | */ 56 | export const createQueryDataSelector: SelectorCreator = (selectQuery, selectParams) => 57 | createSelector([createQueryResponseSelector(selectQuery, selectParams)], get('data')); 58 | 59 | /** 60 | * 基于`createQueryResponseSelector`再获取查询中的`error`对象 61 | * 62 | * @param {Function} selectQuery 获取到整个Query集 63 | * @param {Function} selectParams 获取参数对象 64 | */ 65 | export const createQueryErrorSelector: SelectorCreator = (selectQuery, selectParams) => 66 | createSelector([createQueryResponseSelector(selectQuery, selectParams)], get('error')); 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/reskript/config/tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "noEmit": false, 6 | "emitDeclarationOnly": true, 7 | "declarationDir": "es" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "reskript/config/tslint.json" 3 | } 4 | -------------------------------------------------------------------------------- /types/san-update.d.ts: -------------------------------------------------------------------------------- 1 | // TODO: san-update的ts定义 2 | declare module 'san-update' { 3 | export function immutable(...args: any[]): any; 4 | } 5 | -------------------------------------------------------------------------------- /types/vfile-message.d.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/gucong3000/postcss-markdown/issues/35 2 | declare module 'vfile-message' { 3 | export type VFileMessage = any; 4 | } 5 | --------------------------------------------------------------------------------