一、现象描述
“我参照 Ant Design 官方示例,实现了表格行拖拽排序。但拖拽时发现顺序乱跳,甚至完全不生效,而且没有任何报错。”
为什么明明代码和官方示例一样,却不行?
二、排查过程
第一反应:以为是
DraggableBodyRow写错了检查
useDrag/useDrop是否配对检查
onRow是否正确传递了index和moveRow检查
components是否注册
发现关键线索:年龄列有默认排序
{ title: '年龄', sorter: (a, b) => a.age - b.age, defaultSortOrder: 'descend', // 👈 就是它! }推测:拖拽修改了
dataSource,但排序逻辑立即介入,把数据重新排列,覆盖了拖拽结果验证:注释掉
defaultSortOrder,拖拽恢复正常 ✅
三、原理分析
Ant Design Table 的排序机制:排序状态下,表格显示的是排序后的数据,而不是
dataSource的原始顺序拖拽修改的是
dataSource,但排序逻辑比拖拽逻辑后执行,覆盖了拖拽的结果本质上是两种“排序”逻辑的冲突:一种是用户点击列头触发的排序,一种是拖拽触发的排序
四、解决方案
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 方案一 | 移除defaultSortOrder,让用户手动点击排序 | 优先保证拖拽功能 |
| 方案二 | 拖拽开始时清除排序状态 | 两者共存,但拖拽时禁用列排序 |
| 方案三 | 在拖拽结束后重新应用排序 | 保留排序状态,拖拽后按当前排序重新排列 |
五、总结与建议
教训:在使用 Ant Design 高级功能时,各个配置项之间可能存在冲突,排查时要全面
建议:如果拖拽和排序都要支持,优先考虑用户体验——拖拽时自动清除排序状态,或者用弹窗提示“排序状态下无法拖拽”
Debug 方法论:遇到问题先最小化复现(移除其他配置),再逐步添加
import update from 'immutability-helper'; import React, { useCallback, useRef, useState } from 'react'; import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { Button, Form, Input, Table } from 'antd'; import { LockOutlined, UserOutlined } from '@ant-design/icons'; import './index.scope.css' const type = 'DraggableBodyRow'; const DraggableBodyRow = ({ index, moveRow, className, style, ...restProps }) => { const ref = useRef(null); const [{ isOver, dropClassName }, drop] = useDrop({ accept: type, collect: (monitor) => { const { index: dragIndex } = monitor.getItem() || {} if (dragIndex === index) { return {} } return { isOver: monitor.isOver(), dropClassName: dragIndex < index ? ' drop-over-downward' : ' drop-over-upward' } }, drop: (item) => { moveRow(item.index, index) } }) const [, drag] = useDrag({ type, item: { index, }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }) drop(drag(ref)) return ( <tr ref={ref} className={`${className}${isOver ? dropClassName : ''}`} style={{ cursor: 'move', ...style }} {...restProps} > {restProps.children} </tr> ) } const DragTable = () => { const [form] = Form.useForm(); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [sortOrder, setSortOrder] = useState(null); const getSortTooltip = () => { if (sortOrder === 'ascend') return '点击降序'; if (sortOrder === 'descend') return '取消排序'; return '点击升序'; }; const [dataSource, setDataSource] = useState([ { key: '1', name: '胡胡', age: 32, address: '西湖区湖底公园1号', hobby: '唱歌,跳舞,弹钢琴,画画,写诗,写小说,写剧本,写歌词,写散文,写随笔,写日记,写博客,写微博,写朋友圈,写抖音,写快手,写B站,写知乎,写豆瓣,写小红书,写公众号,写头条号,写百家号,写企鹅号,写新浪微博,写网易号,写搜狐号,写凤凰号,写今日头条号', job: '歌手', salary: '100万', isMarried: '否', hometown: '四川', certification: '无', isRenter: '否', familySize: '3', retirementDate: '2050-01-01', children: [ { key: '1-1', name: '喵喵', age: 42, address: '西湖区湖底公园1号', } ] }, { key: '2', name: '胡彦祖', age: 42, address: '西湖区湖底公园1号', }, { key: '3', name: '胡巴', age: 32, address: '西湖区湖底公园1号', hobby: '唱歌,跳舞,弹钢琴,画画,写诗,写小说,写剧本,写歌词,写散文,写随笔,写日记,写博客,写微博,写朋友圈,写抖音,写快手,写B站,写知乎,写豆瓣,写小红书,写公众号,写头条号,写百家号,写企鹅号,写新浪微博,写网易号,写搜狐号,写凤凰号,写今日头条号', job: '歌手', salary: '100万', isMarried: '否', hometown: '四川', certification: '无', isRenter: '否', familySize: '3', retirementDate: '2050-01-01', }, ]) const defaultColumns = [ { title: '姓名', dataIndex: 'name', key: 'name', width: 150, filters: [ { text: '胡胡', value: '胡胡', children: [ { text: '胡胡子' }] }, { text: '胡彦祖', value: '胡彦祖', }, ], filterMode: 'tree', filterSearch: true, onFilter: (value, record) => record.name.indexOf(value) === 0, }, { title: '年龄', dataIndex: 'age', key: 'age', width: 100, sorter: (a, b) => a.age - b.age, defaultSortOrder: 'descend', showSorterTooltip: { title: getSortTooltip() }, editable: true, }, { title: '住址', dataIndex: 'address', key: 'address', width: 100, sorter: (a, b) => a.name.length - b.name.length, sortDirections: ['descend'], showSorterTooltip: { title: getSortTooltip() }, }, { title: '爱好', dataIndex: 'hobby', key: 'hobby', width: 300, editable: true, }, { title: '工作', dataIndex: 'job', key: 'job', width: 150, editable: true, }, { title: '薪资', dataIndex: 'salary', key: 'salary', }, { title: '是否成家-老家', dataIndex: 'isMarried', key: 'isMarried', width: 200, }, { title: '老家', dataIndex: 'hometown', key: 'hometown', width: 100, }, { title: '技能证书', dataIndex: 'certification', key: 'certification', width: 150, }, { title: '是否租房', dataIndex: 'isRenter', key: 'isRenter', width: 100, }, { title: '几口人', dataIndex: 'familySize', key: 'familySize', width: 100, }, { title: '退休时间', dataIndex: 'retirementDate', key: 'retirementDate', width: 150, fixed: 'right', }, ]; const components = { body: { row: DraggableBodyRow, }, }; const moveRow = useCallback( (dragIndex, hoverIndex) => { const dragRow = dataSource[dragIndex]; setDataSource( update(dataSource, { $splice: [ [dragIndex, 1], [hoverIndex, 0, dragRow], ], }), ); }, [dataSource], ); const onFinish = (values) => { console.log('Received values of form: ', values); setSelectedRowKeys([]) }; const handleTableChange = (pagination, filters, sorter) => { console.log('Table change:', { pagination, filters, sorter }); if (sorter.field) { setSortOrder(sorter.order); } }; return <div> <Form form={form} name="horizontal_login" layout="inline" onFinish={onFinish}> <Form.Item name="username" rules={[{ required: true, message: 'Please input your username!' }]} > <Input prefix={<UserOutlined className="site-form-item-icon" />} placeholder="Username" /> </Form.Item> <Form.Item name="password" rules={[{ required: true, message: 'Please input your password!' }]} > <Input prefix={<LockOutlined className="site-form-item-icon" />} type="password" placeholder="Password" /> </Form.Item> <Form.Item shouldUpdate> {() => ( <Button type="primary" htmlType="submit" disabled={ !form.isFieldsTouched(true) || !!form.getFieldsError().filter(({ errors }) => errors.length).length } > 查询 </Button> )} </Form.Item> </Form> <div className='table-container'> <DndProvider backend={HTML5Backend}> <Table key={dataSource.map(item => item.key).join(',')} components={components} bordered dataSource={dataSource} columns={defaultColumns} onChange={handleTableChange} onRow={(_, index) => { const attr = { index, moveRow } return attr }} scroll={{ x: 'max-content' }} rowSelection={{ type: 'checkbox', selectedRowKeys: selectedRowKeys, onChange: (selectedRowKeys, selectedRows) => { setSelectedRowKeys(selectedRowKeys); console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows); }, }} />; </DndProvider> </div> </div> } export default DragTable