React 拖拽排序实战:react-sortablejs 从表格行 handle 到跨列迁移的 4 个场景配置
【免费下载链接】openprojectOpenProject is the leading open source project management software for product, project and portfolio management. A powerful Jira alternative with agile planning, issue tracking, roadmaps, Gantt charts, time tracking, collaboration features, and more. Available on premises or in the cloud. ⭐ Star us on GitHub项目地址: https://gitcode.com/GitHub_Trending/op/openproject
后台管理界面里"让用户自定义排列模块面板和表格行"的拖拽排序需求,用 react-sortablejs 半天就能落地。本文按表格行排序、审批流跨列迁移、大列表调优、触摸参数四个场景,把每个场景该配哪些参数、会踩哪些坑讲清楚。
为什么选它
OpenProject 官方前端走的是 dragula 与 @atlaskit/pragmatic-drag-and-drop 路线(见 frontend/package.json),并在 frontend/src/common/drag-and-drop/sortable-lists-engine.ts 里包了一层自研引擎。但在 React 子应用里实现同类交互:react-beautiful-dnd 已停止维护且只适合简单单列;@dnd-kit/sortable 跨列迁移要自己拼列表测量逻辑。react-sortablejs 最省事——传一个 React 数组,它直接替你重排;代价是 API 偏老、维护节奏慢。
30 秒跑通:最小可运行示例
最小配置只需要数据、setList 和一个 handle,下面 17 行可以直接放进任何 Vite/CRA 工程:
import { useState } from 'react'; import { ReactSortable } from 'react-sortablejs'; const MODULES = ['Backlog', 'Sprint', 'This Week', 'Done', 'Blocked']; export function ModuleReorder() { const [modules, setModules] = useState(MODULES); return ( <ReactSortable list={modules} setList={setModules} handle=".grip" animation={150}> {modules.map((name) => ( <div className="module-row" key={name}> <span className="grip" aria-label="拖拽手柄">⋮⋮</span> {name} </div> ))} </ReactSortable> ); }关键 props 各一句话:
list/setList:数据源和 setter,拖拽结束后库回调 setList,你不需要读 DOM。handle:只有匹配选择器的元素能发起拖拽,其余区域保持点击语义。animation:占位块过渡的毫秒数,0–200 之间通常够用。ghostClass:原位占位块的 class,默认值sortable-ghost没有样式,必须自己定义。
🖐 场景 A:带 handle 的行级局部排序
工作包列表动辄几十行,用户想把高优先级条目顶到最前;但整行可拖会和"点击选中/展开"冲突,所以必须提供独立的拖拽手柄。OpenProject 自身的工作包模块就是这个结构(见 frontend/src/app/features/work-packages/)。
下面这段把行内的"编辑"按钮区从拖拽范围里排除:
function WorkPackageTable({ rows, setRows }) { return ( <ReactSortable list={rows} setList={setRows} handle=".row-grip" // 只有左侧手柄能发起拖拽 filter=".row-actions" // 行内操作按钮区不参与 preventOnFilter > {rows.map((row) => ( <div className="wp-row" key={row.id}> <span className="row-grip">⠿</span> <span className="wp-id">WP-{row.id}</span> <span className="wp-subject">{row.subject}</span> <div className="row-actions"><button>编辑</button></div> </div> ))} </ReactSortable> ); }关键参数:
handle=".row-grip":拖拽的唯一入口,行内其他点击不受影响。filter=".row-actions":命中选择器的元素不触发拖拽。preventOnFilter:命中 filter 时阻止默认行为,避免误触浏览器缩放/滚动。- 容器默认是
div:别把tr直接塞给 ReactSortable,用 div 行模拟表格或改成卡片列表。
高频坑点:
- 拖拽时条目"消失"→ 默认 ghostClass 无样式,占位块高度塌成 0 → 自定义 ghostClass 并保持占位块原高度。
- handle 不生效→ 手柄元素必须位于拖拽项(容器直接子元素)内部,写在容器外层或祖先上无效。
📥 场景 B:审批流节点跨列迁移
审批流配置是典型的跨列拖拽:节点要在"部门负责人审核 / 财务审核 / 归档"之间移动。先说清机制:内部 onSort 只在 oldIndex 和 newIndex 都落在当前列表范围内才调 setList,跨列拖拽必然越界,状态不会自动同步,必须在 onEnd 里手动合并。
下面这段统一管理三列状态,跨列时在 onEnd 里搬移:
function ApprovalFlowBoard() { const [nodes, setNodes] = useState({ dept: [{ id: 'n-11', title: '金额超五万需双人复核' }], finance: [{ id: 'n-23', title: '付款方式须银行转账' }], archived: [], }); const onEnd = (evt) => { if (evt.from === evt.to) return; // 同列重排,setList 已处理 const fromKey = evt.from.dataset.stage; const toKey = evt.to.dataset.stage; const moved = [...nodes[fromKey]]; const [node] = moved.splice(evt.oldIndex, 1); const target = [...nodes[toKey]]; target.splice(evt.newIndex, 0, node); setNodes({ ...nodes, [fromKey]: moved, [toKey]: target }); }; return ['dept', 'finance', 'archived'].map((key) => ( <section key={key}> <ReactSortable list={nodes[key]} setList={(list) => setNodes((prev) => ({ ...prev, [key]: list }))} group={{ name: 'approval-chain', pull: true, put: true }} />const RowItem = React.memo(function RowItem({ row }) { return <div className="wp-row">{row.subject}</div>; }); function LargeList({ rows, setRows }) { const onSort = useCallback((next) => setRows(next), [setRows]); // 稳定引用 return ( <ReactSortable list={rows} setList={onSort} forceFallback // CSS transform 克隆,避开原生 DnD 的布局抖动 fallbackOnBody animation={0} // 大列表关掉占位动画 > {rows.map((row) => <RowItem key={row.id} row={row} />)} </ReactSortable> ); }
关键参数:
forceFallback:用 transform 克隆替代原生 HTML5 DnD,条目插入/移除时浏览器不做整块布局重排。fallbackOnBody:克隆节点挂到 body,规避祖先 transform/filter 形成包含块导致克隆偏移。animation={0}:大列表关掉占位过渡,省一帧过渡计算。React.memo+useCallback:setList 每次渲染都是新函数,引用一变库内部会重建 Sortable 实例,先稳定引用再谈性能。
高频坑点:
- 怎么优化都是全行重渲染→ 行组件没 memo 或 setList 引用不稳定,整个列表跟着重建 → 先查回调引用是否稳定,再 memo 行组件。
📱 场景 D:触摸误触与灵敏度调优
手机和平板上有两个问题:滚动列表误触发拖拽;拖拽中手指滑出可视边缘后不会继续滚动跟随。
<ReactSortable list={cards} setList={setCards} forceFallback // 触摸不支持 HTML5 DnD,必须走 fallback delay={150} // 触摸后等 150ms 再判定拖拽 delayOnTouchOnly // delay 只对触摸生效,鼠标不受影响 scroll scrollSensitivity={40} scrollSpeed={15} > {cards.map((card) => ( <div key={card.id} className="sp-card">{card.name}</div> ))} </ReactSortable>
关键参数:
forceFallback:触摸设备不支持 HTML5 原生拖拽,不开 fallback 幽灵块根本不跟手。delay+delayOnTouchOnly:触摸后延迟判定拖拽,滚动时不会误触发;配合delayOnTouchOnly保证鼠标体验不变慢。scrollSensitivity/scrollSpeed:距边缘多少像素触发自动滚动、滚多快,必须真机微调。touchStartThreshold:拖拽启动前的最小移动像素数,默认 5;调大降低误触,调小提升跟手性。
高频坑点:
- delay 调大后鼠标也变"迟钝"→ delay 默认对鼠标和触摸都生效 → 打开
delayOnTouchOnly,把延迟限制在触摸端。
配置速查表
配置项 类型 默认值 一句话用途 listT[]— 数据源,要渲染的 React 数组 `set
【免费下载链接】openprojectOpenProject is the leading open source project management software for product, project and portfolio management. A powerful Jira alternative with agile planning, issue tracking, roadmaps, Gantt charts, time tracking, collaboration features, and more. Available on premises or in the cloud. ⭐ Star us on GitHub
项目地址: https://gitcode.com/GitHub_Trending/op/openproject
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考