├── .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 | 
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) |  |
102 | | [loading](https://github.com/TalkingData/rxloop-loading) |  |
103 | | [devtools](https://github.com/TalkingData/rxloop-devtools) |  |
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 |
--------------------------------------------------------------------------------