ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

使用 Refine + Material UI + Strapi v4 构建 React CRUD 管理后台完整指南

使用 Refine + Material UI + Strapi v4 构建 React CRUD 管理后台完整指南 使用 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 开源仓库为依托的实战教程讲解如何用RefineReact 内部工具框架、Material UI组件库与Strapi v4Headless 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、实时realtimemutation modepessimistic、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 APIhttps://api.strapi-v4.refine.dev了解 React 基础与 Material UI 组件用法。初始化 Refine 项目使用 superplate CLI 向导创建项目npm create refine-applatest 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.tsmeta 透传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.tsximport React from react; import { useDataGrid, DataGrid, GridColumns, DateField, List, } from refinedev/mui; import { IPost } from interfaces; export const PostList: React.FC () { const { dataGridProps } useDataGridIPost(); const columns React.useMemoGridColumnsIPost( () [ { field: title, headerName: Title, flex: 1, minWidth: 350 }, { field: createdAt, headerName: CreatedAt, minWidth: 220, renderCell: function render({ row }) { return DateField formatLLL 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 formatLLL/格式化输出默认每页 25 条记录pagination.pageSize默认值筛选输入带 300ms 防抖DEFAULT_FILTER_DEBOUNCE_MS 300保证服务端筛选时 UI 输入保持流畅。useDataGrid同时兼容DataGrid与商业版DataGridPro可放心在需要增强行编辑、树形数据等能力时平滑升级。为了让 posts 文件夹内所有页面可以统一导出创建src/pages/posts/index.tsxexport * 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: demorefine.dev Password: demodemo登录后检查/posts页面文章应以表格结构正确显示且分页开箱即用。鉴权能力来自authProvider本文示例中由 CLI 生成内部基于 axios 实例维护 tokenStrapi 端点在 packages/strapi-v4/src/helpers/auth.ts 中有对应实现。3. 处理关系数据populate 填充分类Strapi v4 默认不会在返回条目时填充关联数据/posts接口的每条记录只带有一个categoryid。要自动从/categories端点把分类标题带出来并显示在表格中需要借助 Strapi v4 的populate特性。Refine 通过meta选项把它透传给数据提供者const { dataGridProps } useDataGridIPost({ //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.useMemoGridColumnsIPost( () [ ... //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 FormMaterial UI 提供了样式完备且高度可定制的输入组件自带 label、helper text 与错误处理但表单状态管理需要第三方库配合。Refine 已内置对 React Hook Form 的集成refinedev/react-hook-form源码见 packages/react-hook-form因此可以放心用 Material UI 组件搭建表单。创建新建页src/pages/posts/create.tsximport { 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 }, } useFormIPost, HttpError, IPost { category: ICategory }(); const { autocompleteProps } useAutocompleteICategory({ resource: categories, }); return ( Create isLoading{formLoading} saveButtonProps{saveButtonProps} Box componentform sx{{ display: flex, flexDirection: column }} autoCompleteoff TextField {...register(title, { required: Title is required })} error{!!errors?.title} helperText{errors.title?.message} marginnormal required fullWidth idtitle labelTitle nametitle autoFocus / Controller control{control} namecategory 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} labelCategory marginnormal variantoutlined error{!!errors.category} helperText{errors.category?.message} required / )} / )} / /Box /Create ); };要点说明useForm来自refinedev/react-hook-form同时接管了 Refine 核心的数据提交与 React Hook Form 的表单状态register负责把输入框注册到表单saveButtonProps直接绑定提交行为formLoading反映保存中的加载态泛型useFormIPost, HttpError, IPost { category: ICategory }()分别表示查询返回类型、错误类型、表单值类型分类下拉使用useAutocomplete源码见 packages/mui/src/hooks/useAutocomplete/index.ts它会以resource: categories为端点自动获取选项列表并返回可直接展开到Autocomplete/上的autocompletePropsController用于把受控组件Autocomplete接入 React Hook FormonChange中把选中的分类对象写回表单字段。导出新建页并在资源上注册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.tsximport { 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 }, } useFormIPost, HttpError, IPost { category: ICategory }({ refineCoreProps: { meta: { populate: [category] } }, }); const { autocompleteProps } useAutocompleteICategory({ resource: categories, defaultValue: query?.data?.data.category.id, queryOptions: { enabled: !!query?.data?.data.category.id }, }); return ( Edit isLoading{formLoading} saveButtonProps{saveButtonProps} Box componentform sx{{ display: flex, flexDirection: column }} autoCompleteoff TextField {...register(title, { required: Title is required })} error{!!errors?.title} helperText{errors.title?.message} marginnormal required fullWidth idtitle labelTitle nametitle defaultValue{ } autoFocus / Controller control{control} namecategory 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} labelCategory marginnormal variantoutlined 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 } useDataGridIPost({ meta: { populate: [category], }, }); const columns React.useMemoGridColumnsIPost( () [ { 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 formatLLL 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 directionrow spacing{1} EditButton sizesmall 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 } useDataGridIPost({ meta: { populate: [category], }, }); const columns React.useMemoGridColumnsIPost( ... { headerName: Actions, headerAlign: center, field: actions, minWidth: 180, align: center, flex: 1, sortable: false, renderCell: function render({ row }) { return ( Stack directionrow spacing{1} EditButton sizesmall hideText recordItemId{row.id} / //highlight-start DeleteButton sizesmall 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: trueEdit/页面便会自动渲染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;默认超时时间为5000ms5 秒可以通过在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.tsmutationMode的默认值是pessimisticoptions配置定义在 packages/core/src/contexts/refine/index.tsx。通过 URL 分享当前页面分页/排序/筛选同步当需要把当前第几页、每页多少条、按什么排序、带了哪些筛选条件的视图分享给同事时最规范的做法是分享一个包含全部参数的 URL例如/posts?current1pageSize8sort[]createdAtorder[]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 表单完成新建与编辑两种删除方式行内按钮与canDeletemutationMode: 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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进