react-beautiful-dnd 表格行拖拽排序实战:固定布局、尺寸锁定与 Reparenting 完整指南
【免费下载链接】react-beautiful-dndBeautiful and accessible drag and drop for lists with React项目地址: https://gitcode.com/gh_mirrors/re/react-beautiful-dnd
导读
<table>是展示表格数据最自然、兼容性最好的语义化容器,而 react-beautiful-dnd 又恰好不要求为<Draggable />和<Droppable />增加任何额外包裹元素——这意味着你可以写出既是合法 HTML、又支持拖拽排序的表格。本文以仓库文档 docs/patterns/tables.md 为主体,结合 stories/src/table 目录下的四个可运行示例,系统讲解表格行重排的两种策略(固定布局、尺寸锁定)以及进阶的 Reparenting(克隆 API / 自定义 Portal)方案,并给出源码级原理,帮助你直接落地可复制、可运行的表格拖拽实现。
为什么<table>也能用 react-beautiful-dnd
react-beautiful-dnd 的核心约束是:<Draggable />与<Droppable />必须在 DOM 中真实渲染、且innerRef能拿到对应的 DOM 节点。与需要额外<div>包裹的库不同,react-beautiful-dnd 允许你把ref和...provided的属性直接铺到<tr>上,因此:
使用<table>的好处 | 提供方 |
|---|---|
| 展示表格数据的干净方式 | 浏览器 |
| 极佳的浏览器兼容性 | 浏览器 |
| 可直接把表格复制粘贴到其他应用 | 浏览器 |
| 可以重排行内项目! | react-beautiful-dnd |
关于列重排的说明:截至当前版本,社区尚未找到实现表格列语义化重排的方案。原因在于 HTML 中没有一个元素能单独代表"一列"——列只是多行中单元格对齐的结果,无法把一个
<Draggable />包在"列"外面使其可拖。如果你找到了可行方案,欢迎向本指南提交 PR。
基础骨架:把<Droppable />和<Draggable />放进表格
在进入策略细节前,先看最基本的结构。以 stories/src/table/with-dimension-locking.jsx 为例:
<table>使用table-layout属性(可在auto/fixed间切换);<Droppable droppableId="table">的droppableProvided.innerRef绑定到<tbody>上;- 每一行由一个
<Draggable draggableId={quote.id} index={index}>包裹,渲染时把provided.innerRef、provided.draggableProps、provided.dragHandleProps全部铺到<tr>上; droppableProvided.placeholder放在<tbody>末尾,用于占位,保证拖拽时其他行让位;onDragEnd中使用stories/src/reorder.js的reorder(list, startIndex, endIndex)完成数组重排。
<Table layout={this.state.layout}> <THead> <tr> <th>Author</th> <th>Content</th> </tr> </THead> <Droppable droppableId="table"> {(droppableProvided) => ( <TBody ref={(ref) => { this.tableRef = ref; droppableProvided.innerRef(ref); }} {...droppableProvided.droppableProps} > {this.state.quotes.map((quote, index) => ( <Draggable draggableId={quote.id} index={index} key={quote.id}> {(provided, snapshot) => ( <TableRow provided={provided} snapshot={snapshot} quote={quote} /> )} </Draggable> ))} {droppableProvided.placeholder} </TBody> )} </Droppable> </Table>reorder的实现在 stories/src/reorder.js,通过splice取出源项再插入目标位置,是onDragEnd中同步更新列表状态的常规工具。
策略一:固定布局(更快、更简单)
适用前提:列宽不随内容变化
该策略要求列的宽度是固定的——即无论单元格里放什么内容,列宽都不变。两种方式都能满足:
- 使用
table-layout: fixed:列宽由首行或显式设置决定; - 使用
table-layout: auto,但手动设置每个单元格的宽度(例如width: 50%)。
核心做法:拖拽时给<tr>加display: table
实现上你唯一需要做的,就是在行被拖拽期间给<tr>设置display: table。在 stories/src/table/with-fixed-columns.jsx 中可以看到,通过snapshot.isDragging动态拼接样式:
const Row = styled.tr` ${(props) => props.isDragging ? ` background: ${colors.G100}; /* maintain cell width while dragging */ display: table; ` : ''}; `; const Cell = styled.td` box-sizing: border-box; padding: ${grid}px; /* locking the width of the cells */ width: 50%; `;拖拽时 react-beautiful-dnd 会给行应用position: fixed之类的变换样式,display: table能让该行继续按表格规则计算宽度,配合单元格显式的width: 50%,从而保持列宽不塌陷。
已知问题与替代方案
部分用户反馈table-layout+display: table的方案在元素拖拽期间样式不稳定。替代做法是:拖拽时既不设置table-layout也不设置display: table,而是永久性地给每个<td>设置固定宽度(例如内联样式width: 100px或 CSS)。这样完全不需要任何事件回调,代码更简单,拖拽时样式也不会丢。
策略二:尺寸锁定(更稳健,但更慢)
原理:拖拽会破坏表格的自动列宽计算
表格的自动列宽(table-layout: auto)依赖所有单元格共同参与计算。当拖拽开始时 react-beautiful-dnd 会给被拖行应用position: fixed,使其脱离表格列宽计算的参与,导致列宽突变。因此需要在拖拽开始前,用内联样式把所有单元格的宽高"锁死",避免列尺寸变化。
用onBeforeDragStart触发锁定
根据 docs/guides/responders.md 的说明,onBeforeDragStart在拖拽即将开始、且所有<Draggable />与<Droppable />的尺寸已经从 DOM 采集完毕后被调用。这个时机恰好适合做表格重排所需的尺寸锁定(该文档明确点名了 tables.md 的用法)。
在 stories/src/table/with-dimension-locking.jsx 中,onBeforeDragStart只负责把应用状态切换为"拖拽中"(示例中通过IsDraggingContext广播给每个单元格):
onBeforeDragStart = () => { this.setState({ isDragging: true }); }; onDragEnd = (result) => { this.setState({ isDragging: false }); // ...根据 result 调用 reorder 并 setState };单元格如何锁定尺寸:getSnapshotBeforeUpdate+componentDidUpdate
真正的锁定发生在TableCell组件内部,利用 React 的getSnapshotBeforeUpdate在 DOM 变更前读取尺寸快照,再在componentDidUpdate里写入内联样式:
getSnapshotBeforeUpdate(prevProps) { if (!this.ref) return null; const isDragStarting = this.props.isDragOccurring && !prevProps.isDragOccurring; if (!isDragStarting) return null; const { width, height } = this.ref.getBoundingClientRect(); return { width, height }; } componentDidUpdate(prevProps, prevState, snapshot) { const ref = this.ref; if (!ref) return; if (snapshot) { if (ref.style.width === snapshot.width) return; ref.style.width = `${snapshot.width}px`; ref.style.height = `${snapshot.height}px`; return; } if (this.props.isDragOccurring) return; // inline styles not applied if (ref.style.width == null) return; // no snapshot and drag is finished - clear the inline styles ref.style.removeProperty('height'); ref.style.removeProperty('width'); }关键点:
- 拖拽开始的瞬间(
isDragOccurring从 false → true),读取getBoundingClientRect()得到宽高快照,写入内联样式锁定; - 拖拽结束后(
isDragOccurring为 false 且没有新快照),清理掉内联的宽高,恢复表格自动计算; - 整个流程不需要任何事件监听器,完全由 React 生命周期驱动。
性能特征与适用规模
该策略在规模变大时性能较差,因为它要求:
- 对每一行调用
render() - 对每一行读取 DOM(
getBoundingClientRect,即文档中所说的window.getComputedStyles类读操作)
对于少于 50 行的表格,这种方案完全够用;更大的表格建议改用固定布局策略,或考虑虚拟列表方案。
进阶:Reparenting(克隆 API / 自定义 Portal)
如果你需要在表格行重排时使用 reparenting(克隆或你自己的 Portal),需要额外几步。建议先阅读 docs/guides/reparenting.md 了解整体思路。
为什么需要 reparenting
react-beautiful-dnd 默认把元素留在原 DOM 位置,仅通过position: fixed移动它。但position: fixed会受到祖先transform的影响,导致拖拽定位错误。解决方式是把拖拽项移动到document.body(或其直接后代)这个新的父容器(即 Portal)中。
必须掌握的 React 挂载时序
在 React 中,把一个已存在的<tr>移入ReactDOM.createPortal时,旧<tr>会被卸载、新<tr>会挂载进 Portal,顺序是:
- 旧
<tr>执行componentWillUnmount - 新
<tr>执行componentWillMount
为了保留被移动行的单元格尺寸,需要按策略二的方式用内联样式锁定尺寸。难点在于:新组件无法直接拿到移动前那个组件的信息,所以必须在旧<tr>卸载时把单元格尺寸读出来存到组件外部,等新<tr>在componentDidMount挂载后再重新应用。
还要注意一个坑:componentDidMount被调用时,你无法确定这次挂载是因为"行不再需要、正在卸载",还是因为"即将移入 Portal"。所以必须显式区分这两种情况。
官方推荐的实现步骤
- 在
<tr>的componentWillUnmount中,从 DOM 读取当前各单元格的宽高,存入组件外部的存储(如模块级snapshotMap),供后续挂载的新组件读取; - 新组件挂载时,若
DraggableStateSnapshot.isDragging为 true,就去查之前记录的宽度,存在则应用该宽度。
示例一:克隆 API(renderClone+getContainerForClone)
克隆 API 是 reparenting 的一等公民方案:拖拽期间原<Draggable />被移除,由renderClone渲染的"克隆体"进入getContainerForClone返回的容器。在 stories/src/table/with-clone.jsx 中可以看到完整的表格版实现。
首先,因为要把<tr>挂进 Portal,React 会对"非表格元素内挂<tr>"给出警告,所以示例创建了一个隐藏的空表格作为 Portal 容器:
// Using a table as the portal so that we do not get react // warnings when mounting a tr element const table = document.createElement('table'); table.classList.add('my-super-cool-table-portal'); Object.assign(table.style, { margin: '0', padding: '0', border: '0', height: '0', width: '0', }); const tbody = document.createElement('tbody'); table.appendChild(tbody); document.body.appendChild(table);Droppable侧配置renderClone与getContainerForClone:
<Droppable droppableId="table" renderClone={(provided, snapshot, rubric) => ( <TableRow provided={provided} snapshot={snapshot} quote={this.state.quotes[rubric.source.index]} /> )} getContainerForClone={() => tbody} >TableCell组件配合模块级snapshotMap完成尺寸的存取:
const snapshotMap = {}; class TableCell extends React.Component { componentDidMount() { const cellId = this.props.cellId; if (!snapshotMap[cellId]) return; if (!this.props.isDragging) { // cleanup the map if it is not being used delete snapshotMap[cellId]; return; } this.applySnapshot(snapshotMap[cellId]); } componentWillUnmount() { const snapshot = this.getSnapshot(); if (!snapshot) return; snapshotMap[this.props.cellId] = snapshot; } getSnapshot = () => { if (!this.ref) return null; const { width, height } = this.ref.getBoundingClientRect(); return { width, height }; }; applySnapshot = (snapshot) => { const ref = this.ref; if (!ref) return; if (ref.style.width === snapshot.width) return; ref.style.width = `${snapshot.width}px`; ref.style.height = `${snapshot.height}px`; }; }数据流是:
onBeforeDragStart把isDragging置 true,通过IsDraggingContext广播;- 拖拽开始时单元格在
getSnapshotBeforeUpdate读取宽高、componentDidUpdate写入内联样式(同策略二); - 原
<tr>卸载时componentWillUnmount把宽高写入snapshotMap; - 克隆体在 Portal 中挂载时,
componentDidMount从snapshotMap取回宽高并应用,从而让 Portal 中的克隆行保持与源表格一致的列宽。
renderClone的类型签名(见 docs/guides/reparenting.md):
renderClone: ?DraggableChildrenFn其中DraggableChildrenFn = (Provided, StateSnapshot, DraggableRubric) => Node | null,与<Draggable />的 children 函数类型完全一致;getContainerForClone: () => HTMLElement,若不定义则默认使用document.body(对应源码 src/view/droppable/connected-droppable.js 中的getContainerForClone: getBody)。
示例二:自定义 Portal(ReactDOM.createPortal)
如果不使用克隆 API,也可以在<Draggable />内部自行调用ReactDOM.createPortal。stories/src/table/with-portal.jsx 展示了这种做法:当snapshot.isDragging为 true 时,把整个<tr>通过ReactDOM.createPortal(child, tbody)移入预先创建的隐藏表格,其余TableCell的尺寸存取逻辑与克隆示例完全一致。
if (!snapshot.isDragging) { return child; } return ReactDOM.createPortal(child, tbody);需要注意(同样见 docs/guides/reparenting.md 的性能警告):任何被 reparenting 的元素都会从头重新渲染,不要把大型组件树移入 Portal,否则会出现明显的 UI 卡顿;官方因此不推荐默认使用 reparenting。
可运行示例一览
仓库在 stories/10-table.stories.js 中注册了完整的 Storybook 故事,可直接对照阅读源码:
| Story 名称 | 对应实现文件 | 核心要点 |
|---|---|---|
| with fixed width columns | stories/src/table/with-fixed-columns.jsx | 固定列宽 + 拖拽时display: table |
| with dimension locking | stories/src/table/with-dimension-locking.jsx | getSnapshotBeforeUpdate锁定全部单元格尺寸 |
| with clone | stories/src/table/with-clone.jsx | renderClone+getContainerForClone+snapshotMap |
| with custom portal | stories/src/table/with-portal.jsx | ReactDOM.createPortal+snapshotMap |
相关文档与资源:
- 响应器生命周期与
onBeforeDragStart的时机与限制:docs/guides/responders.md - Reparenting 的背景、克隆 API 与 Portal 方案:docs/guides/reparenting.md
- 虚拟列表场景下克隆 API 的必用性:docs/patterns/virtual-lists.md
小结
在 react-beautiful-dnd 中重排表格行,优先根据列宽是否固定二选一:固定布局方案快而简单(拖拽时display: table+ 显式列宽即可);尺寸锁定方案用onBeforeDragStart配合getSnapshotBeforeUpdate/componentDidUpdate锁死所有单元格宽高,稳健但需要每行渲染和 DOM 读取,适合 50 行以内的表格。若涉及克隆或 Portal 的 reparenting,则务必掌握componentWillUnmount→componentWillMount的时序,用模块级snapshotMap完成单元格尺寸的"交接",克隆场景优先使用renderClone+getContainerForClone的一等公民 API。
【免费下载链接】react-beautiful-dndBeautiful and accessible drag and drop for lists with React项目地址: https://gitcode.com/gh_mirrors/re/react-beautiful-dnd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考