dva 中的响应编程

11,947 阅读12分钟

刚才在看到《腾讯祭出大招VasSonic,让你的H5页面首屏秒开!》里的一句话:

“计算机领域任何一个问题,都可以通过引入中间层来解决。”

D.Va

最开始听同事说起 dva ,我还以为他守望先锋玩魔怔了 —— 后来才知道他说的 dva 是蚂蚁金服 antd 组件库的御用轻量级框架。不过那个时候我们公司用的还是基于 redux-thunk 体系的脚手架,多少是和 dva 有点差别。所以很长一段时间内,我只是知道有这么个东西而已。

最近开始用它做项目,上手有点别扭。

作为一个兼职前端(好像现在也不算兼职了),我的知识体系还是比较混乱,总是知其然而不知其所以然。虽然 Angular 和 React 代码写的也算不少了,写来写去总缺少临门一脚的顿悟,最后还是一团浆糊。

前几天翻译了一篇讲 Android 函数式响应型编程的文章,突然间就理解了 dva 编码思想。再回头审视用 dva 写的代码,发现那篇文章里讲解的很多理论和 dva 不谋而合,终于有种摸到大门的欣慰。

说起来也满狗血的。

思维盲区

我最开始学习使用 dva 是从《12 步 30 分钟,完成用户管理的 CURD 应用》开始的,这同时也是 dva 的官方教程。然而因为领悟能力太差,最开始完全没理解。前 4 步还跟得上,第 5 步创建 model 和改造 service 就懵逼了。硬着头皮照抄代码,抄到最后数据没出来,我还不知道自己哪儿错了。

大写的尴尬。

现在再看这篇教程,发现从第 5 步的 model 开始,dva 的作者就试图推广一种最近流行的理念:响应型编程

就目前来说,渲染数据流有至少两种方式。由外界改变组件内部状态的主动型,以及由组件监听外界改变而渲染自身的响应型。很多人 —— 尤其是 oop 重度患者 —— 的惯性思维是第一种。无论把负责业务的 service 类扔的多远,service 和 controller 都是直接连接的

以请求数据为例,我还停留在拿取数据推送到组件中进行渲染的阶段。有那么一段时间,我对 container 和 component 之间的值交换还局限在属性传值回调函数层面上。虽然回调函数实现了一定程度上的事件响应,但组件之间仍旧脱离不开互相直连的主动型编码的怪圈。

吃火锅的正确姿势

举一个吃火锅的例子来解释主动型编程响应型编程的差别:

一般情况下,吃火锅的时候都是点了菜和肉摆在自己脸跟前,想吃什么自己夹了往锅里扔,看看快熟了就捞出来吃掉。

这是主动型。

但是这么吃火锅有三个问题:

第一,一切都要亲力亲为。想吃肉就要亲自把肉放到锅里去,想吃菜也要亲自把菜放进去。如果还想吃豆腐、蘑菇、粉条、羊尾鱼丸…想吃的东西越多,操作就越复杂。

第二,既然是亲自放东西,就得把东西摆在自己面前。桌子一共就那么点儿地方,想吃的东西越多占用的空间就越大。既不容易留出足够的空间吃烫熟的茼蒿,也不容易把想吃的牛肉片从眼前一大堆的蔬菜里挑出来。万一要换桌,还得的把这一大堆吃的一起打包带走,漏掉一样就吃不到。

第三,如果我想吃撒尿牛丸和虾滑、鱿鱼,旁边的哥们海鲜过敏。是应该我负责往锅里放然后烫熟捞出来,他从我这里捞他能吃的;还是我俩各放各的,自己捞自己想吃的东西?前者虽然一个人做了共同的事情,但是别人一起吃的时候难免会捞错;后者虽然看起来互不干扰,但是两个人都在烫牛丸,多少是浪费。万一是个鸳鸯锅我还不吃辣怎么办?烫到最后全乱套了。

用编程的术语说,便是:低内聚,高耦合

或许正统的 oop 语言(比如 Java)可以用封装、继承、多态来某种程度的缓解这个问题(仅仅是某种程度上),但是 JavaScript 想从语言的角度实现就会无比操蛋(JavaScript 用 prototype 模拟 oop 实现,而 es6 里的 class 和 Java 里的 class 又完全不是一个东西)。

现在我们换种方式吃火锅:分出一个人来啥也不吃,把所有吃的都放在他面前。想吃蘑菇就对他说一声,让他替你把蘑菇放进火锅烫熟,替你把熟蘑菇放进蘸料碟里。

你唯一要做的事情就是吼一嗓子,然后从自己的蘸料碟里夹蘑菇,吃。

哎呀,这个就太爽了。

想吃猪脑,“来盘猪脑”;想吃鸭血,“来盘鸭血”;想吃 10 盘地瓜片就大喊“来10盘地瓜片”,用不着自己费事一盘一盘的往锅里倒。

而且既然食材都堆在另一个地方,自己面前留一个吃东西的蘸料碟就够用,十干净整洁。桌子随便换,挥挥衣袖带双筷子走就可以了。

一群人组团吃,大家各点各的吃互不干扰。烫火锅的也始终只有一个人,既不会造成资源浪费,又不必让其他人关心额外的东西。

这就是所谓的响应型编码。

被分出去的那个人,在 React 体系里就是 Redux(或者相同功能的库);具体到 dva 框架中,就是 model。

(理想情况下)所有的组件只和 model 连接,互相之间完全没有直接交集,这便是响应型编码思想在 dva 框架中的体现。

dva 中的响应型编码

有了响应型编码的理论以后,我很容易的就理解第 5 步的操作。

此时我的情况是:

通过 dva g model user 可以很方便的创建 model/user.js 并注册进 index.js 中(命令行万岁!) ,虽然目前还什么都没有:

我需要做的事情就是把数据从 api/user接口拉下来,渲染进 route/user 里(component 可以等等再谈)。

把大象...我是说数据渲染进 route/user 需要三步:

  1. 编写请求接口的方法
  2. 使用 1 的方法获得数据
  3. 将 2 数据渲染进页面

编写请求接口的方法

dva 的新手大礼包里已经提供了基础的网络请求函数 utils/resquest.js ,虽然大多数情况下都会对其进行一些扩展才能满足现实项目的需求,但是就目前来说暂且是够用的。

以 oop 观点来看,utils/resquest.js 相当于项目所有请求函数的基类(base class)。如果需要进行具体业务的编写,应该新建一个继承 utils/resquest.js 的子类。但 JavaScript 不算是纯种 oop 的语言,所以惯例都是新建一个具体的业务类 services/user.js,通过在 services/user.jsimport 的方式调用 utils/resquest.js

// 在 services 目录下新建 services/user.js,负责具体的 user 业务

import request from '../utils/request';


export function getUserData() { // 偷懒,暂时把 example.js 的代码拷贝过来
  return request('api/users'); // 这里是一个 promise 对象
}

实际上这个时候如果直接把请求函数写在 route/user.js 里已经可以渲染页面了。


// 这是一个错误的示范

import React, { Component, PropTypes } from 'react';
import * as userService from '../services/user';

class User extends Component {
    static propTypes = {
        className: PropTypes.string,
    };

    constructor(props) {
        super(props);
        this.state = {
          list : []
        }
    }

    componentDidMount() {
      this.getData();
    }

    getData = () => {
      userService.getUserData().then((res) => {
        this.setState({
          list: res.data
        });
      })
    }

    buildContent = () => {
      const {list} = this.state;
      return list.map( (itm, index) => {
        return <div key={index}>{itm.name}</div>
      })
    }
    render() {
        return (
          <div>
            {this.buildContent()}
          </div>

        );
    }
}

export default User;

这明显是主动型编程写法,和 dva 的响应型理念背道而驰。也许简单或者低交互度的界面这么写起来会很省事,但是可扩展性接近于零。一旦复杂度和交互度提升,组件的会变得越来越复杂,最后变成一个巨大的坑。

在 model 中使用 services 函数并获得数据

有了 services/user.js 函数,可以进行具体的请求动作,在 model/user.js 请求数据了。

应该写在 model/user.js 哪里呢?

这里可能又要多说一点所谓纯函数的概念,即对于给定的输入有唯一不变的输出并不含任何明显可见的副作用(side effects)的函数(可参考这篇英文文章或者中文版)。

请求网络数据自带副作用属性(异步操作),而副作用(side effect)看起来确实和 model/user.js 里的某个属性有点相似...

dva 的官方说法是:

真实情况最常见的副作用就是异步操作,所以 dva 提供了 effects 来专门放置副作用,不过可以发现的是,由于 effects 使用了 Generator Creator,所以将异步操作同步化,也是纯函数。

dva 负责处理异步的是封装后的 redux-saga 模块。也就是说,需要使用 call 方法。所以 dva 的请求数据套路是这样的:

  effects: {
    *getData(action, { call, put }) { // 第一个参数是 dispatch 所发出的 action,第二个参数是 dva 封装的 saga 函数。可以直接用 es 6 的解构风格拿取所需要的具体函数。
      const temp = yield call(userService.getUserData, {}); // 因为现在没有参数
      console.log('temp', temp); // 纯函数中不应有副作用(把数据转移到控制台也算副作用),这里只是方便在 chrome 里查看,
    }
  },

写完了?并没有。

赞美太阳...呸!dispatch!

我眼中 dva 里 dispatch-atcion 与 model/effect 的原理有点像 Android 四大组件之一的广播:

  1. 通过 dispatch 函数发出一个包含 type 属性(此为必须)的 action。
  2. dva 监听到 action 后会根据 action 的 type 值寻找对应 model 的 effect 的方法(action.type 的值就是 effects 里的方法名,在 model 外 dispatch 需要使用 modelName/effectsMethodName 的格式)
  3. 找到方法后就调用方法,把 action 作为参数放在第一个位置。

使用 dispatch 的好处是显而易见的:切分业务模块

组件不必再负责具体的业务操作(自己动手涮肉),只需要 dispatch action (大喊一声) 到对应的 model 里(给那个负责上菜的人)。

需要用户列表数据的组件未必只有 route/user.js,其他需要数据的组件可以在自己里面 dispatch action。

同时 model/user.js 的 getData 方法是独一份,你 dispatch 多少 type 为
user/getData (如果在 model 内 dispatch 可以省略前缀)的 action 都得归到我这来处理。

高内聚(业务处理集中),低耦合( 随时随地随便哪个组件随意姿势 dispatch)。

官方教程中给出的做法是在 model 里的订阅部分 subscriptions写一个监听,根据监听到具体的事件(进入 /user 页面)进行特定操作(dispatch action)。

  subscriptions: {
    setup({ dispatch, history }) {  // eslint-disable-line
     return history.listen( ({pathname, query}) => {
        if(pathname === '/user') {
          dispatch({
            type: 'getData',
            payload: {
              txt: 'hello, dva'
            }
          })
        }
      })
    },
  },

这么做同样也是进一步切离业务,不必把 dispatch 写在具体组件的生命周期中,减少组件的复杂程度(其实关键还是 dispatch ,订阅说到底也是为 dispatch 服务的)。

现在应该可以看到输出后的数据了。

渲染数据

虽然现在拿到了数据,但是数据还憋在 model/effects 里和 route/user.js 没什么关系,总的想个办法把数据和组件关联起来。

是时候让 dva 的 state 出场了。

我理解的 dva 中 model 内的 state 属性,实际上是封装后的 Redux 全局 store 的一部分。通过不重复的 namespace(桌号) 确定 store(餐馆) 中唯一的 model(餐桌),把 model/effects 请求到的原始数据(生食)放进 model/reducer (特定的火锅)里进行必要的处理(烫熟),再放进 model/state (蘸料碟)里,route/user.js 只需要从这里拿取所需要的数据(吃的)就可以了。

从 effects 里往 reducer 里传递数据使用的是 saga 的put 方法,参数同样也是一个 action 对象,action 中必须包含的 type 属性的值就是 reducer 属性里的方法名:

import * as userService from '../services/user';

export default {
  namespace: 'user',
  state: {},
  reducers: {
    dealData(state, action) {
      // 理论上 reducer 里的函数应该是纯函数,此处只是为了方便在控制台里看参数
      console.log('state==>', state);
      console.log('action==>', action);
      return { ...state }
    }
  },
  effects: {
    *getData(action, { call, put }) {
      const temp = yield call(userService.getUserData, {});
      yield put({
        type: 'dealData',
        payload: {
          temp
        }
      });
    }
  },
  subscriptions: {
    setup({ dispatch, history }) {  // eslint-disable-line
     return history.listen( ({pathname, query}) => {
        if(pathname === '/user') {
          dispatch({
            type: 'getData',
            payload: {
              txt: 'hello, dva'
            }
          })
        }
      })
    },
  },
};

剩下的做法就是在 model/user.js 的 state 属性里定义一个属性并赋值了。

  state: {
    dataList: []
  },
  reducers: {
    dealData(state,
      { payload: { temp: { data: dataList } } }
      // action
      // { payload: { temp: { data: dataList  } }} 
      // 是 es 6 的解构做法,等同于
      // const {payload} = action;
      // const {temp} = payload;
      // const {data} = temp;
      // const dataList = data;
    ) {
      return { ...state, dataList }; // 必须有返回值(纯函数必须有返回值),否则会报错
      // 经评论提醒 修改 
      // 等同于 
      // let tmp = Object.assign([], this.state)  
      // tmp.dataList = dataList

    }
  },

现在需要的数据已经挂在 model/user.js 的 state 属性里了,最后一步便是在 route/user.js 里使用 connectmapStateToProps 让组件监听数据源,实现响应型编码了。

import React from 'react';
import { connect } from 'dva'; // 0.关键的 connect 
import styles from './User.css';
import * as userService from '../services/user';
function User({ dataList }) { // 5. 这里的属性就是 3 里的返回值

  return (
    <div className={styles.normal}>
      {
        !!dataList.length && dataList.map((data, index) => {
          return <div key={index}>{data.name}</div>
        })
      }
    </div>
  );
}

function mapStateToProps(store) { // 1关键的 mapStateToProps
  const { dataList } = store.user; // 2.从 model/user.js 拿取需要的数据
  return { dataList }; // 3.将数据作为属性返回
}

export default connect(mapStateToProps)(User); // 4.连接组件

碎碎念

其实往后的代码还有蛮多,分页、封装、引入 antd 调整样式。不过都是一些需要花时间慢慢雕琢、顺便发发 dispatch 的细节(其实细节也很重要 >_<),至少理解起来比较容易了。

理解第 5 步思路的顺序是基于数据流向的,而实际开发中的编写顺序刚好是倒过来:先确定页面需要的数据,再编写 model 中的业务,最后把网络接口挂进来。不过现在这么干已经心里有谱,知道怎么回事了。

可喜可贺。