React之Dva

了解Dva

dva = React-Router + Redux + Redux-saga

State:一个对象,保存整个应用状态
View:React 组件构成的视图层
Action:一个对象,描述事件
connect 方法:一个函数,绑定 State 到 View
dispatch 方法:一个函数,发送 Action 到 State
dispatch 方法从哪里来?被 connect 的 Component 会自动在 props 中拥有 dispatch 方法。

数据流向

数据的改变发生通常是通过用户交互行为或者浏览器行为(如路由跳转等)触发的,当此类行为会改变数据的时候可以通过 dispatch 发起一个 action,如果是同步行为会直接通过 Reducers 改变 State ,如果是异步行为(副作用)会先触发 Effects 然后流向 Reducers 最终改变 State,所以在 dva 中,数据流向非常清晰简明,并且思路基本跟开源社区保持一致(也是来自于开源社区)。

State

State 表示 Model 的状态数据,通常表现为一个 javascript 对象(当然它可以是任何值);操作的时候每次都要当作不可变数据(immutable data)来对待,保证每次都是全新对象,没有引用关系,这样才能保证 State 的独立性,便于测试和追踪变化。

Action

Action 是一个普通 javascript 对象,它是改变 State 的唯一途径。无论是从 UI 事件、网络回调,还是 WebSocket 等数据源所获得的数据,最终都会通过 dispatch 函数调用一个 action,从而改变对应的数据。action 必须带有 type 属性指明具体的行为,其它字段可以自定义,如果要发起一个 action 需要使用 dispatch 函数;需要注意的是 dispatch 是在组件 connect Models以后,通过 props 传入的。

dispatch 函数

dispatching function 是一个用于触发 action 的函数,action 是改变 State 的唯一途径,但是它只描述了一个行为,而 dipatch 可以看作是触发这个行为的方式,而 Reducer 则是描述如何改变数据的。

在 dva 中,connect Model 的组件通过 props 可以访问到 dispatch,可以调用 Model 中的 Reducer 或者 Effects,常见的形式如:

Reducer

Reducer(也称为 reducing function)函数接受两个参数:之前已经累积运算的结果和当前要被累积的值,返回的是一个新的累积结果。该函数把一个集合归并成一个单值。

Reducer 的概念来自于是函数式编程,很多语言中都有 reduce API。如在 javascript 中:

  1. [{x:1},{y:2},{z:3}].reduce(function(prev, next){
  2. return Object.assign(prev, next);
  3. })
  4. //return {x:1, y:2, z:3}

在 dva 中,reducers 聚合积累的结果是当前 model 的 state 对象。通过 actions 中传入的值,与当前 reducers 中的值进行运算获得新的值(也就是新的 state)。需要注意的是 Reducer 必须是纯函数,所以同样的输入必然得到同样的输出,它们不应该产生任何副作用。并且,每一次的计算都应该使用immutable data,这种特性简单理解就是每次操作都是返回一个全新的数据(独立,纯净),所以热重载和时间旅行这些功能才能够使用。

Effect

Effect 被称为副作用,在我们的应用中,最常见的就是异步操作。它来自于函数编程的概念,之所以叫副作用是因为它使得我们的函数变得不纯,同样的输入不一定获得同样的输出。

dva 为了控制副作用的操作,底层引入了redux-sagas做异步流程控制,由于采用了generator的相关概念,所以将异步转成同步写法,从而将effects转为纯函数。至于为什么我们这么纠结于 纯函数,如果你想了解更多可以阅读Mostly adequate guide to FP,或者它的中文译本JS函数式编程指南。

Subscription

Subscriptions 是一种从 源 获取数据的方法,它来自于 elm。

Subscription 语义是订阅,用于订阅一个数据源,然后根据条件 dispatch 需要的 action。数据源可以是当前的时间、服务器的 websocket 连接、keyboard 输入、geolocation 变化、history 路由变化等等。

import key from 'keymaster';
...
app.model({
  namespace: 'count',
  subscriptions: {
    keyEvent({dispatch}) {
      key('⌘+up, ctrl+up', () => { dispatch({type:'add'}) });
    },
  }
});

Router

这里的路由通常指的是前端路由,由于我们的应用现在通常是单页应用,所以需要前端代码来控制路由逻辑,通过浏览器提供的 History API 可以监听浏览器url的变化,从而控制路由相关操作。

dva 实例提供了 router 方法来控制路由,使用的是react-router。

Route Components

在组件设计方法中,我们提到过 Container Components,在 dva 中我们通常将其约束为 Route Components,因为在 dva 中我们通常以页面维度来设计 Container Components。

所以在 dva 中,通常需要 connect Model的组件都是 Route Components,组织在/routes/目录下,而/components/目录下则是纯组件(Presentational Components)。

Dva四图图解:

https://yuque.com/flying.ni/the-tower/tvzasn

动态加载model

有不少业务场景下,我们可能会定义出很多个model,但并不需要在应用启动的时候就全部加载,比较典型的是各类管理控制台。如果每个功能页面是通过路由切换,互相之间没有关系的话,通常会使用webpack的require.ensure来做代码模块的懒加载。

function RouterConfig({ history, app }) {
  const routes = [
    {
      path: '/',
      name: 'IndexPage',
      getComponent(nextState, cb) {
        require.ensure([], (require) => {
          registerModel(app, require('./models/dashboard'));
          cb(null, require('./routes/IndexPage'));
        });
      },
    },
    {
      path: '/users',
      name: 'UsersPage',
      getComponent(nextState, cb) {
        require.ensure([], (require) => {
          registerModel(app, require('./models/users'));
          cb(null, require('./routes/Users'));
        });
      },
    },
  ];

  return <Router history={history} routes={routes} />;
}

这样,在视图切换到这个路由的时候,对应的model就会被加载。同理,也可以做model的动态移除,不过,一般情况下是不需要移除的。

使用model共享全局信息

在上一节我们提到,可以动态加载model,也可以移除。从这个角度看,model是可以有不同生命周期的,有些可以与功能视图伴随,而有些可以贯穿整个应用的生命周期。

从业务场景来说,有不少场景是可以做全局model的,比如说,我们在路由之间前进后退,model可以用于在路由间共享数据,比较典型的,像列表页和详情页的互相跳转,就可以用同一份model去共享它们的数据。

注意,如果当前应用中加载了不止一个model,在其中一个的effect里面做select操作,是可以获取另外一个中的state的:

*foo(action, { select }) {
  const { a, b } = yield select();
}

这里,a,b可以分别是两个不同model的state。所以,借助这个特点,我们就不必非要把model按照视图的结构进行组织,可以适当按照业务分类,把一些数据存在对应业务的model中,分别通过不同的effect去更新,在获取的地方再去组合,这样可以使得model拥有更好的复用性。

model的复用

有时候,业务上可能遇到期望把一些与外部关联较少的model拆出来的需求,我们可能会拆出这样的一个model,然后用不同的视图容器去connect它。

export default {
  namespace: 'reusable',
  state: {},
  reducers: {},
  effects: {}
}

所以,在业务上,可能出现的使用情况就是:

 ContainerA <-- ModelA
                   |
    ------------------------------
    |                            |
ContainerB <-- reusable     ContainerC <-- reusable

这里面,ContainerB和ContainerC是ContainerA的下属,它们的逻辑结构一致,只是展现不同。我们可以让它们分别connect同一个model,注意,这个时候,model的修改会同时影响到两个视图,因为model在state中是直接以namespace作key存放的,实际上只有一份实例。

动态扩展model

注意到dva中的每个model,实际上都是普通的JavaScript对象,包含

  • namespace
  • state
  • reducers
  • effects
  • subscriptions

从这个角度看,我们要新增或者覆盖一些东西,都会是比较容易的,比如说,使用Object.assign来进行对象属性复制,就可以把新的内容添加或者覆盖到原有对象上。

长流程的业务逻辑

在业务中,有时候会出现较长的流程,比如说,我们的一个复杂表单的提交,中间会需要去发起多种对视图状态的操作:

*submit(action, { put, call, select }) {
  const formData = yield select(state => {
    const buyModel = state.buy;
    const context = state.context;
    const { stock } = buyModel;
    return {
      uuid: context.uuid,
      market: stock && stock.market,
      stockCode: stock && stock.code,
      stockName: stock && stock.name,
      price: String(buyModel.price),
      // 委托数量
      entrustAmount: String(buyModel.count),
      totalBalance: buyModel.totalBalance,
      availableTzbBalance: buyModel.availableTzbBalance,
      availableDepositBalance: buyModel.availableDepositBalance,
    };
  });
  const result = yield call(post, '/h5/ajax/trade/entrust_buy', formData, { loading: true });

  if (result.success) {
    toast({
      type: 'success',
      content: '委托已受理',
    });
    // 成功之后再获取一次现价,并填入
    // yield put({type: 'fetchQuotation', payload: stock});

    yield put({ type: 'entrustNoChange', payload: result.result && result.result.entrustNo });
    // 清空输入框内容
    yield put({ type: 'searchQueryChange', value: '' });
  }

  // 403时,需要验证密码再重新提交
  if (!result.success && result.resultCode === 403) {
    yield put({ type: 'checkPassword', payload: {} });
    return;
  }

  // 失败之后也需要更新投资宝和保证金金额
  if (result.result) {
    yield put({ type: 'balanceChange', payload: result.result });
  }

  // 重新获取最新可撤单列表
  yield put({ type: 'fetchRevockList' });

  // 返回的结果里面如果有uuid, 用新的uuid替换
  if (result.uuid) {
    yield put({ type: 'context/updateUuid', payload: result.uuid });
  }
},
在一个effect中,可以使用多个put来分别调用reducer来更新状态。

存在另外一些流程,在effect中可能会存在多个异步的服务调用,比如说,要调用一次服务端的验证,成功之后再去提交数据,这时候,在一个effect中就会存在多个call操作了。

使用take操作进行事件监听

一个流程的变动,需要扩散到若干个其他model中

在redux-saga中,提供了take和takeLatest这两个操作,

dva是redux-saga的封装,也是可以使用这种操作的。

所以,我们也可以在dva中使用take操作来监听action。

要理解take操作的语义,可以参见这两种示例的对比:

假设我们有一个事件处理的代码:

someSource.on('click', event => doSomething(event))

这段代码转成用generator来表达,就是下面这个形式:

function* saga() {
  while(true) {
     const event = yield take('click');
     doSomething(event);
  }
}
所以,我们也可以在dva中使用take操作来监听action。

多任务调度

多个任务的串行执行方式,这是业务中最常见的多任务执行方式,只需逐个yield call就可以了。

并行,若干个任务之间不存在依赖关系,并且后续操作对它们的结果无依赖

竞争,若干个任务之间,只要有一个执行完成,就进入下一个环节

子任务,若干个任务,并行执行,但必须全部做完之后,下一个环节才继续执行

任务的并行执行

const [result1, result2]  = yield all([
  call(service1, param1),
  call(service2, param2)
])

把多个要并行执行的东西放在一个数组里,就可以并行执行,等所有的都结束之后,进入下个环节,类似promise.all的操作。一般有一些集成界面,比如dashboard,其中各组件之间业务关联较小,就可以用这种方式去分别加载数据,此时,整体加载时间只取决于时间最长的那个。

ps:yield [];不要写成 yield *[];这两者含义是不同的,后者会顺序执行。

任务的竞争

如果多个任务之间存在竞争关系,可以通过下面这种方式:

const { data, timeout } = yield race({
  data: call(service, 'some data'),
  timeout: call(delay, 1000)
});

if (data)
  put({type: 'DATA_RECEIVED', data});
else
  put({type: 'TIMEOUT_ERROR'});

这个例子比较巧妙地用一个延时一秒的空操作来跟一个网络请求竞争,如果到了一秒,请求还没结束,就让它超时。

这个类似于Promise.race的作用。

跨model的通信

一个流程贯穿多个model

对这个事情,我们可能有若干中不同的解决办法。假设有如下场景:

父容器A,子容器B,二者各自connect了不同的model A和B
父容器中有一个操作,分三个步骤:
model A中某个effect处理第一步
call model B中的某个effect去处理第二步
第二步结束后,再返回model A中做第三步
在dva中,可以用namespace去指定接受action的model,所以可以通过类似这样的方式去组合:

yield call({ type: 'a/foo' });
yield call({ type: 'b/foo' });
yield call({ type: 'a/bar' });

甚至,还可以利用take命令,在另外一个model的某个effect中插入逻辑:

*effectA() {
  yield call(service1);
  yield put({ type: 'service1Success' });
  // 如果我们复用这个effect,但要在这里加一件事,怎么办?
  yield call(service2);
  yield put({ type: 'service2Success' });
}

可以利用之前我们说的take命令:

yield take('a/service1Success');

这样,可以在外部往里面添加一个并行操作,通过这样的组合可以处理一些组合流程。但实际情况下,我们可能要处理的不仅仅是effect,很可能视图组件中还存在后续逻辑,在某个action执行之后,还需要再做某些事情。

yield call({ type: 'a/foo' });
yield call({ type: 'b/foo' });
// 如果这里是要在组件里面做某些事情,怎么办?

可以利用一些特殊手段把流程延伸出来到组件里。比如说,我们通常在组件中dispatch一个action的时候,不会处理后续事情,但可以修改这个过程:
new Promise((resolve, reject) => {
  dispatch({ type: 'reusable/addLog', payload: { data: 9527, resolve, reject } });
})
.then((data) => {
  console.log(`after a long time, ${data} returns`);
});

注意这里,我们是把resolve和reject传到action里面了,所以,只需在effect里面这样处理:

try {
  const result = yield call(service1);
  yield put({ type: 'service1Success', payload: result });
  resolve(result);
}
catch (error) {
  yield put({ type: 'service1Fail', error });
  reject(ex);
}
    import { EffectsCommandMap } from 'dva';
    "global": "^4.3.2",// 用于提供全局函数比如 document 的引用
        "invariant": "^2.2.1",// 一个有趣的断言库

        "redux": "^3.7.1", // redux ,管理 react 状态的库
        "redux-saga": "^0.15.4", // 处理异步数据流
    "warning": "^3.0.0" // 同样是个断言库,不过输出的是警告

    "redux": "^3.7.2" // 提供了 store、dispatch、reducer \AnyAction
    "react-redux": "^5.0.5", // 提供了一个高阶组件,方便在各处调用 store
    "react-router-redux": "5.0.0-alpha.6",// redux 的中间件,在 provider 里可以嵌套 router

dynamic 动态加载(2.0 以后官方提供 1.x 自己手动实现吧)
fetch 请求方法(其实 dva 只是做了一把搬运工)
saga(数据层处理异步的方法)。

Effect

Action 处理器,处理异步动作,基于 Redux-saga 实现。Effect 指的是副作用。根据函数式编程,计算以外的操作都属于 Effect,典型的就是 I/O 操作、数据库读写。

function *addAfter1Second(action, { put, call }) {
  yield call(delay, 1000);
  yield put({ type: 'add' });
}

Generator 函数

Effect 是一个 Generator 函数,内部使用 yield 关键字,标识每一步的操作(不管是异步或同步)。

call 和 put

dva 提供多个 effect 函数内部的处理函数,比较常用的是 call 和 put。

call:执行异步函数
put:发出一个 Action,类似于 dispatch