写 Redux 最烦的是一个功能要开三个文件。actionTypes 定义常量,actions 写创建函数,reducers 写 switch,异步还得再挂一层 redux-thunk 或者 redux-saga,改一个字段得在四个地方来回跳。dva 把这些东西收进了一个 model 文件,namespace、state、reducers、effects、subscriptions 五个字段写完,一个业务模块的数据流就齐了。
这篇是我 2018 年做 dva 项目时整理的实践笔记,从环境搭建、5 个 API、9 个概念一路写到用户管理页的完整落地,最后还有 axios 统一拦截的那套封装。原来的代码和写法我一字没删,另外补了一节现在再看 dva 的判断,什么项目还能继续用它,什么项目应该换掉。读完你至少能自己判断 model 里的一段逻辑该放 reducer 还是 effect。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- dva 到底把 React-Router、Redux、redux-saga 这三样东西粘成了什么形状
- 5 个 API 各管一件什么事,hooks 里那几个配置项分别在什么场景才会用到
- model 的五个字段怎么分工,reducer 和 effect 的边界卡在哪
- subscriptions 这个别的数据流库都没有的东西,能干什么
- 一个计数器例子把 state、action、reducer、effect、subscription 全串一遍
- 用户管理页的完整实践,从抽 model 到拆容器组件、展示组件
- 同一个需求用原生 Redux 和用 dva 分别要写多少东西
- 基于 axios 做统一错误拦截、进度条和权限跳转的一份可抄封装
- 2026 年再看 dva,什么项目还能用,什么项目该换成别的方案
一、环境搭建
dva 自带脚手架 dva-cli,三行命令就能起一个能跑的工程,省掉自己配 webpack、react-router、redux、redux-saga 这一整套的时间。
npm install dva-cli -g |
起完项目先看目录。dva 脚手架生成的结构本身就是一份约定,routes 放页面级组件,components 放可复用的展示组件,models 放数据,services 放接口,这套分法后面所有实践都建立在它上面。
react项目的推荐目录结构(如果使用dva脚手架创建,则自动生成如下)
|── /mock/ # 数据mock的接口文件 |
这里有个容易被忽略的点,routes 这个名字很误导人,它装的不是路由配置,而是页面维度的容器组件,真正的路由表在 router.js 里。后面 umi 出来之后把这个目录改名叫 pages,就是为了消掉这个歧义。umi 那套约定式路由怎么改变目录组织方式,我在 使用 umi 改进 dva 项目开发 里单独写过。
使用 antd
npm i babel-plugin-import --save |
babel-plugin-import是用来按需加载antd的脚本和样式的
antd 那时候整包引进来样式几百 KB,不做按需加载首屏会被拖死。babel-plugin-import 干的事是在编译期把 import { Button } from 'antd' 改写成只引 Button 那一个模块和它的样式文件。
- 编辑
.webpackrc,使babel-plugin-import插件生效
{ |
这段配置放到今天已经不需要了。antd 从 v5 开始换成了 cssinjs 方案,样式跟着组件按需注入,不再有一个整包的 antd.css 要你手动裁剪,babel-plugin-import 也就失去了存在的意义,官方文档里明确不再推荐配它。我把原来的写法留在这儿,是因为你接手 2018 到 2020 年之间的老项目,大概率还会在 .webpackrc 或者 .umirc.js 里看到它,知道它当年解决的是什么问题,你才敢删。
二、初识Dva
2.1 Dva的特性
dva 自己给的定义就一行等式,它不是又造了一个状态管理库,而是把三个已经成熟的东西粘在一起,再补上一层约定。
dva = React-Router + Redux + Redux-saga |
这个定位很关键。你在 dva 里遇到的绝大多数疑问,答案都在 Redux 或者 redux-saga 的文档里,dva 只负责把它们的样板代码收掉。
- 仅有 5 个
API,仅有5个主要的api - 支持
HMR,支持模块的热更新 - 支持
SSR (ServerSideRender),支持服务器端渲染 - 支持
Mobile/ReactNative,支持移动手机端的代码编写 - 支持
TypeScript - 支持路由和
Model的动态加载
2.2 Dva的五个API
整个 dva 的对外接口就下面这张图上的几个方法,从创建实例、装插件、注册 model、注册路由到启动,是一条固定的流水线。

图里这个顺序不是随便排的,它就是每个 dva 项目 index.js 的样子。app.start() 之前的每一步都在往实例上挂东西,start 的那一刻才真正创建 redux store 并把 saga 跑起来,所以 model 和路由必须在 start 之前注册完。
2.2.1 app = dva(Opts)
app = dva(Opts):创建应用,返回dva实例。(注:dva 支持多实例)**
这一步是整个应用唯一的全局配置入口。下面这些 hooks 平时你可能一个都用不上,但知道它们存在很有用,因为像日志、全局错误提示、状态持久化这类需求,正确的挂载点就在这里,而不是散在各个 model 里。
在opts可以配置所有的hooks
const app = dva({ |
hooks包含如下配置项
1、 onError((err, dispatch) => {})
effect执行错误或subscription通过done主动抛错时触发,可用于管理全局出错状态- 注意:
subscription并没有加try...catch,所以有错误时需通过第二个参数done主动抛错
这个 hook 是我认为最值得第一天就配上的。effect 里如果不写 try/catch,请求挂了默认是静默失败的,页面就一直转圈,用户看不到任何提示。有了 onError 你可以在这里统一弹一个 antd 的 message.error,所有 effect 都不用自己处理异常。
app.model({ |
2、 onAction(fn | fn[])
在
action被dispatch时触发,用于注册redux中间件。支持函数或函数数组格式
- 例如我们要通过
redux-logger打印日志
import createLogger from 'redux-logger'; |
onAction 的本质就是 Redux 的 applyMiddleware,任何一个标准 redux 中间件都能塞进来。
3、 onStateChange(fn)
state改变时触发,可用于同步 state 到localStorage,服务器端等
做「刷新页面不丢购物车」「记住上次筛选条件」这类需求就靠它。要注意这个回调触发得非常频繁,每次 state 变都会走一遍,里面别做重活,写 localStorage 记得节流。
4、 onReducer(fn)
封装
reducer执行,比如借助redux-undo实现redo/undo
import undoable from 'redux-undo'; |
5、 onEffect(fn)
封装
effect执行。比如dva-loading基于此实现了自动处理loading状态
这个设计我一直觉得挺聪明。onEffect 相当于给每个 effect 套了一层壳,进函数前打个标记、出来再抹掉,dva-loading 就靠这一层自动维护了全局的 loading.effects['user/query'],你再也不用在每个 model 里手写 showLoading / hideLoading 这一对 reducer。
6、 onHmr(fn)
热替换相关,目前用于
babel-plugin-dva-hmr
7、 extraReducers
指定额外的
reducer,比如redux-form需要指定额外的form reducer
import { reducer as formReducer } from 'redux-form' |
上面这一串里,日常真正会改的其实只有一个。
这里比较常用的是,
history的配置,一般默认的是hashHistory,如果要配置history为browserHistory,可以这样
import createHistory from 'history/createBrowserHistory'; |
换成 browserHistory 之后 URL 里的 # 就没了,代价是服务端要配一条 fallback 规则,把所有未匹配的路径都指回 index.html,否则用户刷新非首页路由会直接 404。这个坑上线当天踩的人特别多。
initialState:指定初始数据,优先级高于model中的state,默认是{},但是基本上都在modal里面设置相应的state
initialState 真正有用的场景是服务端渲染的数据注水,把服务端算好的初始状态直接塞进来。纯客户端项目里基本用不到,state 写在各自的 model 里更好维护。
2.2.2 app.use(Hooks)
app.use(Hooks):配置 hooks 或者注册插件
这里最常见的就是dva-loading插件的配置
import createLoading from 'dva-loading'; |
但是一般对于全局的
loading我们会根据业务的不同来显示相应不同的loading图标,我们可以根据自己的需要来选择注册相应的插件
装上 dva-loading 之后,全局 state 上会多一个 loading 命名空间,组件里 mapStateToProps 直接读 loading.effects['users/query'] 就能拿到那个请求的进行状态,接到 antd Table 的 loading 属性上,一行代码搞定。这是我在 dva 项目里唯一每次都会装的插件。
2.2.3 app.model(ModelObject)
app.model(ModelObject):这个是你数据逻辑处理,数据流动的地方

这张图基本可以当 model 的字段速查表来看。左边进来的是 action,中间 effects 负责跟服务端打交道,reducers 负责改 state,subscriptions 是唯一一个从外部世界(路由、键盘、websocket)往里灌 action 的口子。后面 2.3 节会把这几块逐个拆开。
2.2.4 app.unmodel(namespace)
取消
model注册,清理reducers,effects和subscriptions。subscription如果没有返回unlisten函数,使用app.unmodel会给予警告
这个 API 平时手写得少,但按需加载的路由背后就是它在做卸载。那句警告其实是在提醒你可能有内存泄漏,subscription 里注册的监听如果不返回 unlisten,model 卸载了监听还挂着,路由来回切几次就叠了好几份。
2.2.5 app.router(Function)
注册路由表,这一操作步骤在dva中也很重要
// 注册路由 |
这份路由文件就是一个普通的 React 组件,dva/router 转手导出的其实是 react-router 的能力,所以 react-router 的文档在这里全部适用。
上面这种写法有个明显的问题,所有页面组件都在入口被静态 import 进来了,打包出来是一个大 bundle,首屏得把整个后台系统的代码都下完才能渲染。
如果我们想解决组件动态加载问题,我们的路由文件也可以按照下面的写法来写
import { Router, Switch, Route } from 'dva/router' |
其中
dynamic(opts)中opt包含三个配置项:
app:dva实例,加载models时需要models: 返回Promise数组的函数,Promise返回 dva model`component:返回Promise的函数,Promise返回React Component
dynamic 比 React 自带的懒加载多做了一件事,它能把 model 一起按需加载进来。注意上面 IndexPage 只传了 component 没传 models,Users 两个都传了,这就是「进这个页面时才注册这个页面的 model」。页面多的中后台系统,这一下能砍掉不少首屏体积。
放到今天,React 18 已经内置了 lazy 加 Suspense,路由级分包也是各框架的标配能力,dva/dynamic 这套自研方案的存在感就不强了。原理是一样的,都是 webpack 的动态 import() 拆 chunk。
2.2.6 app.start
启动应用,即将我们的应用跑起来
前面四步都只是往实例上登记信息,直到 app.start('#root') 才真正把 redux store 建起来、把 saga middleware 跑起来、再把 React 应用挂到那个 DOM 节点上。所以顺序不能乱,先 model 再 router 最后 start。
2.3 Dva九个概念
API 只有 5 个,但要把 dva 写明白,得先分清它的 9 个概念:State、Action、dispatch、Reducer、Effect、Subscription、Model、Router、RouteComponent。前六个是数据流本身,后三个是它们的组织方式。
这一节是全篇最该慢下来读的地方,后面所有实践代码都是这几个概念的排列组合。
2.3.1 State
初始值,我们在
dva()初始化的时候和在 modal 里面的state对其两处进行定义,其中 modal 中的优先级低于传给dva()的opts.initialState
// dva()初始化 |
state 在 dva 里跟在 Redux 里是一回事,整个应用一棵状态树,每个 model 的 namespace 占树上的一个属性。上面这个例子最终的全局 state 形状是 { count: 1 },因为 initialState 覆盖了 model 里写的 0。
2.3.2 Action
表示操作事件,可以是同步,也可以是异步
action的格式如下,它需要有一个type,表示这个action要触发什么操作;payload则表示这个action将要传递的数据
{ |
我们通过 dispatch 方法来发送一个 action
dispatch({ type: 'todos/add', payload: 'Learn Dva' }); |
注意 type 里那个斜杠,todos/add 表示「发给 todos 这个 model 的 add」。在 model 内部用 put 发 action 时可以省掉 namespace 直接写 add,跨 model 调用就必须写全。这是 dva 相比裸 Redux 省掉 actionTypes 常量文件的关键,命名空间已经帮你隔离好了,不同 model 里的同名 action 不会撞车。
其实我们可以构建一个Action 创建函数,如下
function addTodo(text) { |
2.3.3 Model
model是dva中最重要的概念,Model非MVC中的M,而是领域模型,用于把数据相关的逻辑聚合到一起,几乎所有的数据,逻辑都在这边进行处理分发
领域模型这个说法值得琢磨一下。它划分的依据不是「数据表」也不是「页面」,而是一块业务实体,比如「待办事项」「用户」「订单」。一块业务实体的初始数据、同步修改、异步请求、外部订阅,全都写在同一个文件里,看代码的时候不用满项目找。
下面这个 todo 的 model 是完整形态,五个字段都齐了,可以先整体扫一遍再看拆解。
import queryString from 'query-string' |
model对象中包含5个重要的属性
state
这里的 state 跟我们刚刚讲的 state 的概念是一样的,只不过她的优先级比初始化的低,但是基本上项目中的 state 都是在这里定义的
namespace
model的命名空间,同时也是他在全局state上的属性,只能用字符串,我们发送在发送action到相应的reducer时,就会需要用到namespace
namespace 是字符串,不能带斜杠,因为斜杠是 dva 用来分隔 namespace 和 action 名的。
Reducer
以
key/value格式定义reducer,用于处理同步操作,唯一可以修改state的地方。由action触发。其实一个纯函数
「唯一可以修改 state 的地方」这句是整套数据流的地基。reducer 必须是纯函数,进去一个旧 state 出来一个新 state,中间不发请求、不读 Date.now()、不改传进来的参数。这也是为什么写法固定是 return { ...state, list } 而不是 state.list = list,直接改会破坏引用比较,连接的组件可能根本不重渲染。
namespace: 'todo', |
Effect
用于处理异步操作和业务逻辑,不直接修改
state,简单的来说,就是获取从服务端获取数据,并且发起一个action交给reducer的地方
effect 和 reducer 的边界就一句话,凡是有副作用的(发请求、读路由参数、setTimeout)都归 effect,凡是纯粹的数据变形都归 reducer。新人最常犯的错是在 reducer 里发请求,那样时间旅行调试直接废掉,state 也变得不可预测。
effect 的函数前面那个星号不是装饰,它是 generator 函数,yield 会把控制权交回给 redux-saga,等这一步跑完再继续。好处是异步代码读起来是从上到下的顺序,没有回调嵌套。
其中它用到了redux-saga,里面有几个常用的函数。
// effects 写法 |

上图是 redux-saga 提供的全部辅助函数,看着吓人,实际项目里你用到的不会超过四个。
在项目中最主要的会用到的是
put与call
具体说,call(fn, ...args) 调用一个返回 Promise 的函数并等它完成,put(action) 相当于在 effect 内部发一个新 action,select(fn) 从全局 state 里取值。上面 todo 的 *addTodo 三个都用上了,先 call 请求接口,再 select 把现有列表取出来,加完新项之后 put 给 reducer 存回去。
多说一句 select,它取的是当前时刻的全局 state,yield 之后 state 可能已经被别的 action 改过了,所以拿到的数据要当快照看,别缓存着重复用。这个我踩过,表现是并发操作下列表偶尔少一条。
Subscription
- 以
key/value格式定义subscription,subscription是订阅,用于订阅一个数据源,然后根据需要 dispatch 相应的 actionsubscription是订阅,用于订阅一个数据源,然后根据需要dispatch相应的action。在app.start()时被执行,数据源可以是当前的时间、当前页面的url、服务器的websocket连接、history路由变化等等。
subscriptions 是 dva 里最有意思的一块,Redux 和 MobX 都没有对应的概念。前面的 action 都是组件主动 dispatch 出来的,subscription 反过来,它让 model 自己去盯着外部世界,条件满足就自己发 action。
最典型的用法就是下面这段,盯着路由变化,一进 /todoList 就自动拉数据。这样页面组件里连 componentDidMount 都不用写,取数逻辑完全收在 model 里,组件退化成纯展示。
- 注意:如果要使用
app.unmodel(),subscription必须返回unlisten方法,用于取消数据订阅
// subscriptions 写法 |
这里有个坑要注意,history.listen 只在路由发生变化时触发,首次进入页面是不会走这个回调的(取决于 history 版本和 dva 版本的行为差异)。如果你发现直接输 URL 打开页面数据是空的,刷新一下又有了,八成就是卡在这儿,解决办法是在 setup 里先手动 dispatch 一次初始请求。
2.3.4 Router
Router表示路由配置信息,项目中的router.js
export default function({ history }){ |
RouteComponent
RouteComponent表示Router里匹配路径的Component,通常会绑定model的数据。如下:
import { connect } from 'dva'; |
connect 来自 react-redux,dva 只是转手导出。它做的事是订阅 store,把 mapStateToProps 挑出来的那部分数据当 props 传给组件,同时自动注入一个 dispatch。所以你在任何被 connect 包过的组件里都能直接 this.props.dispatch(...)。
2.4 整体架构
把上面九个概念连起来,就是下面这张数据流图。

- 首先我们根据
url访问相关的Route-Component,在组件中我们通过dispatch发送action到model里面的effect或者直接Reducer - 当我们将
action发送给Effect,基本上是取服务器上面请求数据的,服务器返回数据之后,effect会发送相应的action给reducer,由唯一能改变state的reducer改变state,然后通过connect重新渲染组件。 - 当我们将
action发送给reducer,那直接由reducer改变state,然后通过connect重新渲染组件
记住这两条分岔就够了。要不要走 effect,判断标准只有一个,这次操作需不需要跟外部世界打交道。
2.5 Dva图解
上面那张架构图是结果,这一节讲的是它怎么一步步演化出来的,理解了演化过程,你就知道每一层为什么存在。
图解一:加入Saga
React只负责页面渲染, 而不负责页面逻辑, 页面逻辑可以从中单独抽取出来, 变成store

使用
Middleware拦截action, 这样一来异步的网络操作也就很方便了, 做成一个Middleware就行了, 这里使用redux-saga这个类库
- 点击创建
Todo的按钮, 发起一个type == addTodo的action saga拦截这个action, 发起http请求, 如果请求成功, 则继续向reducer发一个type == addTodoSucc的action, 提示创建成功, 反之则发送type == addTodoFail的action即可
这就是 saga 的心智模型,它站在 action 到 reducer 的路上做拦截,把「一次用户操作」拆成「发起、成功、失败」三个 action。到这一步为止都还是标准 Redux 生态的玩法,问题是文件散得厉害,一个功能的 action 常量、创建函数、reducer、saga 分在四个目录里。
图解二:Dva表示法

对着这张图看下面三条,dva 的全部创新就在这儿了。
dva做了 3 件很重要的事情
- 把
store及saga统一为一个model的概念, 写在一个 js 文件里面 - 增加了一个
Subscriptions, 用于收集其他来源的action, eg: 键盘操作 model写法很简约, 类似于DSL或者RoR
回到我们要解决的问题。dva 没有发明任何新的数据流思想,它只是把 Redux 那套分散的文件按业务实体重新打了个包。理解了这一层,你在 dva 里踩的坑基本都能去 Redux 和 redux-saga 的文档里找到答案。
三、计数器例子
概念讲完,动手写一个最小可跑的例子。这个计数器会依次用上 state、reducer、effect 和 subscription,跟着敲一遍比看十遍文档管用。
$ dva new myapp |
目录结构介绍
. |
首先是前端的页面,我们使用 class 形式来创建组件,原例子中是使用无状态来创建的。react 创建组件的各种方式,大家可以看React创建组件的三种方式及其区别
我们先修改
route/IndexPage.js
import React from 'react'; |
同时修改样式
routes/IndexPage.css
.normal { |
在
model处理state,在页面里面输出model中的state
- 首先我们在index.js中将
models/example.js,即将model下一行的的注释打开
import dva from 'dva'; |
接下来我们进入
models/example.js,将namespace名字改为count,state对象加上record与current属性。如下
export default { |
接着我们来到
routes/indexpage.js页面,通过的mapStateToProps引入相关的state
import React from 'react'; |
跑起来页面上是两个 0,数据已经从 model 流到组件里了。接下来把交互接上。
通过
+发送action,通过reducer改变相应的state
- 首先我们在
models/example.js,写相应的reducer
export default { |
add1 这个 reducer 顺手做了一件事,它在加 1 的同时比较并更新了历史最高值 record。这种「一次修改牵动多个字段」的逻辑放 reducer 里刚刚好,它是纯计算,没有任何副作用。
在页面的模板
routes/IndexPage.js中+号点击的时候,dispatch一个action
import React from 'react'; |
接下来我们来使用
effect模拟一个数据接口请求,返回之后,通过yield put()改变相应的state
- 首先我们替换相应的
models/example.js的effect
effects: { |
这段要看清楚它到底干了什么:先 call 一个延时 1 秒的函数模拟网络请求,等回来之后 put 一个 minus 给 reducer。所以你 dispatch count/add 看到的现象是「一秒后数字减一」,它演示的是 effect 到 reducer 的接力,不是加法。effect 名字叫 add 只是沿用了官方示例,别被名字带跑。
这里的
delay,是我这边写的一个延时的函数,我们在utils里面编写一个utils.js,一般请求接口的函数都会写在servers文件夹中
export function delay(timeout) { |
订阅订阅键盘事件,使用
subscriptions,当用户按住command+up时候触发添加数字的action
- 在
models/example.js中作如下修改
+import key from 'keymaster'; |
- 在这里你需要安装
keymaster这个依赖
npm install keymaster --save |
- 现在你可以按住
command+up就可以使current加1
这里原文的示例 dispatch 的是 add,也就是上面那个延时 1 秒再减 1 的 effect,跟「使 current 加 1」的描述对不上,我把它改成了直接派发 add1 这个 reducer。你要是想验证 effect,把它换回 add 再看现象就行。
顺着上面聊,这就是 subscription 的价值。键盘事件跟 React 组件树没有任何关系,但它照样能变成一个 action 进到同一条数据流里。websocket 推送、页面可见性变化、定时轮询,都可以用这个口子接进来,而不用在某个组件的 componentDidMount 里挂一堆全局监听。
四、Dva实践
计数器毕竟是玩具。这一节按真实中后台需求走一遍,一个用户管理页,从抽 model、拆组件、加 reducer 到接 effect 和 service,顺序跟实际开发一致。
4.1 抽离Model
抽离
Model,根据设计页面需求,设计相应的Model
抽 model 有两种切法,代码注释里写得很清楚,一种从数据维度切,model 里只放服务端返回的数据;另一种从业务状态切,把弹窗开没开、当前操作的是哪条记录这类 UI 状态也一起放进来。
我倾向第二种。中后台页面的「编辑弹窗」这种状态几乎一定会被 effect 用到,比如提交成功之后要关弹窗、刷列表,如果 modalVisible 存在组件的 this.state 里,effect 就够不着它,你得绕一圈用回调传出去。下面代码里带 + 的几行就是从第一种改到第二种时加的。
// models/users.js |
4.2 设计组件
先设置容器组件的访问路径,再创建组件文件
组件分成容器组件和展示组件两类,这个划分不是 dva 发明的,是 Redux 社区早年的共识。一句话区分,容器组件知道数据从哪来,展示组件只认 props。
这么分的好处在于展示组件可以脱离整个数据流单独测试和复用,你把 UserList 搬到另一个项目里,它不需要知道 dva 的存在。
4.2.1 容器组件
具有监听数据行为的组件,职责是绑定相关联的 model 数据,包含子组件;传入的数据来源于model
import React, { Component } from 'react'; |
这里有个原文的写法我改掉了。原代码是 import React, { Component, PropTypes } from 'react',这在 React 15.5 之后会拿到 undefined,React 16 里 PropTypes 已经从 react 包里移出去了,正确写法是单独装 prop-types 再 import PropTypes from 'prop-types'。这篇文章写于 2018 年,当时这个迁移刚发生不久,很多教程还是老写法。全文的几处我都统一改了。
再往后看,TypeScript 普及之后 propTypes 这套运行时校验用得越来越少,类型检查挪到了编译期。老项目里两者共存也没问题,只是别指望 propTypes 能在生产环境帮你兜底,它默认只在开发环境告警。
4.2.2 展示组件
展示通过
props传递到组件内部数据;传入的数据来源于容器组件向展示组件的props
import React, { Component } from 'react'; |
区别就在最后一行,展示组件没有 connect。它拿不到 dispatch,也订阅不了 store,改数据只能通过父组件传下来的回调。这个限制是故意的。
4.2.3 设置路由
先把路由挂上,页面能访问了再往里填内容,这样每一步改动都能立刻在浏览器里看到效果。
// .src/router.js |
容器组件雏形
// .src/routes/Users.jsx |
4.2.4 设计容器组件
自顶向下的设计方法:先设计容器组件,再逐步细化内部的展示容器
自顶向下的意思是先把页面切成几块、想清楚每块要什么数据,再去实现每一块。反过来先写完一堆小组件再拼,很容易发现数据结构对不上,返工成本高。
组件的定义方式
// 方法一: es6 的写法,当组件设计react生命周期时,可采用这种写法 |
这两种写法的取舍在 2018 年是「要不要生命周期」,React 16.8 之后 Hooks 出来,函数组件也能有状态和副作用了,这个二选一就基本没了,新代码统一写函数组件加 Hooks 就行。原文的类组件写法我保留着,中后台老项目里它还是主流。
容器组件:
// ./src/routes/Users.jsx |
先用写死的假数据把页面结构搭出来,这一步不接 model,纯看布局对不对。等结构定了再把数据源换成 model,改动量很小。
展示组件UserList
// ./src/components/Users/UserList.jsx |
UserList 这段值得留意的是 columns 的写法,antd 的 Table 把列定义抽成了配置数组,render 函数负责把一个字段渲染成任意 JSX。这套配置化的表格 API 一直沿用到现在的 antd v5,后来的 ProTable 也是在它上面加了一层封装。
4.3 添加Reducer
在整个应用中,只有
model中的reducer函数可以直接修改自己所在model的state参数,其余都是非法操作;
并且必须使用return {...state}的形式进行修改
页面骨架有了,接下来把写死的数据挪进 model。这一步会分四小步走,实现 reducer、把组件接到 model、找个时机发 action、最后注册 model。
4.3.1 第一步:实现reducer函数
// models/users.js |
注意 querySuccess 这个命名,它是「查询成功」而不是「查询」,因为 reducer 只负责把已经拿到的数据写进 state。真正的查询动作在 effect 里,这一步先用静态数据把链路跑通。
4.3.2 第二步:关联Model中的数据源
// routes/Users.jsx |
对比一下 4.2.4 那版容器组件,页面结构一行没动,只是把假数据换成了从 users 这个 model 解构出来的字段。这就是前面坚持容器和展示分开的回报。
4.3.3 第三步:通过发起Action,在组件中获取Model中的数据
// models/users.js |
这段代码里其实给了两个方案,在组件的 componentDidMount 里 dispatch,或者在 model 的 subscription 里监听路由。它选了后者。
那为什么不用 componentDidMount 呢?因为取数时机跟着路由走,比跟着组件挂载走更准确。同一个组件可能被多个路由复用,也可能因为父组件重渲被意外重挂,而「访问 /users 就该有用户列表」这个描述是稳定的。取数逻辑放进 model 之后,页面组件里一行取数代码都没有,纯粹接收 props 渲染。
这是我认为 dva 相比同期方案最舒服的一个设计。
4.3.4 第四步: 在index.js中添加models
// model必须在此完成注册,才能全局有效 |
这一行忘了写,页面上会安静地什么都没有,控制台也不一定报错,因为 state.users 压根不存在,解构出来全是 undefined。这个我踩过,排查了半天最后发现是 model 没注册。后来 umi 出了自动注册 model 的约定,就是为了消掉这个低级失误,具体规则在 使用 umi 改进 dva 项目开发 那篇里。
4.4 添加Effects
Effects的作用在于处理异步函数,控制数据流程。
因为在真实场景中,数据都来自服务器,需要在发起异步请求获得返回值后再设置数据,更新state。
因此我们往往在Effects中调用reducer
到这一步才把静态数据换成真实请求。看下面 *query 的结构,它是 effect 的标准形态,先 put 一个 loading,再 call 接口,拿到数据再 put 给 reducer,三段式。
export default { |
上面把 query 函数直接写在 model 文件里了,能跑,但接口一多这个文件就乱了,而且同一个接口在别的 model 里还要再写一遍。
4.5 把请求处理分离到service中
用意在于分离(可复用的)ajax请求
// services/users.js |
分层到这儿就完整了。组件负责渲染和派发,model 负责编排流程,service 负责跟后端对接口,utils/request 负责统一处理鉴权、报错和数据格式。每一层职责单一,出问题的时候你能很快定位到该看哪个文件。
一个建议:service 文件按 model 的粒度一一对应命名,services/users.js 配 models/users.js,找起来不用想。
五、使用dva框架和直接使用redux写法的区别
光说结构没有体感,同一个需求两种写法摆在一起看最直观。需求很简单,点一下按钮,请求 1 秒后把计数加 1,期间显示 loading。
5.1 使用 redux
原生 Redux 的写法要开四个文件,先是 action 常量加创建函数,异步那部分靠 redux-thunk 返回一个函数来做。
// action.js |
reducer 这边要写一个 switch,两个 case 加一个 default,漏了 default 直接把 state 弄丢。
//reducer.js |
// app.js |
入口文件还得自己建 store、挂中间件、套 Provider。
//index.js |
5.2 使用dva
同样的需求换成 dva,action 常量没了,switch 没了,thunk 没了,只剩一个 model 文件。
|
//app.js |
// index.js |
- 使用
redux需要拆分出action模块和reducer模块 dva将action和reducer封装到model中,异步流程采用Generator处理
组件那一侧几乎没变,connect 的用法一模一样,区别只是 mapDispatchToProps 里派发的 type 带上了 namespace。
我一直觉得这个对比是理解 dva 最快的路径。它省掉的不是能力,是重复劳动。代价也很实在,你多了一个 generator 语法要学,多了一层 redux-saga 的调试链路,出错的时候堆栈会比 thunk 深不少。
顺便说一句现在的情况。Redux 官方后来推出了 Redux Toolkit,createSlice 干的事跟 dva 的 model 非常像,同样是把 action 和 reducer 写在一起、同样自动生成 action type,异步用 createAsyncThunk。所以「Redux 样板代码多」这个 dva 当年要解决的痛点,Redux 自己已经解决了。几个主流方案的横向对比可以看 React 状态管理方案对比。
六、使用axios统一处理
这一节是全篇最能直接抄走的部分。中后台项目的请求层要处理的事情高度雷同,超时、前缀、进度条、HTTP 状态码提示、业务错误码提示、401 跳登录,一次写好就能复用到下一个项目。
6.1 示例代码
先看完整版,后面几小节再拆开讲每一块在干嘛。原文这段代码里 .then((response) => 后面直接跟了语句却没有花括号,箭头函数体写漏了,我补上了 {,否则这段是跑不起来的。
// request.js |
6.2 明确响应体
要写好拦截器,第一件事是跟后端把响应体的形状定死。这一步没谈拢,后面所有判断都是猜的。
以微信小程序为例,请求响应数据分为两部分:
- 网络请求是否成功;
- 业务场景值。即便网络请求成功了,业务处理上可能有时也会出错,比如校验不通过
我们在拦截响应时要分别对这两部分进行处理
response = { |
外层的 status 是 HTTP 层的,内层的 code 是业务层的。这两层必须分开处理,一个 200 的响应里业务上完全可能是失败的,比如「验证码错误」,这时候不该走 catch,但要给用户提示。很多人只判断 HTTP 状态码,结果就是用户点了提交没反应,也不知道哪儿错了。
6.3 依赖包分析
import axios from 'axios'; |
import store from '../index';这是dva中导出的对象。即下面代码最终导出的app._store,引入它是因为dispatch对象在里面,我们需要 dispatch 对象进行路由跳转
// index.js |
这个 export default app._store 的技巧值得单独说一句。请求层是普通模块,不是 React 组件,它拿不到 props 里的 dispatch,但 401 的时候又需要跳登录页,所以只能把 store 从入口导出来给它用。
这属于绕过框架的做法,能用但不优雅,隐患是循环依赖,index.js 引 router,router 引页面,页面引 service,service 又引回 index.js。真踩到了(表现通常是某个模块在初始化时是 undefined),可以改成让请求层抛一个特定错误、由 onError 统一处理跳转。
6.4 axios 全局配置
// 设置全局参数,如响应超市时间,请求前缀等。 |
三行配置各管一件事,timeout 防止请求永远挂着,baseURL 把接口前缀收到一处(换环境只改这一行),withCredentials 允许跨域请求带上 cookie。最后这个要跟后端对齐,服务端的 Access-Control-Allow-Origin 不能是 *,必须是具体域名,否则浏览器会直接拒绝。
axios 可以设置很多全局配置,具体可参阅 https://segmentfault.com/a/1190000008470355
6.5 加载 NProgress 过渡组件
页面顶部那条蓝色进度条,成本极低但体验提升很明显,用户至少知道系统在干活而不是卡死了。
// 添加一个请求拦截器,用于设置请求过渡状态 |
NProgress的使用主要有两个方法,当调用NProgress.start();时在浏览器顶部就会出现蓝色小条,当调用NProgress.done();蓝色小条就会消失。我们分别在请求开始和接收到响应调用这两个方法

效果就是这张图里的样子。这里有个细节别漏,响应拦截器的失败分支里也要调 NProgress.done(),请求出错了进度条不收,页面顶上就永远挂着一条走不完的蓝线。上面代码里两个分支都写了。
并发请求下 NProgress 的计数是共享的,同时发三个请求、第一个先回来就会把进度条收掉。要求精确的话得自己维护一个计数器,请求数归零才 done。这块我只在简单页面上验证过,复杂场景建议自己再包一层。
6.6 网络请求成功处理
.then((response) => { |
网络请求状态码为
200-300表示成功,此时还应该判断业务处理是否成功。这个根据具体项目具体规定,比如微信小程序有一套场景值。在实际项目中可以自行规定code = '0000'业务处理完全没问题,code = '1111'校验不通过,code = '2222'数据库出错等等。
- 最后别忘了要返回具体对象
{ ...response.data }
返回 { ...response.data } 而不是整个 response,是为了让上层的 service 和 model 不用关心 axios 的响应包装,effect 里 yield call(query) 拿到的直接就是业务数据。少一层解包,model 代码干净很多。
另外原代码里的 response.data.code != '0000' 用的是宽松相等,0 和 '0000' 这类值比较时容易出意外,建议统一成严格相等 !== 并保证后端返回的一定是字符串。
6.7 网络请求失败处理
// 状态码错误信息 |
- 网络请求失败,首先需要根据
status打印提示消息,告诉用户为什么请求失败。如响应码为401,那么提示用户的文字就会是 用户没有权限(令牌、用户名、密码错误) - 如果是
401错误,表示用户没有权限访问或者用户名密码输入错误,应该跳转到登录页面:dispatch(routerRedux.push('/user/login'));
那几个跳转分支的判断顺序值得看一眼,status >= 404 && status < 422 这种区间写法是把一批不常见的状态码都归到 404 页面去了,能用但不够精确,实际项目里建议只对确定要处理的码做跳转,其余的统一弹提示就够了。
还有一点,error.response 不存在时代表请求根本没发出去或者没收到响应,比如断网、跨域被拦、超时。这种情况没有状态码可查,只能提示网络异常,上面代码里那个 if (!error.response) 分支就是干这个的,别漏。
七、2026 年再看 dva
上面这些写法在当年的 dva 项目上是成立的,这一节说说现在的情况,免得你照着这篇去做新项目的技术选型。
先说结论,dva 现在的活跃度不高了。它的两个核心卖点,一是把 Redux 的样板代码收进 model,二是把 redux-saga 的异步编排包装得更好用,这两件事后来都被别人做了。
Redux 官方推出 Redux Toolkit 之后,createSlice 把 action 和 reducer 写在一起、自动生成 action type、内置 Immer 支持直接改草稿状态,样板代码问题已经不是问题了,而且它是 Redux 官方推荐的默认写法。想更轻的话有 Zustand,几十行 API,没有 Provider 也没有 connect。原理层面的对比我在 React 状态管理方案对比 里写过,另一条路线 MobX 的用法和现状在 MobX 核心概念解析与 React 协同最佳实践 那篇。
更大的变化是「服务端数据」这块从全局状态里被拆出去了。dva 的 model 里那些 list、total、loading、current,本质是服务端数据在客户端的一份缓存副本,而缓存、失效、重试、去重这些事 TanStack Query 这类库做得比手写 effect 好太多。现在比较常见的分层是,服务端数据交给 TanStack Query,纯客户端的 UI 状态交给 Zustand 或者 React 自带的 state,全局 store 一下子瘦了很多。
再说 redux-saga。generator 这套写法学习成本不低,调试的时候堆栈很深,而 async/await 在语言层面已经普及了。saga 真正不可替代的是复杂流程编排(竞态取消、防抖节流、任务并发控制),中后台的增删改查用不上这些。
那 dva 是不是完全不能碰了?也不是。存量项目继续跑没有任何问题,它依赖的是 Redux 和 redux-saga 这两个仍在维护的库,本身很稳定。要做的是别在老项目上再加新 model 的时候纠结,按现有约定写完保持一致就行,一致性比先进性重要。
要是从零开始,我的判断是这样:中后台项目用 umi 的话,直接用它当前版本推荐的数据流方案;不用 umi 的话,Redux Toolkit 或者 Zustand 加 TanStack Query。umi 各版本之间的约定和插件 API 变过好几轮,具体配置以官方文档为准,我在 使用 umi 改进 dva 项目开发 那篇里也标了同样的提醒。
最后提一句,这篇里出现的 dva/router、routerRedux.push 这些都是基于 react-router v3/v4 时代的 API。react-router 到 v6 之后编程式导航换成了 useNavigate 这样的 Hook,写法完全不同了。老代码你照着这篇能看懂,新代码别照抄。
总结
dva 的核心就一个 model 文件。namespace 决定它在全局 state 树上占哪个位置,state 是初始数据,reducers 是唯一能改 state 的地方且必须是纯函数,effects 用 generator 处理所有带副作用的流程,subscriptions 负责把路由、键盘、websocket 这些外部信号接进同一条数据流。
写的时候记住三条边界就不会乱:有副作用的进 effect,纯计算的进 reducer,跟组件生命周期无关的取数时机交给 subscription。请求层单独抽 utils/request 加 services/*,让 model 只关心流程编排,不关心 axios 怎么用。
至于要不要在新项目里用它,我的答案是分情况。存量 dva 项目继续维护完全没问题,它依赖的 Redux 和 redux-saga 都还在;新项目建议看 Redux Toolkit 或者 Zustand,服务端数据单独交给 TanStack Query。这篇里的 model 分层思路仍然有效,换个库照样用得上,那才是真正值得带走的东西。
参考
- dva官方教程
- 官方文档
- 使用Dva的所有知识点
- Dva-React 应用框架在蚂蚁金服的实践
- roadhog介绍
- 创建一个 dva 脚手架工程
- dva 脚手架目录分析
- 12 步 30 分钟,完成用户管理的 CURD 应用 (react+dva+antd)
- dva router4.0 使用实践总结
- dva 2.0中如何使用代码进行路由跳转
- dva 配置 browserHistory 实践总结
- dva-loading 实践用法
- dva 升级2.0版本遇到的问题小结
- dva 中进行页面复用实践总结
- Dva知识地图
- dva-API文档
- Redux Toolkit 官方文档
- redux-saga 官方文档
- 前端进阶之旅