使用 Refine + Material UI + Strapi v4 构建 React CRUD 管理后台完整指南
2026/9/10 13:16:19 网站建设 项目流程

使用 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/muiuseDataGrid与表单集成、@refinedev/strapi-v4数据提供者的populate用法,以及mutationModesyncWithLocation等开箱即用特性,并能直接迁移到自己的业务项目中。

版本提示:本文对应 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:pessimisticoptimisticundoable三种变更策略。

从仓库 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-v4DataProvider是一个工厂函数,签名为DataProvider(apiUrl, httpClient),默认使用包内基于 axios 的axiosInstance(见 packages/strapi-v4/src/dataProvider.ts)。它实现了 Refine 数据提供者要求的全部方法:getListgetManygetOnecreatecreateManyupdateupdateManydeleteOnedeleteManycustom,因此天然支持表格的增删改查与批量操作。

有几个值得注意的实现细节:

  • 服务端分页getList默认采用mode: "server",会把pagination[page]pagination[pageSize]拼进查询串(默认currentPage = 1pageSize = 10),完全匹配 Strapi v4 的分页参数约定;
  • 响应扁平化:Strapi v4 返回的数据包裹在data/attributes结构中,包内的normalizeData会递归地把{ id, attributes }拍平为{ id, ...attributes },让前端可以直接通过row.title这样的点号路径取值(见 packages/strapi-v4/src/utils/normalizeData.ts);
  • meta 透传localefieldspopulatepublicationState都会从调用方传入的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/coreuseTable,并自动生成与<DataGrid/>兼容的rowsrowCountsortModelfilterModelpaginationModel等 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 }}
  • 在源码层面,undoableTimeoutuseUpdateuseDelete等数据 Hook 消费,先从 context 读取默认值、再以 Hook 参数覆盖,超时期间通知会显示剩余秒数并携带撤销动作(见 packages/core/src/hooks/data/useDelete.ts 与 packages/core/src/hooks/data/useUpdate.ts);
  • mutationMode的默认值是pessimisticoptions配置定义在 packages/core/src/contexts/refine/index.tsx。

通过 URL 分享当前页面(分页/排序/筛选同步)

当需要把"当前第几页、每页多少条、按什么排序、带了哪些筛选条件"的视图分享给同事时,最规范的做法是分享一个包含全部参数的 URL,例如:

/posts?current=1&pageSize=8&sort[]=createdAt&order[]=desc

Refine 的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 中的全局配置,并在每次currentPagepageSizesortersfilters变化时把状态写入 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询