思维盲区
我最开始学习使用 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 步的操作。
此时我的情况是:
- 可以通过 http://localhost:8000/user 访问到 user
- 可以通过 http://localhost:8000/api/users 访问到 user 数据
通过 dva g model user
可以很方便的创建 model/user.js
并注册进 index.js
中(命令行万岁!) ,虽然目前还什么都没有:
我需要做的事情就是把数据从 api/user
接口拉下来,渲染进 route/user 里(component 可以等等再谈)。
把大象...我是说数据渲染进 route/user 需要三步:
- 编写请求接口的方法
- 使用 1 的方法获得数据
- 将 2 数据渲染进页面
编写请求接口的方法
dva 的新手大礼包里已经提供了基础的网络请求函数 utils/resquest.js
,虽然大多数情况下都会对其进行一些扩展才能满足现实项目的需求,但是就目前来说暂且是够用的。
以 oop 观点来看,utils/resquest.js
相当于项目所有请求函数的基类(base class)。如果需要进行具体业务的编写,应该新建一个继承 utils/resquest.js
的子类。但 JavaScript 不算是纯种 oop 的语言,所以惯例都是新建一个具体的业务类 services/user.js
,通过在 services/user.js
中 import
的方式调用 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 四大组件之一的广播:
- 通过 dispatch 函数发出一个包含 type 属性(此为必须)的 action。
- dva 监听到 action 后会根据 action 的 type 值寻找对应 model 的 effect 的方法(action.type 的值就是 effects 里的方法名,在 model 外 dispatch 需要使用
modelName/effectsMethodName
的格式) - 找到方法后就调用方法,把 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
里使用 connect
和 mapStateToProps
让组件监听数据源,实现响应型编码了。
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 中的业务,最后把网络接口挂进来。不过现在这么干已经心里有谱,知道怎么回事了。
可喜可贺。
链接:https://juejin.im/post/59946bbcf265da246f37e6d9
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。