1. 为什么这三个东西总被绑在一起讲
刚接触前端那会儿,我看到“umi + dva + antd”这串组合,第一反应是:怎么又是三个英文名,前端圈是不是不叠几个缩写就不会说话。后来真正在项目里用起来才明白,这套组合之所以总被放在一起讲,不是因为它们必须绑定,而是因为它们解决的是同一件事的三个不同层面——路由与工程化、状态管理、UI 组件。你把它们拆开单独用也完全可以,但凑在一起,确实能让一个中后台项目从零到能跑起来的速度快很多。
先说清楚这三个东西各自是干嘛的,不然后面全是空中楼阁。
umi是一个可扩展的企业级前端应用框架,它把路由、构建、部署、插件这些事都收拢到一套约定里。你可以把它理解成一个“已经帮你把脚手架、路由配置、构建工具都安排好的大管家”。以前用 React 做项目,你得自己选构建工具、自己配路由、自己搞按需加载,umi 把这些约定俗成的东西直接内置了,你按它的目录结构写文件,路由就自动生成了。
dva是一个基于 redux 和 redux-saga 的数据流方案。redux 本身用起来挺啰嗦的,action、reducer、dispatch 一堆概念,dva 把它简化成了 model 的概念,一个 model 里同时管 state、reducer、effect,写起来清爽不少。它解决的是“组件之间怎么共享和修改数据”这个问题。
antd是 Ant Design 的 React 组件库,表格、表单、弹窗、按钮这些中后台高频组件它都有,而且设计规范统一。你不需要自己从零写一个表格组件,直接用 antd 的 Table,传数据进去就行。
那为什么小白成长之路要从这三个开始?因为大部分前端新人进公司接的第一个活,就是中后台管理系统。而中后台管理系统最典型的特征就是:页面多、表单多、表格多、状态共享多。umi 管页面和路由,dva 管状态,antd 管界面,正好把这类项目的三大痛点全覆盖了。你把这套跑通,基本上就能独立扛起一个管理后台的前端开发。
提示:这套组合不是唯一解,也不是所有项目都适合。如果是纯展示型官网,用不上 dva;如果是小型工具页,可能一个 React 加几个组件就够了。但在中后台这个场景里,它的成熟度和生态确实省心。
我见过不少新人一上来就去啃 redux 源码、webpack 配置,啃了两周还没跑出一个能点的页面,热情直接磨没了。正确的路径应该是先用框架把项目跑起来,看到效果,再回头理解它内部做了什么。umi + dva + antd 的好处就在于,它让你第一天就能写出有交互、有数据、有样式的页面,这种正反馈对坚持学下去特别重要。
2. 动手之前先把这几个概念捋直
2.1 umi 的约定式路由到底省了什么
传统 React 项目里加一个页面,你要做几件事:新建组件文件、在路由配置文件里注册路径、处理懒加载、可能还要配一下菜单。umi 的约定式路由把这些压缩成了一件事:在pages目录下新建文件。
比如你建一个pages/users/list.js,umi 自动就给你生成了/users/list这个路由。建一个pages/users/detail.js,就有/users/detail。目录结构即路由结构,文件名即路径名。这个约定看起来简单,但它省掉的是每次加页面都要改配置文件的重复劳动,也避免了路由配置和实际文件对不上导致的 404。
当然约定式路由不是万能的。有些动态路由、权限路由、嵌套布局的复杂场景,你还是得用配置式路由去覆盖。umi 允许你在config/routes.js里手写路由配置,两种方式可以混用。我的经验是:常规页面用约定式,特殊页面用配置式,别为了统一而统一。
2.2 dva 的 model 为什么比裸 redux 好上手
裸 redux 的数据流是这样的:组件 dispatch 一个 action,action 被 reducer 接收,reducer 返回新的 state,组件通过 connect 拿到新 state。概念本身不难,但写起来文件多、样板代码多。一个简单的计数器可能要拆成 actionTypes、actions、reducer 三个文件。
dva 把这一套收进了一个 model 对象里:
export default { namespace: 'counter', state: { count: 0 }, reducers: { add(state) { return { ...state, count: state.count + 1 }; }, }, effects: { *addAsync(action, { call, put }) { yield call(delay, 1000); yield put({ type: 'add' }); }, }, };namespace 是这个 model 的命名空间,state 是初始数据,reducers 是同步修改 state 的方法,effects 是处理异步逻辑的地方。组件里通过connect把 model 的 state 和 dispatch 注入进来,就能读写数据了。
这里有个新人特别容易懵的点:reducers 里不能写异步,effects 里不能直接改 state。reducers 必须是纯函数,同步返回新 state;effects 负责处理异步,处理完再通过 put 调用 reducer 去改 state。这个分工一开始觉得别扭,但习惯之后会发现逻辑很清晰——同步的归同步,异步的归异步,调试的时候一眼就知道问题出在哪一层。
2.3 antd 的组件不是拿来就用那么简单
antd 的组件文档写得很全,但新人最容易犯的错是“看到组件就往上堆”,结果页面能跑但体验很差。举几个我踩过的坑。
Table 组件如果不给每行加rowKey,React 会报警告,而且在数据更新时可能出现渲染错乱。rowKey 要选一个每行唯一且稳定的字段,通常用 id,千万别用数组下标,因为排序或删除后下标会变。
Form 组件在 antd 4.x 之后改成了Form.useForm()的用法,不再用getFieldDecorator。很多老教程还在讲旧写法,你照着写会发现根本不生效。新写法是先用const [form] = Form.useForm()拿到表单实例,然后给 Form 传form={form},表单项用name属性绑定字段名。
Modal 组件有个经典坑:visible和onCancel必须配对使用,而且关闭时要记得把内部状态清掉,否则下次打开会残留上次的数据。我一般会在onCancel里同时做两件事:关闭弹窗、重置表单。
注意:antd 的版本差异很大,3.x 和 4.x 的 API 有不少 breaking change。看文档时一定要确认左上角的版本号和你项目里
package.json里的一致,不然会浪费大量时间在“为什么文档里的写法我这儿报错”上。
3. 从零搭一个能跑的最小项目
3.1 环境准备与项目初始化
先把 Node.js 装好,建议用 14 以上的 LTS 版本。然后全局装一个 umi(也可以不装,用 npx 直接跑,但装了之后命令短一点):
npm install -g umi创建项目目录,我习惯用create-umi来生成基础结构:
mkdir my-admin && cd my-admin yarn create umi执行后会让你选模板,选app(应用模板),然后它会问你需不需要 antd、dva 这些,全部勾上。生成完之后yarn装依赖,yarn start启动。浏览器打开http://localhost:8000,能看到 umi 的欢迎页,说明环境通了。
这一步看着简单,但有几个地方容易卡住。一是网络问题导致依赖装不下来,可以换用国内镜像源;二是 Node 版本太低,umi 的一些依赖要求 Node 12 以上;三是端口被占用,umi 默认 8000,被占了会自动换端口,注意看终端输出的实际地址。
3.2 目录结构先看懂再动手
初始化完的项目大概长这样:
my-admin ├── config/ │ └── config.js ├── mock/ ├── src/ │ ├── models/ │ ├── pages/ │ │ └── index.js │ ├── app.js │ └── global.css ├── package.json └── .umirc.jsconfig/config.js是 umi 的配置文件,插件、代理、主题这些都在这里配。src/pages放页面,src/models放 dva 的 model。src/app.js是运行时配置,可以在这里做路由守卫、全局布局之类的事。
我建议新人先把pages/index.js改一改,随便写个<h1>Hello</h1>,保存后看浏览器自动刷新,先建立“改代码—看效果”的直觉。然后再去pages下新建一个about.js,访问/about看能不能打开。这一步能帮你确认约定式路由真的在工作。
3.3 接入 dva 并写第一个 model
虽然 create-umi 生成时勾了 dva,但为了搞清楚它怎么接进来的,我建议手动走一遍。在config/config.js里确认有dva: {}这个配置项,它告诉 umi 启用 dva 插件。
然后在src/models下新建counter.js:
export default { namespace: 'counter', state: { count: 0, }, reducers: { increment(state) { return { ...state, count: state.count + 1 }; }, decrement(state) { return { ...state, count: state.count - 1 }; }, }, };注意 model 的文件名和 namespace 不强制一致,但保持一致会少很多心智负担。umi 会自动扫描src/models下的所有文件并注册,不需要你手动 import。
接着在页面里用起来:
import React from 'react'; import { connect } from 'dva'; import { Button } from 'antd'; const Counter = ({ count, dispatch }) => { return ( <div style={{ padding: 24 }}> <h2>当前计数:{count}</h2> <Button type="primary" onClick={() => dispatch({ type: 'counter/increment' })}> 加一 </Button> <Button style={{ marginLeft: 8 }} onClick={() => dispatch({ type: 'counter/decrement' })}> 减一 </Button> </div> ); }; export default connect(({ counter }) => ({ count: counter.count, }))(Counter);这里connect的第一个参数是一个函数,从全局 state 里挑出这个组件需要的部分。dispatch的 type 格式是namespace/reducer名,这是 dva 的约定。点按钮看数字变化,第一个 dva 数据流就跑通了。
3.4 用 antd 搭一个带表格和表单的页面
光有计数器还不够,中后台的核心是列表加表单。我们来做一个用户列表页,包含一个表格和一个新增用户的弹窗。
先在src/models下建users.js:
const mockData = [ { id: 1, name: '张三', age: 28, role: '管理员' }, { id: 2, name: '李四', age: 24, role: '普通用户' }, ]; export default { namespace: 'users', state: { list: mockData, visible: false, }, reducers: { addUser(state, { payload }) { const newUser = { ...payload, id: state.list.length + 1 }; return { ...state, list: [...state.list, newUser], visible: false }; }, openModal(state) { return { ...state, visible: true }; }, closeModal(state) { return { ...state, visible: false }; }, }, };然后写页面src/pages/users.js:
import React from 'react'; import { connect } from 'dva'; import { Table, Button, Modal, Form, Input, InputNumber } from 'antd'; const Users = ({ list, visible, dispatch, form }) => { const columns = [ { title: '姓名', dataIndex: 'name', key: 'name' }, { title: '年龄', dataIndex: 'age', key: 'age' }, { title: '角色', dataIndex: 'role', key: 'role' }, ]; const handleOk = () => { form.validateFields().then((values) => { dispatch({ type: 'users/addUser', payload: values }); form.resetFields(); }); }; const handleCancel = () => { form.resetFields(); dispatch({ type: 'users/closeModal' }); }; return ( <div style={{ padding: 24 }}> <Button type="primary" style={{ marginBottom: 16 }} onClick={() => dispatch({ type: 'users/openModal' })} > 新增用户 </Button> <Table dataSource={list} columns={columns} rowKey="id" /> <Modal title="新增用户" visible={visible} onOk={handleOk} onCancel={handleCancel} > <Form form={form} layout="vertical"> <Form.Item name="name" label="姓名" rules={[{ required: true }]}> <Input /> </Form.Item> <Form.Item name="age" label="年龄" rules={[{ required: true }]}> <InputNumber style={{ width: '100%' }} /> </Form.Item> <Form.Item name="role" label="角色" rules={[{ required: true }]}> <Input /> </Form.Item> </Form> </Modal> </div> ); }; export default connect(({ users }) => ({ list: users.list, visible: users.visible, }))(Form.create()(Users));这段代码把 umi 的路由、dva 的状态、antd 的组件全用上了。表格展示数据,按钮打开弹窗,表单填完提交后数据进 model,表格自动更新。跑通这个页面,你对这套组合的理解就到位了。
提示:上面用的是
Form.create()这种 antd 3.x 的写法。如果你项目里是 antd 4.x,要改成Form.useForm()的 hooks 写法,否则会报错。判断方法很简单,看package.json里 antd 的版本号,4 开头就用新写法。
4. 那些文档里不会写的坑
4.1 model 命名冲突与数据不更新
dva 的 model 是按 namespace 注册的,如果你两个 model 用了同一个 namespace,后注册的会覆盖先注册的,而且不会有明显报错,只是数据莫名其妙不对。我遇到过一次,两个同事各写了一个usermodel,结果一个的功能死活不生效,排查了半天才发现是命名撞了。所以团队里最好约定一个命名规范,比如按业务模块加前缀。
另一个高频问题是“dispatch 了但页面不更新”。原因通常是 reducer 里直接改了 state 而不是返回新对象。比如写成state.count = 1; return state;,这样 redux 认为 state 没变,不会触发更新。正确写法永远是返回一个新对象:return { ...state, count: 1 };。这个坑新人几乎必踩,记住一句话——reducer 里永远返回新对象,不要原地修改。
4.2 effects 里的错误处理
effects 处理异步请求时,如果接口报错没接住,整个流程会静默失败,页面看起来就是“点了没反应”。我一般会在 effect 里加 try/catch:
effects: { *fetchList(action, { call, put }) { try { const data = yield call(queryList, action.payload); yield put({ type: 'saveList', payload: data }); } catch (e) { yield put({ type: 'saveError', payload: e.message }); } }, },这样出错时至少能把错误信息存到 state 里,页面可以展示出来。别小看这一步,线上环境没有它,排查问题基本靠猜。
4.3 antd 表单的初始值回填
编辑场景下,弹窗打开时要回填已有数据。antd 4.x 里用form.setFieldsValue(),但要注意调用时机——必须在 Form 已经挂载之后调用。如果放在 Modal 的onOpen里,可能 Form 还没渲染,设置不上去。稳妥的做法是用useEffect监听 visible 变化,visible 变 true 时再 setFieldsValue。
还有个细节:setFieldsValue只设置你传进去的字段,没传的字段保持原值。如果你希望每次打开都是干净的表单,要先resetFields()再setFieldsValue(),顺序不能反。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 页面 404 | 文件没放在 pages 下,或路径拼写不符约定 | 检查文件位置和访问路径是否对应 |
| dispatch 后数据不变 | reducer 原地修改了 state | 确认 reducer 返回了新对象 |
| 表格渲染错乱 | 没设 rowKey 或 rowKey 不唯一 | 给 Table 加唯一 rowKey |
| 表单值取不到 | antd 版本与写法不匹配 | 确认 3.x 还是 4.x 的 API |
| model 不生效 | namespace 冲突或文件没被扫描到 | 检查命名和 models 目录位置 |
| 弹窗数据残留 | 关闭时没重置表单 | onCancel 里加 resetFields |
5. 我个人的一些实操体会
这套组合我用了挺长时间,最大的感受是:它的价值不在于技术多先进,而在于把中后台开发里那些重复的、约定俗成的东西标准化了。你不需要每次做新项目都重新选型、重新配路由、重新封装请求,umi 和 dva 把这些都替你做了决定。对新人来说,这种“少做选择”反而是好事,能让你把精力放在业务逻辑上。
但也要清醒地看到它的边界。dva 这几年更新放缓,社区里也有声音说它偏重、不够灵活。如果你的项目状态逻辑特别复杂,或者团队更倾向 hooks 那一套,用 umi 的useModel或者干脆上 zustand、jotai 也是合理选择。工具是为人服务的,别被工具绑住。
最后分享一个我踩过的坑:别在项目初期就过度设计 model 的拆分。我一开始恨不得每个页面一个 model,结果 model 之间互相引用,数据流绕来绕去,改一个地方崩三个页面。后来学乖了,先按业务模块粗粒度地分,等某个 model 确实臃肿了再拆。数据流这东西,简单直接永远比“设计感”重要。
如果你正卡在某个具体报错上,我的建议是先把问题最小化——新建一个干净页面,只保留出问题的那一小段逻辑,能复现就说明问题在这段代码里,不能复现就往依赖和配置上找。这个方法帮我省下的时间,比任何调试工具都多。