Ant Design Table + DND实现可拖拽表格且包含可编辑输入框的代码示例

介绍如何使用 React + Ant Design Table + react-beautiful-dnd 实现一个可拖拽表格,其中包含可编辑的输入框。

这份代码是一个完整的、可运行的 TypeScript (TSX) 示例。它完美融合了 ReactAnt Design Tablereact-beautiful-dnd,并应用了我们之前讨论的“Context 穿透 + 引用冻结”方案,彻底解决了输入框焦点丢失的问题。

核心特点:

  1. 行拖拽:可以拖拽表格行进行排序。
  2. 行内编辑:包含 Input 输入框,输入时焦点不丢失。
  3. 代码规范:使用了 TypeScript 类型定义,且注释详细解释了核心优化点。

前置准备

你需要安装以下依赖:

npm install antd react-beautiful-dnd @types/react-beautiful-dnd
# 或者
yarn add antd react-beautiful-dnd @types/react-beautiful-dnd

完整示例代码

import React, { useState, useCallback, useMemo, useContext } from "react";
import { Table, Input, message } from "antd";
import type { ColumnsType } from "antd/es/table";
import {
  DragDropContext,
  Droppable,
  Draggable,
  DropResult,
  DroppableProvided,
  DraggableProvided,
} from "react-beautiful-dnd";
import { MenuOutlined } from "@ant-design/icons";

// 定义数据类型
interface DataType {
  key: string;
  name: string;
  age: number;
  address: string;
}

// ----------------------------------------------------------------------------
// 核心优化 1: 创建 Context
// 作用:用于跨层级传递 DroppableProvided,绕过 Table 的 props 更新机制
// ----------------------------------------------------------------------------
const DragContext = React.createContext<DroppableProvided | undefined>(
  undefined
);

// ----------------------------------------------------------------------------
// 核心优化 2: 静态的 BodyWrapper 组件
// 作用:它从 Context 消费数据,而不是通过 props。
// 这样即使 provided 变了,Table 看到的这个组件引用也没变,就不会销毁 tbody。
// ----------------------------------------------------------------------------
const DragBodyWrapper = (
  props: React.HTMLAttributes<HTMLTableSectionElement>
) => {
  const provided = useContext(DragContext);

  if (!provided) return <tbody {...props} />;

  return (
    <tbody {...props} {...provided.droppableProps} ref={provided.innerRef}>
      {props.children}
      {provided.placeholder}
    </tbody>
  );
};

// ----------------------------------------------------------------------------
// 静态的 Row 组件
// 作用:处理每一行的拖拽逻辑
// ----------------------------------------------------------------------------
const DraggableBodyRow = (props: any) => {
  const { index, className, style, ...restProps } = props;

  // 这里的 index 是通过 Table onRow 传入的
  // data-row-key 是 antd 自动生成的
  return (
    <Draggable draggableId={restProps["data-row-key"]} index={index}>
      {(provided: DraggableProvided, snapshot: any) => (
        <tr
          {...restProps}
          ref={provided.innerRef}
          {...provided.draggableProps}
          {...provided.dragHandleProps}
          style={{
            ...style,
            ...provided.draggableProps.style,
            // 拖拽时给点背景色
            background: snapshot.isDragging ? "#e6f7ff" : "inherit",
            display: snapshot.isDragging ? "table" : "table-row",
          }}
        />
      )}
    </Draggable>
  );
};

const DraggableEditableTable: React.FC = () => {
  // 初始化模拟数据
  const [dataSource, setDataSource] = useState<DataType[]>([
    {
      key: "1",
      name: "John Brown",
      age: 32,
      address: "New York No. 1 Lake Park",
    },
    { key: "2", name: "Jim Green", age: 42, address: "London No. 1 Lake Park" },
    { key: "3", name: "Joe Black", age: 32, address: "Sidney No. 1 Lake Park" },
    { key: "4", name: "Mike Smith", age: 25, address: "Tokyo No. 1 Street" },
  ]);

  // ----------------------------------------------------------------------------
  // 核心优化 3: 稳定的数据更新函数
  // 作用:使用函数式更新 (prev => ...),移除对 dataSource 的依赖。
  // 配合 useCallback,保证 handleInputChange 这个函数引用永远不变。
  // ----------------------------------------------------------------------------
  const handleInputChange = useCallback((key: string, value: string) => {
    setDataSource((prev) => {
      const newData = [...prev];
      const index = newData.findIndex((item) => item.key === key);
      if (index > -1) {
        newData[index] = { ...newData[index], name: value };
      }
      return newData;
    });
  }, []); // 依赖为空,函数句柄稳定

  // 处理拖拽结束
  const onDragEnd = (result: DropResult) => {
    if (!result.destination) return;

    const sourceIndex = result.source.index;
    const destinationIndex = result.destination.index;

    if (sourceIndex === destinationIndex) return;

    const newData = Array.from(dataSource);
    const [movedItem] = newData.splice(sourceIndex, 1);
    newData.splice(destinationIndex, 0, movedItem);

    setDataSource(newData);
    message.success("排序已更新");
  };

  // ----------------------------------------------------------------------------
  // 核心优化 4: 稳定的 Columns 定义
  // 作用:依赖项包含 handleInputChange (它是稳定的),所以 columns 也是稳定的。
  // 这防止了 Table 因为 columns 变化而重新渲染所有单元格。
  // ----------------------------------------------------------------------------
  const columns: ColumnsType<DataType> = useMemo(
    () => [
      {
        title: "排序",
        dataIndex: "sort",
        width: 60,
        align: "center",
        render: () => (
          <MenuOutlined style={{ cursor: "grab", color: "#999" }} />
        ),
      },
      {
        title: "Name (Editable)",
        dataIndex: "name",
        render: (text, record) => (
          <Input
            value={text}
            // 这里的 onChange 引用了上面稳定的 handleInputChange
            onChange={(e) => handleInputChange(record.key, e.target.value)}
            bordered={false} // 让它看起来像纯文本,点击变输入框
            style={{ padding: 0 }}
          />
        ),
      },
      {
        title: "Age",
        dataIndex: "age",
      },
      {
        title: "Address",
        dataIndex: "address",
      },
    ],
    [handleInputChange]
  ); // 仅依赖 handleInputChange

  // ----------------------------------------------------------------------------
  // 核心优化 5: 冻结 components 配置
  // 作用:依赖为空数组 [],保证 components 对象在组件生命周期内只有一份引用。
  // Antd Table 发现 components 没变,就不会销毁 tbody DOM。
  // ----------------------------------------------------------------------------
  const components = useMemo(
    () => ({
      body: {
        wrapper: DragBodyWrapper,
        row: DraggableBodyRow,
      },
    }),
    []
  );

  return (
    <div style={{ padding: 20 }}>
      <h3>Ant Design Table + DND + Editable Input (Focus Fixed)</h3>
      <p style={{ color: "#666", marginBottom: 20 }}>
        试着在输入框连续输入字符,焦点不会丢失;同时支持按住左侧图标拖拽排序。
      </p>

      <DragDropContext onDragEnd={onDragEnd}>
        <Droppable droppableId="draggable-table">
          {(provided: DroppableProvided) => (
            // ----------------------------------------------------------------------------
            // 核心优化 6: Context 注入
            // 作用:将变化的 provided 通过 Context 传给底层的 DragBodyWrapper。
            // ----------------------------------------------------------------------------
            <DragContext.Provider value={provided}>
              <Table
                columns={columns}
                dataSource={dataSource}
                rowKey="key"
                components={components}
                pagination={false}
                // 将 index 传给 Row 组件
                onRow={(record, index) =>
                  ({
                    index,
                    "data-row-key": record.key,
                  } as any)
                }
              />
            </DragContext.Provider>
          )}
        </Droppable>
      </DragDropContext>
    </div>
  );
};

export default DraggableEditableTable;

注意事项:React 18 StrictMode

如果你使用的是 React 18 并且在开发环境开启了 <React.StrictMode>react-beautiful-dnd 可能会无法正常工作(这是该库的一个已知兼容性问题,与我们的 Context 方案无关)。