使用 Refine + Material UI + Strapi v4 构建 React CRUD 管理后台完整指南
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
本文是一篇以 Refine 开源仓库为依托的实战教程,讲解如何用Refine(React 内部工具框架)、Material UI(组件库)与Strapi v4(Headless CMS / 数据服务)三者组合,从零搭建一个支持登录鉴权、列表分页、关系数据填充、增删改查与"可撤销(undoable)"变更模式的管理后台。读完本文,你将掌握 Refine 的资源(resource)体系、@refinedev/mui的useDataGrid与表单集成、@refinedev/strapi-v4数据提供者的populate用法,以及mutationMode、syncWithLocation等开箱即用特性,并能直接迁移到自己的业务项目中。
版本提示:本文对应 Refine v3.x 时期的文章,仓库源码示例已更新到 v4.x。Refine v4 与 v3 向后兼容,相关差异可参考仓库内的 迁移指南。
为什么选择 Refine 搭建管理后台
UI 设计与数据交互是管理后台开发中最耗时、最难维护的两部分。Material UI 解决前者(现成的组件与主题),Refine 解决后者(数据获取、状态管理、路由、鉴权与变更策略),Strapi 则充当可插拔的 REST API 后端。Refine 在仓库中的定位是"headless React internal tool framework":它不强制绑定任何 UI 库,原生支持 Ant Design 与 Material UI,同时保持后端无关(backend agnostic),可通过数据提供者(data provider)对接任意 API。其内置能力包括:
- 数据获取与状态管理(基于 TanStack Query);
- 路由(react-router、nextjs、remix 等路由提供者);
- 鉴权(auth provider)、授权(access control)、国际化(i18n)、实时(realtime);
- mutation mode:
pessimistic、optimistic、undoable三种变更策略。
从仓库 package.json 可以看出,整个项目采用 pnpm workspace 的 monorepo 结构,@refinedev/core是框架内核,@refinedev/mui、@refinedev/strapi-v4、@refinedev/react-hook-form等都是围绕它的独立包。
前置条件
- Node.js 版本最低为
v16.14.0; - 一个 Strapi v4 API(本文使用 Refine 提供的 Fake Strapi API:
https://api.strapi-v4.refine.dev); - 了解 React 基础与 Material UI 组件用法。
初始化 Refine 项目
使用 superplate CLI 向导创建项目:
npm create refine-app@latest material-ui-example -- -p refine-react -b v3在 CLI 向导中按以下选项选择:
? Do you want to use a UI Framework?: ❯ Material UI ? Do you want an extended theme?: ❯ No ? Do you want to add dark mode support?: ❯ No ? Router Provider: ❯ React Router v6 ? Data Provider: ❯ Strapi v4 ? Do you want a customized layout? ❯ No ? i18n - Internationalization: ❯ No向导会自动创建项目并安装依赖,同时完成 Strapi v4 数据提供者的基础接入配置。
配置 Strapi v4 数据提供者
数据提供者是 Refine 中负责把各种 API 抽象成统一 CRUD 接口的适配层。CLI 向导会自动添加@refinedev/strapi-v4的依赖与DataProvider工厂函数。接下来只需把 API URL 指向目标服务:
export const API_URL = "https://api.strapi-v4.refine.dev";从源码看,@refinedev/strapi-v4的DataProvider是一个工厂函数,签名为DataProvider(apiUrl, httpClient),默认使用包内基于 axios 的axiosInstance(见 packages/strapi-v4/src/dataProvider.ts)。它实现了 Refine 数据提供者要求的全部方法:getList、getMany、getOne、create、createMany、update、updateMany、deleteOne、deleteMany与custom,因此天然支持表格的增删改查与批量操作。
有几个值得注意的实现细节:
- 服务端分页:
getList默认采用mode: "server",会把pagination[page]与pagination[pageSize]拼进查询串(默认currentPage = 1、pageSize = 10),完全匹配 Strapi v4 的分页参数约定; - 响应扁平化:Strapi v4 返回的数据包裹在
data/attributes结构中,包内的normalizeData会递归地把{ id, attributes }拍平为{ id, ...attributes },让前端可以直接通过row.title这样的点号路径取值(见 packages/strapi-v4/src/utils/normalizeData.ts); - meta 透传:
locale、fields、populate、publicationState都会从调用方传入的meta对象中读取并拼入查询参数,这正是后面关系数据填充的关键通道; - 筛选与排序:Refine 的
CrudFilters/CrudSorting会被转换为 Strapi 风格语法,例如排序输出field:asc并用逗号连接(见 packages/strapi-v4/src/utils/generateSort.ts),嵌套字段的过滤会展开为filters[field][$operator]形式(见 packages/strapi-v4/src/utils/generateFilter.ts)。
CRUD 操作实现
下面按"列表 → 资源注册 → 关系数据 → 新建 → 编辑 → 删除"的顺序实现完整 CRUD。
1. 列表页:展示数据
首先为接口数据定义 TypeScript 接口。在src/interfaces/index.d.ts中写入:
export interface ICategory { id: number; title: string; } export interface IPost { id: number; title: string; content: string; status: "published" | "draft" | "rejected"; category: ICategory; createdAt: string; }然后创建列表页src/pages/posts/list.tsx:
import React from "react"; import { useDataGrid, DataGrid, GridColumns, DateField, List, } from "@refinedev/mui"; import { IPost } from "interfaces"; export const PostList: React.FC = () => { const { dataGridProps } = useDataGrid<IPost>(); const columns = React.useMemo<GridColumns<IPost>>( () => [ { field: "title", headerName: "Title", flex: 1, minWidth: 350 }, { field: "createdAt", headerName: "CreatedAt", minWidth: 220, renderCell: function render({ row }) { return <DateField format="LLL" value={row.createdAt} />; }, }, ], [], ); return ( <List> <DataGrid {...dataGridProps} columns={columns} autoHeight /> </List> ); };这段代码的关键在于useDataGrid与<DataGrid/>的组合:
<DataGrid/>是 Material UI 的原生组件,把记录以表格形式逐行渲染,columns是必填属性;useDataGrid是 Refine 为 Material UI 提供的适配 Hook(源码见 packages/mui/src/hooks/useDataGrid/index.ts),它内部调用核心包@refinedev/core的useTable,并自动生成与<DataGrid/>兼容的rows、rowCount、sortModel、filterModel、paginationModel等 props。也就是说,排序、筛选、分页这些数据交互能力通过一行{...dataGridProps}即可全部生效;columns数组中的field用于映射 API 响应中的字段键名,renderCell则用于按数据类型选择合适渲染组件——例如时间字段用<DateField format="LLL"/>格式化输出;- 默认每页 25 条记录(
pagination.pageSize默认值),筛选输入带 300ms 防抖(DEFAULT_FILTER_DEBOUNCE_MS = 300),保证服务端筛选时 UI 输入保持流畅。
useDataGrid同时兼容<DataGrid>与商业版<DataGridPro>,可放心在需要增强行编辑、树形数据等能力时平滑升级。
为了让 posts 文件夹内所有页面可以统一导出,创建src/pages/posts/index.tsx:
export * from "./list";2. 注册资源:把页面接入 Refine 应用
在src/App.tsx中把/posts端点注册为资源,并把列表页挂到list上:
import { Refine } from "@refinedev/core"; import { useNotificationProvider, RefineSnackbarProvider, CssBaseline, GlobalStyles, Layout, ThemeProvider, LightTheme, ReadyPage, ErrorComponent, } from "@refinedev/mui"; import routerProvider from "@refinedev/react-router-v6"; import { DataProvider } from "@refinedev/strapi-v4"; import { authProvider, axiosInstance } from "./authProvider"; import { API_URL } from "./constants"; //highlight-next-line import { PostList } from "./pages/posts"; function App() { return ( <ThemeProvider theme={LightTheme}> <CssBaseline /> <GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} /> <RefineSnackbarProvider> <Refine notificationProvider={useNotificationProvider} Layout={Layout} ReadyPage={ReadyPage} catchAll={<ErrorComponent />} routerProvider={routerProvider} authProvider={authProvider} dataProvider={DataProvider(API_URL + `/api`, axiosInstance)} //highlight-start resources={[ { name: "posts", list: PostList, }, ]} //highlight-end /> </RefineSnackbarProvider> </ThemeProvider> ); } export default App;注意resources是<Refine/>上代表 API 端点的属性,其中每个资源的name必须与后端端点一一对应。启动应用:
npm run dev应用会自动重定向到由name决定的 URL(即/posts)。此时会要求登录,使用示例凭证:
Username: demo@refine.dev Password: demodemo登录后检查/posts页面:文章应以表格结构正确显示,且分页开箱即用。鉴权能力来自authProvider(本文示例中由 CLI 生成,内部基于 axios 实例维护 token,Strapi 端点在 packages/strapi-v4/src/helpers/auth.ts 中有对应实现)。
3. 处理关系数据:populate 填充分类
Strapi v4 默认不会在返回条目时填充关联数据,/posts接口的每条记录只带有一个categoryid。要自动从/categories端点把分类标题带出来并显示在表格中,需要借助 Strapi v4 的populate特性。Refine 通过meta选项把它透传给数据提供者:
const { dataGridProps } = useDataGrid<IPost>({ //highlight-start meta: { populate: ["category"], }, //highlight-end });回忆数据提供者源码:getList会把meta.populate直接放入查询对象并用qs.stringify序列化(见 packages/strapi-v4/src/dataProvider.ts),因此populate: ["category"]会变成populate[0]=category发送给 Strapi,服务器随即内联返回完整的分类对象。
接下来在PostList中添加分类列:
const columns = React.useMemo<GridColumns<IPost>>( () => [ ... //highlight-start { field: "category.title", headerName: "Category", minWidth: 250, flex: 1, renderCell: function render({ row }) { return row.category?.title; }, }, //highlight-end ... ], [], );field: "category.title"利用了normalizeData扁平化后的数据结构,可以直接按点号路径读取内联分类的标题。
提示:如果你使用的是不支持自动关系填充的 REST API,可以手工在
meta中传递fields/locale等参数,或参考仓库文档 data fetching 指南 中的做法,在数据获取层自行处理关联。
4. 新建记录:Material UI 表单 + React Hook Form
Material UI 提供了样式完备且高度可定制的输入组件(自带 label、helper text 与错误处理),但表单状态管理需要第三方库配合。Refine 已内置对 React Hook Form 的集成(@refinedev/react-hook-form,源码见 packages/react-hook-form),因此可以放心用 Material UI 组件搭建表单。
创建新建页src/pages/posts/create.tsx:
import { HttpError } from "@refinedev/core"; import { Box, TextField, Autocomplete, useAutocomplete, Create, } from "@refinedev/mui"; import { useForm, Controller } from "@refinedev/react-hook-form"; import { IPost, ICategory } from "interfaces"; export const PostCreate: React.FC = () => { const { refineCore: { formLoading }, saveButtonProps, register, control, formState: { errors }, } = useForm<IPost, HttpError, IPost & { category: ICategory }>(); const { autocompleteProps } = useAutocomplete<ICategory>({ resource: "categories", }); return ( <Create isLoading={formLoading} saveButtonProps={saveButtonProps}> <Box component="form" sx={{ display: "flex", flexDirection: "column" }} autoComplete="off" > <TextField {...register("title", { required: "Title is required" })} error={!!errors?.title} helperText={errors.title?.message} margin="normal" required fullWidth id="title" label="Title" name="title" autoFocus /> <Controller control={control} name="category" rules={{ required: "Category is required" }} render={({ field }) => ( <Autocomplete {...autocompleteProps} {...field} onChange={(_, value) => { field.onChange(value); }} getOptionLabel={(item) => { return item.title ? item.title : ""; }} isOptionEqualToValue={(option, value) => value === undefined || option?.id?.toString() === (value?.id ?? value)?.toString() } renderInput={(params) => ( <TextField {...params} label="Category" margin="normal" variant="outlined" error={!!errors.category} helperText={errors.category?.message} required /> )} /> )} /> </Box> </Create> ); };要点说明:
useForm(来自@refinedev/react-hook-form)同时接管了 Refine 核心的数据提交与 React Hook Form 的表单状态:register负责把输入框注册到表单,saveButtonProps直接绑定提交行为,formLoading反映保存中的加载态;- 泛型
useForm<IPost, HttpError, IPost & { category: ICategory }>()分别表示"查询返回类型、错误类型、表单值类型"; - 分类下拉使用
useAutocomplete(源码见 packages/mui/src/hooks/useAutocomplete/index.ts),它会以resource: "categories"为端点自动获取选项列表,并返回可直接展开到<Autocomplete/>上的autocompleteProps; Controller用于把受控组件(Autocomplete)接入 React Hook Form,onChange中把选中的分类对象写回表单字段。
导出新建页并在资源上注册:
export * from "./create";... import { PostList, // highlight-next-line PostCreate, } from "pages/posts"; ... resources={[ { name: "posts", list: PostList, // highlight-next-line create: PostCreate, }, ]} ...刷新浏览器,即可从零新建一篇带分类的文章。
5. 编辑记录:表单预填充 + 行内编辑入口
创建编辑页src/pages/posts/edit.tsx:
import { HttpError } from "@refinedev/core"; import { Controller, useForm } from "@refinedev/react-hook-form"; import { Edit, Box, TextField, Autocomplete, useAutocomplete, } from "@refinedev/mui"; import { IPost, ICategory } from "interfaces"; export const PostEdit: React.FC = () => { const { refineCore: { formLoading }, saveButtonProps, register, control, formState: { errors }, } = useForm<IPost, HttpError, IPost & { category: ICategory }>({ refineCoreProps: { meta: { populate: ["category"] } }, }); const { autocompleteProps } = useAutocomplete<ICategory>({ resource: "categories", defaultValue: query?.data?.data.category.id, queryOptions: { enabled: !!query?.data?.data.category.id }, }); return ( <Edit isLoading={formLoading} saveButtonProps={saveButtonProps}> <Box component="form" sx={{ display: "flex", flexDirection: "column" }} autoComplete="off" > <TextField {...register("title", { required: "Title is required" })} error={!!errors?.title} helperText={errors.title?.message} margin="normal" required fullWidth id="title" label="Title" name="title" defaultValue={" "} autoFocus /> <Controller control={control} name="category" rules={{ required: "Category is required" }} defaultValue={ as any} render={({ field }) => ( <Autocomplete {...autocompleteProps} {...field} onChange={(_, value) => { field.onChange(value); }} getOptionLabel={(item) => { return item.title ? item.title : autocompleteProps?.options?.find( (p) => p.id.toString() === item.toString(), )?.title ?? ""; }} isOptionEqualToValue={(option, value) => value === undefined || option?.id?.toString() === (value?.id ?? value)?.toString() } renderInput={(params) => ( <TextField {...params} label="Category" margin="normal" variant="outlined" error={!!errors.category} helperText={errors.category?.message} required /> )} /> )} /> </Box> </Edit> ); };与新建页的差异在于:
useForm通过refineCoreProps.meta.populate: ["category"]让getOne拉取单条记录时一并填充分类,表单因此能回显已有的分类值;useAutocomplete通过defaultValue把当前记录的分类 id 作为下拉初始选中项,并用queryOptions.enabled控制仅在存在分类 id 时才发起额外请求;- 编辑完成后点保存,
useForm会调用数据提供者的update方法(源码中对应PUT ${apiUrl}/${resource}/${id},见 packages/strapi-v4/src/dataProvider.ts)。
同样导出并注册:
export * from "./edit";接下来在列表页的每一行增加"Actions"列与<EditButton/>:
import React from "react"; import { useDataGrid, DataGrid, GridColumns, DateField, List, //highlight-start Stack, EditButton, //highlight-end } from "@refinedev/mui"; import { IPost } from "interfaces"; export const PostList: React.FC = () => { const { dataGridProps } = useDataGrid<IPost>({ meta: { populate: ["category"], }, }); const columns = React.useMemo<GridColumns<IPost>>( () => [ { field: "title", headerName: "Title", flex: 1, minWidth: 350 }, { field: "category.title", headerName: "Category", minWidth: 250, flex: 1, renderCell: function render({ row }) { return row.category?.title; }, }, { field: "createdAt", headerName: "CreatedAt", minWidth: 220, renderCell: function render({ row }) { return <DateField format="LLL" value={row.createdAt} />; }, }, //highlight-start { headerName: "Actions", headerAlign: "center", field: "actions", minWidth: 180, align: "center", flex: 1, sortable: false, renderCell: function render({ row }) { return ( <Stack direction="row" spacing={1}> <EditButton size="small" hideText recordItemId={row.id} /> </Stack> ); }, }, //highlight-end ], [], ); return ( <List> <DataGrid {...dataGridProps} columns={columns} autoHeight /> </List> ); };... import { PostList, PostCreate, // highlight-next-line PostEdit } from "pages/posts"; ... resources={[ { name: "posts", list: PostList, create: PostCreate, // highlight-next-line edit: PostEdit }, ]} ...现在每行都有编辑按钮,点击即可进入对应记录的编辑表单并更新数据。
6. 删除记录:行内删除按钮与编辑页删除
Refine 不会自动为每一行添加删除按钮,因此第一种方式是在Actions列中显式加入<DeleteButton/>:
import React from "react"; import { useDataGrid, DataGrid, GridColumns, EditButton, DateField, List, Stack, //highlight-next-line DeleteButton, } from "@refinedev/mui"; import { IPost } from "interfaces"; export const PostList: React.FC = () => { const { dataGridProps } = useDataGrid<IPost>({ meta: { populate: ["category"], }, }); const columns = React.useMemo<GridColumns<IPost>>( ... { headerName: "Actions", headerAlign: "center", field: "actions", minWidth: 180, align: "center", flex: 1, sortable: false, renderCell: function render({ row }) { return ( <Stack direction="row" spacing={1}> <EditButton size="small" hideText recordItemId={row.id} /> //highlight-start <DeleteButton size="small" hideText recordItemId={row.id} /> //highlight-end </Stack> ); }, }, ], [], ); return ( <List> <DataGrid {...dataGridProps} columns={columns} autoHeight /> </List> ); };点击删除按钮并确认后,<DeleteButton/>会调用数据提供者的deleteOne(源码对应DELETE ${apiUrl}/${resource}/${id},见 packages/strapi-v4/src/dataProvider.ts)。
第二种方式是把删除按钮放进编辑页:只需在资源对象上设置canDelete: true,<Edit/>页面便会自动渲染<DeleteButton/>。
... function App() { return ( <ThemeProvider theme={LightTheme}> <CssBaseline /> <GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} /> <RefineSnackbarProvider> <Refine notificationProvider={useNotificationProvider} Layout={Layout} ReadyPage={ReadyPage} catchAll={<ErrorComponent />} routerProvider={routerProvider} authProvider={authProvider} dataProvider={DataProvider(API_URL + `/api`, axiosInstance)} resources={[ { name: "posts", list: PostList, create: PostCreate, edit: PostEdit, //highlight-next-line canDelete: true, }, ]} /> </RefineSnackbarProvider> </ThemeProvider> ); } export default App;至此,列表、新建、编辑、删除四条链路全部打通,一个功能完整的 CRUD 管理后台已经成型。
实现 mutation mode:让界面更"跟手"
mutation mode 决定变更操作的副作用(如 UI 更新、跳转)何时执行。Refine 提供三种模式:
pessimistic(悲观):等待服务器确认变更成功后才更新 UI,最保守;optimistic(乐观):UI 立即更新,不等服务器确认,若失败再回滚;undoable(可撤销):UI 立即更新,仿佛变更已成功,但会等待一段可配置的超时时间再真正把变更提交到服务器;超时前可以在通知中点击"撤销",界面会随之回滚。
下面启用undoable模式,删除操作会先产生"已删除"的即时反馈,通知栏同时弹出撤销入口:
... function App() { return ( <ThemeProvider theme={LightTheme}> <CssBaseline /> <GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} /> <RefineSnackbarProvider> <Refine notificationProvider={useNotificationProvider} Layout={Layout} ReadyPage={ReadyPage} catchAll={<ErrorComponent />} routerProvider={routerProvider} authProvider={authProvider} dataProvider={DataProvider(API_URL + `/api`, axiosInstance)} resources={[ { name: "posts", list: PostList, create: PostCreate, edit: PostEdit, canDelete: true, }, ]} //highlight-next-line options={{ mutationMode: "undoable" }} /> </RefineSnackbarProvider> </ThemeProvider> ); } export default App;- 默认超时时间为
5000ms(5 秒),可以通过在<Refine/>上设置undoableTimeout属性调整,例如options={{ mutationMode: "undoable", undoableTimeout: 10000 }}; - 在源码层面,
undoableTimeout由useUpdate、useDelete等数据 Hook 消费,先从 context 读取默认值、再以 Hook 参数覆盖,超时期间通知会显示剩余秒数并携带撤销动作(见 packages/core/src/hooks/data/useDelete.ts 与 packages/core/src/hooks/data/useUpdate.ts); mutationMode的默认值是pessimistic,options配置定义在 packages/core/src/contexts/refine/index.tsx。
通过 URL 分享当前页面(分页/排序/筛选同步)
当需要把"当前第几页、每页多少条、按什么排序、带了哪些筛选条件"的视图分享给同事时,最规范的做法是分享一个包含全部参数的 URL,例如:
/posts?current=1&pageSize=8&sort[]=createdAt&order[]=descRefine 的syncWithLocation选项可以把这些状态自动同步到 URL 查询参数中,这样既可以直接复制链接分享,也可以手动修改 URL 参数来调整分页、排序与筛选:
... function App() { return ( <ThemeProvider theme={LightTheme}> <CssBaseline /> <GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} /> <RefineSnackbarProvider> <Refine ... options={{ mutationMode: "undoable", //highlight-next-line syncWithLocation: true }} /> </RefineSnackbarProvider> </ThemeProvider> ); } export default App;从源码看,useTable会先取 Hook 参数中的syncWithLocation,再回退到 context 中的全局配置,并在每次currentPage、pageSize、sorters、filters变化时把状态写入 URL(见 packages/core/src/hooks/useTable/index.ts)。由于useDataGrid底层就是useTable,所以这套同步机制对 Material UI 表格同样生效;仓库测试 packages/core/src/hooks/useTable/index.spec.ts 中也覆盖了syncWithLocation: true的场景。
结语
本文以 Refine + Material UI + Strapi v4 三件套,从零实现了一个带登录鉴权、CRUD、关系数据填充、可撤销删除、URL 状态同步的完整管理后台。整个过程的核心收益是:声明式资源注册 + 数据提供者抽象 + 框架内置 Hook让你几乎不需要手写数据请求逻辑,排序、分页、筛选、表单提交、删除确认这些高频能力开箱即用。
本文覆盖的内容包括:
- 用 superplate CLI 引导初始化 Refine 应用;
- 接入 Strapi v4 数据提供者并理解其分页、扁平化、
meta透传实现; - 基于
useDataGrid与<DataGrid/>搭建列表、注册资源; - 通过
populate处理关系数据; - 用
@refinedev/react-hook-form+ Material UI 表单完成新建与编辑; - 两种删除方式(行内按钮与
canDelete); mutationMode: "undoable"与syncWithLocation两个提升体验的框架特性。
如果想继续深入,可以在仓库中找到对应的完整可运行示例 examples/data-provider-strapi-v4,以及数据提供者官方文档 documentation/docs/packages/。Refine 作为 MIT 开源的 React 内部工具框架,文档完善、示例丰富,适合作为管理后台类应用的快速落地底座。
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考