Refine 集成 React DND 构建看板:useDrag 与 useDrop 完整实战指南
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
导读:本文以 Refine 项目为例,系统讲解如何利用 React DND 库(
react-dnd+react-dnd-html5-backend)为管理后台构建可拖拽的看板(Kanban Board)。你会完整掌握DndProvider的接入方式、useDrag/useDrop两个核心 Hook 的用法与类型匹配原理、基于 RefineuseList的数据驱动渲染,以及性能优化、错误排查和移动端 Touch Backend 支持等进阶技巧。文末附有可直接运行的完整示例工程,位于 examples/blog-react-dnd。
背景:为什么用 React DND 而不是原生拖拽 API
HTML 原生 Drag-and-Drop API 是 Web 平台的一项先驱能力,但其使用门槛较高——实现一次拖拽往往需要编写大量样板代码,对初学者并不友好。为此社区出现了 React-Beautiful-DND、React DND 等封装库,把底层细节收敛起来。
React DND 提供的是低层级(lower-level)的解决方案:它不像其他库那样提供现成组件,而是通过useDrag和useDrop两个 Hook 包装你的现有组件,并向其中注入拖拽行为相关的 props。这意味着你可以完全掌控视觉表现,把精力放在界面细节上,而拖拽的核心机制由库来负责。它基于 HTML5 拖拽 API 实现,同时支持鼠标与触摸事件,非常适合在管理后台中构建高度可维护的拖放界面。
本文的示例运行在一个 Refine 项目中。Refine 是一个 headless 的 React 框架,用于快速构建 CRUD 应用(管理面板、仪表盘、内部工具等),其特点是 UI 无关(可与 Ant Design、Mantine、Material UI 或任意自定义设计无缝集成)、后端无关,并内置了认证、状态管理、数据获取与路由等能力。headless 意味着它不提供预设 UI 组件,你可以完全掌控应用外观,使其符合团队的品牌与设计规范。本文示例的数据层正是通过 Refine 的useListHook 从 REST API 拉取,再交给 React DND 做拖拽。
项目准备:初始化 Refine 应用
使用 Refine CLI 创建项目:
npm create refine-app@latest按如下选项选择即可生成带 CRUD 页面的基础工程:
✔ Choose a project template · Refine(Next.js) ✔ What would you like to name your project?: · refine-dnd ✔ Choose your backend service to connect: · REST API ✔ Do you want to use a UI Framework?: · Ant Design ✔ Do you want to add example pages?: · Yes ✔ Do you need any Authentication logic?: · No ✔ Do you need i18n (Internationalization) support?: · No ✔ Choose a package manager: · npm进入项目目录并安装 React DND 及其 HTML5 后端:
npm install react-dnd react-dnd-html5-backend启动开发服务器:
npm run dev浏览器会自动打开预览;若未自动打开,可手动访问http://localhost:3000。仓库中的完整示例工程 examples/blog-react-dnd/package.json 使用的依赖版本为react-dnd@^16.0.1、react-dnd-html5-backend@^16.0.1、@refinedev/core@^4.56.0、@refinedev/antd@^5.44.0、antd@^5.23.0与react@^19.1.0,可作为版本对齐的参考。
搭建 Dashboard 页面
脚手架项目默认没有 Dashboard 页,需要先手动创建。在pages目录下新建dashboardPage.tsx:
import { Typography } from "antd"; function DashBoardPage() { return <Typography>This is the Dashboard page</Typography>; } export default DashBoardPage;然后在App.tsx中引入该页面,注册路由与资源。核心改动有两点:把<DashboardPage />作为 index 路由元素,并在<Refine />的resources数组中添加dashboard资源(list: "/"),使其出现在侧边栏菜单中:
import DashBoardPage from "./pages/dashboardPage"; // ... <Refine dataProvider={dataProvider("https://api.fake-rest.refine.dev")} notificationProvider={useNotificationProvider} routerProvider={routerProvider} resources={[ { name: "dashboard", list: "/", }, { name: "blog_posts", list: "/blog-posts", create: "/blog-posts/create", edit: "/blog-posts/edit/:id", show: "/blog-posts/show/:id", meta: { canDelete: true }, }, // ... ]} options={{ syncWithLocation: true, warnWhenUnsavedChanges: true }} > <Routes> <Route element={ <ThemedLayout Header={() => <Header sticky />} Sider={(props) => <ThemedSider {...props} fixed />} > <Outlet /> </ThemedLayout> } > <Route index element={<DashBoardPage />} /> {/* ... 其他路由 */} </Route> </Routes> </Refine>保存后点击侧边栏的Dashboard按钮即可进入该页面。完整的App.tsx结构可参考仓库中的 examples/blog-react-dnd/src/App.tsx。
集成 React DND:DndProvider 与 HTML5Backend
使用 React DND 前,必须用<DndProvider />包裹应用根组件,并传入后端(backend)。回到App.tsx,引入并包裹:
import { Refine } from "@refinedev/core"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; function App() { return ( <DndProvider backend={HTML5Backend}> <Refine /* ... */ /> </DndProvider> ); } export default App;React DND 为触摸设备与非触摸设备分别提供独立后端。HTML5Backend基于 HTML5 拖拽 API 实现,用于桌面端:它实现最直接、在绝大多数现代浏览器上工作良好,但同样受限于 HTML5 API 的浏览器差异与不一致性。若漏掉DndProvider,整个拖拽将完全不生效(详见后文“错误排查”)。
核心 Hook 速览:useDrag 与 useDrop
useDrag:让元素可拖拽
useDrag用于创建可被拖动的元素,接收一个配置对象,返回[收集到的状态, 拖拽源 ref]:
const [{ isDragging }, dragRef] = useDrag({ item: { type: "item", item: myitem }, begin: () => { console.log("drag began"); }, end: (dropResult) => { console.log("drag end"); }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), });- 返回值第一项是当前拖拽状态对象,第二项是要绑定到可拖拽元素上的 ref。
item定义拖拽过程中可用的数据,建议包含type属性,以便与useDrop的accept属性匹配。collect是回调函数,用于访问 drag-and-drop monitor(拖拽监视器),这里收集isDragging(布尔值,表示当前是否有元素正在被拖拽)。begin与end分别在拖拽开始和结束时触发。
useDrop:创建可接收拖拽的放置区
useDrop与useDrag配合使用,用于创建能接收拖入元素的放置目标:
const [{ isOver, canDrop }, dropRef] = useDrop({ accept: "item", drop: (item, monitor) => { console.log(`Dropped item: ${JSON.stringify(item)}`); }, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), });accept声明该放置区接受的元素类型,必须与useDrag中的type一致(本例均为"item")。- 当拖拽元素悬停于目标上方时,
isOver与canDrop为true;元素被放下时调用drop函数,入参是被拖拽的 item。
看板数据层:枚举、模型与 useData Hook
开始写组件前,先准备“常量”数据层,位于src/components/constants/下。
enums.ts:定义列类型与卡片类型
export enum ColumnTypes { ORDERS = "Orders", IN_PROGRESS = "In Progress", DELIVERED = "Delivered", RETURNED = "Returned", } export enum cardType { ORDER = "Order", }看板包含四列:待处理订单(Orders)、进行中(In Progress)、已交付(Delivered)与已退回(Returned);每列都是可放置区,接受的卡片类型为Order。
models.ts:定义数据接口
import { ColumnTypes } from "./enums"; export interface OrderProps { id: number; title: string; desc: string; column: ColumnTypes; } export interface dragItem { index: number; id: OrderProps["id"]; } export interface IProduct { id: number; name: string; material: string; column?: ColumnTypes.ORDERS; }OrderProps描述每个订单的属性;dragItem描述被拖拽的条目;IProduct是商品数据模型,用于对接数据提供者返回的数据。仓库中该文件的实际实现见 examples/blog-react-dnd/src/components/constants/models.ts。
useData.ts:用 Refine useList 拉取并改写数据
import React from "react"; import { ColumnTypes } from "./enums"; import { IProduct } from "components/constants/models"; import { useList } from "@refinedev/core"; function useData() { // 使用 Refine 的 useList Hook 从 products 端点获取数据 const { result: { data }, } = useList<IProduct>({ config: { pagination: { currentPage: 2, }, }, resource: "products", }); // 改写数据:为每个条目追加 column 属性 const newArr = data?.map((i: IProduct) => { return { ...i, column: ColumnTypes.ORDERS, }; }); return [newArr, data?.data]; } export default useData;useList是 Refine 提供的列表数据 Hook,这里请求的是<Refine />上dataProvider指向的 fake REST API 的products端点,并显式翻到第 2 页(currentPage: 2)。从实现上讲,这与直接执行fetch("https://api.fake-rest.refine.dev/products")获取数据等价,但由 Refine 数据提供者统一处理请求、缓存与状态。map遍历返回数据,为每个对象追加column: ColumnTypes.ORDERS。column属性决定卡片归属于哪一列;当前所有条目都被标记为Orders的子项,后续拖拽时通过修改该字段实现列间移动。- 函数返回新旧两份数组:
newArr(带 column 字段)与data?.data(原始数据),后者用于触发副作用更新。
看板布局:Column 与 Cards 组件
Column 组件(可复用的列容器)
import React from "react"; import { Row, Col } from "antd"; function Column({ children, name }: { children: any; name: string }) { return ( <Row gutter={30}> <Col style={{ backgroundColor: "#e3e7ee", width: "270px", padding: "15px", minHeight: "170px", maxHeight: "690px", borderRadius: "5px", overflowY: "scroll", }} > <div style={{ fontSize: "17px", marginLeft: "10px", marginBottom: "15px", color: "#84878c", }} > {name} </div> <div style={{ width: "100%", height: "75%", padding: "4px", }} > {children} </div> </Col> </Row> ); } export default Column;该组件接收children(列内卡片)与name(列标题)两个 props,使用 Ant Design 的Row/Col声明式布局。固定宽度 270px、可滚动区域,便于重复渲染四列。
DashboardPage:渲染四列并过滤卡片
import React from "react"; import Column from "../components/columns"; import { ColumnTypes } from "../components/constants/enums"; import { Space } from "antd"; function DashboardPage() { const { ORDERS, IN_PROGRESS, DELIVERED, RETURNED } = ColumnTypes; return ( <div> <Space direction="horizontal" align="baseline" size={109} style={{ display: "flex", justifyContent: "center", marginTop: "20px", }} > <Column name={ORDERS}>{}</Column> <Column name={IN_PROGRESS}>{}</Column> <Column name={DELIVERED}>{}</Column> <Column name={RETURNED}>{}</Column> </Space> </div> ); } export default DashboardPage;通过ColumnTypes枚举把四列渲染到画布上。此时列内尚无卡片内容。
渲染卡片:useState + useEffect 联动数据
import React, { useEffect, useState } from "react"; import { Space } from "antd"; import Column from "../components/columns"; import Cards from "../components/cards"; import { ColumnTypes } from "../components/constants/enums"; import useData from "../components/constants/useData"; function DashboardPage() { const [newArr, products] = useData(); const [orders, setOrders] = useState<any[] | undefined>([]); useEffect(() => { setOrders(newArr); }, [products]); const columnItem = (columnName: string) => { return ( orders && orders .filter((order) => order.column === columnName) .map((order, index) => ( <Cards key={order.id} name={order.name} material={order.material} setOrders={setOrders} index={index} /> )) ); }; // ... 渲染四列时传入 columnItem(ORDERS) 等 }useData返回newArr与products;useState初始化orders为空数组,useEffect在products变化时把newArr写入状态(依赖项用原始products触发,避免引用不稳定造成死循环)。columnItem(columnName)先按column字段过滤卡片,再映射为<Cards />。由于目前所有条目column均为Orders,卡片只会出现在 Orders 列。
最后把columnItem作为每列的 children 传入:
<Column name={ORDERS}>{columnItem(ORDERS)}</Column> <Column name={IN_PROGRESS}>{columnItem(IN_PROGRESS)}</Column> <Column name={DELIVERED}>{columnItem(DELIVERED)}</Column> <Column name={RETURNED}>{columnItem(RETURNED)}</Column>Cards 组件(基础版)
import React from "react"; import { Card } from "antd"; function Cards({ name, material, setOrders, }: { name: string; material: string; setOrders: any; index: number; }) { return ( <Card title={name} className="card" style={{ marginBottom: "15px", boxShadow: "1px 4px 11px -2px rgba(135,135,135,0.75)", }} > {material} </Card> ); } export default Cards;这是最简单的卡片展示组件,接收name、material等 props 并用 Ant Design 的Card渲染。仓库中的最终实现见 examples/blog-react-dnd/src/components/cards.tsx。
让卡片可拖拽:接入 useDrag
在Cards组件内声明useDrag,指定卡片类型与 item 数据,并把返回的dragref 绑定到Card上:
import React from "react"; import { Card } from "antd"; import { cardType } from "./constants/enums"; import { useDrag } from "react-dnd"; function Cards({ name, material, setOrders }: { /* ... */ }) { const [{ isDragging }, drag] = useDrag({ type: cardType.ORDER, item: { name }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }); return ( <Card ref={drag} title={name} className="card" style={{ opacity: isDragging ? "0.5" : "1", marginBottom: "15px", boxShadow: "1px 4px 11px -2px rgba(135,135,135,0.75)", }} > {material} </Card> ); } export default Cards;type: cardType.ORDER使用枚举统一类型标识。collect回调通过 monitor 的isDragging()判断卡片是否正在被拖拽,并据此把卡片透明度调整为0.5(拖拽中)或1(正常)。
完成这一步后,浏览器中的卡片即可被拖起。
让列可接收拖放:接入 useDrop
放置区位于每一列,因此在Column组件内声明useDrop:
import React from "react"; import { Row, Col } from "antd"; import { useDrop } from "react-dnd"; import { cardType } from "./constants/enums"; function Column({ children, name }: { children: any; name: string }) { const [{ canDrop, isOver }, dropref] = useDrop({ accept: cardType.ORDER, drop: () => ({ name, }), collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }); return ( <Row gutter={30}> <Col style={{ backgroundColor: "#e3e7ee", width: "270px", padding: "15px", minHeight: "170px", maxHeight: "690px", borderRadius: "5px", overflowY: "scroll", }} > <div style={{ fontSize: "17px", marginLeft: "10px", marginBottom: "15px", color: "#84878c", }} > {name} </div> <div ref={dropref} style={{ width: "100%", height: "75%", padding: "4px", border: isOver ? "dashed 1px black" : "", }} > {children} </div> </Col> </Row> ); } export default Column;accept: cardType.ORDER与useDrag的type保持一致。drop回调返回{ name }——即当前列的名字,供拖拽源在end阶段读取(通过monitor.getDropResult())。collect收集isOver与canDrop;当卡片悬停在放置区上方时二者为true。此时把放置区的边框改为虚线(dashed 1px black)作为视觉反馈。
类型匹配是关键:useDrag与useDrop共享同一类型(Order),所以悬停时isOver/canDrop才会返回true。这也解释了为什么useDrop的accept必须与useDrag的type完全一致。
实现列间移动:orderColumnChange 与 end 回调
目前放下卡片还不会改变位置。需要在Cards组件中加入排序函数,并在useDrag的end回调里根据落点更新列归属。
先添加状态更新函数:
const orderColumnChange = (CurrentOrder: any, columnName: string) => { setOrders((prevState: string[]) => { return prevState.map((item: any) => { return { ...item, column: item.name === CurrentOrder.name ? columnName : item.column, }; }); }); };它遍历前一个状态,把“名称匹配当前拖拽卡片”的条目的column改为目标列名,从而让该卡片成为目标列的子项。
再在useDrag中补充end回调,根据monitor.getDropResult()返回的列名分派到对应列:
import { ColumnTypes, cardType } from "./constants/enums"; import { useDrag } from "react-dnd"; import { IProduct } from "./constants/models"; const [{ isDragging }, drag] = useDrag({ type: cardType.ORDER, item: { name }, end: (order, monitor) => { const dropResult = monitor.getDropResult<IProduct>(); if (dropResult) { const { name } = dropResult; const { ORDERS, IN_PROGRESS, DELIVERED, RETURNED } = ColumnTypes; switch (name) { case ORDERS: orderColumnChange(order, ColumnTypes.ORDERS); break; case IN_PROGRESS: orderColumnChange(order, ColumnTypes.IN_PROGRESS); break; case DELIVERED: orderColumnChange(order, ColumnTypes.DELIVERED); break; case RETURNED: orderColumnChange(order, ColumnTypes.RETURNED); break; default: break; } } }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), });至此,卡片可以被拖放到任意一列,且状态会实时更新、重渲染到对应列中。仓库中cards.tsx、columns.tsx的最终代码与本文一致,可直接对照查看 examples/blog-react-dnd/src/components/cards.tsx 与 examples/blog-react-dnd/src/components/columns.tsx。
性能优化:让大量可拖拽项保持流畅
当可拖拽项数量增大时,性能可能成为瓶颈。以下是实用策略:
- 避免不必要的重渲染:利用
React.memo或useMemo缓存不变的拖拽项。例如可拖拽条目本身未变化时,对其做 memo 化可节省大量处理时间。 - 批量更新状态:一次 drop 需要更新多个条目时,尽量合并为一次状态更新,避免多次触发 React 渲染周期(如上面的
orderColumnChange就是一次性map出全新数组后调用一次setOrders)。 - 收窄 collect 函数的范围:
useDrag/useDrop的collect很强大,但塞入过多逻辑会拖慢性能,只收集必要的数据即可。 - 虚拟化大列表:面对成百上千个可拖拽项,可引入
react-window或react-virtualized之类的虚拟列表库,只渲染可见项,显著降低 React 的渲染负担。
错误排查与调试
常见问题与对策:
缺少 DndProvider:如果忘记用DndProvider包裹应用,拖拽功能完全不工作。确保根组件包含它:
import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; function App() { return ( <DndProvider backend={HTML5Backend}> <YourApp /> </DndProvider> ); }useDrag 与 useDrop 类型不匹配:useDrag的type必须与useDrop的accept一致,否则放置区无法识别被拖拽的条目:
const [{ isDragging }, dragRef] = useDrag({ type: "item" }); const [{ isOver }, dropRef] = useDrop({ accept: "item" });Monitor 数据不更新:如果collect拿不到准确数据,确认你从 monitor 返回了正确的取值:
collect: (monitor) => ({ isDragging: monitor.isDragging() });这类小问题排查起来很耗时,建议结合浏览器控制台的详细报错信息与 React DND 官方文档对照处理。
移动端支持:切换到 Touch Backend
默认的HTML5Backend在桌面浏览器表现良好,但在移动设备上并不理想。React DND 为触摸屏提供了独立的 Touch Backend:
npm install react-dnd-touch-backend然后在DndProvider中把HTML5Backend替换为TouchBackend:
import { DndProvider } from "react-dnd"; import { TouchBackend } from "react-dnd-touch-backend"; function App() { return ( <DndProvider backend={TouchBackend}> <YourApp /> </DndProvider> ); }还可以通过options定制触摸行为,例如调整拖拽延迟:
const backendOptions = { enableMouseEvents: true, delay: 100, }; <DndProvider backend={TouchBackend} options={backendOptions}> <YourApp /> </DndProvider>;enableMouseEvents: true让鼠标事件也可用(便于在同时支持触控与鼠标的设备上调试),delay控制触摸按下到开始拖拽的等待毫秒数,可避免与页面滚动等手势冲突。经此调整,拖拽功能即可在桌面端与移动端同时工作。
总结
本文完成了从零到一的全流程:通过 Refine CLI 初始化带 CRUD 页面的工程,集成 React DND 的DndProvider与HTML5Backend,利用useList拉取数据并渲染出包含四列的看板,通过useDrag让卡片可拖拽、useDrop让列成为放置区,并用end回调 + 状态更新实现列间移动。随后补充了性能优化、常见错误排查与移动端 Touch Backend 的支持方案。
在 Refine 生态中,React DND 这类 headless 集成方式与框架本身的“UI 无关”设计高度契合:数据层交给 Refine 的 data provider 与 Hooks,交互层交给 React DND,而视觉层完全由你掌控。完整的可运行示例(含App.tsx、cards.tsx、columns.tsx、enums.ts、models.ts、useData.ts及页面入口)可在仓库 examples/blog-react-dnd 中直接查看、安装依赖并运行体验。
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考