React-Admin 企业级后台实战:从架构设计到性能优化的完整指南
1. 为什么我最终选了 React-Admin 来搭管理系统
后台管理系统这个东西,做过的人都知道,需求翻来覆去就那几样:列表、筛选、增删改查、权限、表单校验、导入导出。但真要从零手写,一个中等复杂度的后台,光是把表格分页、筛选联动、表单校验、路由权限这几块拼起来,没个两三周根本下不来。我前前后后参与过七八个后台项目,早期用 jQuery 拼页面,后来转 Vue,再后来团队统一到 React 生态,中间试过不少脚手架和低代码平台,最后在“可控性”和“开发效率”之间找到的平衡点,就是React-Admin。
这篇内容我想聊的是:React-Admin 到底是什么、它解决了哪些实际问题、一个企业级管理系统从零到能交付的完整路径长什么样,以及我在多个项目里踩过的坑和总结出来的配置套路。适合两类人看:一类是刚接触 React-Admin、想快速上手的中级前端;另一类是已经用过但总觉得“能用但不好用”、想把它真正吃透、改造成贴合自己业务的老手。全文会围绕真实项目里最常遇到的场景展开,代码和配置都是可以直接抄去改的,不是那种跑个 demo 就完事的教程。
先说清楚 React-Admin 的定位。它不是一个“低代码平台”,也不是一个“开箱即用的成品后台”,它更像是一套约定优于配置的框架层:帮你把数据提供者(dataProvider)、认证(authProvider)、路由、列表页、编辑页、表单这些骨架搭好,你只需要按它的约定去填业务逻辑。它的核心抽象是“资源(Resource)”——一个资源对应一组 CRUD 页面,你声明name、list、edit、create,框架就自动帮你生成路由、菜单、权限入口。这种设计的好处是心智负担极低,坏处是当你需要高度定制时,得先理解它的约定,否则会跟框架“打架”。
我选它的核心理由有三条。第一,数据层与 UI 层解耦得干净。dataProvider 是一个纯函数接口,你后端是 REST、GraphQL、还是自研的 RPC,只要实现那几个方法(getList、getOne、create、update、delete 等),上层组件完全不用改。这意味着后端换协议、加网关、做聚合,前端几乎零改动。第二,MUI 生态的成熟度。React-Admin 默认基于 Material UI,组件质量、无障碍支持、主题定制能力都在线,企业级后台最看重的“稳定感”它有。第三,社区和文档的密度。遇到问题基本能搜到答案,源码结构清晰,改起来不慌。
但我也得把丑话说前面:React-Admin 不是银弹。它的默认列表页在数据量大、列多、需要复杂行内操作时性能会吃紧;它的表单体系基于 react-hook-form,灵活但学习曲线不低;它的权限模型偏粗粒度,细到按钮级的权限得自己补。这些后面都会展开讲。
2. 项目整体架构与核心思路拆解
2.1 分层设计:把“框架约定”和“业务定制”隔离开
一个能长期维护的企业级后台,最怕的就是业务代码和框架代码搅在一起。我在项目里强制推行三层结构:框架层(React-Admin 原生能力)→ 适配层(自定义 dataProvider、authProvider、layout、theme)→ 业务层(各资源的 list/edit/create 组件)。框架层尽量不动,适配层集中处理所有“跟后端协议、跟公司设计规范”相关的脏活,业务层只关心字段和交互。
这么分的好处很直接:后端接口改字段名,只改适配层的 dataProvider 映射;公司换主题色,只改 theme;某个业务页面要特殊布局,只动那一个资源组件。我见过太多项目把接口映射散落在各个页面里,后期改一个字段名要全局搜索替换,那才是真正的技术债。
具体到目录结构,我习惯这样组织:
src/ App.tsx // Admin 根组件,挂 Provider 和 Resource dataProvider/ index.ts // 统一导出,按环境切换 restProvider.ts // REST 适配 graphqlProvider.ts // 备用 authProvider/ index.ts layout/ MainLayout.tsx // 自定义布局:侧边栏、顶栏、面包屑 Menu.tsx theme/ index.ts // MUI 主题定制 resources/ users/ index.ts // 资源声明 UserList.tsx UserEdit.tsx UserCreate.tsx orders/ ... components/ // 跨资源复用的业务组件 hooks/ // 自定义 hooks这个结构不是拍脑袋定的,它对应的是“变更频率”的划分:resources下每个目录变更最频繁,dataProvider和theme变更最少。把稳定的放底层、易变的放上层,是控制维护成本的基本功。
2.2 数据流设计:dataProvider 是整个系统的“咽喉”
React-Admin 的所有数据请求都收敛到 dataProvider 这一个入口,这是它最值钱的设计。我把它理解成“前端的 API 网关”:所有请求从这里出去,所有响应从这里回来,天然适合做统一埋点、统一错误处理、统一 loading 策略、统一鉴权头注入。
一个典型的 dataProvider 方法签名是这样的(以 getList 为例):
getList: async (resource, params) => { const { page, perPage } = params.pagination; const { field, order } = params.sort; const query = { ...params.filter, _sort: field, _order: order, _start: (page - 1) * perPage, _end: page * perPage, }; const url = `${apiUrl}/${resource}?${stringify(query)}`; const { json, headers } = await httpClient(url); return { data: json, total: parseInt(headers.get('content-range')?.split('/').pop() || '0', 10), }; },这里有个关键点很多人第一次会踩:total 必须返回,否则分页器算不出总页数,列表页会显示成“只有一页”。REST 后端通常用Content-Range头或者响应体里的total字段,你得跟后端约定好。我一般要求后端在列表接口里固定返回{ data: [], total: number },比解析响应头稳。
另一个高频坑是筛选参数的序列化。React-Admin 的 filter 是一个对象,直接拼到 query string 里,如果后端要求filter[name]=xxx这种嵌套格式,就得在 dataProvider 里做一层转换。我建议把这类转换写成独立的serializeFilter函数,别塞在 getList 里,方便单测。
2.3 认证与权限:authProvider 只解决“你是谁”,“你能干啥”得自己补
authProvider 的接口很薄:login、logout、checkAuth、checkError、getIdentity、getPermissions。前四个是登录态管理,后两个是身份和权限。但注意,React-Admin 原生只做到“路由级权限”——你可以在 Resource 上配options: { permissions: ['admin'] },框架会帮你隐藏没权限的菜单项。但按钮级、字段级、行级权限,它不管。
我的做法是在 authProvider 的 getPermissions 里返回一个权限码数组,然后在业务组件里用一个自定义 hookusePermission()去判断:
const usePermission = () => { const { permissions } = usePermissions(); return (code: string) => permissions?.includes(code) ?? false; };按钮上就写{can('order:refund') && <RefundButton />}。这套方案简单、可测、不依赖框架内部实现。字段级权限则通过自定义SimpleForm的sanitize或者直接在 input 上做条件渲染。别指望框架给你现成的,自己封一层反而更可控。
3. 核心细节解析与实操要点
3.1 列表页:性能与体验的平衡术
列表页是后台用得最多、也最容易出问题的页面。React-Admin 默认的<Datagrid>在列数超过 15、单页数据超过 50 条时,滚动会明显掉帧。原因不复杂:它把每一行都渲染成完整的 MUI 组件树,DOM 节点数量爆炸。
我的优化套路分三档,按数据量选:
| 数据规模 | 方案 | 关键配置 |
|---|---|---|
| 单页 < 30 行 | 默认 Datagrid | 无需改动 |
| 单页 30~100 行 | Datagrid + 精简列 | rowClick="edit",去掉行内多余按钮 |
| 单页 > 100 行或列 > 20 | 虚拟滚动表格 | 换ra-datagrid或自研虚拟列表 |
虚拟滚动这块,社区有现成方案,但我的经验是:别一上来就上虚拟滚动。先把列精简、把行内操作收进“更多”菜单、把图片懒加载,往往就能解决 80% 的卡顿。真到了必须虚拟滚动的量级,说明你的列表页设计本身可能有问题——用户真的需要一屏看 100 行吗?多数时候是筛选条件没做好,逼着用户翻页找数据。
列表页还有一个高频需求是自定义列渲染。比如订单状态要显示成带颜色的标签,金额要千分位,时间要格式化。React-Admin 的<TextField>支持render属性,但我更推荐封装业务组件:
const StatusField = ({ record }) => { const colorMap = { paid: 'success', pending: 'warning', refunded: 'default' }; return <Chip label={record.status} color={colorMap[record.status]} size="small" />; };这样在<Datagrid>里直接<StatusField source="status" />,复用性和可读性都好。注意source属性是 React-Admin 用来取值的约定,自定义组件里通过record[source]拿,别写死字段名。
3.2 表单体系:react-hook-form 的坑与解法
React-Admin 的表单底层是 react-hook-form,这套组合很强大,但有几个坑必须提前知道。
坑一:默认值不生效。<SimpleForm>的defaultValues只在首次渲染时读一次,如果你在编辑页异步拉数据后再 set,表单不会更新。正确做法是用<Edit>的queryOptions配合mutationMode,或者干脆用<Form>手动控制。我一般用<Edit>包<SimpleForm>,数据由框架注入,别自己再拉一遍。
坑二:联动字段的 watch 时机。比如“省份选完才加载城市”,需要在useFormContext里 watch 省份字段。注意 watch 要在组件内部调用,不能在<SimpleForm>外面,否则拿不到 form context。
坑三:数组字段(ArrayInput)的校验。嵌套数组的校验规则容易写错层级,validate要写在每个<SimpleFormIterator>的子 input 上,而不是外层。我踩过一次,校验规则写在外层,结果数组里任何一项出错都提示“整体错误”,定位不到具体行。
一个典型的联动表单片段:
const ProvinceCityInput = () => { const { watch, setValue } = useFormContext(); const province = watch('province'); const { data: cities } = useGetList('cities', { filter: { province }, enabled: !!province, }); useEffect(() => { setValue('city', undefined); }, [province, setValue]); return ( <SelectInput source="city" choices={cities || []} disabled={!province} /> ); };这里enabled: !!province是关键,避免省份没选就发请求。useEffect里清空 city 也是必须的,否则用户切换省份后旧城市值还留着,提交时数据就脏了。
3.3 自定义布局:别跟默认 Layout 死磕
React-Admin 默认的<Layout>是左侧菜单 + 顶栏,够用但不够“企业”。公司一般要求:可折叠侧边栏、多级菜单、顶栏放用户信息和通知、面包屑、标签页导航。我的建议是基于默认 Layout 改,而不是从零写。默认 Layout 已经把路由、菜单、权限、响应式都处理好了,你只需要覆盖appBar、menu、sidebar这几个插槽。
多级菜单是个典型例子。默认<Menu>只支持一级,要支持嵌套,得自定义Menu组件,用 MUI 的<Collapse>包一层。注意菜单项的to属性要跟 Resource 的路由对上,React-Admin 的路由规则是/${resource},子路由是/${resource}/:id,别自己乱拼。
标签页导航(多页签)是另一个高频需求,但 React-Admin 原生不支持。我的实现思路是:监听路由变化,把访问过的路由 push 进一个状态数组,渲染成 MUI 的<Tabs>,点击切换时navigate到对应路由。注意要处理“关闭当前页签后跳转到哪”的逻辑,一般跳到相邻页签或首页。这块代码量不大,但边界情况多,建议单独抽一个useTabshook。
4. 实操过程与核心环节实现
4.1 从零初始化:依赖选择与版本锁定
初始化这一步,我的原则是锁版本、少依赖。React-Admin 的版本迭代不算慢,大版本之间偶有 breaking change,团队项目一定要在package.json里锁死主版本,别用^。
核心依赖清单:
{ "react": "^18.2.0", "react-dom": "^18.2.0", "react-admin": "^4.16.0", "react-hook-form": "^7.48.0", "@mui/material": "^5.14.0", "@mui/icons-material": "^5.14.0", "ra-data-simple-rest": "^4.16.0" }注意react-admin和@mui/material的版本要匹配,v4 对应 MUI v5,v5 对应 MUI v5 但内部有调整。装完先跑一个最小 demo:一个 Resource、一个 dataProvider、一个 authProvider,确认列表能出来、登录能跳转,再往上堆业务。别一上来就配一堆东西,出问题定位不到。
初始化命令(以 Vite 为例,比 CRA 快很多):
npm create vite@latest admin-app -- --template react-ts cd admin-app npm install react-admin @mui/material @mui/icons-material ra-data-simple-rest用 TypeScript 是强烈建议的,React-Admin 的类型定义很完整,Identifier、RaRecord、DataProvider这些类型能帮你省掉大量运行时错误。尤其是 dataProvider 的返回值,类型不对编译期就报错,比运行时白屏强。
4.2 dataProvider 实战:REST 适配的完整实现
ra-data-simple-rest能用,但企业项目里基本都要自己改。我贴一个生产环境用过的精简版,重点看错误处理和参数转换:
import { DataProvider, fetchUtils } from 'react-admin'; import { stringify } from 'query-string'; const apiUrl = '/api'; const httpClient = (url: string, options: any = {}) => { if (!options.headers) options.headers = new Headers({ Accept: 'application/json' }); const token = localStorage.getItem('token'); if (token) options.headers.set('Authorization', `Bearer ${token}`); return fetchUtils.fetchJson(url, options); }; export const dataProvider: DataProvider = { getList: async (resource, params) => { const { page, perPage } = params.pagination; const { field, order } = params.sort; const query = { ...params.filter, _sort: field, _order: order, _start: (page - 1) * perPage, _end: page * perPage, }; const url = `${apiUrl}/${resource}?${stringify(query)}`; const { json, headers } = await httpClient(url); return { data: json.data, total: json.total, }; }, getOne: async (resource, params) => { const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`); return { data: json }; }, getMany: async (resource, params) => { const query = { id: params.ids }; const { json } = await httpClient(`${apiUrl}/${resource}?${stringify(query)}`); return { data: json }; }, getManyReference: async (resource, params) => { const { page, perPage } = params.pagination; const query = { ...params.filter, [params.target]: params.id, _start: (page - 1) * perPage, _end: page * perPage, }; const { json } = await httpClient(`${apiUrl}/${resource}?${stringify(query)}`); return { data: json.data, total: json.total }; }, create: async (resource, params) => { const { json } = await httpClient(`${apiUrl}/${resource}`, { method: 'POST', body: JSON.stringify(params.data), }); return { data: json }; }, update: async (resource, params) => { const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, { method: 'PUT', body: JSON.stringify(params.data), }); return { data: json }; }, updateMany: async (resource, params) => { const responses = await Promise.all( params.ids.map(id => httpClient(`${apiUrl}/${resource}/${id}`, { method: 'PUT', body: JSON.stringify(params.data), }) ) ); return { data: responses.map(({ json }) => json.id) }; }, delete: async (resource, params) => { const { json } = await httpClient(`${apiUrl}/${resource}/${params.id}`, { method: 'DELETE', }); return { data: json }; }, deleteMany: async (resource, params) => { await Promise.all( params.ids.map(id => httpClient(`${apiUrl}/${resource}/${id}`, { method: 'DELETE' }) ) ); return { data: params.ids }; }, };几个细节值得说。getMany是列表页里引用字段(<ReferenceField>)批量拉数据用的,如果后端不支持?id=1&id=2这种批量查询,就得在 dataProvider 里循环单查再拼,性能会差,建议推动后端支持。updateMany和deleteMany是批量操作,注意返回的data格式:updateMany 返回 id 数组,deleteMany 也返回 id 数组,别返回完整对象,框架只认 id。
错误处理这块,fetchJson在非 2xx 时会抛错,React-Admin 会捕获并触发authProvider.checkError。我一般在这里统一处理 401 跳登录、403 提示无权限、500 提示服务异常。别在每个页面里写 try-catch,收敛到一处。
4.3 权限落地:从菜单到按钮的完整链路
权限这块我按“菜单可见 → 路由可进 → 按钮可点 → 字段可看”四层来做。
菜单层用 Resource 的options.permissions,配合 authProvider 的 getPermissions 返回的数组,框架自动过滤。路由层用自定义<Admin>的authProvider里的 checkAuth,未登录跳登录页。按钮层用前面说的usePermissionhook。字段层用条件渲染。
一个完整的 Resource 声明示例:
<Resource name="orders" list={OrderList} edit={OrderEdit} create={OrderCreate} options={{ permissions: ['order:view'] }} icon={ShoppingCartIcon} />注意options.permissions是数组,表示“拥有其中任一权限即可见”。如果要做“必须同时拥有”,得自己扩展,框架不支持 AND 逻辑。
按钮级权限的典型写法:
const OrderListActions = () => { const can = usePermission(); return ( <TopToolbar> {can('order:export') && <ExportButton />} {can('order:batchRefund') && <BulkRefundButton />} </TopToolbar> ); };这里有个坑:usePermission依赖usePermissions,而usePermissions在 authProvider 的 getPermissions 返回前是 undefined,会导致按钮闪烁。解法是在<Admin>外层加一个 loading 态,或者用usePermissions({ enabled: true })配合骨架屏。别小看这个闪烁,用户看到按钮出现又消失,体验很差。
4.4 主题定制:让后台不那么“Material”
MUI 默认主题偏“谷歌风”,企业后台一般要更紧凑、更中性。定制入口是<Admin theme={...}>,传一个 MUI 的createTheme结果。我常用的几项调整:
const theme = createTheme({ palette: { primary: { main: '#1f6feb' }, background: { default: '#f5f6f8' }, }, typography: { fontSize: 13, fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif', }, components: { MuiTableCell: { styleOverrides: { root: { padding: '8px 12px' }, }, }, MuiButton: { defaultProps: { disableElevation: true }, }, }, });fontSize: 13和padding: 8px 12px是让表格更紧凑的关键,默认的 14px 和 16px 在大屏上显得松散。disableElevation去掉按钮阴影,视觉上更“后台”。这些细节单看不起眼,但整体调完,后台的“专业感”会明显提升。
主题定制还有个进阶玩法:按角色切换主题。比如运营角色用蓝色主色,财务角色用绿色,通过 authProvider 的 getIdentity 拿到角色后动态生成 theme。这个需求不常见,但真遇到时,用useMemo包一层 createTheme 就行,别在 render 里直接调,会重复创建。
5. 常见问题与排查技巧实录
5.1 列表页数据不刷新:缓存与失效的博弈
React-Admin 内部用 react-query 做缓存,这是好事也是坑。典型现象:编辑保存后返回列表,数据还是旧的。原因通常是 mutation 成功后没有正确 invalidate 对应的 query key。
框架默认会在 update/create/delete 后自动 invalidate 当前资源的列表缓存,但如果你用了自定义的useGetList且 key 不一致,就失效不了。排查思路:打开 react-query 的 devtools,看 mutation 后哪些 query 被标记为 stale。如果没标记,检查你的 dataProvider 是否返回了正确的data.id——框架靠 id 来定位缓存。
另一个高频场景是跨资源联动。比如改了订单状态,用户列表里的“最近订单”字段要更新。这种得手动queryClient.invalidateQueries(['users'])。我一般把 queryClient 通过 context 暴露出去,在 mutation 的onSuccess里调。
5.2 表单提交后跳转异常:redirect 的三种模式
<Create>和<Edit>的redirect属性有三个值:list、edit、false。默认是list,保存后回列表。但如果你在弹窗里编辑(比如从列表页点开一个 Dialog),redirect 成 list 会导致弹窗关了但页面没变,体验割裂。这时候要设redirect={false},手动控制关闭弹窗。
还有个坑:redirect传函数时,参数是(basePath, id, data),返回的是路由字符串。我见过有人返回undefined,结果框架跳到 404。要么返回明确路径,要么返回 false,别返回空。
5.3 大数据量导出卡死:分页拉取 + 流式下载
导出功能是后台标配,但直接getList拉全量数据,几万条就把浏览器干趴了。我的方案是分页拉取 + 前端拼 CSV + Blob 下载,每页 500 条,拉完一页拼一页,最后统一触发下载。关键代码:
const exportAll = async () => { const pageSize = 500; let page = 1; let allRows: any[] = []; while (true) { const { data, total } = await dataProvider.getList('orders', { pagination: { page, perPage: pageSize }, sort: { field: 'id', order: 'ASC' }, filter: currentFilter, }); allRows = allRows.concat(data); if (allRows.length >= total) break; page++; } const csv = toCSV(allRows); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'orders.csv'; a.click(); URL.revokeObjectURL(url); };注意toCSV要处理逗号、换行、引号的转义,别直接 join。还有中文乱码问题,Blob 的 type 里加charset=utf-8,CSV 内容开头加 BOM(\uFEFF),Excel 打开才不乱码。这个 BOM 坑我踩过不止一次。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 列表页一直 loading | dataProvider 返回值缺 total | 检查 getList 返回结构 |
| 分页器显示 0 页 | total 解析失败 | 看响应头或响应体字段名 |
| 编辑保存后数据没变 | 缓存未失效 | react-query devtools 看 stale 标记 |
| 表单默认值不显示 | defaultValues 时机问题 | 改用 Edit 注入数据 |
| 菜单项不显示 | permissions 不匹配 | 打印 getPermissions 返回值 |
| 按钮点击无反应 | 权限 hook 返回 undefined | 检查 authProvider 是否就绪 |
| 导出中文乱码 | 缺 BOM | Blob 内容前加 \uFEFF |
| 路由 404 | Resource name 与路径不符 | 检查 name 和自定义路由 |
5.5 几个只有踩过才知道的细节
第一,<ReferenceInput>的filter要 memo。如果你在 filter 里传了一个内联对象,每次 render 都会触发重新请求,列表页会疯狂刷接口。用useMemo包一下,或者把 filter 提到组件外。
第二,<AutocompleteInput>在大量选项时会卡。默认它会渲染所有选项,几千条就废了。解法是用filterToQuery配合后端搜索,别在前端过滤。filterToQuery把输入框的值转成 filter 对象,传给 dataProvider,让后端做模糊匹配。
第三,<Datagrid>的rowClick设成edit后,行内按钮的点击会被拦截。需要在按钮上e.stopPropagation(),否则点按钮同时触发行跳转。这个坑很隐蔽,因为表现是“点按钮没反应”或“跳转了两次”。
第四,生产环境要关掉 react-query 的 devtools 和 React-Admin 的 debug 模式。<Admin>有个disableTelemetry属性,设成 true 可以少发一些统计请求。虽然不影响功能,但企业内网环境里,多余的外部请求可能触发安全告警。
6. 我在多个项目里沉淀下来的配置套路
6.1 环境切换:一套代码跑多环境
企业项目基本都有 dev、test、prod 三套环境,接口地址、上传地址、埋点开关都不同。我的做法是用 Vite 的import.meta.env配合.env文件,dataProvider 里读import.meta.env.VITE_API_URL。注意别把敏感配置写进前端 env,前端 env 是打包进 bundle 的,谁都能看到。鉴权 token 走运行时获取,别硬编码。
环境切换的另一个坑是跨域。dev 环境用 Vite 的 proxy 转发,prod 环境走同域部署或网关。proxy 配置里记得changeOrigin: true,否则后端可能因为 Host 头不对拒绝请求。
6.2 错误边界:别让一个页面崩了整个后台
React-Admin 默认没有全局错误边界,某个资源组件抛错,整个 Admin 白屏。我一般在<Admin>外层包一个自定义 ErrorBoundary,捕获后显示“页面出错了,请刷新重试”,同时上报错误日志。错误边界只能捕获 render 阶段的错误,异步请求的错误还是得在 dataProvider 里处理。
6.3 打包优化:按资源拆包
React-Admin 的 bundle 不小,全量打包出来 1MB+ 很常见。优化手段是路由级懒加载:每个 Resource 的 list/edit/create 用React.lazy包一层,配合<Suspense>。这样首屏只加载当前路由的代码,切换资源时再按需加载。实测首屏体积能降 40% 左右。
懒加载的坑是<Admin>的 Resource 声明需要同步,不能把整个 Resource 懒加载,只能懒加载里面的组件。写法:
const UserList = lazy(() => import('./resources/users/UserList')); <Resource name="users" list={UserList} />配合<Suspense fallback={<Loading />}>包在<Admin>外层。注意 fallback 别用太重的组件,一个简单的 spinner 就行。
6.4 测试策略:dataProvider 单测 + 关键页面 E2E
后台系统的测试,我重点放在两处:dataProvider 的单测,和登录、列表、编辑三条主链路的 E2E。dataProvider 是纯函数,用 jest mock fetch 就能测,覆盖参数转换、错误处理、返回值结构。E2E 用 Playwright,跑一遍“登录 → 进列表 → 编辑保存 → 回列表看到新数据”,这条链路通了,基本功能就稳了。
别追求 100% 覆盖率,后台系统的 UI 变化快,写太多组件单测维护成本高。把 dataProvider 和 authProvider 测透,比测一堆按钮有没有渲染出来有价值得多。
6.5 上线前的检查清单
最后分享一份我每次上线前必过的清单,都是血泪教训换来的:
- dataProvider 的 total 字段在真实数据量下验证过(别用 mock 的 10 条测)
- 所有 Resource 的 permissions 配置跟后端权限码对齐
- 表单的必填校验、格式校验在提交前全部触发
- 导出功能在 1 万条数据下不卡死
- 401 时能正确跳登录,且登录后回到原页面
- 生产环境关掉所有 console.log 和 debug 面板
- 主题在 1366 和 1920 两种分辨率下都检查过
- 打包产物 gzip 后主包 < 300KB
这套东西跑下来,一个中等复杂度的企业后台,从零到能交付,我个人的节奏是两周左右:第一周搭骨架、通 dataProvider、做登录和权限,第二周堆业务页面、调样式、补导出和批量操作。当然这是熟手节奏,第一次做的话,预留一个月比较稳。
React-Admin 这个框架,我的整体评价是“下限很高,上限看你投入”。它帮你把最枯燥的 CRUD 骨架搭好了,剩下的就是往里填业务。真正拉开项目质量差距的,不是你会不会用它的组件,而是你有没有把 dataProvider 这层适配做干净、有没有把权限体系想清楚、有没有在性能和体验上做取舍。这些事框架替不了你,但一旦做对了,后面加需求就是复制粘贴改字段,那才是它真正省时间的地方。