注:本文所有内容都参考自Ant Design 团队的官方教程,只是部分改动与精炼,特此申明!
0. DVA的简单介绍
上图中左侧是服务端代码的层次结构,由 Controller、Service、Data Access 三层组成服务端系统:
- Controller 层负责与用户直接打交道,渲染页面、提供接口等,侧重于
展示型逻辑
。 - Service 层负责处理业务逻辑,供 Controller 层调用。
- Data Access 层顾名思义,负责与数据源对接,进行纯粹的数据读写,供 Service 层调用。
上图的右侧是前端代码的结构,同样需要进行必要的分层:
- Page 负责与用户直接打交道:渲染页面、接受用户的操作输入,侧重于
展示型交互性逻辑
。 - Model 负责处理业务逻辑,为 Page 做数据、状态的读写、变换、暂存等。
- Service 负责与 HTTP 接口对接,进行纯粹的数据读写。
Model 是前端分层中的腰部力量,承上启下,负责管理数据(状态)。业界主流的状态管理类库有 redux、mobx,等。我们也可以使用 DVA 框架承担这一角色。
DVA 是基于 redux、redux-saga 和 react-router 的轻量级前端框架及最佳实践沉淀。其中,model 是 DVA 中最重要的概念,一个简单的 model 示例如下:
app.model({
namespace: 'todoList',
state: [],
effects: {
*query({ _ }, { put, call }) {
const rsp = yield call(queryTodoListFromServer);
const todoList = rsp.data;
yield put({ type: 'save', payload: todoList });
},
},
reducers: {
save(state, { payload: todoList }) {
return [...state, todoList];
},
},
});
DVA 的 model 对象有几个基本的属性,需要大家了解。
- namespace:model 的命名空间,只能用字符串。一个大型应用可能包含多个 model,通过namespace区分。
- state:当前 model 状态的初始值,表示当前状态。
- reducer :用于处理同步操作,可以修改state,由 action 触发。reducer 是一个纯函数,它接受当前的 state 及一个数据体(payload)作为入参,返回一个新的 state。
- effect:用于处理异步操作(例如:与服务端交互)和业务逻辑,也是由 action 触发。但是,它不可以修改 state,要通过触发 action 调用 reducer 实现对 state 的间接操作。
- action :是 reducers 及 effects 的触发器,一般是一个对象,形如
{ type: 'add', payload: todo }
,通过 type 属性可以匹配到具体某个 reducer 或者 effect,payload 属性则是数据体,用于传送给 reducer 或 effect。
一.未使用DVA来制作卡片页面
先来看看我们的目录,其实很简单,就用了三个页面
- config层下的config.js,这里主要用来配置路由
- page层下的puzzlecards.js
- model层下的puzzlecards.js
首先我们再page目录下建立页面文件puzzlecards.js ,并把它加入到路由。首先我们再page目录下面
建立页面文件puzzlecards.js
import React, { Component } from 'react';
import { Card, Button } from 'antd';
export default class PuzzleCardsPage extends Component {
constructor(props) {
super(props);
this.counter = 100;
this.state = {
cardList: [
{
id: 1,
setup: 'Did you hear about the two silk worms in a race?',
punchline: 'It ended in a tie',
},
{
id: 2,
setup: 'What happens to a frog\'s car when it breaks down?',
punchline: 'It gets toad away',
},
],
}
}
addNewCard = () => {
this.setState(prevState => {
const prevCardList = prevState.cardList;
this.counter += 1;
const card = {
id: this.counter,
setup: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,',
punchline: 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
};
return {
cardList: prevCardList.concat(card),
};
});
}
render() {
return (
<div>
{
this.state.cardList.map(card => {
return (
<Card key={card.id}>
<div>Q: {card.setup}</div>
<div>
<strong>A: {card.punchline}</strong>
</div>
</Card>
);
})
}
<div>
<Button onClick={this.addNewCard}> 添加卡片 </Button>
</div>
</div>
);
}
}
往config.js里面添加一条路由
export default {
routes: [
{
path: '/',
component: '../layout',
routes: [
{ path: 'puzzlecards', component: './puzzlecards' }, //添加的路由
]
}
],
};
注意:这里每次添加的卡片内容都相同,只是为了演示,无所谓,但是注意唯独 id 不能相同。为了产生唯一的 id,我们在组件中新加了一个 counter 成员,它只是为了产生唯一 id(我们这里给了 100)。
启动页面后我们就可以看到如下页面了
好,这样就完成了,但是在实际情况当中,我们会对这个程序提出下面几点需求:
- 在实际的前端开发中,像 cardList 中包含的那些数据,一般都是通过发起异步 http 请求从后端服务中获得。
- 我们希望把数据逻辑(cardList 相关逻辑)和视图逻辑(PuzzleCardsPage)分开管理在不同的模块中,「关注分离」使得代码更加健壮,同时易于调试。
- 我们希望这些数据在需要的时候,可以提供给不同的组件使用:也即数据共享。
而我们DVA就可以完成上述需求!
- 通过把状态上提到 dva model 中,我们把数据逻辑从页面中抽离出来。
- 通过 effect 优雅地处理数据生成过程中的副作用,副作用中最常见的就是异步逻辑。
- dva model 中的数据可以注入给任意组件。
- 另外,dva 允许把数据逻辑再拆分(「页面」常常就是分隔的标志),以 namespace 区分。当你觉得有必要时,不同的 namespace 之间的 state 是可以互相访问的。
二.使用DVA来制作卡片页面
先贴代码吧
page/puzzlecards.js
import React, { Component } from 'react';
import { Card ,Button } from 'antd';
import { connect } from 'dva';
const namespace = 'puzzlecards';
const mapStateToProps = (state) => {
const cardList = state[namespace].data;
return {
cardList,
};
};
const mapDispatchToProps = (dispatch) => {
return {
onClickAdd: (newCard) => {
const action = {
type: `${namespace}/addNewCard`,
payload: newCard,
};
dispatch(action);
},
};
};
@connect(mapStateToProps, mapDispatchToProps)
export default class PuzzleCardsPage extends Component {
render() {
return (
<div>
{
this.props.cardList.map(card => {
return (
<Card key={card.id}>
<div>Q: {card.setup}</div>
<div>
<strong>A: {card.punchline}</strong>
</div>
</Card>
);
})
}
<div>
<Button onClick={() => this.props.onClickAdd({
setup: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
punchline: 'here we use dva',
})}> 添加卡片 </Button>
</div>
</div>
);
}
}
mode/puzzlecards.js(这就是我们新添加的mode页面)
export default {
namespace: 'puzzlecards',
state: {
data: [
{
id: 1,
setup: 'Did you hear about the two silk worms in a race?',
punchline: 'It ended in a tie',
},
{
id: 2,
setup: 'What happens to a frog\'s car when it breaks down?',
punchline: 'It gets toad away',
},
],
counter: 100,
},
reducers: {
addNewCard(state, { payload: newCard }) {
const nextCounter = state.counter + 1;
const newCardWithId = { ...newCard, id: nextCounter };
const nextData = state.data.concat(newCardWithId);
return {
data: nextData,
counter: nextCounter,
};
}
},
};
下图是整个DVA驱动的过程:
如果有基础的看了上面这张图应该一目了然了,基础不好也没关系,下面我们对照代码一一说明。
2.1 使用 connect 对接静态的 dva model
首先,注意 dva model 的定义。一个基本的 dva model 最少具备两个成员:namespace 和 state。namespace 来作为一个 model 的唯一标识,state 中就是该 model 管理的数据(把第一节中的state从page提升到了model层中—状态提升)。
其次,看页面文件的变化:我们删除了组件(page层)本身的 state,同时添加了 @connect(mapStateToProps)
。connect 是连接 dva 和 React 两个平行世界的关键,一定要理解。
- connect 让组件获取到两样东西:1. model 中的数据;2. 驱动 model 改变的方法。
- connect 本质上只是一个 javascript 函数,通过
@
装饰器语法使用,放置在组件定义的上方; - connect 既然是函数,就可以接受入参,第一个入参是最常用的,它需要是一个函数,我们习惯给它命名叫做 mapStateToProps,顾名思义就是把 dva model 中的 state 通过组件的 props 注入给组件。通过实现这个函数,我们就能实现把 dva model 的 state 注入给组件,第二个参数后面介绍。
mapStateToProps这个函数的入参 state 其实是 dva 中所有 state 的总合(对于初学 js 的人可能会很疑惑:这个入参是谁给传入的呢?其实你不用关心,你只需知道 dva 框架会适时调用 mapStateToProps,并传入 dva model state 作为入参)。我们自己定义的 dva model state 就是以 namespace 为 key 的 state 成员。所以 const namespace = 'puzzlecards'
中的 puzzlecards必须和 model 中的定义完全一致。
dva 期待 mapStateToProps 函数返回一个 对象,这个对象会被 dva 并入到 props 中,在上面的例子中我们取到数据后,把它改名为 cardList 并返回( 注意返回的不是 cardList 本身,而是一个包含了 cardList 的对象! ),cardList 就可以在子组件中通过 props 被访问到了。
2.2 使用 dispatch 和 reducer 改变 dva model(按钮部分)
React 有一个基本的哲学:数据映射到视图。无论什么途径,我们点击按钮后,本质上都是去触发 state 的改变,state 的改变再映射回视图。所以我们这里的目标就是使得每次点击按钮,触发 dva model 的中卡片数据再添加一条。而在 dva 的语境中,是统一通过dispatch函数来做这件事情。
通过使用mapDispatchToProps 和 dispatch 这两者,我们可以给组件注入方法,组件使用这些方法能给 dva model 发消息。this.props.onClickAdd
就是被注入的方法。
onClickAdd 是怎么被注入的呢 ?答案就在于我们给 connect 传入了第二个函数:mapDispatchToProps。我们习惯用这个名字是因为它精炼地说明了这个函数的作用:以 dispatch 为入参,返回一个挂着函数的对象,这个对象上的函数会被 dva 并入 props,注入给组件使用。
我们在 onClickAdd 函数中调用 dispatch 派发了一个 action,action 包含 onClickAdd 传递过来的内容 { setup, punchline }
作为 payload,action 的 type 是 puzzlecards/addNewCard
。addNewCard 在这个例子中是 reducer 的名字,这个我们下面会讲到。dispatch 函数就是和 dva model 打交道的唯一途径。 dispatch 函数接受一个 对象 作为入参,在概念上我们称它为 action,唯一强制要包含的是 type 字段,string 类型,用来告诉 dva 我们想要干什么(我们把想做的事情通过 action 描述出来,并通过 dispatch 告诉 dva model)。我们可以选择给 action 附着其他字段,这里约定用payload字段表示额外信息。
dva model 中可以定义一个叫做 reducer 的成员用来响应 action 并修改 state。每一个 reducer 都是一个 function,action 派发后,通过 action.type 被唯一地匹配到,随后执行函数体逻辑,返回值被 dva 使用作为新的 state。state 的改变随后会被 connect 注入到组件中,触发视图改变。
somefunction(state /* old state */, { payload }) {
// ... do calculation
return {
// ... build a new object as next state and return it
};
}
reducer 应该是一个 "纯函数",它的返回值作为新的 state。dva 会注入旧的 state 和 action 中的 payload,是否使用完全根据需要决定;返回值必须是一个新构造对象,绝不能把旧 state 的引用返回(reducer 干的事情和 React 中 setState(prevState => { ... })
很像,都要返回一个新构造的对象,但区别是:reducer 的返回值会 整个取代 (Replace) 老的 state,而 setState 中回调函数的返回值是会 融合(Merge) 到老的 state 中去。)!