DragSortTable:一个让我怀疑人生的滚动重置 Bug

一句话总结:DragSortTable 把 DndContext 放错了位置,每次勾选都会触发整个拖拽系统重启,导致滚动位置丢失。解决方案很简单------把 DndContext 挪到外面去!


一、问题描述

在使用 @ant-design/pro-componentsDragSortTable 组件时,当用户勾选表格行(触发 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)),
  });
});

分析:

  • SortableContextitems 依赖 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 没有 DndContextSortableContext 包装:

  1. 没有额外的 Context 层:勾选变化只影响 checkbox 状态
  2. DOM 结构稳定:表格行不会因为状态变化而重新创建
  3. 滚动状态由浏览器管理:不受 React 渲染周期影响

三、解决方案设计

3.1 设计目标

  1. 保持拖拽功能:支持行拖拽排序
  2. 保持滚动位置:勾选等操作不影响滚动位置
  3. 保持原有 API:尽量兼容现有使用方式
  4. 性能优化:避免不必要的重新渲染

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 } }} />;

原因:

  • 通过 components prop 替换 ProTable 的行渲染
  • useSortable hook 在行级别注册到 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 解决方案核心

  1. DndContext 外置:将拖拽上下文提升到 ProTable 外部
  2. 自定义行组件 :通过 components.body.row 注入拖拽能力
  3. 稳定引用 :使用 useMemouseCallback 避免不必要的重新渲染

6.3 设计原则

  1. 关注点分离:拖拽逻辑与表格渲染逻辑分离
  2. 最小影响原则:尽量不修改 ProTable 的原有行为
  3. 性能优先:避免不必要的重新渲染

七、最后的话

如果你也遇到了类似的问题------某个组件的状态变化影响了其他功能------不妨检查一下:

是不是有什么 Context 或 Provider 藏在了不该藏的地方?

组件封装是一门艺术,放对了位置,事半功倍;放错了位置,Bug 不断。

一句话总结:位置放对了,问题就解决了!

相关推荐
渐儿3 小时前
组件库开发入门到生产(从零封装到 npm 发布)
前端
KaMeidebaby4 小时前
卡梅德生物技术快报|单 B 细胞抗体制备:流程优化、表达系统适配与性能数据
前端·数据库·其他·百度·新浪微博
lichenyang4534 小时前
从鸿蒙 AI 聊天 Demo 学习 ArkUI V2:第一天上手记录
前端
进击的松鼠4 小时前
OpenClaw 的五层架构设计与解析
前端·架构·agent
JavaGuide4 小时前
Claude Code 新功能Agent View 发布:终于不用在一堆终端窗口里找 Agent 了!
前端·后端·agent
不简说4 小时前
前端可视化打印设计器sv-print,一口气更新了30版
前端·源码·产品
颖火虫盟主4 小时前
Claude Code Hook 系统详解与 Hello World 实操
前端·网络·数据库
JavaGuide5 小时前
Claude Code + BrowserAct,夯爆了!一句话让 AI 帮你操控浏览器。
前端·后端·ai编程
七十二時_阿川5 小时前
Electron WebContents 完全指南:页面渲染、导航控制与安全实战
前端·electron