Back to Blog
· 5 min read

协同表格 react-data-grid 的基本框架

React

react-data-grid 是 Comcast 开源的 React 表格库,被众多企业用于生产环境,它的设计思路对理解协同表格很有帮助。

表格组件的常见交互问题

表格组件,尤其是协同表格,存在比较复杂的场景、交互和边界问题,这里简单列举一下用户交互层面的常见场景和问题:

  1. 编辑器

类型复杂:例如普通文本、长文本(富文本)、数字、日期、时间、单选、多选、链接等

编辑状态切换:点击交互、悬浮交互、焦点/失焦交互等

定位:单元格内编辑(这个不用考虑定位问题),弹出层编辑(需要考虑在哪儿弹出以及边界情况)

校验:输入类型限制、脱敏等,不同编辑器有个字的验证规则

  1. 状态管理

编辑状态:哪个单元格正在被编辑?

选中状态:哪个、哪些被选中,是 shift 多选还是拖拽框选?

行列变化:新增/删除/重排/拖拽/过滤/分组等

撤销/重做:快捷键操作、历史记录、有效期等

  1. 用户交互

键盘导航:方向键切换选中单元格、tab 跳转等 a11y 问题

快捷键:符合用户直觉的复制、粘贴、全选、撤销等

拖拽:下拉填充、规则定义、列宽调整、行拖拽等

多选:shift 点击范围多选、control/command 点击单个多选、鼠标拖拽框选等

  1. 性能问题

数据加载:大量数据如何加载?

重渲染:编辑状态变化如何避免整表刷新?

虚拟滚动:只渲染可视区域和缓冲区的行/列

而对于协同表格,还需要考虑到数据同步问题以及在界面上,如何友好地更新数据和状态,这也是一个较为复杂的议题,这里先不考虑数据同步的场景,单从用户界面侧,学习一下 react-data-grid 的设计实现。

react-data-grid 基本架构

设计理念

react-data-grid 的设计围绕几个核心原则:

  1. 配置驱动:通过列定义配置所有行为
  2. 渲染器模式:用自定义组件处理渲染和编辑
  3. 统一状态管理:编辑状态由 DataGrid 统一管理
  4. 虚拟化:高性能渲染大量数据

组件层级

flowchart TD
    A[DataGrid] --> B[HeaderRow]
    A --> C[Rows]
    A --> D[EditorContainer]
    A --> E[SummaryRow]

    C --> F[Row]
    F --> G[Cell]
    G --> H[CellRenderer]

    D --> I[Editor]
    I --> J[EditorContainer]

数据流

flowchart LR
    A[用户交互] --> B[DataGrid]
    B --> C[更新 state]
    C --> D[触发重渲染]
    D --> E[渲染 Cell/Editor]
    E --> F[Editor 回调]
    F --> B

核心模式:Renderers

Renderer 是 react-data-grid 的核心概念:用自定义组件处理单元格的渲染和编辑

const columns = [
  {
    key: 'name',
    name: '姓名',
    renderer: <CustomCellRenderer />,
    editor: <CustomEditor />,
  }
];

这种方式把”怎么显示”和”怎么编辑”完全交给开发者,库本身只负责框架。

分类

类型用途接口
cellRenderer单元格渲染{ row, column, isCellSelected }
headerRenderer表头渲染{ column, sortDirection }
rowRenderer行渲染{ row, renderers }
summaryRowRenderer汇总行渲染{ row, isCellSelected }

Demo

// 自定义单元格渲染器
const CustomCellRenderer = ({ row, column, isCellSelected }) => {
  const value = row[column.key];

  return (
    <div
      className={`cell ${isCellSelected ? 'selected' : ''}`}
      style={{ backgroundColor: isCellSelected ? '#e3f2fd' : 'transparent' }}
    >
      {value}
    </div>
  );
};

好处

  • 灵活性高:任何 React 组件都可以作为 renderer
  • 可复用:同一个 renderer 可以用于不同列
  • 关注点分离:显示逻辑和表格逻辑解耦

风险

  • 过度灵活可能导致不一致

编辑器

EditorContainer

react-data-grid 使用 EditorContainer 统一管理编辑状态:

class EditorContainer extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      editing: null,  // { rowIdx, colIdx }
      value: null
    };
  }

  startEdit = ({ rowIdx, colIdx }) => {
    this.setState({
      editing: { rowIdx, colIdx },
      value: this.getCellValue(rowIdx, colIdx)
    });
  }

  commitEdit = () => {
    const { rowIdx, colIdx } = this.state.editing;
    const { onCellCommit } = this.props;

    onCellCommit({
      rowIdx,
      colIdx,
      value: this.state.value
    });

    this.setState({ editing: null });
  }
}

接口

react-data-grid 定义了编辑器的标准接口:

interface CellEditor {
  // 获取编辑器当前值
  getValue(): any;

  // 获取原始值(用于取消编辑)
  getOldValue(): any;

  // 验证编辑器值
  validate(): boolean;

  // 可选:焦点处理
  focus?(): void;

  // 可选:获取 props
  getProps?(): any;
}

内置编辑器

react-data-grid 提供了一些内置编辑器:

编辑器用途
AutoComplete自动补全
DropDownEditor下拉选择
DateEditor日期选择

自定义编辑器

class TextEditor extends React.Component {
  getValue() {
    return {
      [this.props.column.key]: this.input.value
    };
  }

  getOldValue() {
    return {
      [this.props.column.key]: this.props.value
    };
  }

  validate() {
    return this.input.value.length > 0;
  }

  render() {
    return (
      <input
        ref={(node) => this.input = node}
        defaultValue={this.props.value}
        onBlur={() => this.props.onCommit(this.getValue())}
      />
    );
  }
}

编辑器的难点

难点解决方案
编辑器与单元格位置绝对定位、z-index 管理
编辑器生命周期onFocus、onBlur、onCommit
验证失败处理显示错误状态、阻止提交
键盘事件处理方向键、Tab、Enter、Escape

状态管理

class DataGrid extends React.Component {
  state = {
    rows: [],           // 数据
    selected: null,     // 选中状态 { rowIdx, colIdx }
    editing: null,     // 编辑状态 { rowIdx, colIdx }
    sortColumns: [],   // 排序列
    filters: {},       // 过滤条件
    columnWidths: {},  // 列宽
  };
}

状态更新模式

// 统一的状态更新入口
updateState = (updates) => {
  this.setState(updates, () => {
    // 状态更新后的回调
    this.persistState();
  });
};

编辑状态传递

// DataGrid -> Cell -> Editor
<DataGrid>
  <Cell
    isEditing={this.state.editing?.rowIdx === rowIdx && this.state.editing?.colIdx === colIdx}
    onEdit={this.startEdit}
  />
</DataGrid>

编辑状态的管理设计需要考虑到一下几点:

  • 编辑与选中状态的冲突,正在编辑时是否还能选中其它单元格,编辑时点击其它单元格的交互如何设计
  • 支持批量编辑时,复制粘贴多行数据如何处理,数据格式不匹配时如何处理,复制的单元格比目标单元格多怎么处理
  • 撤销功能,如何设计历史栈

键盘导航

handleKeyDown = (e) => {
  const { selected, editing } = this.state;

  if (editing) {
    // 编辑状态下的键盘处理
    if (e.key === 'Escape') {
      this.cancelEdit();
    } else if (e.key === 'Enter') {
      this.commitEdit();
    } else if (e.key === 'Tab') {
      e.preventDefault();
      this.moveToNextCell(e.shiftKey);
    }
  } else {
    // 非编辑状态的键盘处理
    if (e.key === 'ArrowDown') {
      this.moveSelection(1, 0);
    }
    // ...
  }
};

Tab 导航的常见问题

  • 循环跳转, 到最后一列后回到第一列?
  • 跨行时,编辑完一行后 tab 自动跳到下一行?
  • 跳过不可编辑列,如何配置哪些列可编辑?
  • 编辑中提交,Tab 时自动提交还是取消?

虚拟滚动

当数据量达到数万行时,一次性渲染所有 DOM 会导致严重性能问题,因而需要使用虚拟滚动。

react-data-grid 的虚拟化

// 简化版虚拟滚动
class VirtualRows extends React.Component {
  render() {
    const { rows, scrollTop, rowHeight } = this.props;
    const startIdx = Math.floor(scrollTop / rowHeight);
    const endIdx = startIdx + VISIBLE_COUNT;

    const visibleRows = rows.slice(startIdx, endIdx);

    return (
      <div style={{ height: rows.length * rowHeight }}>
        <div style={{ transform: `translateY(${startIdx * rowHeight}px)` }}>
          {visibleRows.map((row, i) => (
            <Row
              key={row.id}
              row={row}
              index={startIdx + i}
            />
          ))}
        </div>
      </div>
    );
  }
}

虚拟化的挑战

挑战说明
行高不确定动态行高如何处理?
滚动位置同步虚拟滚动与实际滚动如何同步?
粘性列固定列如何在虚拟滚动中保持?
性能滚动时如何避免频繁计算?

协作

flowchart LR
    A[用户 A] --> B[本地状态]
    C[用户 B] --> D[本地状态]

    B --> E[协同服务器]
    D --> E

    E --> F[CRDT 算法]
    F --> G[冲突解决]
    G --> H[同步状态]
  • CRDT:无冲突复制数据类型,用于解决并发编辑
  • OT:操作转换,另一个主流方案
  • WebSocket:实时通信

相关资料