├── .github ├── contributing.md └── commit-convention.md ├── packages ├── core │ ├── README.md │ ├── test │ │ ├── helpers │ │ │ ├── actionTypes.js │ │ │ ├── actionCreators.js │ │ │ └── reducers.js │ │ ├── index.spec.js │ │ ├── typescript │ │ │ ├── tsconfig.json │ │ │ ├── store.ts │ │ │ └── user.ts │ │ ├── typescript.spec.js │ │ ├── call.spec.js │ │ ├── plugins │ │ │ ├── context.spec.js │ │ │ └── index.spec.js │ │ ├── create-store.spec.js │ │ └── rxloop.spec.js │ ├── src │ │ ├── index.js │ │ ├── utils.js │ │ ├── call.js │ │ ├── plugins │ │ │ ├── index.js │ │ │ └── context │ │ │ │ └── index.js │ │ ├── check-model.js │ │ └── rxloop.js │ ├── package.json │ └── index.d.ts ├── devtools │ ├── README.md │ ├── index.d.ts │ ├── package.json │ └── src │ │ └── index.js ├── immer │ ├── README.md │ ├── index.d.ts │ ├── test │ │ └── index.spec.js │ ├── package.json │ └── src │ │ └── index.js └── loading │ ├── README.md │ ├── index.d.ts │ ├── package.json │ ├── src │ └── index.js │ └── test │ └── index.spec.js ├── docs ├── change-log.md ├── rxloop.png ├── basics │ ├── index.md │ ├── examples.md │ ├── basic-concepts.md │ ├── getting-started.md │ └── error-handler.md ├── advanced │ ├── index.md │ ├── integration-with-rxjs.md │ ├── multi-state-and-single-state.md │ ├── typescript.md │ ├── cancellation.md │ ├── cross-model-dispatch-action.md │ └── middleware.md ├── sidebar.md ├── index.html ├── api.md └── index.md ├── babel.upward.js ├── .gitignore ├── .prettierrc ├── lerna.json ├── examples ├── ajax-cancel │ ├── index.html │ ├── package.json │ └── src │ │ └── index.js ├── error-handler │ ├── index.html │ ├── package.json │ └── src │ │ └── index.js ├── single-state │ ├── index.html │ ├── package.json │ └── src │ │ └── index.js ├── subscriptions │ ├── package.json │ ├── index.html │ └── src │ │ └── index.js ├── counter-basic │ ├── package.json │ ├── index.html │ └── src │ │ └── index.js ├── loading-plugin │ ├── index.html │ ├── package.json │ └── src │ │ └── index.js └── immer-plugin │ ├── package.json │ ├── index.html │ └── src │ └── index.js ├── jest.config.js ├── .vscode └── settings.json ├── babel.config.js ├── LICENSE ├── scripts ├── release.sh └── build.js ├── .circleci └── config.yml ├── package.json ├── CHANGELOG.md ├── README-en_US.md ├── rollup.config.js └── README.md /.github/contributing.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/commit-convention.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/devtools/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/immer/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/loading/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/change-log.md: -------------------------------------------------------------------------------- 1 | # 更新记录 2 | 3 | ## 0.9.x 4 | 5 | -------------------------------------------------------------------------------- /docs/rxloop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TalkingData/rxloop/HEAD/docs/rxloop.png -------------------------------------------------------------------------------- /packages/core/test/helpers/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const ADD_TODO = 'test/ADD_TODO' 2 | -------------------------------------------------------------------------------- /babel.upward.js: -------------------------------------------------------------------------------- 1 | module.exports = require("babel-jest").createTransformer({ 2 | rootMode: "upward", 3 | }); -------------------------------------------------------------------------------- /packages/core/src/index.js: -------------------------------------------------------------------------------- 1 | export { rxloop as default } from './rxloop'; 2 | export { call } from './call'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | lib 4 | es 5 | dist 6 | coverage 7 | yarn-error.log 8 | lerna-debug.log 9 | .cache -------------------------------------------------------------------------------- /docs/basics/index.md: -------------------------------------------------------------------------------- 1 | # 基础 2 | 3 | - [基础](index.md) 4 | - [快速上手](getting-started.md) 5 | - [异常处理](error-handler.md) 6 | - [示例](examples.md) -------------------------------------------------------------------------------- /packages/core/test/index.spec.js: -------------------------------------------------------------------------------- 1 | import rxloop from '../'; 2 | 3 | test('export plugins', () => { 4 | expect(rxloop).not.toBeUndefined(); 5 | }); 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/test/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2015", "dom"], 4 | "strict": true, 5 | "baseUrl": "../.." 6 | } 7 | } -------------------------------------------------------------------------------- /packages/core/test/helpers/actionCreators.js: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_TODO, 3 | } from './actionTypes' 4 | 5 | export function addTodo(text) { 6 | return { type: ADD_TODO, text } 7 | } 8 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmClient": "yarn", 3 | "useWorkspaces": false, 4 | "version": "1.0.0-alpha.5", 5 | "lerna": "2.11.0", 6 | "packages": [ 7 | "packages/*" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/test/typescript/store.ts: -------------------------------------------------------------------------------- 1 | import rxloop, { RxLoopInstance } from '../../'; 2 | import user from './user'; 3 | 4 | const app: RxLoopInstance = rxloop(); 5 | 6 | app.model(user); 7 | app.start(); 8 | 9 | export default app; 10 | -------------------------------------------------------------------------------- /packages/core/test/typescript.spec.js: -------------------------------------------------------------------------------- 1 | import { checkDirectory } from 'typings-tester' 2 | 3 | describe('TypeScript definitions', function() { 4 | it('should compile against index.d.ts', () => { 5 | checkDirectory(__dirname + '/typescript') 6 | }) 7 | }) -------------------------------------------------------------------------------- /docs/advanced/index.md: -------------------------------------------------------------------------------- 1 | # 高级特性 2 | 3 | - [高级特性](index.md) 4 | - [请求取消](cancellation.md) 5 | - [与 RxJS 集成](integration-with-rxjs.md) 6 | - [多状态与单一状态树](multi-state-and-single-state.md) 7 | - [在 Model 之间传递消息](cross-model-dispatch-action.md) 8 | - [中间件](middleware.md) -------------------------------------------------------------------------------- /packages/core/src/utils.js: -------------------------------------------------------------------------------- 1 | export { default as isPlainObject } from 'is-plain-object'; 2 | export const isFunction = o => Object.prototype.toString.call(o) === '[object Function]'; 3 | export const isAllFunction = o => Object.keys(o).every(key => isFunction(o[key])); 4 | export const noop = () => {}; 5 | -------------------------------------------------------------------------------- /packages/core/test/helpers/reducers.js: -------------------------------------------------------------------------------- 1 | function id(state = []) { 2 | return ( 3 | state.reduce((result, item) => (item.id > result ? item.id : result), 0) + 1 4 | ) 5 | } 6 | 7 | export function ADD_TODO(state = [], action) { 8 | return [ 9 | ...state, 10 | { 11 | id: id(state), 12 | text: action.text 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /examples/ajax-cancel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | rxLoop basic example 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/error-handler/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | rxLoop basic example 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/single-state/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | rxLoop basic example 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/loading/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export interface Config { 4 | name?: string, 5 | } 6 | 7 | export interface API { 8 | onModel$: Observable, 9 | onPipeStart$: Observable, 10 | onPipeEnd$: Observable, 11 | onPipeCancel$: Observable, 12 | onPipeError$: Observable, 13 | } 14 | 15 | export type Plugin = (api: API) => void; 16 | 17 | export default function loading(opts?: Config): Plugin; 18 | -------------------------------------------------------------------------------- /docs/advanced/integration-with-rxjs.md: -------------------------------------------------------------------------------- 1 | # 与 RxJS 集成 2 | 3 | ## 将 rxloop 串联到 RxJS 数据流中 4 | ```javascript 5 | // 输入数据 6 | fromEvent(button, 'click') 7 | .pipe( 8 | map((data) => { 9 | return { 10 | type: 'counter/increment', 11 | data, 12 | }; 13 | }), 14 | ) 15 | .subscribe(app); 16 | 17 | // 输出 18 | app.counter$.pipe( 19 | map(() => { 20 | return {}; 21 | }), 22 | ) 23 | .subscribe((data) => { 24 | // this.setState(data); 25 | }); 26 | ``` -------------------------------------------------------------------------------- /packages/immer/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export interface API { 4 | onModel$: Observable, 5 | onEpicStart$: Observable, 6 | onEpicEnd$: Observable, 7 | onEpicCancel$: Observable, 8 | onEpicError$: Observable, 9 | } 10 | 11 | export type Plugin = (api: API) => void; 12 | 13 | export interface Config { 14 | disabled?: string[], 15 | } 16 | 17 | export default function rxloopImmer(opts?: Config): Plugin; 18 | -------------------------------------------------------------------------------- /docs/basics/examples.md: -------------------------------------------------------------------------------- 1 | # 示例 2 | 3 | 1. [基本的计数器](https://codesandbox.io/s/mz6yyw17vy) 4 | 2. [单一状态和多状态树](https://codesandbox.io/s/348w57x936) 5 | 3. [错误处理](https://codesandbox.io/s/0qmn89noj0) 6 | 4. [取消异步请求](https://codesandbox.io/s/3vy8ox7zx5) 7 | 5. [使用 react-redux 绑定 rxloop](https://codesandbox.io/s/y3www03181) 8 | 6. [任务列表应用](https://codesandbox.io/s/ypwo37zmo1) 9 | 7. [loading 插件](https://codesandbox.io/s/8l1mnx18v2) 10 | 8. [immer 插件](https://codesandbox.io/s/343wrnq6pp) 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | rootDir: __dirname, 4 | transform: { 5 | // "^.+\\.js$": "babel-jest", 6 | "^.+\\.js$": "/babel.upward.js", 7 | // "^.+\\.(ts|tsx)$": "ts-jest" 8 | }, 9 | watchPathIgnorePatterns: ['/node_modules/'], 10 | moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], 11 | moduleNameMapper: { 12 | '^@rxloop/(.*?)$': '/packages/$1' 13 | }, 14 | testMatch: [`${process.cwd()}/test/**/*spec.[jt]s?(x)`] 15 | }; -------------------------------------------------------------------------------- /examples/subscriptions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/subscriptions-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "parcel index.html --open" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@rxloop/core": "1.0.0-alpha.4", 14 | "@rxloop/devtools": "1.0.0-alpha.4" 15 | }, 16 | "devDependencies": { 17 | "parcel-bundler": "^1.12.4" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/devtools/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export interface API { 4 | onModel$: Observable, 5 | onEpicStart$: Observable, 6 | onEpicEnd$: Observable, 7 | onEpicCancel$: Observable, 8 | onEpicError$: Observable, 9 | onStart$: Observable, 10 | } 11 | 12 | export type Plugin = (api: API) => void; 13 | 14 | export interface Config { 15 | blacklist?: String[], 16 | } 17 | 18 | export default function rxloopDevtools(conf?: Config): Plugin; 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use the project's typescript version 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | 5 | "cSpell.enabledLanguageIds": ["markdown", "plaintext", "text", "yml"], 6 | 7 | // Use prettier to format typescript, javascript and JSON files 8 | "[typescript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[javascript]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[json]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | } 17 | } -------------------------------------------------------------------------------- /examples/ajax-cancel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/ajax-cancel-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode development --open --hot" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "rxjs": "^6.2.0", 14 | "@rxloop/core": "1.0.0-alpha.4" 15 | }, 16 | "devDependencies": { 17 | "webpack": "^4.41.2", 18 | "webpack-cli": "^3.3.9", 19 | "webpack-dev-server": "3.7.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/error-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/error-handler-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode development --open --hot" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "rxjs": "^6.2.0", 14 | "@rxloop/core": "1.0.0-alpha.4" 15 | }, 16 | "devDependencies": { 17 | "webpack": "^4.41.2", 18 | "webpack-cli": "^3.3.9", 19 | "webpack-dev-server": "3.7.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/counter-basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/counter-basic-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode development --open --hot" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@rxloop/core": "1.0.0-alpha.4", 14 | "@rxloop/devtools": "1.0.0-alpha.4" 15 | }, 16 | "devDependencies": { 17 | "webpack": "^4.41.2", 18 | "webpack-cli": "^3.3.9", 19 | "webpack-dev-server": "3.7.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/loading-plugin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | rxLoop basic example 8 | 13 | 14 | 15 |
16 |
17 | 18 |
19 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/subscriptions/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Subscriptions example 8 | 9 | 10 |
11 |

12 | 17 |

18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/immer-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/plugin-immer-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode development --open --hot" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "rxjs": "^6.2.0", 14 | "@rxloop/core": "1.0.0-alpha.4", 15 | "@rxloop/immer": "1.0.0-alpha.4" 16 | }, 17 | "devDependencies": { 18 | "webpack": "^4.41.2", 19 | "webpack-cli": "^3.3.9", 20 | "webpack-dev-server": "3.7.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/single-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/single-state-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode development --open --hot" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "rxjs": "^6.2.0", 14 | "@rxloop/core": "1.0.0-alpha.4", 15 | "@rxloop/devtools": "1.0.0-alpha.4" 16 | }, 17 | "devDependencies": { 18 | "webpack": "^4.41.2", 19 | "webpack-cli": "^3.3.9", 20 | "webpack-dev-server": "3.7.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/loading-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/plugin-loading-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode development --open --hot" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "rxjs": "^6.2.0", 14 | "@rxloop/core": "1.0.0-alpha.4", 15 | "@rxloop/loading": "1.0.0-alpha.4" 16 | }, 17 | "devDependencies": { 18 | "webpack": "^4.41.2", 19 | "webpack-cli": "^3.3.9", 20 | "webpack-dev-server": "3.7.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/sidebar.md: -------------------------------------------------------------------------------- 1 | - [介绍](index.md) 2 | - [基础](basics/index.md) 3 | - [快速上手](basics/getting-started.md) 4 | - [基础概念](basics/basic-concepts.md) 5 | - [错误处理](basics/error-handler.md) 6 | - [示例](basics/examples.md) 7 | - [高级特性](advanced/index.md) 8 | - [请求取消](advanced/cancellation.md) 9 | - [与 RxJS 集成](advanced/integration-with-rxjs.md) 10 | - [多状态与单一状态树](advanced/multi-state-and-single-state.md) 11 | - [在 Model 之间传递消息](advanced/cross-model-dispatch-action.md) 12 | - [中间件](advanced/middleware.md) 13 | - [TypeScript](advanced/typescript.md) 14 | - [API](api.md) 15 | - [更新记录](https://github.com/TalkingData/rxloop/blob/master/CHANGELOG.md) 16 | -------------------------------------------------------------------------------- /examples/immer-plugin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | rxLoop immmer plugin example 8 | 13 | 14 | 15 |
16 |
17 | 18 | 19 |
20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /packages/core/test/typescript/user.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Model, Action, OperatorsMap } from '../../'; 3 | 4 | type State = { 5 | name: string, 6 | } 7 | 8 | const state: State = { 9 | name: 'wxnet', 10 | }; 11 | 12 | const user: Model = { 13 | state, 14 | name: 'user', 15 | reducers: { 16 | info(state: any) { 17 | return state; 18 | } 19 | }, 20 | pipes: { 21 | getUserInfo(action$: Observable, { call }: OperatorsMap) { 22 | return action$.pipe( 23 | call(async () => { 24 | return { type: 'info' }; 25 | }), 26 | ); 27 | } 28 | }, 29 | }; 30 | 31 | export default user; -------------------------------------------------------------------------------- /examples/counter-basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | rxloop basic example 8 | 9 | 10 |
11 |

12 | Clicked: 0 times 13 | 14 | 15 | 16 | 17 |

18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/core/src/call.js: -------------------------------------------------------------------------------- 1 | import { Subject, from, empty } from 'rxjs'; 2 | import { switchMap, catchError, takeUntil } from 'rxjs/operators'; 3 | 4 | const handler = (f) => (action) => { 5 | const cancel$ = action.__cancel__ || empty(); 6 | const bus$ = action.__bus__ || new Subject(); 7 | 8 | const [ model, pipe ] = action.type.split('/'); 9 | return from(f(action)).pipe( 10 | takeUntil(cancel$), 11 | catchError((error) => { 12 | bus$.next({ 13 | type: `${model}/${pipe}/error`, 14 | error, 15 | model, 16 | pipe, 17 | }); 18 | return empty(); 19 | }), 20 | ); 21 | }; 22 | 23 | export const call = (f) => switchMap( handler(f) ); 24 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // const { NODE_ENV } = process.env; 2 | 3 | module.exports = { 4 | babelrcRoots: [ 5 | ".", 6 | "packages/*", 7 | ], 8 | "presets": [ 9 | [ 10 | "@babel/preset-env", 11 | { 12 | "targets": { 13 | // node: 'current', 14 | "browsers": [ "ie >= 11" ], 15 | }, 16 | "exclude": ["transform-async-to-generator", "transform-regenerator"], 17 | "modules": false, 18 | "loose": true 19 | } 20 | ] 21 | ], 22 | "plugins": [ 23 | "@babel/plugin-proposal-object-rest-spread", 24 | "@babel/plugin-transform-runtime" 25 | ], 26 | "env": { 27 | "test": { 28 | "presets": [ 29 | [ "@babel/preset-env", { "modules": "cjs" } ] 30 | ] 31 | }, 32 | } 33 | } -------------------------------------------------------------------------------- /docs/advanced/multi-state-and-single-state.md: -------------------------------------------------------------------------------- 1 | # 多状态与单一状态树 2 | 3 | ```javascript 4 | // 添加 user 模型 5 | app.model({ 6 | name: 'user', 7 | state: { 8 | name: 'wxnet', 9 | email: 'test@gmail.com', 10 | }, 11 | }); 12 | 13 | // 每一个模型会对应一个状态树,比如之前创建的 counter 模型。 14 | // counter 状态树 15 | const counter$ = app.stream('counter'); 16 | // user 状态树 17 | const user$ = app.stream('user'); 18 | 19 | // 可以在不同的组件中,自由订阅模型状态的变更 20 | counter$.subscribe( 21 | (state) => { 22 | // this.setState(state); 23 | }, 24 | ); 25 | 26 | user$.subscribe( 27 | (state) => { 28 | // this.setState(state); 29 | }, 30 | ); 31 | 32 | // 还可以直接使用 stream 方法,获取单一状态树。 33 | const singleState$ = app.stream(); 34 | 35 | singleState$.subscribe( 36 | (state) => { 37 | // this.setState(state); 38 | }, 39 | ); 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/advanced/typescript.md: -------------------------------------------------------------------------------- 1 | # TypeScript 2 | 3 | 在 TypeScript 中为模型标注静态类型,可以在开发阶段避免很多错误。 4 | 5 | ```typescript 6 | import { Model, Action } from '@rxloop/core'; 7 | import { Observable } from 'rxjs'; 8 | import { map } from 'rxjs/operators'; 9 | 10 | const comment: Model = { 11 | name: 'comment', 12 | state: { 13 | comments: [], 14 | }, 15 | reducers: { 16 | comments(state: any, action: Action) { 17 | state.comments = action.data.comments; 18 | }, 19 | }, 20 | pipes: { 21 | loadComments(action$: Observable): Observable { 22 | return action$.pipe( 23 | map((data: any) => { 24 | return { 25 | type: 'comments', 26 | data: { 27 | comments: [], 28 | }, 29 | }; 30 | }), 31 | ); 32 | }, 33 | }, 34 | }; 35 | export default comment; 36 | ``` -------------------------------------------------------------------------------- /packages/core/src/plugins/index.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { filter } from 'rxjs/operators'; 3 | import { isFunction } from '../utils'; 4 | import context from './context'; 5 | 6 | // plugins 7 | export default function init(plugins, plugin$) { 8 | this.plugin$ = plugin$; 9 | 10 | invariant( 11 | plugins.every(plugin => isFunction(plugin)), 12 | '[plugins] all plugin should be function', 13 | ); 14 | 15 | plugins.push(context()); 16 | 17 | const source = evt => this.plugin$.pipe( filter(e => e.action === evt) ); 18 | 19 | plugins.forEach(plugin => plugin.call(this, { 20 | onModelBeforeCreate$: source('onModelBeforeCreate'), 21 | onModelCreated$: source('onModelCreated'), 22 | onPipeStart$: source('onPipeStart'), 23 | onPipeEnd$: source('onPipeEnd'), 24 | onPipeCancel$: source('onPipeCancel'), 25 | onPipeError$: source('onPipeError'), 26 | onStatePatch$: source('onStatePatch'), 27 | onStart$: source('onStart'), 28 | })); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/devtools/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/devtools", 3 | "version": "1.0.0-alpha.5", 4 | "description": "@rxloop/devtools", 5 | "repository": { 6 | "type": "git", 7 | "url": "git@github.com:TalkingData/rxloop.git" 8 | }, 9 | "keywords": [ 10 | "rxjs", 11 | "model", 12 | "vue" 13 | ], 14 | "author": "wxnet (https://github.com/wxnet2013)", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/TalkingData/rxloop/issues" 18 | }, 19 | "homepage": "https://talkingdata.github.io/rxloop", 20 | "peerDependencies": { 21 | "@rxloop/core": ">=1.0.0-alpha.4", 22 | "rxjs": "^6.0.0-0" 23 | }, 24 | "devDependencies": { 25 | "@rxloop/core": "^1.0.0-alpha.5", 26 | "rxjs": "^6.5.3" 27 | }, 28 | "main": "lib/devtools.js", 29 | "module": "es/devtools.js", 30 | "unpkg": "dist/devtools.js", 31 | "typings": "./index.d.ts", 32 | "files": [ 33 | "dist", 34 | "lib", 35 | "es", 36 | "src", 37 | "index.d.ts" 38 | ], 39 | "publishConfig": { 40 | "access": "public" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 rxloop 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. -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/core", 3 | "version": "1.0.0-alpha.5", 4 | "description": "@rxloop/core", 5 | "scripts": { 6 | "clean": "rimraf lib dist es", 7 | "test": "jest --config ../../jest.config.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:TalkingData/rxloop.git" 12 | }, 13 | "keywords": [ 14 | "rxjs", 15 | "model", 16 | "react", 17 | "vue" 18 | ], 19 | "author": "wxnet (https://github.com/wxnet2013)", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/TalkingData/rxloop/issues" 23 | }, 24 | "dependencies": { 25 | "invariant": "^2.2.4", 26 | "is-plain-object": "^2.0.4" 27 | }, 28 | "peerDependencies": { 29 | "rxjs": "^6.0.0-0" 30 | }, 31 | "devDependencies": { 32 | "rxjs": "^6.5.3" 33 | }, 34 | "main": "lib/core.js", 35 | "module": "es/core.js", 36 | "unpkg": "dist/core.js", 37 | "typings": "./index.d.ts", 38 | "files": [ 39 | "dist", 40 | "lib", 41 | "es", 42 | "src", 43 | "index.d.ts" 44 | ], 45 | "publishConfig": { 46 | "access": "public" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/loading/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/loading", 3 | "version": "1.0.0-alpha.5", 4 | "description": "@rxloop/loading", 5 | "scripts": { 6 | "test": "jest --config ../../jest.config.js" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git@github.com:TalkingData/rxloop.git" 11 | }, 12 | "keywords": [ 13 | "rxjs", 14 | "model", 15 | "vue" 16 | ], 17 | "author": "wxnet (https://github.com/wxnet2013)", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/TalkingData/rxloop/issues" 21 | }, 22 | "homepage": "https://talkingdata.github.io/rxloop", 23 | "peerDependencies": { 24 | "@rxloop/core": "^1.0.0-alpha.4", 25 | "rxjs": "^6.0.0-0" 26 | }, 27 | "main": "lib/loading.js", 28 | "module": "es/loading.js", 29 | "unpkg": "dist/loading.js", 30 | "typings": "./index.d.ts", 31 | "files": [ 32 | "dist", 33 | "lib", 34 | "es", 35 | "src", 36 | "index.d.ts" 37 | ], 38 | "devDependencies": { 39 | "@rxloop/core": "^1.0.0-alpha.5", 40 | "rxjs": "^6.5.3" 41 | }, 42 | "publishConfig": { 43 | "access": "public" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /examples/immer-plugin/src/index.js: -------------------------------------------------------------------------------- 1 | import rxloop from '@rxloop/core'; 2 | import immer from '@rxloop/immer'; 3 | 4 | const counter = { 5 | name: 'counter', 6 | state: { 7 | counter: 0, 8 | }, 9 | reducers: { 10 | increment(state) { 11 | state.counter = state.counter + 1; 12 | 13 | // no more ... and return 14 | // return { 15 | // ...state, 16 | // counter: state.counter + 1, 17 | // }; 18 | }, 19 | decrement(state) { 20 | state.counter = state.counter - 1; 21 | 22 | // no more ... and return 23 | // return { 24 | // ...state, 25 | // counter: state.counter - 1, 26 | // }; 27 | }, 28 | }, 29 | }; 30 | 31 | const app = rxloop({ 32 | plugins: [ immer() ], 33 | }); 34 | app.model(counter); 35 | 36 | app.stream('counter').subscribe(state => { 37 | document.getElementById('counter').innerHTML = state.counter; 38 | }); 39 | 40 | document.getElementById('increment').onclick = () => { 41 | app.dispatch({ 42 | type: 'counter/increment', 43 | }); 44 | }; 45 | 46 | document.getElementById('decrement').onclick = () => { 47 | app.dispatch({ 48 | type: 'counter/decrement', 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /packages/immer/test/index.spec.js: -------------------------------------------------------------------------------- 1 | import rxloop from '@rxloop/core'; 2 | import immer from '../'; 3 | 4 | const store = rxloop({ 5 | plugins: [ 6 | immer(), 7 | ], 8 | }); 9 | 10 | const testState = { 11 | a: 1, 12 | arr: [1], 13 | }; 14 | 15 | store.model({ 16 | name: 'test', 17 | state: testState, 18 | reducers: { 19 | add(state) { 20 | delete state.__action__; 21 | state.a = 2; 22 | }, 23 | arr(state) { 24 | delete state.__action__; 25 | state.arr.push(2); 26 | }, 27 | }, 28 | }); 29 | store.stream('test').subscribe(); 30 | 31 | describe('Basic usage', () => { 32 | test('immer number', () => { 33 | store.dispatch({ 34 | type: 'test/add', 35 | }); 36 | expect(testState).toEqual({ 37 | a: 1, 38 | arr: [1], 39 | }); 40 | expect(store.getState('test')).toEqual({ 41 | a: 2, 42 | arr: [1], 43 | }); 44 | }); 45 | test('immer array', () => { 46 | store.dispatch({ 47 | type: 'test/arr', 48 | }); 49 | expect(testState).toEqual({ 50 | a: 1, 51 | arr: [1], 52 | }); 53 | expect(store.getState('test')).toEqual({ 54 | a: 2, 55 | arr: [1, 2], 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /examples/error-handler/src/index.js: -------------------------------------------------------------------------------- 1 | import rxloop from '@rxloop/core'; 2 | 3 | const counter = { 4 | name: 'counter', 5 | state: { 6 | counter: 0, 7 | }, 8 | reducers: { 9 | increment(state) { 10 | return { 11 | ...state, 12 | counter: state.counter + 1 13 | }; 14 | }, 15 | decrement(state) { 16 | return { 17 | ...state, 18 | counter: state.counter + 1 19 | }; 20 | }, 21 | }, 22 | pipes: { 23 | getData(action$, { call, map }) { 24 | return action$.pipe( 25 | call(async () => { 26 | throw new Error('not login'); 27 | }), 28 | map((data) => { 29 | return { 30 | data, 31 | type: 'increment', 32 | }; 33 | }), 34 | ); 35 | }, 36 | } 37 | }; 38 | 39 | const store = rxloop(); 40 | store.model(counter); 41 | 42 | store.stream('counter').subscribe((state) => { 43 | if (state.error) { 44 | console.log(state.error); 45 | return; 46 | } 47 | console.log(state); 48 | }); 49 | 50 | store.dispatch({ 51 | type: 'counter/getData', 52 | }); 53 | 54 | setTimeout(() => { 55 | store.dispatch({ 56 | type: 'counter/getData', 57 | }); 58 | },1000); 59 | -------------------------------------------------------------------------------- /packages/immer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxloop/immer", 3 | "version": "1.0.0-alpha.5", 4 | "description": "@rxloop/immer", 5 | "scripts": { 6 | "test": "jest --config ../../jest.config.js" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git@github.com:TalkingData/rxloop.git" 11 | }, 12 | "keywords": [ 13 | "rxjs", 14 | "model", 15 | "vue" 16 | ], 17 | "author": "wxnet (https://github.com/wxnet2013)", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/TalkingData/rxloop/issues" 21 | }, 22 | "homepage": "https://talkingdata.github.io/rxloop", 23 | "dependencies": { 24 | "immer": "^4.0.2", 25 | "lodash": "^4.17.11" 26 | }, 27 | "peerDependencies": { 28 | "@rxloop/core": "^1.0.0-alpha.4", 29 | "rxjs": "^6.0.0-0" 30 | }, 31 | "main": "lib/immer.js", 32 | "module": "es/immer.js", 33 | "unpkg": "dist/immer.js", 34 | "typings": "./index.d.ts", 35 | "files": [ 36 | "dist", 37 | "lib", 38 | "es", 39 | "src", 40 | "index.d.ts" 41 | ], 42 | "devDependencies": { 43 | "@rxloop/core": "^1.0.0-alpha.5", 44 | "rxjs": "^6.5.3" 45 | }, 46 | "publishConfig": { 47 | "access": "public" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /examples/ajax-cancel/src/index.js: -------------------------------------------------------------------------------- 1 | import rxloop from '@rxloop/core'; 2 | 3 | const apiSlow = async () => { 4 | const data = await new Promise((resolve) => { 5 | setTimeout(() => resolve({}), 2000); 6 | }); 7 | return { code: 200, data }; 8 | }; 9 | 10 | const counter = { 11 | name: 'counter', 12 | state: { 13 | counter: 0, 14 | }, 15 | reducers: { 16 | increment(state) { 17 | return { 18 | ...state, 19 | counter: state.counter + 1 20 | }; 21 | }, 22 | decrement(state) { 23 | return { 24 | ...state, 25 | counter: state.counter + 1 26 | }; 27 | }, 28 | }, 29 | pipes: { 30 | getData(action$, { call, map }) { 31 | return action$.pipe( 32 | call(async () => { 33 | return await apiSlow(); 34 | }), 35 | map((data) => { 36 | return { 37 | data, 38 | type: 'increment', 39 | }; 40 | }), 41 | ); 42 | }, 43 | } 44 | }; 45 | 46 | const store = rxloop(); 47 | store.model(counter); 48 | 49 | store.stream('counter').subscribe((state) => { 50 | console.log(state); 51 | }); 52 | 53 | store.dispatch({ 54 | type: 'counter/getData', 55 | }); 56 | 57 | // 取消异步请求 58 | store.dispatch({ 59 | type: 'counter/getData/cancel', 60 | }); 61 | -------------------------------------------------------------------------------- /packages/immer/src/index.js: -------------------------------------------------------------------------------- 1 | import produce from "immer"; 2 | import cloneDeep from 'lodash/fp/cloneDeep'; 3 | 4 | export default function rxloopImmer() { 5 | return function init() { 6 | function createReducer(action = {}, reducer = () => {}) { 7 | return (state) => { 8 | try { 9 | const rtn = produce(state, draft => { 10 | const compatiableRet = reducer(draft, action); 11 | if (compatiableRet !== undefined) { 12 | // which means you are use redux pattern 13 | // it's compatiable. https://github.com/mweststrate/immer#returning-data-from-producers 14 | return compatiableRet; 15 | } 16 | }); 17 | return rtn === undefined ? {} : rtn; 18 | } catch(e) { 19 | // 在 Vue 下开启 rxloop-immer 插件 20 | // 当对数组执行 push、unshift、splice 三个方法时会报错 21 | // 这个时候降级为深拷贝方式 22 | if(e.toString().indexOf('observeArray')) { 23 | console.warn('Downgrade to deepclone when call methods: push、unshift、splice in Vue'); 24 | const draft = cloneDeep(state); 25 | reducer(draft, action); 26 | return Object.freeze(draft); 27 | } 28 | throw e; 29 | } 30 | } 31 | } 32 | this.createReducer = createReducer; 33 | }; 34 | }; -------------------------------------------------------------------------------- /packages/core/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | export { Observable, Subject } from 'rxjs'; 3 | 4 | export interface Action { 5 | type: string, 6 | payload?: any, 7 | data?: any, 8 | } 9 | 10 | export interface OperatorsMap { 11 | call: Function, 12 | map: Function, 13 | dispatch: Function, 14 | put: Function, 15 | cancel$: Observable, 16 | [key: string]: any, 17 | } 18 | 19 | export type Reducer = (state: T, action?: Action) => T; 20 | 21 | export type Pipe = (action$: Observable, operators: OperatorsMap) => Observable; 22 | 23 | type MapObject = { 24 | [key: string]: T, 25 | } 26 | 27 | export interface Model { 28 | name: string, 29 | state: T, 30 | reducers?: MapObject, 31 | pipes?: MapObject, 32 | } 33 | 34 | export interface Unsubscribe { 35 | (): void 36 | } 37 | 38 | export interface RxLoopInstance { 39 | model: (model: Model) => void, 40 | stream: (modelName: String) => Observable, 41 | dispatch: (action: Action) => void, 42 | subscribe: (listener: () => void) => Unsubscribe, 43 | getState: (modelName: String) => any, 44 | next: (action: Action) => void, 45 | start: () => void, 46 | } 47 | 48 | export interface Config { 49 | plugins?: Function[], 50 | onError?: Function, 51 | } 52 | 53 | export default function rxloop(conf?: Config): RxLoopInstance; -------------------------------------------------------------------------------- /docs/advanced/cancellation.md: -------------------------------------------------------------------------------- 1 | # 取消请求 2 | 3 | 在复杂单页应用下,会经常频繁地切换路由,需要及时取消上一个界面未完成的请求。 4 | 在 RxJS 中,一般用 `takeUntil` 操作符来终止数据流,rxloop 将这一过程封装为 `call` 操作符,在 call 中调用的所有异步过程,可以轻松取消。 5 | 6 | 接下来我们通过一个简单的实例,演示下在 rxloop 中如何取消一个 pipe。 7 | 8 | ## 模拟慢请求 9 | 首先使用 Promise 来模拟一个慢请求,这个接口会在 5 秒后返回数据。 10 | ```javascript 11 | const apiSlow = async () => { 12 | const data = await new Promise((resolve) => { 13 | setTimeout(() => resolve({}), 5000); 14 | }); 15 | return { code: 200, data }; 16 | }; 17 | ``` 18 | 19 | ## 取消异步请求 20 | 在 pipes 中,串联一个 `takeUntil` 操作符,这个操作符会“监听“取消信号 cancel$. 21 | 22 | ```javascript 23 | import rxloop from 'rxloop'; 24 | // ... 25 | getData(action$, { call, map }) { 26 | return action$.pipe( 27 | call(async () => { 28 | return await apiSlow(); 29 | }), 30 | map((data) => { 31 | return { 32 | data, 33 | type: 'increment', 34 | }; 35 | }), 36 | ); 37 | } 38 | // ... 39 | ``` 40 | 41 | dispatch 方法,不仅能发起异步请求,还能出取消信号。 42 | ```javascript 43 | const actionGetData = { 44 | type: 'counter/getData', 45 | }; 46 | 47 | const actionGetDataCancel = { 48 | type: 'counter/getData/cancel', 49 | }; 50 | app.dispatch(actionGetDataCancel); 51 | ``` 52 | 53 | 以 React 为例,组件在销毁之前,取消异步请求: 54 | ```javascript 55 | // ... 56 | componentWillUnmount() { 57 | app.dispatch({ 58 | type: 'counter/getData/cancel', 59 | }); 60 | } 61 | // ... 62 | ``` 63 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Select a option to release (input a serial number):" 4 | echo 5 | 6 | select VERSION in patch minor major "Specific Version" 7 | do 8 | echo 9 | if [[ $REPLY =~ ^[1-4]$ ]]; then 10 | if [[ $REPLY == 4 ]]; then 11 | read -p "Enter a specific version: " -r VERSION 12 | echo 13 | if [[ -z $REPLY ]]; then 14 | VERSION=$REPLY 15 | fi 16 | fi 17 | 18 | read -p "Release $VERSION - are you sure? (y/n) " -n 1 -r 19 | echo 20 | 21 | if [[ $REPLY =~ ^[Yy]$ || -z $REPLY ]]; then 22 | # pre release task 23 | npm run lint 24 | npm run test 25 | npm run clean 26 | npm run build 27 | 28 | # bump version 29 | npm version $VERSION 30 | NEW_VERSION=$(node -p "require('./package.json').version") 31 | echo Releasing ${NEW_VERSION} ... 32 | 33 | # npm release 34 | npm whoami 35 | npm publish 36 | echo "✅ Released to npm." 37 | 38 | # github release 39 | git add CHANGELOG.md 40 | git commit -m "chore: changelog" 41 | git push 42 | git tag -a v${NEW_VERSION} -m "version $NEW_VERSION" 43 | git push origin v${NEW_VERSION} 44 | echo "✅ Released to Github." 45 | else 46 | echo Cancelled 47 | fi 48 | break 49 | else 50 | echo Invalid \"${REPLY}\" 51 | echo "To continue, please input a serial number(1-4) of an option." 52 | echo 53 | fi 54 | done 55 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rxloop 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /docs/basics/basic-concepts.md: -------------------------------------------------------------------------------- 1 | # 基础概念 2 | 3 | 1. View:应用的视图层 4 | 2. State:一个对象,保存整个应用状态 5 | 3. Action:一个对象,描述事件 6 | 4. dispatch 方法:一个函数,发送 Action 到 State 7 | 5. Model:一个对象,用于组织应用的逻辑关系 8 | 9 | ## State 和 View 10 | 11 | State 是储存数据的地方,收到 Action 以后,会更新数据。 12 | View 是由组件构成的 UI 层,订阅 Model 推送的 State ,渲染成 HTML 代码,只要 State 有变化,View 就会自动更新。 13 | 14 | ## Action 15 | 16 | ```javascript 17 | { 18 | type: 'submit', 19 | payload: {} 20 | } 21 | ``` 22 | ## dispatch 方法 23 | 24 | dispatch 是一个函数方法,用来将 Action 发送给 State。 25 | 26 | ```javascript 27 | dispatch({ 28 | type: 'submit', 29 | payload: {} 30 | }) 31 | ``` 32 | 33 | ## model 对象 34 | 35 | ```javascript 36 | { 37 | name: 'count', 38 | state: 0, 39 | reducers: { 40 | add(state) { return state + 1 }, 41 | }, 42 | pipes: { 43 | addAfterOneSecond(action$, { call, dispatch, put }) { 44 | return action$.pipe( 45 | call(async () => { 46 | await delay(1000); 47 | return { a: 1 }; 48 | }), 49 | map((data) => { 50 | // or put 51 | dispatch({ 52 | type: 'user/getInfo', 53 | }); 54 | return { type: 'add', data }; 55 | }), 56 | ); 57 | }, 58 | }, 59 | } 60 | ``` 61 | 62 | 1. name: 当前 Model 的名称。整个应用的 State,由多个小的 Model 的 State 以 name 为 key 合成 63 | 2. state: 该 Model 当前的状态。数据保存在这里,直接决定了视图层的输出 64 | 3. reducers: Action 处理器,处理同步动作,用来算出最新的 State 65 | 4. pipes:Action 处理器,处理异步动作 66 | 67 | ### Reducer 68 | 69 | Reducer 是 Action 处理器,用来处理同步操作,可以看做是 state 的计算器。它的作用是根据 Action,从上一个 State 算出当前 State。 70 | 71 | ### Pipe 72 | Action 处理器,处理副作用,根据函数式编程,计算以外的操作都属于 Effect,典型的就是 I/O 操作、数据库读写。 73 | -------------------------------------------------------------------------------- /packages/core/src/plugins/context/index.js: -------------------------------------------------------------------------------- 1 | export default function context() { 2 | return function init({ 3 | onModelBeforeCreate$, 4 | onStatePatch$, 5 | onPipeStart$, 6 | onPipeEnd$, 7 | onPipeError$, 8 | onPipeCancel$, 9 | }) { 10 | this.context = {}; 11 | 12 | onModelBeforeCreate$.subscribe(({ model }) => { 13 | const context = { 14 | source: '', 15 | }; 16 | 17 | if ( model.pipes ) { 18 | context.pipe = {}; 19 | Object.keys(model.pipes).forEach(pipe => { 20 | context.pipe[pipe] = 'pending'; 21 | }); 22 | } 23 | 24 | this.context[model.name] = context; 25 | }); 26 | 27 | onStatePatch$.subscribe(({ model, reducerAction }) => { 28 | const context = this.context[model]; 29 | if (context) { 30 | context.source = reducerAction.__source__.reducer || reducerAction.__source__.pipe; 31 | } 32 | }); 33 | 34 | onPipeStart$.subscribe(({ model, pipe }) => { 35 | const context = this.context[model]; 36 | context.source = ''; 37 | context.pipe[pipe] = 'start'; 38 | }); 39 | 40 | onPipeEnd$.subscribe(({ model, pipe }) => { 41 | const context = this.context[model]; 42 | context.pipe[pipe] = 'success'; 43 | }); 44 | 45 | onPipeError$.subscribe(({ model, pipe }) => { 46 | const context = this.context[model]; 47 | context.source = pipe; 48 | context.pipe[pipe] = 'error'; 49 | }); 50 | 51 | onPipeCancel$.subscribe(({ model, pipe }) => { 52 | const context = this.context[model]; 53 | context.source = pipe; 54 | context.pipe[pipe] = 'cancel'; 55 | }); 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /packages/devtools/src/index.js: -------------------------------------------------------------------------------- 1 | import { combineLatest } from 'rxjs'; 2 | import { map, withLatestFrom } from 'rxjs/operators'; 3 | 4 | export default function rxloopDevtools(config = {}) { 5 | return function init({ 6 | onStatePatch$: action$, 7 | onStart$, 8 | }) { 9 | if (!window.__REDUX_DEVTOOLS_EXTENSION__) { 10 | console.warn( 11 | 'You need to install Redux DevTools Extension,when using rxloop devtool plugin.\r\n' + 12 | 'To see more infomation about DevTools: https://github.com/zalmoxisus/redux-devtools-extension/' 13 | ); 14 | return; 15 | } 16 | onStart$.subscribe(() => { 17 | const devTools = window.__REDUX_DEVTOOLS_EXTENSION__.connect(); 18 | 19 | const streams = []; 20 | const models = []; 21 | Object.keys(this._stream).forEach(name => { 22 | // ignore items in blacklist 23 | if (config.blacklist && config.blacklist.includes(name)) { 24 | return; 25 | } 26 | models.push(name); 27 | streams.push(this[`${name}$`]); 28 | }); 29 | 30 | const store$ = combineLatest( ...streams ).pipe( 31 | map((arr) => { 32 | const store = {}; 33 | models.forEach(( model, index) => { 34 | store[model] = arr[index]; 35 | }); 36 | return store; 37 | }), 38 | ); 39 | 40 | store$.subscribe((store) => devTools.init(store)).unsubscribe(); 41 | 42 | const output$ = store$.pipe( 43 | withLatestFrom(action$), 44 | map( 45 | ([store, { reducerAction: action }]) => devTools.send(action, store) 46 | ), 47 | ); 48 | output$.subscribe(); 49 | }); 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /examples/counter-basic/src/index.js: -------------------------------------------------------------------------------- 1 | import rxloop from '@rxloop/core'; 2 | import devTools from '@rxloop/devtools'; 3 | 4 | const counter = { 5 | name: 'counter', 6 | state: 0, 7 | reducers: { 8 | increment(state) { 9 | return state + 1; 10 | }, 11 | decrement(state) { 12 | return state - 1; 13 | } 14 | }, 15 | }; 16 | 17 | const app = rxloop({ 18 | plugins: [ devTools() ] 19 | }); 20 | 21 | app.model(counter); 22 | app.model({ 23 | name: 'user1', 24 | state: { 25 | name: 'wxx', 26 | } 27 | }); 28 | app.model({ 29 | name: 'user2', 30 | state: { 31 | name: 'wxx', 32 | } 33 | }); 34 | app.model({ 35 | name: 'user3', 36 | state: { 37 | name: 'wxx', 38 | } 39 | }); 40 | 41 | app.start(); 42 | 43 | var valueEl = document.getElementById('value'); 44 | app.stream('counter').subscribe((state) => { 45 | valueEl.innerHTML = state; 46 | }); 47 | 48 | document.getElementById('increment') 49 | .addEventListener('click', function () { 50 | app.dispatch({ type: 'counter/increment' }) 51 | }) 52 | 53 | document.getElementById('decrement') 54 | .addEventListener('click', function () { 55 | app.dispatch({ type: 'counter/decrement' }) 56 | }) 57 | 58 | document.getElementById('incrementIfOdd') 59 | .addEventListener('click', function () { 60 | if (app.getState('counter') % 2 !== 0) { 61 | app.dispatch({ type: 'counter/increment' }) 62 | } 63 | }) 64 | 65 | document.getElementById('incrementAsync') 66 | .addEventListener('click', function () { 67 | setTimeout(function () { 68 | app.dispatch({ type: 'counter/increment' }) 69 | }, 1000) 70 | }) -------------------------------------------------------------------------------- /packages/core/src/check-model.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { isPlainObject, isAllFunction } from './utils'; 3 | 4 | export default function checkModel(model, rootState) { 5 | const { 6 | name, 7 | reducers, 8 | pipes, 9 | subscriptions, 10 | } = model; 11 | 12 | // name should be defined 13 | invariant( 14 | name, 15 | `[app.model] name should be defined`, 16 | ); 17 | 18 | // name should be string 19 | invariant( 20 | typeof name === 'string', 21 | `[app.model] name should be string, but got ${typeof name}`, 22 | ); 23 | 24 | // name should be unique 25 | invariant( 26 | rootState[name] === (void 0), 27 | `[app.model] name should be unique`, 28 | ); 29 | 30 | // reducers should be plain object 31 | if (reducers) { 32 | invariant( 33 | isPlainObject(reducers), 34 | `[app.model] reducers should be plain object, but got ${typeof reducers}`, 35 | ); 36 | invariant( 37 | isAllFunction(reducers), 38 | `[app.model] all reducer should be function`, 39 | ); 40 | } 41 | 42 | // pipes should be plain object 43 | if (pipes) { 44 | invariant( 45 | isPlainObject(pipes), 46 | `[app.model] pipes should be plain object, but got ${typeof pipes}`, 47 | ); 48 | invariant( 49 | isAllFunction(pipes), 50 | `[app.model] all pipe should be function`, 51 | ); 52 | } 53 | 54 | if (subscriptions) { 55 | invariant( 56 | isPlainObject(subscriptions), 57 | `[app.model] subscriptions should be plain object, but got ${typeof subscriptions}`, 58 | ); 59 | invariant( 60 | isAllFunction(subscriptions), 61 | `[app.model] all subscriptions should be function`, 62 | ); 63 | } 64 | } -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ## rxLoop 4 | 创建一个应用 5 | 6 | ```javascript 7 | import rxLoop from '@rxloop/core'; 8 | const app = rxLoop(); 9 | ``` 10 | 11 | ## app.model(model) 12 | 新建一个名字为 counter,初始状态为 0,有两个 reducer 的 model。 13 | 14 | ```javascript 15 | app.model({ 16 | name: 'counter', 17 | state: 0, 18 | reducers: { 19 | increment(state) { 20 | return state + 1; 21 | }, 22 | decrement(state) { 23 | return state - 1; 24 | } 25 | }, 26 | pipes: { 27 | getData(action$, cancel$) { 28 | return action$.pipe( 29 | mapTo({ 30 | type: 'increment', 31 | }), 32 | ); 33 | } 34 | } 35 | }); 36 | ``` 37 | ### reducer 38 | 这里 reducer 跟 Redux 中的是完全一致的,纯函数,用于叠加修改当前的 state,签名为: 39 | 40 | ```javascript 41 | (state, action) => state; 42 | ``` 43 | 44 | ### pipe 45 | pipe 的概念来源于知名 Redux 中间件 redux-observable,在 pipe 中组合、发起和取消异步请求,签名为: 46 | 47 | ```javascript 48 | (action$, cancel$) => action$; 49 | ``` 50 | 51 | ## app.getState(modelName) 52 | ```javascript 53 | app.getState('counter'); 54 | ``` 55 | 56 | ## subscribe 57 | 订阅数据源 58 | ```javascript 59 | app.stream('counter').subscribe((state) => {}); 60 | ``` 61 | 或者 62 | ```javascript 63 | app.counter$.subscribe((state) => {}); 64 | ``` 65 | 66 | ## app.dispatch(action) 67 | 68 | ### 同步更新 69 | 可以 dispatch 一个 action 到 reducers 中,同步地修改状态值。 70 | 71 | ```javascript 72 | app.dispatch({ 73 | type: 'counter/increment', 74 | }); 75 | ``` 76 | 77 | ### 异步更新 78 | 还可以 dispatch 一个 action 到 pipes 中,异步地修改状态值。 79 | 80 | ```javascript 81 | app.dispatch({ 82 | type: 'counter/getData', 83 | }); 84 | ``` 85 | 86 | ### 取消异步更新 87 | 另外,还可以取消未完成的异步请求: 88 | 89 | ```javascript 90 | app.dispatch({ 91 | type: 'counter/getData/cancel', 92 | }); 93 | ``` 94 | 95 | -------------------------------------------------------------------------------- /examples/loading-plugin/src/index.js: -------------------------------------------------------------------------------- 1 | import rxloop from '@rxloop/core'; 2 | import loading from '@rxloop/loading'; 3 | 4 | const apiSlow = async () => { 5 | const data = await new Promise((resolve) => { 6 | setTimeout(() => resolve({}), 1000); 7 | }); 8 | return { code: 200, data }; 9 | }; 10 | 11 | const counter = { 12 | name: 'counter', 13 | state: { 14 | counter: 0, 15 | }, 16 | reducers: { 17 | increment(state) { 18 | return { 19 | ...state, 20 | counter: state.counter + 1 21 | }; 22 | }, 23 | decrement(state) { 24 | return { 25 | ...state, 26 | counter: state.counter + 1 27 | }; 28 | }, 29 | }, 30 | pipes: { 31 | setData(action$, { map }){ 32 | return action$.pipe( 33 | map(() => { 34 | return { 35 | type: 'increment', 36 | }; 37 | }), 38 | ); 39 | }, 40 | getData(action$, { call, map }) { 41 | return action$.pipe( 42 | call(async () => { 43 | return await apiSlow(); 44 | }), 45 | map((data) => { 46 | return { 47 | data, 48 | type: 'increment', 49 | }; 50 | }), 51 | ); 52 | } 53 | } 54 | }; 55 | 56 | const app = rxloop({ 57 | plugins: [ loading() ], 58 | }); 59 | app.model(counter); 60 | app.start(); 61 | 62 | app.stream('counter').subscribe(state => { 63 | document.getElementById('counter').innerHTML = state.loading.getData ? 'loading' : state.counter; 64 | }); 65 | 66 | // loading 状态 67 | app.stream('loading').subscribe(state => { 68 | // 某个 pipe 的 loading 状态 69 | console.log(state.pipes.counter); 70 | }); 71 | 72 | document.getElementById('getdata').onclick = () => { 73 | app.dispatch({ 74 | type: 'counter/getData', 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /examples/subscriptions/src/index.js: -------------------------------------------------------------------------------- 1 | import rxloop from '@rxloop/core'; 2 | import devTools from '@rxloop/devtools'; 3 | 4 | const filter = { 5 | name: 'filter', 6 | state: { 7 | city: 0, 8 | }, 9 | reducers: { 10 | selectCity(state, action) { 11 | return { 12 | city: action.city, 13 | }; 14 | }, 15 | }, 16 | }; 17 | 18 | const chart = { 19 | name: 'chart', 20 | state: { 21 | list: [], 22 | }, 23 | reducers: { 24 | setList(state, action) { 25 | return { 26 | list: action.list, 27 | }; 28 | }, 29 | }, 30 | subscriptions: { 31 | model(source, { dispatch }) { 32 | source('filter/selectCity').subscribe((action) => { 33 | dispatch({ 34 | type: 'chart/setList', 35 | list: [1,2,3], 36 | }); 37 | }); 38 | }, 39 | }, 40 | }; 41 | 42 | const table = { 43 | name: 'table', 44 | state: { 45 | list: [], 46 | }, 47 | reducers: { 48 | setList(state, action) { 49 | return { 50 | list: action.list, 51 | }; 52 | }, 53 | }, 54 | subscriptions: { 55 | model(source, { dispatch }) { 56 | source('filter/selectCity').subscribe((action) => { 57 | dispatch({ 58 | type: 'table/setList', 59 | list: [4,5,6], 60 | }); 61 | }); 62 | }, 63 | }, 64 | }; 65 | 66 | const store = rxloop({ 67 | plugins: [ devTools() ] 68 | }); 69 | 70 | [filter, chart, table].forEach(model => store.model(model)); 71 | 72 | store.start(); 73 | 74 | 75 | store.stream('chart').subscribe((data) => { 76 | console.log(data); 77 | }); 78 | store.stream('table').subscribe((data) => { 79 | console.log(data); 80 | }); 81 | 82 | document.getElementById('city').onchange = () => { 83 | store.dispatch({ 84 | type: 'filter/selectCity', 85 | city: 1, 86 | }); 87 | }; 88 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | working_directory: ~/rxloop 5 | docker: 6 | - image: circleci/node:8-browsers 7 | 8 | jobs: 9 | install: 10 | <<: *defaults 11 | steps: 12 | - checkout 13 | - restore_cache: 14 | keys: 15 | - v1-rxloop-{{ .Branch }}-{{ checksum "yarn.lock" }} 16 | - v1-rxloop-{{ .Branch }} 17 | - v1-rxloop 18 | - run: 19 | name: Installing Dependencies 20 | command: yarn 21 | - save_cache: 22 | paths: 23 | - ./node_modules 24 | key: v1-rxloop-{{ .Branch }}-{{ checksum "yarn.lock" }} 25 | - persist_to_workspace: 26 | root: ~/ 27 | paths: 28 | - rxloop 29 | 30 | lint-types: 31 | <<: *defaults 32 | steps: 33 | - attach_workspace: 34 | at: ~/ 35 | - run: 36 | name: Linting 37 | command: | 38 | yarn lint 39 | - run: 40 | name: Testing Types 41 | command: | 42 | yarn test:types 43 | 44 | test-unit: 45 | <<: *defaults 46 | steps: 47 | - attach_workspace: 48 | at: ~/ 49 | - run: 50 | name: Running Unit Tests 51 | command: | 52 | yarn test 53 | test-cover: 54 | <<: *defaults 55 | steps: 56 | - attach_workspace: 57 | at: ~/ 58 | - run: yarn test:cov 59 | - run: 60 | name: report coverage stats for non-PRs 61 | command: | 62 | if [[ -z $CI_PULL_REQUEST ]]; then 63 | ./node_modules/.bin/codecov 64 | fi 65 | 66 | workflows: 67 | version: 2 68 | install-and-parallel-test: 69 | jobs: 70 | - install 71 | - test-cover: 72 | requires: 73 | - install 74 | - lint-types: 75 | requires: 76 | - install 77 | - test-unit: 78 | requires: 79 | - install -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*", 5 | "examples/*" 6 | ], 7 | "scripts": { 8 | "build": "node ./scripts/build.js core loading immer devtools", 9 | "lint": "echo 'lint'", 10 | "pretest": "yarn run build", 11 | "test": "lerna run test", 12 | "test:cov": "npm test -- --coverage", 13 | "test:types": "echo 'test types'", 14 | "format": "prettier --write \"{src,test}/**/*.js\"" 15 | }, 16 | "keywords": [ 17 | "rxjs", 18 | "model", 19 | "react", 20 | "vue" 21 | ], 22 | "author": "wxnet (https://github.com/wxnet2013)", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/TalkingData/rxloop/issues" 26 | }, 27 | "homepage": "https://talkingdata.github.io/rxloop", 28 | "devDependencies": { 29 | "@babel/cli": "^7.0.0", 30 | "@babel/core": "^7.0.0", 31 | "@babel/node": "^7.0.0", 32 | "@babel/plugin-external-helpers": "^7.0.0", 33 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0", 34 | "@babel/plugin-transform-runtime": "^7.6.2", 35 | "@babel/preset-env": "^7.0.0", 36 | "@babel/preset-flow": "^7.0.0", 37 | "@babel/register": "^7.0.0", 38 | "@babel/runtime": "^7.6.3", 39 | "babel-eslint": "^9.0.0", 40 | "babel-jest": "^24.9.0", 41 | "chalk": "^2.4.2", 42 | "codecov": "^3.1.0", 43 | "conventional-changelog-cli": "^2.0.5", 44 | "cross-env": "^5.2.0", 45 | "execa": "^3.2.0", 46 | "fs-extra": "^8.1.0", 47 | "jest": "^24.9.0", 48 | "lerna": "^2.11.0", 49 | "minimist": "^1.2.0", 50 | "path": "^0.12.7", 51 | "prettier": "^1.14.0", 52 | "rimraf": "^3.0.0", 53 | "rollup": "^1.24.0", 54 | "rollup-plugin-babel": "^4.3.3", 55 | "rollup-plugin-commonjs": "^8.4.1", 56 | "rollup-plugin-node-resolve": "3.4.0", 57 | "rollup-plugin-replace": "^2.2.0", 58 | "rollup-plugin-terser": "^5.1.1", 59 | "rxjs": "^6.5.3", 60 | "typescript": "^3.2.4", 61 | "typings-tester": "^0.3.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /examples/single-state/src/index.js: -------------------------------------------------------------------------------- 1 | import rxloop from '@rxloop/core'; 2 | import devTools from '@rxloop/devtools'; 3 | 4 | const getCounterApi = () => { 5 | return new Promise((r) => { 6 | setTimeout(() => r(100), 500); 7 | }); 8 | }; 9 | 10 | const app = rxloop({ 11 | plugins: [ devTools() ] 12 | }); 13 | 14 | app.model({ 15 | name: 'counter', 16 | state: { 17 | counter: 0, 18 | }, 19 | reducers: { 20 | inc(state) { 21 | return { 22 | ...state, 23 | counter: state.counter + 1, 24 | }; 25 | }, 26 | init(state, action) { 27 | return { 28 | ...state, 29 | counter: action.counter, 30 | }; 31 | }, 32 | }, 33 | pipes: { 34 | getCounter(action$, { call, map, dispatch }) { 35 | return action$.pipe( 36 | call(async () => { 37 | return await getCounterApi(); 38 | }), 39 | map((data) => { 40 | dispatch({ 41 | type: 'user/info', 42 | user: { email: 'test@test.com' }, 43 | }); 44 | return { 45 | type: 'init', 46 | counter: data, 47 | } 48 | }), 49 | ); 50 | } 51 | }, 52 | }); 53 | 54 | app.model({ 55 | name: 'user', 56 | state: { 57 | name: 'wxnet', 58 | email: 'www@ddd.com', 59 | }, 60 | reducers: { 61 | info(state, action) { 62 | return { 63 | ...state, 64 | ...action.user, 65 | }; 66 | }, 67 | }, 68 | }); 69 | 70 | app.start(); 71 | 72 | // const user$ = app.stream('user'); 73 | // const counter$ = app.stream('counter'); 74 | 75 | // combineLatest( user$, counter$ ).pipe( 76 | // map(([user, counter]) => { 77 | // return { 78 | // user, 79 | // counter, 80 | // }; 81 | // }), 82 | // ).subscribe(state => { 83 | // console.log(state); 84 | // }); 85 | 86 | app.stream().subscribe(state => { 87 | console.log(state); 88 | }); 89 | 90 | app.dispatch({ 91 | type: 'counter/inc', 92 | }); 93 | app.dispatch({ 94 | type: 'counter/getCounter', 95 | }); 96 | 97 | -------------------------------------------------------------------------------- /packages/core/test/call.spec.js: -------------------------------------------------------------------------------- 1 | import rxloop, { call } from '../'; 2 | import { Subject } from 'rxjs'; 3 | 4 | const delay = (ms) => new Promise((r) => setTimeout(() => r(), ms)); 5 | 6 | describe('call pipe', () => { 7 | test('call pipe', (done) => { 8 | const test$$ = new Subject(); 9 | test$$.pipe( 10 | call(async (action) => { 11 | await delay(500); 12 | return { data: 1, type: action.type }; 13 | }), 14 | ).subscribe(v => { 15 | expect(v).toEqual({ data: 1, type: 'test/getData' }); 16 | done(); 17 | }); 18 | test$$.next({ type: 'test/getData' }); 19 | }); 20 | 21 | test('Process error', (done) => { 22 | const listenerA = jest.fn(); 23 | const store = rxloop(); 24 | store.model({ 25 | name: 'test', 26 | state: 0, 27 | reducers: { 28 | add(state) { return state }, 29 | }, 30 | pipes: { 31 | errorTest(action$, { call }) { 32 | return action$.pipe( 33 | call(async () => { 34 | throw 'error!'; 35 | }), 36 | ); 37 | }, 38 | }, 39 | }); 40 | 41 | store.stream('test').subscribe(listenerA); 42 | 43 | store.dispatch({ type: 'test/errorTest' }); 44 | 45 | setTimeout(() => { 46 | expect(listenerA.mock.calls.length).toBe(2); 47 | expect(listenerA.mock.calls[1][0]).toEqual({ 48 | type: 'test/errorTest/error', 49 | error: 'error!', 50 | model: 'test', 51 | pipe: 'errorTest', 52 | }); 53 | done(); 54 | }, 500); 55 | }); 56 | 57 | test('cancel process', (done) => { 58 | const listenerA = jest.fn() 59 | 60 | const test$$ = new Subject(); 61 | test$$.pipe( 62 | call(async () => { 63 | await delay(500); 64 | }), 65 | ).subscribe(listenerA); 66 | const action = { type: 'test/getData' }; 67 | action.__cancel__ = new Subject(); 68 | action.__bus__ = new Subject(); 69 | test$$.next(action); 70 | action.__cancel__.next(1); 71 | setTimeout(() => { 72 | expect(listenerA.mock.calls.length).toBe(0); 73 | done(); 74 | }, 1000); 75 | }); 76 | 77 | }); -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | /* 2 | ``` 3 | yarn build core loading immer devtools 4 | ``` 5 | */ 6 | 7 | const fs = require('fs-extra') 8 | const path = require('path') 9 | const chalk = require('chalk') 10 | const execa = require('execa') 11 | const args = require('minimist')(process.argv.slice(2)) 12 | 13 | const targets = args._ 14 | // const formats = args.formats || args.f 15 | const devOnly = args.devOnly || args.d 16 | // const prodOnly = !devOnly && (args.prodOnly || args.p) 17 | // const buildAllMatching = args.all || args.a 18 | // const lean = args.lean || args.l 19 | // const commit = execa.sync('git', ['rev-parse', 'HEAD']).stdout.slice(0, 7) 20 | 21 | run() 22 | async function run() { 23 | await buildAll(targets); 24 | // if (!targets.length) { 25 | 26 | // await buildAll(targets); 27 | // // checkAllSizes(allTargets) 28 | // } 29 | // else { 30 | // await buildAll(fuzzyMatchTarget(targets, buildAllMatching)) 31 | // checkAllSizes(fuzzyMatchTarget(targets, buildAllMatching)) 32 | // } 33 | } 34 | 35 | async function buildAll(targets) { 36 | for (const target of targets) { 37 | await build(target) 38 | } 39 | } 40 | 41 | // async function buildAll() { 42 | // await build('devtools'); 43 | // console.log(targets); 44 | // } 45 | 46 | async function build(target) { 47 | const pkgDir = path.resolve(`packages/${target}`) 48 | const pkg = require(`${pkgDir}/package.json`) 49 | 50 | await fs.remove(`${pkgDir}/lib`) 51 | await fs.remove(`${pkgDir}/es`) 52 | await fs.remove(`${pkgDir}/dist`) 53 | 54 | 55 | const env = 56 | (pkg.buildOptions && pkg.buildOptions.env) || 57 | (devOnly ? 'development' : 'production') 58 | 59 | await execa( 60 | 'rollup', 61 | [ 62 | '-c', 63 | '--environment', 64 | [ 65 | // `COMMIT:${commit}`, 66 | `NODE_ENV:${env}`, 67 | `TARGET:${target}`, 68 | // formats ? `FORMATS:${formats}` : ``, 69 | // args.types ? `TYPES:true` : ``, 70 | // prodOnly ? `PROD_ONLY:true` : ``, 71 | // lean ? `LEAN:true` : `` 72 | ] 73 | .filter(Boolean) 74 | .join(',') 75 | ], 76 | { stdio: 'inherit' } 77 | ) 78 | } -------------------------------------------------------------------------------- /packages/core/test/plugins/context.spec.js: -------------------------------------------------------------------------------- 1 | import rxloop from '../../'; 2 | import { mapTo, tap, delay } from "rxjs/operators"; 3 | 4 | describe('Pipe success', () => { 5 | const store = rxloop(); 6 | 7 | store.model({ 8 | name: 'user', 9 | state: { 10 | name: 'wxnet', 11 | }, 12 | reducers: { 13 | info(state){ 14 | return state; 15 | } 16 | }, 17 | pipes: { 18 | a(action$) { 19 | return action$.pipe( 20 | mapTo({ type: 'info' }), 21 | ); 22 | }, 23 | b(action$) { 24 | return action$.pipe( 25 | delay(300), 26 | mapTo({ type: 'info' }), 27 | ); 28 | }, 29 | c(action$) { 30 | return action$.pipe( 31 | tap(() => { 32 | throw 'error'; 33 | }), 34 | mapTo({ type: 'info' }), 35 | ); 36 | }, 37 | }, 38 | }); 39 | 40 | test('Default state', () => { 41 | expect(store.context.user).toEqual({ 42 | source: '', 43 | pipe: { 44 | a: 'pending', 45 | b: 'pending', 46 | c: 'pending', 47 | } 48 | }); 49 | }); 50 | 51 | test('The a pipe status is success', (done) => { 52 | store.dispatch({ 53 | type: 'user/a', 54 | }); 55 | store.stream('user').subscribe(() => { 56 | expect(store.context.user).toEqual({ 57 | source: 'a', 58 | pipe: { 59 | a: 'success', 60 | b: 'pending', 61 | c: 'pending', 62 | } 63 | }); 64 | done(); 65 | }); 66 | }); 67 | 68 | test('The b pipe status is canceled', () => { 69 | store.dispatch({ 70 | type: 'user/b/cancel', 71 | }); 72 | expect(store.context.user).toEqual({ 73 | source: 'b', 74 | pipe: { 75 | a: 'success', 76 | b: 'cancel', 77 | c: 'pending', 78 | } 79 | }); 80 | }); 81 | 82 | test('The c pipe status is error', () => { 83 | store.dispatch({ 84 | type: 'user/c', 85 | }); 86 | expect(store.context.user).toEqual({ 87 | source: 'c', 88 | pipe: { 89 | a: 'success', 90 | b: 'cancel', 91 | c: 'error', 92 | } 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /docs/advanced/cross-model-dispatch-action.md: -------------------------------------------------------------------------------- 1 | # 跨 model 通信 2 | 3 | rxloop 支持两种跨 model 通信方式,在 pipe 中主动发送消息和在 Subscriptions 订阅其它 model 的消息。 4 | 5 | 一、在 subscriptions 中订阅消息 6 | 7 | 在 subscriptions 中用 model 函数设置 model 之间的订阅关系,然后通过函数的第一个参数获取任意 model 发送的 pipe 或者 reducer 数据源,对于 pipe 类型数据源,可设置不同的key 监听异步执行前后的消息。 8 | 9 | ```js 10 | const filter = { 11 | name: 'filter', 12 | state: { 13 | city: 0, 14 | }, 15 | reducers: { 16 | selectCity(state, action) { 17 | return { 18 | city: action.city, 19 | }; 20 | }, 21 | }, 22 | pipes: { 23 | getData(action$) { 24 | return action$.pipe( 25 | // ... 26 | ); 27 | }, 28 | }, 29 | }; 30 | 31 | const chart = { 32 | name: 'chart', 33 | // ... 34 | subscriptions: { 35 | model(source, { dispatch }) { 36 | source('filter/selectCity').subscribe((action) => { 37 | dispatch({ 38 | type: 'chart/setList', 39 | list: [1,2,3], 40 | }); 41 | }); 42 | // pipe 执行前的消息 43 | source('filter/getData').subscribe((action) => { 44 | dispatch({ 45 | type: 'chart/setList', 46 | list: [1,2,3], 47 | }); 48 | }); 49 | // pipe 执行完成后的消息 50 | source('filter/getData/end').subscribe((action) => { 51 | dispatch({ 52 | type: 'chart/setList', 53 | list: [1,2,3], 54 | }); 55 | }); 56 | }, 57 | }, 58 | }; 59 | 60 | const table = { 61 | name: 'table', 62 | // ... 63 | subscriptions: { 64 | model(source, { dispatch }) { 65 | source('filter/selectCity').subscribe((action) => { 66 | dispatch({ 67 | type: 'table/setList', 68 | list: [4,5,6], 69 | }); 70 | }); 71 | }, 72 | }, 73 | }; 74 | ``` 75 | 76 | 二、在 pipe 中向其它 model 发送消息 77 | 78 | 调用 `dispatch` 方法,并指定 action 的 type 为 `model/pipe`。 79 | 80 | ```js 81 | dispatch({ 82 | type: 'a/getData', 83 | data, 84 | }); 85 | ``` 86 | 87 | Model A: 88 | ```js 89 | name: 'a', 90 | pipes: { 91 | getData(action$) { 92 | // ... 93 | }, 94 | }, 95 | ``` 96 | 97 | Model B: 98 | ```js 99 | name: 'b', 100 | pipes: { 101 | getData(action$, { dispatch, map }) { 102 | action$.pipe( 103 | map((data) => { 104 | dispatch({ 105 | type: 'a/getData', 106 | data, 107 | }); 108 | return { 109 | type: 'reducer-name', 110 | data, 111 | }; 112 | }), 113 | ); 114 | }, 115 | }, 116 | ``` 117 | 118 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # 介绍 2 | 3 | rxloop 是基于 [RxJS](https://github.com/ReactiveX/rxjs) 实现的超轻量级状态管理容器,核心代码仅 100 行左右,实现了 [redux](https://github.com/reactjs/redux)、[redux-observable](https://github.com/redux-observable/redux-observable) 组合的基本功能。 4 | 5 | ![rxloop](rxloop.png) 6 | 7 | ## 对比 Redux 8 | Redux 是轻量级的可预测状态管理容器,是 flux 架构的最优秀实现,在 React 项目引入 Redux 可以保持良好的项目维护性,但在开发阶段,不同用途的功能函数分散在各处,要完成一个功能,需要频繁的修改这些小文件,造成了开发效率地低下。 9 | 10 | rxloop 实现了 Redux 中基本的单向同步数据流(View -> Action -> Reducer -> State -> View),又将Redux 中分散的功能函数,收敛到一个 Model 文件中,通过派发 action 经由 reducer 对 State 实现叠加修改,借助单向数据流,保证了项目的可维护性,同时平衡了开发阶段地效率。 11 | 12 | ```javascript 13 | const counterModel = { 14 | name: 'counter', 15 | state: 0, 16 | reducers: { 17 | increment(state) { 18 | return state + 1; 19 | }, 20 | decrement(state) { 21 | return state - 1; 22 | } 23 | }, 24 | }; 25 | ``` 26 | 27 | ## 对比 redux-observable 28 | Redux 不引入任何中间件的情况下,仅能处理同步请求,要处理异步逻辑,需要引入中间件,最流行的有三个:[redux-thunk](https://github.com/reduxjs/redux-thunk)、[redux-saga](https://github.com/redux-saga/redux-saga) 和[redux-observable](https://github.com/redux-observable/redux-observable)。 29 | 30 | 1. redux-thunk 通过 thunk 模式,需要在 Redux 的 ActionCreater 中处理异步逻辑,破坏了 ActionCreater 的职责单一性。 31 | 2. redux-saga 是前端 saga 模式的实现,创造性的使用 es6 的 Generator 函数,将异步逻辑封装在 sagas 之中,保证了异步逻辑的可维护性。 32 | 3. redux-observable 将 RxJS 的能力,导入到 Redux,并借助 RxJS 数据流管道实现异步逻辑,将最终结果合并到 State 之中。 33 | 34 | 实际上 RxJS 本身,也可以作为状态管理容器,项目中引入了 RxJS,就没有必要再使用 Redux,而 redux-observable 是将 RxJS 的数据管道引入到 Redux。 35 | 36 | 相比 redux-observable,rxloop 正好相反,本身借助 RxJS 的能力,轻易实现对异步逻辑的处理,同时将 Redux 的能力导入到 RxJS 之中。 37 | 38 | 完整代码: 39 | 40 | ```javascript 41 | import rxLoop from '@rxloop/core'; 42 | 43 | const counterModel = { 44 | name: 'counter', 45 | state: 0, 46 | reducers: { 47 | increment(state) { 48 | return state + 1; 49 | }, 50 | decrement(state) { 51 | return state - 1; 52 | }, 53 | }, 54 | pipes: { 55 | loadData(action$) { 56 | return action$.pipe( 57 | // switchMap(...), 58 | // map(...), 59 | // filter(...), 60 | mapTo({ 61 | action: 'increment' 62 | }), 63 | ); 64 | } 65 | } 66 | }; 67 | 68 | const app = rxLoop(); 69 | app.model(counterModel); 70 | 71 | app.stream('counter').subscribe((state) => { 72 | // ele.innerHTML = state; 73 | }); 74 | 75 | app.dispatch({ type: 'counter/increment' }); 76 | app.dispatch({ type: 'counter/loadData' }); 77 | ``` 78 | ## 链接 79 | 1. [代码仓库](https://github.com/TalkingData/rxloop) 80 | 2. [counter-basic](https://github.com/TalkingData/rxloop/tree/master/examples/counter-basic) 81 | 3. [todo-react](https://github.com/TalkingData/rxloop/tree/master/examples/todo-react) 82 | -------------------------------------------------------------------------------- /docs/advanced/middleware.md: -------------------------------------------------------------------------------- 1 | # 插件 2 | 在初始化应用时,可以根据需要配置一些插件。 3 | 4 | ```javascript 5 | const store = rxloop({ 6 | plugins: [ 7 | loading(), 8 | immer(), 9 | ], 10 | }); 11 | ``` 12 | ## loading 插件 13 | 14 | 安装 loading 插件后,会自动创建一个 loading 模型,可以在全局状态下监测其它模型的 pipes 状态。 15 | 16 | ```javascript 17 | import rxloop from '@rxloop/core'; 18 | import loading from '@rxloop/loading'; 19 | 20 | const store = rxloop({ 21 | plugins: [ loading() ], 22 | }); 23 | 24 | store.model({ 25 | name: 'modelA', 26 | state: { 27 | a: 1, 28 | }, 29 | reducers: { 30 | add(state) { 31 | return state; 32 | }, 33 | }, 34 | pipes: { 35 | getData(action$) { 36 | return action$.pipe( 37 | mapTo({ 38 | type: 'add', 39 | }), 40 | ); 41 | }, 42 | setData(action$) { 43 | return action$.pipe( 44 | mapTo({ 45 | type: 'add', 46 | }), 47 | ); 48 | }, 49 | }, 50 | }); 51 | 52 | store.stream('loading').subscribe((loading))) => { 53 | console.log(loading.pipes.modelA.getData); 54 | console.log(loading.pipes.modelA.setData); 55 | }); 56 | ``` 57 | 58 | [查看 demo](https://github.com/TalkingData/rxloop/tree/master/examples/loading-plugin) 59 | 60 | ## immer 插件 61 | 插件将 [immer](https://github.com/mweststrate/immer) 的能力集成到应用中,可以以更简洁直观的方式去创建一个不可变的状态对象。 62 | 63 | ```javascript 64 | import rxloop from '@rxloop/core'; 65 | import immer from '@rxloop/immer'; 66 | 67 | const store = rxloop({ 68 | plugins: [ immer() ], 69 | }); 70 | 71 | store.model({ 72 | name: 'commnet', 73 | state: { 74 | list: [], 75 | }, 76 | reducers: { 77 | add(state) { 78 | state.list.push({ id: 1, txt: 'text' }); 79 | }, 80 | }, 81 | }); 82 | ``` 83 | [查看 demo](https://github.com/TalkingData/rxloop/tree/master/examples/immer-plugin) 84 | 85 | ## 插件开发 86 | 87 | 在插件中可订阅 core 的数据流,比如创建 Model、pipe 启动和结束等。 88 | 89 | ```typescript 90 | export interface API { 91 | onModel$: Observable, 92 | onPipeStart$: Observable, 93 | onPipeEnd$: Observable, 94 | onPipeCancel$: Observable, 95 | onPipeError$: Observable, 96 | } 97 | 98 | export type Plugin = (api: API) => void; 99 | ``` 100 | 101 | 插件开发示例: 102 | ```javascript 103 | function logger() { 104 | return function setup({ 105 | onModel$, 106 | onPipeStart$, 107 | onPipeEnd$, 108 | onPipeCancel$, 109 | onPipeError$, 110 | }) { 111 | onModel$.subscribe(json => console.info(json)); 112 | onPipeStart$.subscribe(json => console.info(json)); 113 | onPipeEnd$.subscribe(json => console.info(json)); 114 | onPipeCancel$.subscribe(json => console.warn(json)); 115 | onPipeError$.subscribe(json => console.error(json)); 116 | } 117 | } 118 | 119 | const store = rxloop({ 120 | plugins: [ logger() ], 121 | }); 122 | ``` 123 | -------------------------------------------------------------------------------- /docs/basics/getting-started.md: -------------------------------------------------------------------------------- 1 | # 快速上手 2 | 3 | 本篇会从安装 rxloop 开始,逐步深入到同步、异步、组合和订阅 model 的状态。 4 | 5 | ## 安装 6 | ```bash 7 | $ npm i @rxloop/core rxjs 8 | ``` 9 | 或 10 | ```bash 11 | $ yarn add @rxloop/core rxjs 12 | ``` 13 | 14 | ## 在项目中引入 15 | 除了 rxloop 之前,往往还需要引入 RxJS 的很多操作符。 16 | 17 | ```javascript 18 | import rxloop from '@rxloop/core'; 19 | import { combineLatest } from 'rxjs'; 20 | import { switchMap, map, mapTo } from 'rxjs/operators'; 21 | ``` 22 | 23 | 接着,使用 rxloop 函数创建一个应用: 24 | 25 | ```javascript 26 | const store = rxloop(); 27 | ``` 28 | 29 | ## 创建 Model 30 | rxloop 不强制状态树的唯一性,这一点跟 Redux 不同,推荐的方式是按业务的领域模型划分不同的 model,使用 model 方法创建不同的 model。 31 | 32 | ```javascript 33 | // 创建 todos 的模型 34 | store.model({ 35 | name: 'todos', 36 | state: { 37 | list: [], 38 | }, 39 | }); 40 | 41 | // 创建用户模型 42 | store.model({ 43 | name: 'user', 44 | state: { 45 | name: '', 46 | email: '', 47 | }, 48 | }); 49 | ``` 50 | model 的 name 要求全局唯一,执行两次 model 方法后,会自动创建 两个 RxJS 数据流。 51 | 52 | ## 创建 Reducers 53 | rxloop 中的 reducer 跟 Redux 中是一致的,reducer 是一个原则单一的纯函数。 54 | 55 | 在 flux 架构中,不允许直接修改 Model 的数据,需要在 View 层中,派发 action,通过 reducer 来修改。 56 | 57 | 这里仅以 todos 的模型为例,一个名叫 setList 的 reducer 像 model 中添加一个列表数据。 58 | 59 | ```javascript 60 | store.model({ 61 | name: 'todos', 62 | state: { 63 | list: [], 64 | }, 65 | reducers: { 66 | setList(state, action) { 67 | return { 68 | ...state, 69 | list: [ 70 | ...state.list, 71 | ...action.list, 72 | ] 73 | }; 74 | }, 75 | // other... 76 | }, 77 | }); 78 | ``` 79 | 80 | 除 setList 之外,还可以添加更多的 reducer。 81 | 82 | ## 创建 pipes 83 | rxloop 将业务中所有的副作用,隔离在 pipes 中,一个 pipe 是一个纯函数,函数的第一个参数是一个输入流,在 pipe 内部,可以使用 RxJS 的 pipe 串联、组合不同的异步逻辑。 84 | 85 | 数据流最终 map 的数据,必须符合 action 的规范,关联到不同的 reducer: 86 | 87 | ```javascript 88 | getTodos(action$) { 89 | return action$.pipe( 90 | // other pipe method.. 91 | mapTo({ 92 | action: 'setList', 93 | list: [], 94 | }), 95 | ); 96 | } 97 | ``` 98 | 99 | 完整大代码如下: 100 | 101 | ```javascirpt 102 | store.model({ 103 | name: 'todos', 104 | state: { 105 | list: [], 106 | }, 107 | reducers: { 108 | setList(state, action) { 109 | return { 110 | ...state, 111 | list: [ 112 | ...state.list, 113 | ...action.list, 114 | ] 115 | }; 116 | }, 117 | // other... 118 | }, 119 | pipes: { 120 | getTodos(action$) { 121 | return action$.pipe( 122 | // other pipe method.. 123 | mapTo({ 124 | action: 'setList', 125 | list: [], 126 | }), 127 | ); 128 | }, 129 | // other... 130 | }, 131 | }); 132 | ``` 133 | 134 | ## 订阅状态变更 135 | 136 | 执行 `app.model` 方法会创建一个以model name 为名称的 RxJS 数据流,在业务代码里,可以订阅这些数据流,然后更新 View 的状态。 137 | 138 | ```javascript 139 | store.stream('todos').subscribe((state) => { 140 | // .... 141 | // this.setState(state); 142 | }); 143 | ``` 144 | 145 | 当然也可以通过 RxJS 的操作符,对这些数据流做进一步的操作,上面的代码创建了 `store.stream('todo')` 和 `store.stream('user')` 两个数据流。 146 | 147 | ```javascript 148 | const state$ = combineLatest(store.stream('todo'), store.stream('user')); 149 | state$.subscribe((state) => { 150 | // this.setState(state); 151 | }); 152 | ``` 153 | 154 | ## 派发事件 155 | rxloop 通过 dispatch 方法派发 action,来修改 model 的数据,这一点跟 Redux 是一致的,不同的是 rxloop 支持创建多个状态树,在 model 外部派发事件时,需要指定具体的 model name: 156 | 157 | ```javascript 158 | // 出发 todos model 中的 pipes 159 | store.dispatch({ 160 | type: 'todos/getTodos', 161 | params: {}, 162 | }); 163 | ``` 164 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.0.0-alpha.4](https://github.com/TalkingData/rxloop/compare/v1.0.0-alpha.3...v1.0.0-alpha.4) (2018-11-15) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * fix version ([248936b](https://github.com/TalkingData/rxloop/commit/248936b)) 7 | * rollup ([c93afd9](https://github.com/TalkingData/rxloop/commit/c93afd9)) 8 | 9 | 10 | 11 | 12 | # [1.0.0-alpha.2](https://github.com/TalkingData/rxloop/compare/v1.0.0-alpha.1...v1.0.0-alpha.2) (2018-11-02) 13 | 14 | 15 | ### Features 16 | 17 | * Record the source of the state change ([a81d842](https://github.com/TalkingData/rxloop/commit/a81d842)) 18 | 19 | 20 | 21 | 22 | # [1.0.0-alpha.1](https://github.com/TalkingData/rxloop/compare/v0.12.2...v1.0.0-alpha.1) (2018-11-01) 23 | 24 | 25 | 26 | 27 | ## [0.12.2](https://github.com/TalkingData/rxloop/compare/v0.12.1...v0.12.2) (2018-10-09) 28 | 29 | ### Bug Fixes 30 | 31 | * Improve ts type definition ([80db3f9](https://github.com/TalkingData/rxloop/commit/80db3f9)) 32 | * Should return unsubscribe method ([f923931](https://github.com/TalkingData/rxloop/commit/f923931)) 33 | 34 | ### Features 35 | 36 | * **plugin:** all plugin should be function ([b20ce76](https://github.com/TalkingData/rxloop/commit/b20ce76)) 37 | * add subscribe method ([0f63cd7](https://github.com/TalkingData/rxloop/commit/0f63cd7)) 38 | 39 | 40 | 41 | ## [0.11.1](https://github.com/TalkingData/rxloop/compare/v0.11.0...v0.11.1) (2018-09-23) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * Add model name to action ([28e3ccc](https://github.com/TalkingData/rxloop/commit/28e3ccc)) 47 | 48 | 49 | ### Features 50 | 51 | * Single store support ([74d6b07](https://github.com/TalkingData/rxloop/commit/74d6b07)) 52 | * **plugin:** add onModelBeforeCreate hooks ([572ebb8](https://github.com/TalkingData/rxloop/commit/572ebb8)) 53 | 54 | 55 | 56 | 57 | ## [0.10.1](https://github.com/TalkingData/rxloop/compare/v0.10.0...v0.10.1) (2018-09-21) 58 | 59 | ### Bug Fixes 60 | 61 | * fix test error ([8075df8](https://github.com/TalkingData/rxloop/commit/8075df8)) 62 | * rename hooks ([d6b63fc](https://github.com/TalkingData/rxloop/commit/d6b63fc)) 63 | 64 | 65 | ### Features 66 | * add onStatePatch hooks ([8a2e859](https://github.com/TalkingData/rxloop/commit/8a2e859)) 67 | * add devtool support ([26f417d](https://github.com/TalkingData/rxloop/commit/26f417d)) 68 | * **plugin:** add start hooks ([63d1d37](https://github.com/TalkingData/rxloop/commit/63d1d37)) 69 | 70 | 71 | 72 | 73 | ## [0.9.2](https://github.com/TalkingData/rxloop/compare/v0.9.1...v0.9.2) (2018-09-08) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * Pass model name to createReducer ([5995e7b](https://github.com/TalkingData/rxloop/commit/5995e7b)) 79 | * Improve pipe type definition ([96a8cdd](https://github.com/TalkingData/rxloop/commit/96a8cdd)) 80 | * **plugin:** add onPipeError hook ([4c877f2](https://github.com/TalkingData/rxloop/commit/4c877f2)) 81 | 82 | ### Features 83 | 84 | * Check reducers and pipes ([90b43af](https://github.com/TalkingData/rxloop/commit/90b43af)) 85 | * Add global error hook ([1e01903](https://github.com/TalkingData/rxloop/commit/1e01903)) 86 | 87 | 88 | 89 | 90 | ## [0.8.3](https://github.com/TalkingData/rxloop/compare/v0.8.2...v0.8.3) (2018-08-27) 91 | 92 | ### Bug Fixes 93 | 94 | * Fix ts type error ([64141ec](https://github.com/TalkingData/rxloop/commit/64141ec)) 95 | * Support ts ([8638f9d](https://github.com/TalkingData/rxloop/commit/8638f9d)) 96 | 97 | ### Features 98 | * Support plugins ([09ad09b](https://github.com/TalkingData/rxloop/commit/09ad09b)) 99 | -------------------------------------------------------------------------------- /docs/basics/error-handler.md: -------------------------------------------------------------------------------- 1 | # 错误处理 2 | 3 | rxloop 支持应用和模型两个级别的错误处理,所有模型的异常会汇总到应用的 onError 钩子函数之中,在订阅模型数据时,可以单独检测该模型的异常情况,具体用法可以参考下面代码的注释: 4 | 5 | ```javascript 6 | import rxloop from '@rxloop/core'; 7 | 8 | // 模拟异常接口,后面会在 counter 模型中调用这个接口。 9 | // 调用时接口直接抛出异常信息。 10 | const apiCrashed = async () => { 11 | throw new Error('Http Error'); 12 | }; 13 | 14 | // 在创建应用时, 15 | // 可以注册全局的 onError 事件,能够统一监听到应用中所有模型的异常信息,在这里可以将应用异常上报监控服务。 16 | const store = rxloop({ 17 | onError(err) { 18 | console.log('Global error handler...'); 19 | console.log(err); 20 | // 上报异常信息到监控服务 21 | }, 22 | }); 23 | 24 | // 定义简单的 counter 模型 25 | store.model({ 26 | name: 'counter', 27 | state: { 28 | counter: 0, 29 | }, 30 | reducers: { 31 | increment(state) { 32 | return { 33 | ...state, 34 | counter: state.counter + 1, 35 | }; 36 | }, 37 | decrement(state) { 38 | return { 39 | ...state, 40 | counter: state.counter - 1, 41 | }; 42 | }, 43 | }, 44 | pipes: { 45 | getData(action$, { call, map }) { 46 | return action$.pipe( 47 | call(async () => { 48 | // 这里调用接口时,api 服务崩溃了 49 | return await apiCrashed(); 50 | }), 51 | map((data) => { 52 | return { 53 | data, 54 | type: 'increment', 55 | }; 56 | }), 57 | ); 58 | } 59 | }, 60 | }); 61 | 62 | // 订阅 counter 模型的数据流 63 | store.stream('counter').subscribe( 64 | (state) => { 65 | // 除了全局的异常处理,还可以单独处理 counter 模型的异常。 66 | if (state.error) { 67 | console.log('Model error handler...'); 68 | console.log(err); 69 | return; 70 | } 71 | // setState(state); 72 | }, 73 | ); 74 | 75 | store.dispatch({ 76 | type: 'counter/getData', 77 | }); 78 | ``` 79 | 80 | 运行代码,会在控制台中看到全局和模型两个级别的异常信息: 81 | ``` 82 | Global error handler... 83 | index.js:52 Objectpipe: "getData"error: Error: Http Error 84 | at apiCrashed (webpack:///./src/index.js?:10:9) 85 | at MergeMapSubscriber.action$.pipe.Object [as project] (webpack:///./src/index.js?:36:69) 86 | at MergeMapSubscriber._tryNext (webpack:///./node_modules/rxjs/_esm5/internal/operators/mergeMap.js?:71:27) 87 | at MergeMapSubscriber._next (webpack:///./node_modules/rxjs/_esm5/internal/operators/mergeMap.js?:61:18) 88 | at MergeMapSubscriber.Subscriber.next (webpack:///./node_modules/rxjs/_esm5/internal/Subscriber.js?:64:18) 89 | at FilterSubscriber._next (webpack:///./node_modules/rxjs/_esm5/internal/operators/filter.js?:42:30) 90 | at FilterSubscriber.Subscriber.next (webpack:///./node_modules/rxjs/_esm5/internal/Subscriber.js?:64:18) 91 | at Subject.next (webpack:///./node_modules/rxjs/_esm5/internal/Subject.js?:58:25) 92 | at Object.dispatch (webpack:///./node_modules/@rxloop/core/es/rxloop.js?:202:10) 93 | at eval (webpack:///./src/index.js?:94:5)model: "counter"__proto__: Object 94 | 95 | index.js:76 Model error handler... 96 | index.js:77 Error: Http Error 97 | at apiCrashed (index.js:10) 98 | at MergeMapSubscriber.action$.pipe.Object [as project] (index.js:36) 99 | at MergeMapSubscriber._tryNext (mergeMap.js:71) 100 | at MergeMapSubscriber._next (mergeMap.js:61) 101 | at MergeMapSubscriber.Subscriber.next (Subscriber.js:64) 102 | at FilterSubscriber._next (filter.js:42) 103 | at FilterSubscriber.Subscriber.next (Subscriber.js:64) 104 | at Subject.next (Subject.js:58) 105 | at Object.dispatch (rxloop.js:202) 106 | at eval (index.js:94) 107 | ``` 108 | 109 | 大家还可以到 examples 目录中,查看异常处理 demo: 110 | 111 | [https://github.com/TalkingData/rxloop/tree/master/examples/error-handler](https://github.com/TalkingData/rxloop/tree/master/examples/error-handler) 112 | -------------------------------------------------------------------------------- /README-en_US.md: -------------------------------------------------------------------------------- 1 | # rxloop 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![npm download][download-image]][download-url] 5 | 6 | [npm-image]: https://img.shields.io/npm/v/@rxloop/core.svg?style=flat-square 7 | [npm-url]: https://npmjs.org/package/@rxloop/core 8 | [download-image]: https://img.shields.io/npm/dm/@rxloop/core.svg?style=flat-square 9 | [download-url]: https://npmjs.org/package/@rxloop/core 10 | 11 | [中文 README](README-zh_CN.md) 12 | > rxloop = Redux + redux-observable. 13 | 14 | RxJS-based predictable state management container, ultra-lightweight "Redux + redux-observable" architecture. 15 | 16 | ## Features 17 | * Facilitate the abstract front-end domain model, free choice of multi-state or single state tree; 18 | * Easy to learn and use: Only four apis, friendly to Redux and RxJS; 19 | * Isolation side effects: using the asynchronous processing capabilities of RxJS, free combination, cancel AJAX and other asynchronous calls in the Pipes; 20 | * Extensions RxJS: rxloop can be cascaded into RxJS data pipelines, eventually distributing multiple data pipes. 21 | 22 | ## Installation 23 | Via npm: 24 | ```bash 25 | $ npm install @rxloop/core 26 | ``` 27 | 28 | Or yarn 29 | ```bash 30 | $ yarn add @rxloop/core 31 | ``` 32 | 33 | Or introduced through CDN 34 | ```html 35 | 36 | 37 | 44 | ``` 45 | 46 | ## Hello rxloop 47 | ```javascript 48 | import rxloop from '@rxloop/core'; 49 | 50 | // Create a globally unique app in one application 51 | const app = rxloop(); 52 | 53 | // In the application, 54 | // you can create multiple business models, 55 | // such as the following user and counter models 56 | app.model({ 57 | name: 'user', 58 | state: { name: 'wxnet' } 59 | }); 60 | app.model({ 61 | name: 'counter', 62 | state: { 63 | counter: 0, 64 | }, 65 | reducers: { 66 | inc(state) { 67 | return { 68 | ...state, 69 | counter: state.counter + 1 70 | }; 71 | }, 72 | dec(state) { 73 | return { 74 | ...state, 75 | counter: state.counter - 1 76 | }; 77 | }, 78 | }, 79 | }); 80 | 81 | // Subscribe to the status of the counter model at the View level, 82 | // When the model state changes, 83 | // use View layer framework-related methods to synchronize View updates, 84 | // such as React's setState method 85 | app.stream('counter').subscribe((state) => { 86 | // this.setState(state); 87 | }); 88 | 89 | // In the view layer, 90 | // you can dispatch an action via the dispatch method 91 | // Action updates the model state via pipes or reducers 92 | app.dispatch({ 93 | type: 'counter/inc', 94 | }); 95 | app.dispatch({ 96 | type: 'counter/inc', 97 | }); 98 | app.dispatch({ 99 | type: 'counter/dec', 100 | }); 101 | ``` 102 | 103 | For more features such as asynchronous requests, cancellation requests, etc., 104 | you can read through the documentation 👇. 105 | 106 | ## Documentation 107 | 108 | 1. [Quick start](https://talkingdata.github.io/rxloop/#/basics/getting-started) 109 | 2. [Error handling](https://talkingdata.github.io/rxloop/#/basics/error-handler) 110 | 3. [Integration with RxJS](https://talkingdata.github.io/rxloop/#/advanced/integration-with-rxjs) 111 | 4. [Multi-state and single-state trees](https://talkingdata.github.io/rxloop/#/advanced/multi-state-and-single-state) 112 | 113 | ## Examples 114 | 115 | 1. [counter-basic](https://github.com/TalkingData/rxloop/tree/master/examples/counter-basic) 116 | 2. [ajax-cancel](https://github.com/TalkingData/rxloop/tree/master/examples/ajax-cancel) 117 | 3. [error-handler](https://github.com/TalkingData/rxloop/tree/master/examples/error-handler) 118 | 4. [React todolist app with rxloop](https://github.com/TalkingData/rxloop-react-todos) 119 | 120 | ## License 121 | MIT 122 | -------------------------------------------------------------------------------- /packages/loading/src/index.js: -------------------------------------------------------------------------------- 1 | export default function loading( 2 | config = { 3 | name: 'loading', 4 | } 5 | ) { 6 | return function init({ 7 | onModelBeforeCreate$, 8 | onPipeStart$, 9 | onPipeEnd$, 10 | onPipeCancel$, 11 | onPipeError$, 12 | onStart$, 13 | }) { 14 | const _model = { 15 | name: config.name, 16 | state: { 17 | pipes: {}, 18 | }, 19 | reducers: { 20 | init(state, action) { 21 | state.pipes = action.pipes; 22 | return state; 23 | }, 24 | pipeStart(state, action) { 25 | const pipeCounterKey = `${action.pipe}Counter`; 26 | let pipeCounter = state.pipes[action.model][pipeCounterKey] + action.loading; 27 | 28 | state.pipes[action.model][pipeCounterKey] = pipeCounter; 29 | state.pipes[action.model][action.pipe] = pipeCounter > 0; 30 | 31 | return state; 32 | }, 33 | pipeStop(state, action) { 34 | const pipeCounterKey = `${action.pipe}Counter`; 35 | state.pipes[action.model][pipeCounterKey] = 0; 36 | state.pipes[action.model][action.pipe] = false; 37 | return state; 38 | }, 39 | }, 40 | }; 41 | this.model(_model); 42 | this.stream(config.name).subscribe(); 43 | 44 | onModelBeforeCreate$.subscribe(({ model }) => { 45 | if ( 46 | typeof model.state !== 'object' || 47 | !model.pipes || 48 | model.state.loading !== void 0 49 | ) return; 50 | 51 | const loading = {}; 52 | Object.keys(model.pipes).forEach(pipe => { 53 | loading[`${pipe}Counter`] = 0; 54 | loading[pipe] = false; 55 | }); 56 | 57 | model.state.loading = loading; 58 | model.reducers.loadingStart = loadingStart; 59 | model.reducers.loadingEnd = loadingEnd; 60 | 61 | function loadingStart(state, { payload: { pipe } }) { 62 | const pipeCounterKey = `${pipe}Counter`; 63 | const pipeCounter = state.loading[pipeCounterKey] + 1 64 | state.loading[pipeCounterKey] = pipeCounter; 65 | state.loading[pipe] = pipeCounter > 0; 66 | return state; 67 | } 68 | 69 | function loadingEnd(state, { payload: { pipe } }) { 70 | state.loading[`${pipe}Counter`] = 0; 71 | state.loading[pipe] = false; 72 | return state; 73 | } 74 | }); 75 | 76 | // hooks 77 | onStart$.subscribe(() => { 78 | const pipes = {}; 79 | Object.keys(this._stream).forEach((model) => { 80 | if (model === 'loading') return; 81 | pipes[model] = {}; 82 | Object.keys(this._pipes[model]).forEach((pipe) => { 83 | pipes[model][pipe] = false; 84 | pipes[model][`${pipe}Counter`] = 0; 85 | }); 86 | }); 87 | this.dispatch({ 88 | pipes, 89 | type: `${config.name}/init`, 90 | }); 91 | }); 92 | 93 | onPipeStart$.subscribe(({ model, pipe }) => { 94 | this.dispatch({ 95 | model, 96 | pipe, 97 | type: `${config.name}/pipeStart`, 98 | loading: 1, 99 | }); 100 | this.dispatch({ 101 | type: `${model}/loadingStart`, 102 | payload: { pipe }, 103 | }); 104 | }); 105 | 106 | onPipeEnd$.subscribe(({ model, pipe }) => { 107 | this.dispatch({ 108 | model, 109 | pipe, 110 | type: `${config.name}/pipeStop`, 111 | loading: 0, 112 | isEnd: true, 113 | }); 114 | this.dispatch({ 115 | type: `${model}/loadingEnd`, 116 | payload: { pipe }, 117 | }); 118 | }); 119 | 120 | onPipeError$.subscribe(({ model, pipe }) => { 121 | this.dispatch({ 122 | model, 123 | pipe, 124 | type: `${config.name}/pipeStop`, 125 | loading: 0, 126 | isError: true, 127 | }); 128 | this.dispatch({ 129 | type: `${model}/loadingEnd`, 130 | payload: { pipe }, 131 | }); 132 | }); 133 | 134 | onPipeCancel$.subscribe(({ model, pipe }) => { 135 | this.dispatch({ 136 | model, 137 | pipe, 138 | type: `${config.name}/pipeStop`, 139 | loading: 0, 140 | isCancel: true, 141 | }); 142 | this.dispatch({ 143 | type: `${model}/loadingEnd`, 144 | payload: { pipe }, 145 | }); 146 | }); 147 | }; 148 | }; 149 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import nodeResolve from 'rollup-plugin-node-resolve' 3 | import babel from 'rollup-plugin-babel' 4 | import replace from 'rollup-plugin-replace' 5 | import commonjs from 'rollup-plugin-commonjs' 6 | import { terser } from 'rollup-plugin-terser' 7 | 8 | if (!process.env.TARGET) { 9 | throw new Error('TARGET package must be specified via --environment flag.') 10 | } 11 | 12 | const packagesDir = path.resolve(__dirname, 'packages') 13 | const packageDir = path.resolve(packagesDir, process.env.TARGET) 14 | const name = path.basename(packageDir) 15 | const resolve = p => path.resolve(packageDir, p) 16 | const pkg = require(resolve(`package.json`)) 17 | const globalName = { 18 | core: 'rxloop', 19 | loading: 'rxloopLoading', 20 | immer: 'rxloopImmer', 21 | devtools: 'rxloopDevtools', 22 | }; 23 | // const packageOptions = pkg.buildOptions || {} 24 | 25 | // import pkg from './package.json' 26 | 27 | export default [ 28 | // CommonJS 29 | { 30 | input: `${packageDir}/src/index.js`, 31 | output: { file: `${packageDir}/lib/${name}.js`, format: 'cjs', indent: false }, 32 | external: [ 33 | ...Object.keys(pkg.dependencies || {}), 34 | ...Object.keys(pkg.peerDependencies || {}) 35 | ], 36 | plugins: [ 37 | nodeResolve({ 38 | mainFields: ['jsnext', 'main'], 39 | // jsnext: true, 40 | // main: true 41 | }), 42 | 43 | babel({ 44 | runtimeHelpers: true 45 | }), 46 | commonjs(), // immer 47 | ] 48 | }, 49 | 50 | // ES 51 | { 52 | input: `${packageDir}/src/index.js`, 53 | output: { file: `${packageDir}/es/${name}.js`, format: 'es', indent: false }, 54 | external: [ 55 | ...Object.keys(pkg.dependencies || {}), 56 | ...Object.keys(pkg.peerDependencies || {}) 57 | ], 58 | plugins: [ 59 | nodeResolve({ 60 | // mainFields: ['jsnext', 'main'], 61 | // jsnext: true, 62 | // main: true 63 | }), 64 | babel({ 65 | runtimeHelpers: true 66 | }), 67 | commonjs(), // immer 68 | ] 69 | }, 70 | 71 | // ES for Browsers 72 | { 73 | input: `${packageDir}/src/index.js`, 74 | output: { file: `${packageDir}/es/${name}.mjs`, format: 'es', indent: false }, 75 | plugins: [ 76 | nodeResolve({ 77 | mainFields: ['jsnext', 'main'], 78 | // jsnext: true, 79 | // main: true 80 | }), 81 | babel({ 82 | runtimeHelpers: true 83 | }), 84 | commonjs(), 85 | replace({ 86 | 'process.env.NODE_ENV': JSON.stringify('production') 87 | }), 88 | terser({ 89 | compress: { 90 | pure_getters: true, 91 | unsafe: true, 92 | unsafe_comps: true, 93 | warnings: false 94 | } 95 | }) 96 | ] 97 | }, 98 | 99 | // UMD Development 100 | { 101 | input: `${packageDir}/src/index.js`, 102 | output: { 103 | file: `${packageDir}/dist/${name}.js`, 104 | format: 'umd', 105 | name: globalName[name], 106 | exports: 'named', 107 | indent: false, 108 | }, 109 | plugins: [ 110 | nodeResolve({ 111 | mainFields: ['jsnext', 'main'], 112 | // jsnext: true, 113 | // main: true 114 | }), 115 | babel({ 116 | exclude: 'node_modules/**', 117 | runtimeHelpers: true 118 | }), 119 | commonjs(), 120 | replace({ 121 | 'process.env.NODE_ENV': JSON.stringify('development') 122 | }) 123 | ] 124 | }, 125 | 126 | // UMD Production 127 | { 128 | input: `${packageDir}/src/index.js`, 129 | output: { 130 | file: `${packageDir}/dist/${name}.min.js`, 131 | format: 'umd', 132 | name: globalName[name], 133 | exports: 'named', 134 | indent: false, 135 | }, 136 | plugins: [ 137 | nodeResolve({ 138 | mainFields: ['jsnext', 'main'], 139 | // jsnext: true, 140 | // main: true 141 | }), 142 | babel({ 143 | exclude: 'node_modules/**', 144 | runtimeHelpers: true 145 | }), 146 | commonjs(), 147 | replace({ 148 | 'process.env.NODE_ENV': JSON.stringify('production') 149 | }), 150 | terser({ 151 | compress: { 152 | pure_getters: true, 153 | unsafe: true, 154 | unsafe_comps: true, 155 | warnings: false 156 | } 157 | }) 158 | ] 159 | } 160 | ] 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 简体中文 | [English](README-en_US.md) 2 | # rxloop [![NPM version][npm-image]][npm-url] [![npm download][download-image]][download-url] [![Build Status][build-status-image]][build-status-url] [![codecov status][codecov-image]][codecov-url] [![license][license-image]][license-url] 3 | 4 | [npm-image]: https://img.shields.io/npm/v/@rxloop/core.svg?style=shield&colorB=brightgreen 5 | [npm-url]: https://npmjs.org/package/@rxloop/core 6 | [download-image]: https://img.shields.io/npm/dm/@rxloop/core.svg?style=shield&colorB=brightgreen 7 | [download-url]: https://npmjs.org/package/@rxloop/core 8 | [build-status-image]: https://circleci.com/gh/TalkingData/rxloop/tree/master.png?style=shield&colorB=brightgreen 9 | [build-status-url]: https://circleci.com/gh/TalkingData/rxloop 10 | [codecov-image]: https://img.shields.io/codecov/c/github/TalkingData/rxloop/master.svg?style=shield&colorB=brightgreen 11 | [codecov-url]: https://codecov.io/github/TalkingData/rxloop?branch=master 12 | [license-image]: https://img.shields.io/npm/l/@rxloop/core.svg?style=shield&colorB=brightgreen&longCache=true 13 | [license-url]: https://github.com/TalkingData/rxloop/blob/master/LICENSE 14 | 15 | 16 | > rxloop = Redux + redux-observable. 17 | 18 | 基于 RxJS 的可预测状态管理容器,超轻量级的 “redux + redux-observable” 架构。 19 | 20 | ## 特性 21 | * elm 概念:通过 reducers、pipes 组织 model,支持多状态或单一状态树; 22 | * 易学易用:仅有五个 api,对 Redux、RxJS 用户友好; 23 | * 插件机制:比如 [@rxloop/loading](https://github.com/TalkingData/rxloop-loading) 可以自动处理 loading 状态,[@rxloop/devtools](https://github.com/TalkingData/rxloop-devtools) 可视化状态树,便于代码调试; 24 | * 扩展 RxJS:rxloop 能够串联到 RxJS 数据管道之中,最终能够分发出多个数据管道。 25 | 26 | ## 安装 27 | **`rxjs` 需要作为 peer dependency 引入。** 28 | 29 | 通过 npm 方式: 30 | ```bash 31 | $ npm install @rxloop/core rxjs 32 | ``` 33 | 34 | 或者 yarn 方式 35 | ```bash 36 | $ yarn add @rxloop/core rxjs 37 | ``` 38 | 39 | ## 快速上手 40 | ```javascript 41 | import rxloop from '@rxloop/core'; 42 | 43 | // 在一个应用创建一个全局唯一的 app 44 | const store = rxloop(); 45 | 46 | // 在应用中,可以创建多个业务模型,比如下面的 user 和 counter 模型 47 | store.model({ 48 | name: 'user', 49 | state: { name: 'wxnet' } 50 | }); 51 | 52 | store.model({ 53 | name: 'counter', 54 | state: { 55 | counter: 0, 56 | }, 57 | reducers: { 58 | inc(state) { 59 | return { 60 | ...state, 61 | counter: state.counter + 1 62 | }; 63 | }, 64 | dec(state) { 65 | return { 66 | ...state, 67 | counter: state.counter - 1 68 | }; 69 | }, 70 | }, 71 | }); 72 | 73 | // 在 View 层订阅 counter 模型的状态 74 | // 当模型状态变更时,使用 View 层框架相关方法同步 View 的更新,比如 React 的 setState 方法 75 | store.stream('counter').subscribe((state) => { 76 | // this.setState(state); 77 | }); 78 | 79 | // 在 view 层,可以通过 dispatch 方法派发 action 80 | // action 会经由 pipes 或 reducers 更新 model 状态 81 | store.dispatch({ 82 | type: 'counter/inc', 83 | }); 84 | 85 | store.dispatch({ 86 | type: 'counter/inc', 87 | }); 88 | 89 | store.dispatch({ 90 | type: 'counter/dec', 91 | }); 92 | ``` 93 | 94 | ## 集成 95 | 1. [与 Vue 集成](https://github.com/TalkingData/vue-rxloop) 96 | 2. [与 React 集成](https://github.com/TalkingData/react-rxloop) 97 | 98 | ## 插件 99 | | Plugins | NPM | 100 | |----------|:-------------:| 101 | | [immer](https://github.com/TalkingData/rxloop-immer) | ![immer](https://img.shields.io/npm/v/@rxloop/immer.svg?style=shield&colorB=brightgreen) | 102 | | [loading](https://github.com/TalkingData/rxloop-loading) | ![immer](https://img.shields.io/npm/v/@rxloop/loading.svg?style=shield&colorB=brightgreen) | 103 | | [devtools](https://github.com/TalkingData/rxloop-devtools) | ![immer](https://img.shields.io/npm/v/@rxloop/devtools.svg?style=shield&colorB=brightgreen) | 104 | 105 | 106 | ## 更多示例 107 | 108 | 1. [基本的计数器](https://codesandbox.io/s/mz6yyw17vy) 109 | 2. [单一状态和多状态树](https://codesandbox.io/s/348w57x936) 110 | 3. [错误处理](https://codesandbox.io/s/0qmn89noj0) 111 | 4. [取消异步请求](https://codesandbox.io/s/3vy8ox7zx5) 112 | 5. [使用 react-rxloop 绑定 react 和 rxloop](https://codesandbox.io/s/y3www03181) 113 | 6. [任务列表应用](https://codesandbox.io/s/ypwo37zmo1) 114 | 7. [loading 插件](https://codesandbox.io/s/8l1mnx18v2) 115 | 8. [immer 插件](https://codesandbox.io/s/343wrnq6pp) 116 | 117 | 118 | ## [文档索引](https://github.com/TalkingData/rxloop/blob/master/docs/sidebar.md) 119 | 120 | - [介绍](https://github.com/TalkingData/rxloop/blob/master/docs/index.md) 121 | - [基础](https://github.com/TalkingData/rxloop/blob/master/docs/basics/index.md) 122 | - [快速上手](https://github.com/TalkingData/rxloop/blob/master/docs/basics/getting-started.md) 123 | - [错误处理](https://github.com/TalkingData/rxloop/blob/master/docs/basics/error-handler.md) 124 | - [示例](https://github.com/TalkingData/rxloop/blob/master/docs/basics/examples.md) 125 | - [高级特性](https://github.com/TalkingData/rxloop/blob/master/docs/advanced/index.md) 126 | - [请求取消](https://github.com/TalkingData/rxloop/blob/master/docs/advanced/cancellation.md) 127 | - [与 RxJS 集成](https://github.com/TalkingData/rxloop/blob/master/docs/advanced/integration-with-rxjs.md) 128 | - [多状态与单一状态树](https://github.com/TalkingData/rxloop/blob/master/docs/advanced/multi-state-and-single-state.md) 129 | - [在 Model 之间传递消息](https://github.com/TalkingData/rxloop/blob/master/docs/advanced/cross-model-dispatch-action.md) 130 | - [中间件](https://github.com/TalkingData/rxloop/blob/master/docs/advanced/middleware.md) 131 | - [TypeScript](https://github.com/TalkingData/rxloop/blob/master/docs/advanced/typescript.md) 132 | - [API](https://github.com/TalkingData/rxloop/blob/master/docs/api.md) 133 | - [更新记录](https://github.com/TalkingData/rxloop/blob/master/CHANGELOG.md) 134 | 135 | ## 协议许可 136 | MIT 137 | -------------------------------------------------------------------------------- /packages/loading/test/index.spec.js: -------------------------------------------------------------------------------- 1 | import rxloop from '@rxloop/core'; 2 | import loading from '../'; 3 | import { delay, mapTo, map } from "rxjs/operators"; 4 | 5 | const app1 = rxloop(); 6 | 7 | const app = rxloop({ 8 | plugins: [ loading() ], 9 | }); 10 | 11 | app.model({ 12 | name: 'test', 13 | state: { 14 | a: 1, 15 | }, 16 | reducers: { 17 | add(state) { 18 | return { 19 | ...state, 20 | a: state.a + 1, 21 | }; 22 | }, 23 | }, 24 | pipes: { 25 | getData(action$) { 26 | return action$.pipe( 27 | mapTo({ 28 | type: 'add', 29 | }), 30 | ); 31 | }, 32 | setData(action$) { 33 | return action$.pipe( 34 | mapTo({ 35 | type: 'add', 36 | }), 37 | ); 38 | }, 39 | getSlowlyData(action$) { 40 | return action$.pipe( 41 | delay(2000), 42 | mapTo({ 43 | type: 'add', 44 | }), 45 | ); 46 | }, 47 | }, 48 | }); 49 | 50 | app.stream('test').subscribe(); 51 | 52 | app.start(); 53 | 54 | describe('test pipe loading', () => { 55 | test('exposes the public API', () => { 56 | expect(Object.keys(app1)).not.toContain('loading$'); 57 | expect(Object.keys(app)).toContain('loading$'); 58 | }); 59 | 60 | test('loading state', () => { 61 | expect(app.getState('test')).toEqual({ 62 | a: 1, 63 | loading: { 64 | getData: false, 65 | getDataCounter: 0, 66 | setData: false, 67 | setDataCounter: 0, 68 | getSlowlyData: false, 69 | getSlowlyDataCounter: 0, 70 | }, 71 | }); 72 | expect(app.getState('loading')).toEqual({ 73 | pipes: { 74 | test: { 75 | getData: false, 76 | getDataCounter: 0, 77 | setData: false, 78 | setDataCounter: 0, 79 | getSlowlyData: false, 80 | getSlowlyDataCounter: 0, 81 | }, 82 | }, 83 | }); 84 | }); 85 | 86 | test('loading test', (done) => { 87 | app.dispatch({ 88 | type: 'test/getSlowlyData', 89 | }); 90 | expect(app.getState('test')).toEqual({ 91 | a: 1, 92 | loading: { 93 | getData: false, 94 | getDataCounter: 0, 95 | setData: false, 96 | setDataCounter: 0, 97 | getSlowlyData: true, 98 | getSlowlyDataCounter: 1, 99 | }, 100 | }); 101 | expect(app.getState('loading')).toEqual({ 102 | pipes: { 103 | test: { 104 | getData: false, 105 | getDataCounter: 0, 106 | setData: false, 107 | setDataCounter: 0, 108 | getSlowlyData: true, 109 | getSlowlyDataCounter: 1, 110 | }, 111 | }, 112 | }); 113 | setTimeout(() => { 114 | expect(app.getState('test')).toEqual({ 115 | a: 2, 116 | loading: { 117 | getData: false, 118 | getDataCounter: 0, 119 | setData: false, 120 | setDataCounter: 0, 121 | getSlowlyData: false, 122 | getSlowlyDataCounter: 0, 123 | }, 124 | }); 125 | expect(app.getState('loading')).toEqual({ 126 | pipes: { 127 | test: { 128 | getData: false, 129 | getDataCounter: 0, 130 | setData: false, 131 | setDataCounter: 0, 132 | getSlowlyData: false, 133 | getSlowlyDataCounter: 0, 134 | }, 135 | }, 136 | }); 137 | done(); 138 | }, 3000); 139 | }); 140 | }); 141 | 142 | describe('test pipe loading when error', () => { 143 | const app = rxloop({ 144 | plugins: [ loading() ], 145 | }); 146 | app.model({ 147 | name: 'test', 148 | state: {}, 149 | reducers: { 150 | add(state) { 151 | return state; 152 | } 153 | }, 154 | pipes: { 155 | getDataError(action$) { 156 | return action$.pipe( 157 | delay(2000), 158 | map(() => { 159 | throw 'boom!'; 160 | }), 161 | ); 162 | }, 163 | }, 164 | }); 165 | 166 | app.start(); 167 | app.stream('loading').subscribe(); 168 | let state = null; 169 | app.stream('test').subscribe( 170 | data => (state = data), 171 | () => {}, 172 | ); 173 | test('loading test', (done) => { 174 | app.dispatch({ type: 'test/getDataError' }); 175 | setTimeout(() => { 176 | expect(state).toEqual( 177 | { loading: { getDataErrorCounter: 0, getDataError: false } } 178 | ); 179 | expect(app.getState('loading')).toEqual({ 180 | pipes: { 181 | test: { 182 | getDataError: false, 183 | getDataErrorCounter: 0, 184 | }, 185 | }, 186 | }); 187 | done(); 188 | }, 3000); 189 | }); 190 | }); 191 | 192 | describe('test pipe loading when error', () => { 193 | const app = rxloop({ 194 | plugins: [ loading() ], 195 | }); 196 | app.model({ 197 | name: 'test', 198 | state: { 199 | loading: true, 200 | }, 201 | reducers: { 202 | add(state) { 203 | return state; 204 | } 205 | }, 206 | pipes: { 207 | getData(action$) { 208 | return action$.pipe( 209 | mapTo({ 210 | type: 'add', 211 | }), 212 | ); 213 | }, 214 | }, 215 | }); 216 | 217 | app.start(); 218 | app.stream('loading').subscribe(); 219 | 220 | test('Should not to replace loading state', () => { 221 | expect(app.getState('test')).toEqual({ 222 | loading: true, 223 | }); 224 | expect(app.getState('loading')).toEqual({ 225 | pipes: { 226 | test: { 227 | getDataCounter: 0, 228 | getData: false, 229 | } 230 | } 231 | }); 232 | }); 233 | }); -------------------------------------------------------------------------------- /packages/core/test/create-store.spec.js: -------------------------------------------------------------------------------- 1 | import rxloop from '..'; 2 | import { addTodo } from './helpers/actionCreators' 3 | import * as reducers from './helpers/reducers' 4 | 5 | function createStore(reducers, state, name = 'test') { 6 | const store = rxloop(); 7 | store.model({ 8 | name, 9 | state, 10 | reducers, 11 | }); 12 | return store; 13 | } 14 | 15 | describe('createStore', () => { 16 | it('exposes the public API', () => { 17 | const store = createStore(reducers, []) 18 | const methods = Object.keys(store) 19 | 20 | expect(methods).toContain('subscribe') 21 | expect(methods).toContain('dispatch') 22 | expect(methods).toContain('getState') 23 | }) 24 | 25 | it('passes the initial state', () => { 26 | const store = createStore(reducers, [ 27 | { 28 | id: 1, 29 | text: 'Hello' 30 | } 31 | ]) 32 | expect(store.getState('test')).toEqual([ 33 | { 34 | id: 1, 35 | text: 'Hello' 36 | } 37 | ]) 38 | }) 39 | 40 | it('applies the reducer to the previous state', () => { 41 | const store = createStore(reducers, []) 42 | expect(store.getState('test')).toEqual([]) 43 | 44 | store.dispatch(addTodo('Hello')) 45 | expect(store.getState('test')).toEqual([ 46 | { 47 | id: 1, 48 | text: 'Hello' 49 | } 50 | ]) 51 | 52 | store.dispatch(addTodo('World')) 53 | expect(store.getState('test')).toEqual([ 54 | { 55 | id: 1, 56 | text: 'Hello' 57 | }, 58 | { 59 | id: 2, 60 | text: 'World' 61 | } 62 | ]) 63 | }) 64 | 65 | it('applies the reducer to the initial state', () => { 66 | const store = createStore(reducers, [ 67 | { 68 | id: 1, 69 | text: 'Hello' 70 | } 71 | ]) 72 | expect(store.getState('test')).toEqual([ 73 | { 74 | id: 1, 75 | text: 'Hello' 76 | } 77 | ]) 78 | 79 | store.dispatch(addTodo('World')) 80 | expect(store.getState('test')).toEqual([ 81 | { 82 | id: 1, 83 | text: 'Hello' 84 | }, 85 | { 86 | id: 2, 87 | text: 'World' 88 | } 89 | ]) 90 | }) 91 | 92 | it('supports multiple subscriptions', () => { 93 | const store = createStore(reducers, []) 94 | const listenerA = jest.fn() 95 | const listenerB = jest.fn() 96 | 97 | let unsubscribeA = store.subscribe(listenerA) 98 | expect(listenerA.mock.calls.length).toBe(1) 99 | expect(listenerB.mock.calls.length).toBe(0) 100 | 101 | const unsubscribeB = store.subscribe(listenerB) 102 | expect(listenerA.mock.calls.length).toBe(1) 103 | expect(listenerB.mock.calls.length).toBe(1) 104 | 105 | unsubscribeA() 106 | unsubscribeB() 107 | }) 108 | 109 | it('only removes listener once when unsubscribe is called', () => { 110 | const store = createStore(reducers, []) 111 | const listenerA = jest.fn() 112 | const listenerB = jest.fn() 113 | 114 | const unsubscribeA = store.subscribe(listenerA) 115 | store.subscribe(listenerB) 116 | 117 | unsubscribeA() 118 | unsubscribeA() 119 | 120 | store.dispatch(addTodo('')) 121 | expect(listenerA.mock.calls.length).toBe(1) 122 | expect(listenerB.mock.calls.length).toBe(2) 123 | }) 124 | 125 | it('only removes relevant listener when unsubscribe is called', () => { 126 | const store = createStore(reducers, []) 127 | const listener = jest.fn() 128 | 129 | store.subscribe(listener) 130 | const unsubscribeSecond = store.subscribe(listener) 131 | unsubscribeSecond() 132 | 133 | store.dispatch(addTodo('')) 134 | expect(listener.mock.calls.length).toBe(3) 135 | }) 136 | 137 | it('notifies only subscribers active at the moment of current dispatch', () => { 138 | const store = createStore(reducers, []) 139 | 140 | const listener1 = jest.fn() 141 | const listener2 = jest.fn() 142 | const listener3 = jest.fn() 143 | 144 | let listener3Added = false 145 | const maybeAddThirdListener = () => { 146 | if (!listener3Added) { 147 | listener3Added = true 148 | store.subscribe(() => listener3()) 149 | } 150 | } 151 | 152 | store.subscribe(() => listener1()) 153 | store.subscribe(() => { 154 | listener2() 155 | maybeAddThirdListener() 156 | }) 157 | 158 | expect(listener1.mock.calls.length).toBe(1) 159 | expect(listener2.mock.calls.length).toBe(1) 160 | expect(listener3.mock.calls.length).toBe(1) 161 | 162 | store.dispatch(addTodo('')) 163 | expect(listener1.mock.calls.length).toBe(2) 164 | expect(listener2.mock.calls.length).toBe(2) 165 | expect(listener3.mock.calls.length).toBe(2) 166 | }) 167 | 168 | it('uses the last snapshot of subscribers during nested dispatch', () => { 169 | const store = createStore(reducers, []) 170 | 171 | const listener1 = jest.fn() 172 | const listener2 = jest.fn() 173 | const listener3 = jest.fn() 174 | const listener4 = jest.fn() 175 | 176 | let unsubscribe4 177 | const unsubscribe1 = store.subscribe(() => { 178 | listener1() 179 | expect(listener1.mock.calls.length).toBe(1) 180 | expect(listener2.mock.calls.length).toBe(0) 181 | expect(listener3.mock.calls.length).toBe(0) 182 | expect(listener4.mock.calls.length).toBe(0) 183 | 184 | 185 | unsubscribe4 = store.subscribe(listener4) 186 | // store.dispatch(unknownAction()) 187 | 188 | expect(listener1.mock.calls.length).toBe(1) 189 | expect(listener2.mock.calls.length).toBe(0) 190 | expect(listener3.mock.calls.length).toBe(0) 191 | expect(listener4.mock.calls.length).toBe(1) 192 | }) 193 | unsubscribe1() 194 | 195 | store.subscribe(listener2) 196 | store.subscribe(listener3) 197 | 198 | store.dispatch(addTodo('')) 199 | expect(listener1.mock.calls.length).toBe(1) 200 | expect(listener2.mock.calls.length).toBe(2) 201 | expect(listener3.mock.calls.length).toBe(2) 202 | expect(listener4.mock.calls.length).toBe(2) 203 | 204 | unsubscribe4() 205 | store.dispatch(addTodo('')) 206 | expect(listener1.mock.calls.length).toBe(1) 207 | expect(listener2.mock.calls.length).toBe(3) 208 | expect(listener3.mock.calls.length).toBe(3) 209 | expect(listener4.mock.calls.length).toBe(2) 210 | }) 211 | 212 | it('provides an up-to-date state when a subscriber is notified', done => { 213 | const store = createStore(reducers, []) 214 | store.dispatch(addTodo('Hello')) 215 | store.subscribe(() => { 216 | expect(store.getState('test')).toEqual([ 217 | { 218 | id: 1, 219 | text: 'Hello' 220 | } 221 | ]) 222 | done() 223 | }) 224 | }) 225 | 226 | it('only accepts plain object actions', () => { 227 | const store = createStore(reducers, []) 228 | function AwesomeMap() {} 229 | 230 | ;[null, undefined, 42, 'hey', new AwesomeMap()].forEach(nonObject => 231 | expect(() => store.dispatch(nonObject)).toThrow(/plain/) 232 | ) 233 | }) 234 | 235 | it('handles nested dispatches gracefully', () => { 236 | function foo(state, action) { 237 | if (action.type === 'test/foo') { 238 | state.foo = 1; 239 | } 240 | return state 241 | } 242 | 243 | function bar(state, action) { 244 | if (action.type === 'test/bar') { 245 | state.bar = 2; 246 | } 247 | return state 248 | } 249 | 250 | const store = createStore({ foo, bar }, { 251 | foo: 0, 252 | bar: 0, 253 | }) 254 | 255 | store.subscribe(function kindaComponentDidUpdate() { 256 | const state = store.getState('test') 257 | if (state.bar === 0) { 258 | store.dispatch({ type: 'test/bar' }) 259 | } 260 | }) 261 | 262 | store.dispatch({ type: 'test/foo' }) 263 | expect(store.getState('test')).toEqual({ 264 | foo: 1, 265 | bar: 2 266 | }) 267 | }) 268 | 269 | it('throws if action type is missing', () => { 270 | const store = createStore(reducers, []) 271 | expect(() => store.dispatch({})).toThrow( 272 | "[action] action should be a plain Object with type" 273 | ) 274 | }) 275 | 276 | it('throws if action type is undefined', () => { 277 | const store = createStore(reducers, []) 278 | expect(() => store.dispatch({ type: undefined })).toThrow( 279 | "[action] action should be a plain Object with type" 280 | ) 281 | }) 282 | 283 | it('throws if listener is not a function', () => { 284 | const store = createStore(reducers, []) 285 | 286 | expect(() => store.subscribe()).toThrow() 287 | expect(() => store.subscribe('')).toThrow() 288 | expect(() => store.subscribe(null)).toThrow() 289 | expect(() => store.subscribe(undefined)).toThrow() 290 | }) 291 | }) 292 | -------------------------------------------------------------------------------- /packages/core/test/plugins/index.spec.js: -------------------------------------------------------------------------------- 1 | import rxloop from '../../'; 2 | import { from } from 'rxjs'; 3 | import { mapTo, map, takeUntil, switchMap } from "rxjs/operators"; 4 | 5 | describe('Error check', () => { 6 | test('Throw error if all plugin should not be function', () => { 7 | expect(() => { 8 | rxloop({ 9 | plugins: [ 10 | function plugin() {}, 11 | 'plugin', 12 | ], 13 | }); 14 | }).toThrow('[plugins] all plugin should be function'); 15 | }); 16 | 17 | test('Should not throw error', () => { 18 | expect(() => { 19 | rxloop({ 20 | plugins: [ 21 | function pluginOne() {}, 22 | function pluginTwo() {}, 23 | ], 24 | }); 25 | }).not.toThrow('[plugins] all plugin should be function'); 26 | }); 27 | }); 28 | 29 | describe('Basic api', () => { 30 | test('plugin event source', () => { 31 | rxloop({ 32 | plugins: [ 33 | function pluginOne({ 34 | onModelBeforeCreate$, 35 | onModelCreated$, 36 | onPipeStart$, 37 | onPipeEnd$, 38 | onPipeCancel$, 39 | onPipeError$, 40 | }) { 41 | expect(onModelBeforeCreate$).not.toBeUndefined(); 42 | expect(onModelCreated$).not.toBeUndefined(); 43 | expect(onPipeStart$).not.toBeUndefined(); 44 | expect(onPipeEnd$).not.toBeUndefined(); 45 | expect(onPipeCancel$).not.toBeUndefined(); 46 | expect(onPipeError$).not.toBeUndefined(); 47 | }, 48 | function pluginTwo({ 49 | onModelBeforeCreate$, 50 | onModelCreated$, 51 | onPipeStart$, 52 | onPipeEnd$, 53 | onPipeCancel$, 54 | onPipeError$, 55 | }) { 56 | expect(onModelBeforeCreate$).not.toBeUndefined(); 57 | expect(onModelCreated$).not.toBeUndefined(); 58 | expect(onPipeStart$).not.toBeUndefined(); 59 | expect(onPipeEnd$).not.toBeUndefined(); 60 | expect(onPipeCancel$).not.toBeUndefined(); 61 | expect(onPipeError$).not.toBeUndefined(); 62 | }, 63 | ], 64 | }); 65 | }); 66 | 67 | test('Event onModel will be dispached', (done) => { 68 | const app = rxloop({ 69 | plugins: [ 70 | function pluginOne({ onModelCreated$ }) { 71 | onModelCreated$.subscribe((data) => { 72 | expect(data).toEqual({ 73 | type: 'plugin', 74 | action: 'onModelCreated', 75 | model: 'test' 76 | }); 77 | done(); 78 | }); 79 | }, 80 | ], 81 | }); 82 | app.model({ name: 'test', state: {} }); 83 | }); 84 | 85 | test('Event onPipeStart will be dispached', (done) => { 86 | const app = rxloop({ 87 | plugins: [ 88 | function pluginOne({ onPipeStart$ }) { 89 | onPipeStart$.subscribe((data) => { 90 | delete data.data.__cancel__; 91 | delete data.data.__bus__; 92 | 93 | expect(data).toEqual({ 94 | type: 'plugin', 95 | action: "onPipeStart", 96 | model: 'test', 97 | pipe: "getData", 98 | data: { 99 | type: "test/getData", 100 | }, 101 | }); 102 | done(); 103 | }); 104 | }, 105 | ], 106 | }); 107 | 108 | app.model({ 109 | name: 'test', 110 | state: {}, 111 | reducers: { 112 | add(state) { return state }, 113 | }, 114 | pipes: { 115 | getData(action$) { 116 | return action$.pipe( 117 | mapTo({ 118 | type: 'add', 119 | }), 120 | ); 121 | }, 122 | }, 123 | }); 124 | app.dispatch({ 125 | type: 'test/getData', 126 | }); 127 | }); 128 | 129 | test('Event onPipeEnd will be dispached', (done) => { 130 | const app = rxloop({ 131 | plugins: [ 132 | function pluginOne({ onPipeEnd$ }) { 133 | onPipeEnd$.subscribe((data) => { 134 | expect(data).toEqual({ 135 | data: { 136 | type: 'add', 137 | payload: 1, 138 | }, 139 | type: 'plugin', 140 | action: "onPipeEnd", 141 | model: 'test', 142 | pipe: "getData", 143 | }); 144 | done(); 145 | }); 146 | }, 147 | ], 148 | }); 149 | 150 | app.model({ 151 | name: 'test', 152 | state: {}, 153 | reducers: { 154 | add(state) { return state }, 155 | }, 156 | pipes: { 157 | getData(action$) { 158 | return action$.pipe( 159 | mapTo({ 160 | type: 'add', 161 | payload: 1, 162 | }), 163 | ); 164 | }, 165 | }, 166 | }); 167 | app.dispatch({ 168 | type: 'test/getData', 169 | }); 170 | }); 171 | 172 | test('Event onPipeCancel will be dispached', (done) => { 173 | const apiSlow = async () => { 174 | const data = await new Promise((resolve) => { 175 | setTimeout(() => resolve({}), 5000); 176 | }); 177 | return { code: 200, data }; 178 | }; 179 | 180 | const app = rxloop({ 181 | plugins: [ 182 | function pluginOne({ onPipeCancel$ }) { 183 | onPipeCancel$.subscribe((data) => { 184 | expect(data).toEqual({ 185 | type: 'plugin', 186 | action: "onPipeCancel", 187 | model: 'test', 188 | pipe: "getSlowlyData", 189 | }); 190 | done(); 191 | }); 192 | }, 193 | ], 194 | }); 195 | 196 | app.model({ 197 | name: 'test', 198 | state: {}, 199 | reducers: { 200 | add(state) { 201 | return { 202 | ...state, 203 | a: 1, 204 | }; 205 | }, 206 | }, 207 | pipes: { 208 | getSlowlyData(action$, cancel$) { 209 | return action$.pipe( 210 | switchMap(() => { 211 | return from(apiSlow).pipe( takeUntil(cancel$) ); 212 | }), 213 | mapTo({ 214 | type: 'add', 215 | }), 216 | ); 217 | }, 218 | }, 219 | }); 220 | app.dispatch({ 221 | type: 'test/getSlowlyData', 222 | }); 223 | app.dispatch({ 224 | type: 'test/getSlowlyData/cancel', 225 | }); 226 | }); 227 | 228 | test('Event onPipeError will be dispached', (done) => { 229 | const app = rxloop({ 230 | plugins: [ 231 | function pluginOne({ onPipeError$ }) { 232 | onPipeError$.subscribe((data) => { 233 | expect(data).toEqual({ 234 | error: 'pipe error', 235 | type: 'plugin', 236 | action: "onPipeError", 237 | model: 'test', 238 | pipe: "getErrorData", 239 | }); 240 | done(); 241 | }); 242 | }, 243 | ], 244 | }); 245 | 246 | app.model({ 247 | name: 'test', 248 | state: {}, 249 | reducers: { 250 | add(state) { 251 | return { 252 | ...state, 253 | a: 1, 254 | }; 255 | }, 256 | }, 257 | pipes: { 258 | getErrorData(action$) { 259 | return action$.pipe( 260 | map(() => { 261 | throw 'pipe error'; 262 | // return { 263 | // type: 'add', 264 | // }; 265 | }), 266 | ); 267 | }, 268 | }, 269 | }); 270 | app.dispatch({ 271 | type: 'test/getErrorData', 272 | }); 273 | }); 274 | 275 | const mockfn = jest.fn(); 276 | const app = rxloop({ 277 | plugins: [ 278 | function pluginOne({ onStatePatch$ }) { 279 | onStatePatch$.subscribe(mockfn); 280 | }, 281 | ], 282 | }); 283 | 284 | app.model({ 285 | name: 'counter', 286 | state: { 287 | counter: 0, 288 | }, 289 | reducers: { 290 | inc(state) { 291 | return { 292 | counter: state.counter + 1, 293 | }; 294 | }, 295 | dec(state) { 296 | return { 297 | counter: state.counter - 1, 298 | }; 299 | }, 300 | }, 301 | }); 302 | app.start(); 303 | app.stream('counter').subscribe(); 304 | 305 | app.dispatch({ 306 | type: 'counter/inc', 307 | }); 308 | app.dispatch({ 309 | type: 'counter/inc', 310 | }); 311 | app.dispatch({ 312 | type: 'counter/dec', 313 | }); 314 | 315 | test('Event onStatePatch will be dispached', () => { 316 | expect(mockfn.mock.calls.length).toBe(3); 317 | expect(mockfn.mock.calls[0][0]).toEqual({ 318 | state: { counter: 1 }, 319 | reducerAction: { type: 'counter/inc', __source__: { reducer: 'inc' } }, 320 | model: 'counter', 321 | type: 'plugin', 322 | action: 'onStatePatch', 323 | }); 324 | expect(mockfn.mock.calls[1][0]).toEqual({ 325 | state: { counter: 2 }, 326 | reducerAction: { type: 'counter/inc', __source__: { reducer: 'inc' } }, 327 | model: 'counter', 328 | type: 'plugin', 329 | action: 'onStatePatch', 330 | }); 331 | expect(mockfn.mock.calls[2][0]).toEqual({ 332 | state: { counter: 1 }, 333 | reducerAction: { type: 'counter/dec', __source__: { reducer: 'dec' } }, 334 | model: 'counter', 335 | type: 'plugin', 336 | action: 'onStatePatch', 337 | }); 338 | }); 339 | 340 | }); -------------------------------------------------------------------------------- /packages/core/src/rxloop.js: -------------------------------------------------------------------------------- 1 | import { Subject, BehaviorSubject, throwError, combineLatest, merge } from "rxjs"; 2 | import { filter, scan, map, publishReplay, refCount, catchError } from "rxjs/operators"; 3 | import invariant from 'invariant'; 4 | import checkModel from './check-model'; 5 | import { isPlainObject, isFunction, noop } from './utils'; 6 | import initPlugins from './plugins'; 7 | import { call } from './call'; 8 | 9 | export function rxloop( config = {} ) { 10 | const option = { 11 | plugins: [], 12 | onError() {}, 13 | ...config, 14 | }; 15 | 16 | const bus$ = new Subject(); 17 | 18 | function createStream(type) { 19 | return bus$.pipe( 20 | filter(e => e.type === type), 21 | ); 22 | } 23 | 24 | function createReducerStreams(name, reducers, out$$) { 25 | const stream = this._stream[name]; 26 | 27 | Object.keys(reducers).forEach(type => { 28 | // 为每一个 reducer 创建一个数据流, 29 | stream[`${name}/${type}$`] = createStream(`${name}/${type}`); 30 | 31 | // 将数据流导入到 reducer 之中,进行同步状态数据计算 32 | stream[`${name}/${type}$`].pipe( 33 | map(action => { 34 | const rtn = this.createReducer(action, reducers[type]); 35 | action.__source__ = { reducer: type }; 36 | rtn.__action__ = action; 37 | return rtn; 38 | }), 39 | ) 40 | // 将同步计算结果推送出去 41 | .subscribe(out$$); 42 | }); 43 | } 44 | 45 | function createPipeStreams(name, reducers, pipes, out$$) { 46 | const stream = this._stream[name]; 47 | const errors = this._errors[name]; 48 | 49 | Object.keys(pipes).forEach(type => { 50 | // pipes 中函数名称不能跟 reducers 里的函数同名 51 | invariant( 52 | !stream[`${name}/${type}$`], 53 | `[pipes] duplicated type ${type} in pipes and reducers`, 54 | ); 55 | 56 | // 为每一个 pipe 创建一个数据流, 57 | stream[`${name}/${type}$`] = createStream(`${name}/${type}`); 58 | stream[`${name}/${type}/cancel$`] = createStream(`${name}/${type}/cancel`); 59 | stream[`${name}/${type}/error$`] = createStream(`${name}/${type}/error`); 60 | 61 | errors.push(stream[`${name}/${type}/error$`]); 62 | 63 | stream[`${name}/${type}$`].subscribe(data => { 64 | data.__cancel__ = stream[`pipe_${type}_cancel$`]; 65 | data.__bus__ = bus$; 66 | 67 | this.dispatch({ 68 | data, 69 | type: 'plugin', 70 | action: 'onPipeStart', 71 | model: name, 72 | pipe: type, 73 | }); 74 | }); 75 | 76 | stream[`${name}/${type}/cancel$`].subscribe(() => { 77 | this.dispatch({ 78 | type: 'plugin', 79 | action: 'onPipeCancel', 80 | model: name, 81 | pipe: type, 82 | }); 83 | }); 84 | 85 | stream[`${name}/${type}/error$`].subscribe(({ model, pipe, error }) => { 86 | option.onError({ model, pipe, error }); 87 | this.dispatch({ 88 | model, 89 | pipe, 90 | error, 91 | type: 'plugin', 92 | action: 'onPipeError', 93 | }); 94 | }); 95 | 96 | // 将数据流导入到 pipe 之中,进行异步操作 97 | stream[`${name}/${type}/end$`] = pipes[type].call(this, 98 | stream[`${name}/${type}$`], 99 | { call, map, dispatch, put: dispatch, cancel$: stream[`${name}/${type}/cancel$`] } 100 | ).pipe( 101 | map(action => { 102 | const { type: reducer } = action; 103 | invariant( 104 | type, 105 | '[pipes] action should be a plain object with type', 106 | ); 107 | invariant( 108 | reducers[reducer], 109 | `[pipes] undefined reducer ${reducer}`, 110 | ); 111 | this.dispatch({ 112 | data: action, 113 | type: 'plugin', 114 | action: 'onPipeEnd', 115 | model: name, 116 | pipe: type, 117 | }); 118 | const rtn = this.createReducer(action, reducers[reducer]); 119 | action.type = `${name}/${action.type}`; 120 | action.__source__ = { pipe: type }; 121 | rtn.__action__ = action; 122 | return rtn; 123 | }), 124 | catchError((error) => { 125 | option.onError({ 126 | error, 127 | model: name, 128 | pipe: type, 129 | }); 130 | this.dispatch({ 131 | error, 132 | type: 'plugin', 133 | action: 'onPipeError', 134 | model: name, 135 | pipe: type, 136 | }); 137 | return throwError(error); 138 | }), 139 | ); 140 | 141 | // 将异步计算结果推送出去 142 | stream[`${name}/${type}/end$`].subscribe(out$$); 143 | }); 144 | } 145 | 146 | function createModelStream(name, state, out$$) { 147 | const output$ = out$$.pipe( 148 | scan((prevState, reducer) => { 149 | const nextState = reducer(prevState); 150 | if (reducer.__action__) { 151 | this.dispatch({ 152 | state: nextState, 153 | reducerAction: reducer.__action__, 154 | model: name, 155 | type: 'plugin', 156 | action: 'onStatePatch', 157 | }); 158 | delete reducer.__action__; 159 | } 160 | return nextState; 161 | }, state), 162 | publishReplay(1), 163 | refCount(), 164 | ); 165 | 166 | this[`${name}$`] = merge( 167 | output$, 168 | merge(...this._errors[name]), 169 | ); 170 | } 171 | 172 | 173 | /* 174 | https://github.com/TalkingData/rxloop/issues/2 175 | { 176 | name: 'table', 177 | subscriptions: { 178 | model(source, { dispatch }) { 179 | source('filter/change').subscribe((action)) => { 180 | dispatch({ type: 'table/add' }); 181 | }); 182 | source('filter/change/end').subscribe((action) => { 183 | dispatch({ type: 'table/add' }); 184 | }); 185 | }, 186 | } 187 | } 188 | */ 189 | function createSubscriptions(subscriptions) { 190 | 191 | function source(key) { 192 | const [,name] = key.match(/(\w+)\/(\w+)(?:\/(\w+))?/); 193 | return this._stream[name][`${key}$`]; 194 | } 195 | 196 | // sub = (model|key|mouse|socket|router) 197 | Object.keys(subscriptions).forEach(sub => { 198 | subscriptions[sub](source.bind(this), { call, map, dispatch, put: dispatch }); 199 | }); 200 | } 201 | 202 | function model({ name, state = {}, reducers = {}, pipes = {}, subscriptions = {} }) { 203 | checkModel({ name, state, reducers, pipes, subscriptions }, this._state); 204 | 205 | this.dispatch({ 206 | type: 'plugin', 207 | action: 'onModelBeforeCreate', 208 | model: { name, state, reducers, pipes }, 209 | }); 210 | 211 | this._state[name] = state; 212 | this._reducers[name] = reducers; 213 | this._pipes[name] = pipes; 214 | this._subscriptions[name] = subscriptions; 215 | this._stream[name] = {}; 216 | this._errors[name] = []; 217 | 218 | const out$$ = new BehaviorSubject(state => state); 219 | 220 | // 为 reducers 创建同步数据流 221 | createReducerStreams.call(this, name, reducers, out$$); 222 | 223 | // 为 pipes 创建异步数据流 224 | createPipeStreams.call(this, name, reducers, pipes, out$$); 225 | 226 | // 创建数据流出口 227 | createModelStream.call(this, name, state, out$$); 228 | 229 | createSubscriptions.call(this, subscriptions); 230 | 231 | this.dispatch({ 232 | type: 'plugin', 233 | action: 'onModelCreated', 234 | model: name, 235 | }); 236 | } 237 | 238 | function dispatch(action) { 239 | invariant( 240 | isPlainObject(action), 241 | '[action] Actions must be plain objects.', 242 | ); 243 | invariant( 244 | action.type, 245 | '[action] action should be a plain Object with type', 246 | ); 247 | bus$.next(action); 248 | } 249 | 250 | function getState(name) { 251 | let stream$ = this.stream(name); 252 | let _state; 253 | stream$.subscribe(state => (_state = state)); 254 | return _state; 255 | } 256 | 257 | function subscribe(listener) { 258 | invariant( 259 | isFunction(listener), 260 | 'Expected the listener to be a function', 261 | ); 262 | const sub = this.stream().subscribe(listener); 263 | 264 | let isSubscribed = true 265 | return function unsubscribe() { 266 | if (!isSubscribed) return; 267 | isSubscribed = false; 268 | sub.unsubscribe(); 269 | }; 270 | } 271 | 272 | function stream(name) { 273 | let stream$ = !!name ? this[`${name}$`] : this.getSingleStore(); 274 | !!name && invariant( 275 | stream$, 276 | `[app.stream] model "${name}" must be registered`, 277 | ); 278 | return stream$; 279 | } 280 | 281 | function getSingleStore() { 282 | const streams = []; 283 | const models = []; 284 | 285 | Object.keys(this._stream).forEach(name => { 286 | models.push(name); 287 | streams.push(this[`${name}$`]); 288 | }); 289 | 290 | return combineLatest( ...streams ).pipe( 291 | map((arr) => { 292 | const store = {}; 293 | models.forEach(( model, index) => { 294 | store[model] = arr[index]; 295 | }); 296 | return store; 297 | }), 298 | ); 299 | } 300 | 301 | function createReducer(action = {}, reducer = noop) { 302 | return (state) => reducer(state, action); 303 | } 304 | 305 | function start() { 306 | this.dispatch({ 307 | type: 'plugin', 308 | action: 'onStart', 309 | }); 310 | } 311 | 312 | const app = { 313 | _state: {}, 314 | _stream: {}, 315 | _errors: {}, 316 | _reducers: {}, 317 | _pipes: {}, 318 | _subscriptions: {}, 319 | getSingleStore, 320 | model, 321 | subscribe, 322 | getState, 323 | stream, 324 | createReducer, 325 | dispatch, 326 | start, 327 | next: dispatch, 328 | }; 329 | 330 | initPlugins.call(app, option.plugins, createStream('plugin')); 331 | 332 | return app; 333 | } 334 | -------------------------------------------------------------------------------- /packages/core/test/rxloop.spec.js: -------------------------------------------------------------------------------- 1 | import rxloop from '../'; 2 | import { of } from 'rxjs'; 3 | import { map, mapTo } from "rxjs/operators"; 4 | 5 | const app = rxloop(); 6 | app.model({ 7 | name: 'counter', 8 | state: { 9 | counter: 0, 10 | }, 11 | reducers: { 12 | increment(state,) { 13 | return { 14 | ...state, 15 | counter: state.counter + 1, 16 | }; 17 | }, 18 | decrement(state,) { 19 | return { 20 | ...state, 21 | counter: state.counter - 1, 22 | }; 23 | }, 24 | setCounter(state, action) { 25 | return { 26 | ...state, 27 | counter: action.counter, 28 | }; 29 | } 30 | }, 31 | pipes: { 32 | getRemoteCount(action$) { 33 | return action$.pipe( 34 | mapTo({ 35 | type: 'setCounter', 36 | counter: 100, 37 | }), 38 | ); 39 | }, 40 | isNotAction(action$) { 41 | return action$.pipe( 42 | mapTo({ 43 | counter: 100, 44 | }), 45 | ); 46 | } 47 | } 48 | }); 49 | 50 | app.model({ 51 | name: 'comment', 52 | state: { 53 | list: [], 54 | }, 55 | }); 56 | 57 | app.model({ 58 | name: 'a', 59 | state: { 60 | txt: 'a', 61 | }, 62 | reducers: { 63 | update(state, action) { 64 | return { 65 | ...state, 66 | ...action.data, 67 | }; 68 | }, 69 | }, 70 | pipes: { 71 | setTxt(action$, { map, dispatch }) { 72 | return action$.pipe( 73 | map(() => { 74 | dispatch({ 75 | type: 'b/update', 76 | data: { txt: 'updated from a' }, 77 | }); 78 | return { 79 | type: 'update', 80 | data: { txt: 'updated' }, 81 | }; 82 | }), 83 | ); 84 | } 85 | }, 86 | }); 87 | 88 | app.model({ 89 | name: 'b', 90 | state: { 91 | txt: 'b', 92 | }, 93 | reducers: { 94 | update(state, action) { 95 | return { 96 | ...state, 97 | ...action.data, 98 | }; 99 | } 100 | } 101 | }); 102 | 103 | app.model({ 104 | name: 'test_subscriptions_a', 105 | state: { 106 | info: 0, 107 | }, 108 | reducers: { 109 | update(state, action) { 110 | console.log(1, action); 111 | return { 112 | ...state, 113 | info: action.data, 114 | }; 115 | } 116 | }, 117 | pipes: { 118 | getData(action$, { map }) { 119 | return action$.pipe( 120 | map(() => { 121 | return { 122 | type: 'update', 123 | data: 22, 124 | }; 125 | }), 126 | ); 127 | }, 128 | }, 129 | }); 130 | 131 | app.model({ 132 | name: 'test_subscriptions_b', 133 | state: { 134 | info: 0, 135 | }, 136 | reducers: { 137 | update(state, action) { 138 | return { 139 | ...state, 140 | info: action.data, 141 | }; 142 | } 143 | }, 144 | subscriptions: { 145 | model(source, { dispatch }) { 146 | source('test_subscriptions_a/update').subscribe(() => { 147 | dispatch({ 148 | type: 'test_subscriptions_b/update', 149 | data: 1, 150 | }); 151 | }); 152 | source('test_subscriptions_a/getData/end').subscribe(() => { 153 | dispatch({ 154 | type: 'test_subscriptions_b/update', 155 | data: 3, 156 | }); 157 | }); 158 | }, 159 | }, 160 | }); 161 | 162 | app.model({ 163 | name: 'test_subscriptions_c', 164 | state: { 165 | info: {}, 166 | }, 167 | reducers: { 168 | update(state) { 169 | return { 170 | ...state, 171 | info: 2, 172 | }; 173 | } 174 | }, 175 | subscriptions: { 176 | model(source, { dispatch }) { 177 | source('test_subscriptions_a/update').subscribe(() => { 178 | dispatch({ 179 | type: 'test_subscriptions_c/update', 180 | }); 181 | }); 182 | }, 183 | }, 184 | }); 185 | 186 | describe('Basic api', () => { 187 | test('exposes the public API', () => { 188 | const apis = Object.keys(app); 189 | 190 | expect(apis).toContain('model'); 191 | expect(apis).toContain('counter$'); 192 | expect(apis).toContain('comment$'); 193 | expect(apis).toContain('dispatch'); 194 | expect(apis).toContain('getState'); 195 | expect(apis).toContain('stream'); 196 | expect(apis).toContain('plugin$'); 197 | }); 198 | 199 | test('default counter state is { counter: 0 }', () => { 200 | expect(app.getState('counter')).toEqual({ 201 | counter: 0, 202 | }); 203 | }); 204 | 205 | test('default comment state is { list: [] }', () => { 206 | expect(app.getState('comment')).toEqual({ 207 | list: [], 208 | }); 209 | }); 210 | 211 | }); 212 | 213 | describe('Error check', () => { 214 | test('throws if is pass an existed name', () => { 215 | expect(() => app.model({ name: 'counter' })).toThrow() 216 | }); 217 | 218 | test('throws if is duplicated type in pipes and reducers', () => { 219 | expect(() => { 220 | app.model({ 221 | name: 'test', 222 | state: {}, 223 | reducers: { 224 | aa(state) { 225 | return state; 226 | }, 227 | }, 228 | pipes: { 229 | aa(action$) { 230 | return action$.pipe( 231 | mapTo({}) 232 | ); 233 | }, 234 | }, 235 | }); 236 | }).toThrow() 237 | }); 238 | 239 | test('throws if reducers is not a plain object', () => { 240 | expect(() => app.model({ name: 'c', state: 1, reducers: 'object' })) 241 | .toThrow('[app.model] reducers should be plain object, but got string'); 242 | }); 243 | 244 | test('throws if all reducer should not be function', () => { 245 | expect(() => app.model({ 246 | name: 'd', 247 | state: 1, 248 | reducers: { 249 | a() {}, 250 | b: 'b', 251 | }, 252 | })) 253 | .toThrow('[app.model] all reducer should be function'); 254 | }); 255 | 256 | test('throws if pipes is not a plain object', () => { 257 | expect(() => app.model({ name: 'c', state: 1, pipes: 'object' })) 258 | .toThrow('[app.model] pipes should be plain object, but got string'); 259 | }); 260 | 261 | test('throws if all pipe should not be function', () => { 262 | expect(() => app.model({ 263 | name: 'e', 264 | state: 1, 265 | pipes: { 266 | a() {}, 267 | b: 'b', 268 | }, 269 | })) 270 | .toThrow('[app.model] all pipe should be function'); 271 | }); 272 | 273 | test('throws if subscriptions is not a plain object', () => { 274 | expect(() => app.model({ name: 'c', state: 1, subscriptions: 'object' })) 275 | .toThrow('[app.model] subscriptions should be plain object, but got string'); 276 | }); 277 | 278 | test('throws if all subscription should not be function', () => { 279 | expect(() => app.model({ 280 | name: 'e', 281 | state: 1, 282 | subscriptions: { 283 | a() {}, 284 | b: 'b', 285 | }, 286 | })) 287 | .toThrow('[app.model] all subscriptions should be function'); 288 | }); 289 | 290 | test('throws if is not pass an undifined model', () => { 291 | expect(() => app.stream('undifined')).toThrow(); 292 | }); 293 | 294 | test('throws if is not pass a plain object with type', () => { 295 | expect(() => app.dispatch({})).toThrow(); 296 | }); 297 | }); 298 | 299 | describe('Basic usage', () => { 300 | test('dispatch increment: state is { counter: 4 }', () => { 301 | app.dispatch({ 302 | type: 'counter/increment', 303 | }); 304 | app.dispatch({ 305 | type: 'counter/increment', 306 | }); 307 | app.dispatch({ 308 | type: 'counter/increment', 309 | }); 310 | of(1).pipe( 311 | mapTo({ 312 | type: 'counter/increment', 313 | }), 314 | ).subscribe(app); 315 | expect(app.getState('counter')).toEqual({ 316 | counter: 4, 317 | }); 318 | }); 319 | 320 | test('dispatch decrement: state is { counter: 2 }', () => { 321 | app.dispatch({ 322 | type: 'counter/decrement', 323 | }); 324 | app.dispatch({ 325 | type: 'counter/decrement', 326 | }); 327 | expect(app.getState('counter')).toEqual({ 328 | counter: 2, 329 | }); 330 | let _value; 331 | app.stream('counter').subscribe(value => (_value = value)); 332 | expect(_value).toEqual({ 333 | counter: 2, 334 | }); 335 | }); 336 | 337 | test('dispatch getRemoteCount: state is { counter: 100 }', () => { 338 | app.dispatch({ 339 | type: 'counter/getRemoteCount', 340 | }); 341 | expect(app.getState('counter')).toEqual({ 342 | counter: 100, 343 | }); 344 | }); 345 | }); 346 | 347 | describe('Cross model usage', () => { 348 | test('dispatch model A: update model B', () => { 349 | app.dispatch({ 350 | type: 'a/setTxt', 351 | }); 352 | expect(app.getState('a')).toEqual({ 353 | txt: 'updated', 354 | }); 355 | expect(app.getState('b')).toEqual({ 356 | txt: 'updated from a', 357 | }); 358 | }); 359 | 360 | test('subscribe reducer', () => { 361 | app.dispatch({ 362 | type: 'test_subscriptions_a/update', 363 | data: 1, 364 | }); 365 | expect(app.getState('test_subscriptions_b')).toEqual({ 366 | info: 1, 367 | }); 368 | expect(app.getState('test_subscriptions_c')).toEqual({ 369 | info: 2, 370 | }); 371 | }); 372 | 373 | test('subscribe pipe', () => { 374 | app.dispatch({ 375 | type: 'test_subscriptions_a/getData', 376 | }); 377 | expect(app.getState('test_subscriptions_b')).toEqual({ 378 | info: 3, 379 | }); 380 | }); 381 | }); 382 | 383 | describe('check config', () => { 384 | test('Mount a plugin', () => { 385 | const mockPlugin = jest.fn(); 386 | rxloop({ 387 | plugins: [ 388 | mockPlugin, 389 | ], 390 | }); 391 | expect(mockPlugin.mock.calls.length).toBe(1); 392 | const pluginEvts = Object.keys(mockPlugin.mock.calls[0][0]); 393 | expect(pluginEvts.length).toBe(8); 394 | expect(pluginEvts).toContain('onModelBeforeCreate$'); 395 | expect(pluginEvts).toContain('onModelCreated$'); 396 | expect(pluginEvts).toContain('onPipeStart$'); 397 | expect(pluginEvts).toContain('onPipeEnd$'); 398 | expect(pluginEvts).toContain('onPipeCancel$'); 399 | expect(pluginEvts).toContain('onPipeError$'); 400 | expect(pluginEvts).toContain('onStatePatch$'); 401 | expect(pluginEvts).toContain('onStart$'); 402 | }); 403 | test('global error hook', (done) => { 404 | const app = rxloop({ 405 | onError(json) { 406 | expect(json).toEqual({ 407 | error: 'boom!', 408 | model: 'test', 409 | pipe: 'getData', 410 | }); 411 | done(); 412 | }, 413 | }); 414 | 415 | app.model({ 416 | name: 'test', 417 | state: {}, 418 | reducers: { 419 | add(state) { 420 | return state; 421 | }, 422 | }, 423 | pipes: { 424 | getData(action$) { 425 | return action$.pipe( 426 | map(() => { 427 | throw 'boom!'; 428 | }), 429 | ); 430 | }, 431 | }, 432 | }); 433 | app.dispatch({ type: 'test/getData' }); 434 | }); 435 | }); 436 | 437 | describe('Single store', () => { 438 | const app = rxloop(); 439 | app.model({ 440 | name: 'user', 441 | state: { 442 | user: 'user', 443 | }, 444 | }); 445 | app.model({ 446 | name: 'counter', 447 | state: { 448 | counter: 0, 449 | }, 450 | reducers: { 451 | inc(state) { 452 | return { 453 | counter: state.counter + 1, 454 | }; 455 | } 456 | }, 457 | }); 458 | app.dispatch({ type: 'counter/inc' }); 459 | 460 | test('single stream test', (done) => { 461 | app.stream().subscribe(store => { 462 | expect(store).toEqual({ 463 | user: { 464 | user: 'user', 465 | }, 466 | counter: { 467 | counter: 1, 468 | }, 469 | }); 470 | done(); 471 | }); 472 | }); 473 | 474 | test('Subscribe single stream', (done) => { 475 | const unsubscribe = app.subscribe(() => { 476 | expect(app.getState()).toEqual({ 477 | user: { 478 | user: 'user', 479 | }, 480 | counter: { 481 | counter: 1, 482 | }, 483 | }); 484 | done(); 485 | }); 486 | unsubscribe(); 487 | }); 488 | 489 | test('get single store test', () => { 490 | expect(app.getState('user')).toEqual({ 491 | user: 'user', 492 | }); 493 | expect(app.getState('counter')).toEqual({ 494 | counter: 1, 495 | }); 496 | expect(app.getState()).toEqual({ 497 | user: { 498 | user: 'user', 499 | }, 500 | counter: { 501 | counter: 1, 502 | }, 503 | }); 504 | }); 505 | }); 506 | --------------------------------------------------------------------------------