一句话总结:DragSortTable 把 DndContext 放错了位置,每次勾选都会触发整个拖拽系统重启,导致滚动位置丢失。解决方案很简单------把 DndContext 挪到外面去!
一、问题描述
在使用 @ant-design/pro-components 的 DragSortTable 组件时,当用户勾选表格行(触发 rowSelection 变化)后,表格滚动位置会自动重置到第一行,影响用户体验。
简单来说:你勾选一行,表格就跳回顶部了。
二、问题根因分析
2.1 DragSortTable 源码分析
DragSortTable 位于 @ant-design/pro-table/es/components/DragSortTable/index.js,其核心实现如下:
javascript
function DragSortTable(props) {
// ... 省略部分代码
return wrapSSR(
/*#__PURE__*/ _jsx(
ProTable,
_objectSpread(
_objectSpread({}, otherProps),
{},
{
columns:
(_otherProps$columns = otherProps.columns) === null ||
_otherProps$columns === void 0
? void 0
: _otherProps$columns.map(function (item) {
// 处理 dragSortKey 列
}),
onLoad: wrapOnload,
rowKey: rowKey,
tableViewRender: function tableViewRender(_, defaultDom) {
return /*#__PURE__*/ _jsx(DndContext, {
children: defaultDom,
});
},
dataSource: dataSource,
components: components,
onDataSourceChange: onDataSourceChange,
},
),
),
);
}
2.2 关键问题点
问题点 1:tableViewRender 每次渲染都是新函数
javascript
tableViewRender: function tableViewRender(_, defaultDom) {
return /*#__PURE__*/ _jsx(DndContext, {
children: defaultDom,
});
}
分析:
tableViewRender是在组件函数体内定义的函数- 每次父组件重新渲染,这个函数都会被重新创建
- 虽然函数名相同,但引用不同,React 会认为 props 变化
打个比方:
你每次进门都换一把新钥匙,虽然钥匙长得一样,但门锁会认为这是新钥匙,需要重新验证。
问题点 2:DndContext 重新初始化
javascript
// useDragSort.js 第 143-153 行
var memoDndContext = useMemo(
function () {
return function (contextProps) {
return /*#__PURE__*/ _jsx(DndContext, {
modifiers: [restrictToVerticalAxis],
sensors: sensors,
collisionDetection: closestCenter,
onDragEnd: handleDragEnd,
children: contextProps.children,
});
};
},
[handleDragEnd, sensors],
);
分析:
memoDndContext被正确 memo 化- 但
handleDragEnd依赖dataSource:
javascript
var handleDragEnd = useCallback(
function (event) {
// ...
var newData = arrayMove(
dataSource || [],
parseInt(active.id),
parseInt(over.id),
);
onDragSortEnd === null ||
onDragSortEnd === void 0 ||
onDragSortEnd(parseInt(active.id), parseInt(over.id), newData || []);
},
[dataSource, onDragSortEnd],
);
- 当
dataSource变化时,handleDragEnd重新创建 - 导致
memoDndContext重新创建 - 导致
DndContext重新初始化
打个比方:
你家的电视机每次有人敲门都会重启一次,因为"开门"这个动作被错误地和"看电视"绑在一起了。
问题点 3:SortableContext 的 items 变化
javascript
var DraggableContainer = useRefFunction(function (p) {
return /*#__PURE__*/ _jsx(SortableContext, {
items: dataSource.map(function (_, index) {
return index === null || index === void 0 ? void 0 : index.toString();
}),
strategy: verticalListSortingStrategy,
children: /*#__PURE__*/ _jsx(SortContainer, _objectSpread({}, p)),
});
});
分析:
SortableContext的items依赖dataSource- 每次数组引用变化,都会触发重新渲染
2.3 完整触发链
markdown
用户勾选行
↓
selectedRowKeys 状态变化
↓
父组件重新渲染
↓
DragSortTable 接收新 props
↓
tableViewRender 函数引用变化(新函数)
↓
ProTable 检测到 props 变化
↓
内部 useDragSort hook 重新执行
↓
handleDragEnd 因 dataSource 依赖重新创建
↓
memoDndContext 重新创建
↓
DndContext 组件重新初始化
↓
SortableContext 重新初始化
↓
表格 DOM 重建
↓
滚动位置重置到顶部 💥
一句话总结问题原因:
DragSortTable 把 DndContext 放在了 ProTable 的渲染路径里,导致每次状态变化都会把整个拖拽系统"重启"一遍。
2.4 为什么普通 ProTable 不会有这个问题?
普通 ProTable 没有 DndContext 和 SortableContext 包装:
- 没有额外的 Context 层:勾选变化只影响 checkbox 状态
- DOM 结构稳定:表格行不会因为状态变化而重新创建
- 滚动状态由浏览器管理:不受 React 渲染周期影响
三、解决方案设计
3.1 设计目标
- 保持拖拽功能:支持行拖拽排序
- 保持滚动位置:勾选等操作不影响滚动位置
- 保持原有 API:尽量兼容现有使用方式
- 性能优化:避免不必要的重新渲染
3.2 核心设计思路
将 DndContext 提升到 ProTable 外部,避免 ProTable 内部渲染影响拖拽上下文。
打个比方:
之前:电视机放在门口,每次开门都会碰倒电视机 现在:电视机放在客厅,开门不会影响电视机
scss
┌─────────────────────────────────────────┐
│ DndContext (稳定,不受 ProTable 影响) │
│ ┌─────────────────────────────────────┐│
│ │ SortableContext ││
│ │ ┌─────────────────────────────────┐││
│ │ │ ProTable │││
│ │ │ (正常渲染,不受拖拽上下文影响) │││
│ │ │ │││
│ │ │ components.body.row │││
│ │ │ ┌─────────────────────────────┐│││
│ │ │ │ SortableRow (自定义行组件) ││││
│ │ │ │ - 应用 transform 样式 ││││
│ │ │ │ - 渲染拖拽把手 ││││
│ │ │ └─────────────────────────────┘│││
│ │ └─────────────────────────────────┘││
│ └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
3.3 关键设计点
3.3.1 DndContext 外置
tsx
// ❌ 错误做法:DndContext 在 ProTable 内部
<ProTable
tableViewRender={(_, dom) => <DndContext>{dom}</DndContext>}
/>
// ✅ 正确做法:DndContext 在 ProTable 外部
<DndContext>
<SortableContext>
<ProTable />
</SortableContext>
</DndContext>
原因:
- 外置后,ProTable 的 props 变化不会触发 DndContext 重新初始化
- DndContext 的生命周期独立于 ProTable
3.3.2 自定义 components.body.row
tsx
const SortableRow = ({
'data-row-key': dataRowKey,
children,
style,
...props
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: dataRowKey });
const rowStyle = {
...style,
transform: CSS.Transform.toString(transform),
transition,
// 拖拽时的视觉效果
opacity: isDragging ? 0.9 : 1,
boxShadow: isDragging ? '0 4px 12px rgba(0, 0, 0, 0.15)' : undefined,
};
return (
<tr {...props} ref={setNodeRef} style={rowStyle}>
{enhancedChildren}
</tr>
);
};
// 使用
<ProTable components={{ body: { row: SortableRow } }} />;
原因:
- 通过
componentsprop 替换 ProTable 的行渲染 useSortablehook 在行级别注册到 SortableContext- 不影响 ProTable 的其他功能(如 rowSelection)
3.3.3 拖拽把手独立处理
tsx
// 遍历 children,为拖拽把手列添加 listeners
const enhancedChildren = useMemo(() => {
React.Children.forEach(children, (child) => {
if (child.key === dragSortKey) {
// 为拖拽列添加拖拽事件
result.push(
React.cloneElement(child, {
children: (
<div {...listeners} {...attributes}>
<HolderOutlined />
{child.props.children}
</div>
),
}),
);
}
});
}, [children, dragSortKey, listeners, attributes]);
原因:
- 只在拖拽把手列绑定拖拽事件
- 避免整行都可拖拽导致误触
- 保持其他列的正常交互(点击、选择等)
3.3.4 稳定的 itemIds
tsx
const itemIds = useMemo(
() => dataSource.map((item) => String(item[rowKey])),
[dataSource, rowKey],
);
原因:
- 使用
useMemo缓存 itemIds - 只有 dataSource 真正变化时才重新计算
- 避免不必要的 SortableContext 更新
四、实现代码
4.1 SortableTable.tsx
tsx
import { HolderOutlined } from '@ant-design/icons';
import type { DragEndEvent } from '@dnd-kit/core';
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import React, { useCallback, useMemo } from 'react';
// 可排序的行组件
interface SortableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
'data-row-key': string;
dragSortKey?: string;
}
const SortableRow: React.FC<SortableRowProps> = ({
'data-row-key': dataRowKey,
dragSortKey,
children,
style,
...props
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: dataRowKey });
// 拖拽时的行样式
const rowStyle: React.CSSProperties = {
...style,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.9 : 1,
background: isDragging ? '#fafafa' : undefined,
boxShadow: isDragging ? '0 4px 12px rgba(0, 0, 0, 0.15)' : undefined,
zIndex: isDragging ? 999 : undefined,
};
// 为拖拽把手列添加 listeners
const enhancedChildren = useMemo(() => {
if (!dragSortKey) return children;
const result: React.ReactNode[] = [];
React.Children.forEach(children, (child) => {
if (React.isValidElement(child)) {
const childKey = (child as React.ReactElement<any>).key;
if (childKey === dragSortKey) {
result.push(
React.cloneElement(child as React.ReactElement<any>, {
children: (
<div
style={{
display: 'flex',
alignItems: 'center',
cursor: isDragging ? 'grabbing' : 'grab',
}}
{...listeners}
{...attributes}
>
<HolderOutlined
style={{ color: isDragging ? '#1890ff' : '#999' }}
/>
{(child as React.ReactElement<any>).props?.children}
</div>
),
}),
);
return;
}
}
result.push(child);
});
return result;
}, [children, dragSortKey, listeners, attributes, isDragging]);
return (
<tr {...props} ref={setNodeRef} style={rowStyle}>
{enhancedChildren}
</tr>
);
};
// 拖拽表格包装组件
interface SortableTableWrapperProps {
rowKey: string;
dataSource: any[];
dragSortKey: string;
onDragEnd: (newData: any[]) => void;
children: React.ReactNode;
}
export const SortableTableWrapper: React.FC<SortableTableWrapperProps> = ({
rowKey,
dataSource,
dragSortKey,
onDragEnd,
children,
}) => {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 2 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = dataSource.findIndex(
(item) => String(item[rowKey]) === active.id,
);
const newIndex = dataSource.findIndex(
(item) => String(item[rowKey]) === over.id,
);
if (oldIndex !== -1 && newIndex !== -1) {
onDragEnd(arrayMove(dataSource, oldIndex, newIndex));
}
}
},
[dataSource, rowKey, onDragEnd],
);
const itemIds = useMemo(
() => dataSource.map((item) => String(item[rowKey])),
[dataSource, rowKey],
);
const components = useMemo(
() => ({
body: {
row: (rowProps: any) => (
<SortableRow {...rowProps} dragSortKey={dragSortKey} />
),
},
}),
[dragSortKey],
);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{React.cloneElement(children as React.ReactElement<any>, {
components,
})}
</SortableContext>
</DndContext>
);
};
export default SortableTableWrapper;
4.2 使用方式
tsx
import { ProTable } from '@ant-design/pro-components';
import SortableTableWrapper from './components/SortableTable';
// 在组件中使用
<SortableTableWrapper
rowKey="orderNo"
dataSource={tableData}
dragSortKey="sort"
onDragEnd={(newData) => {
// 处理拖拽后的数据
setTableData(newData);
}}
>
<ProTable
rowKey="orderNo"
dataSource={tableData}
columns={columns}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
/>
</SortableTableWrapper>;
五、方案对比
| 特性 | DragSortTable | 新方案 (ProTable + dnd-kit) |
|---|---|---|
| 滚动位置保持 | ❌ 会重置 | ✅ 保持 |
| 拖拽功能 | ✅ 支持 | ✅ 支持 |
| rowSelection 兼容 | ⚠️ 有问题 | ✅ 完全兼容 |
| 性能 | ⚠️ 频繁重新渲染 | ✅ 优化渲染 |
| 代码复杂度 | 简单(封装好) | 中等(需自定义) |
| 灵活性 | 低 | 高 |
六、总结
6.1 问题本质
DragSortTable 的问题本质是 组件封装层次不当 ,将 DndContext 放在了 ProTable 的渲染路径中,导致 ProTable 的任何状态变化都会触发拖拽上下文重新初始化。
通俗理解:
拖拽系统被错误地"绑"在了表格的渲染流程里,表格一动,拖拽系统就得重启。
6.2 解决方案核心
- DndContext 外置:将拖拽上下文提升到 ProTable 外部
- 自定义行组件 :通过
components.body.row注入拖拽能力 - 稳定引用 :使用
useMemo和useCallback避免不必要的重新渲染
6.3 设计原则
- 关注点分离:拖拽逻辑与表格渲染逻辑分离
- 最小影响原则:尽量不修改 ProTable 的原有行为
- 性能优先:避免不必要的重新渲染
七、最后的话
如果你也遇到了类似的问题------某个组件的状态变化影响了其他功能------不妨检查一下:
是不是有什么 Context 或 Provider 藏在了不该藏的地方?
组件封装是一门艺术,放对了位置,事半功倍;放错了位置,Bug 不断。
一句话总结:位置放对了,问题就解决了!