Back to Blog
· 5 min read

协同表格 react-data-grid(二)

React

上一篇文章 介绍了 react-data-grid 的基本架构:EditorContainer 负责编辑状态,Renderers 模式负责灵活渲染,这篇记录一下相关的思路和方案。

状态与渲染为什么要分离

无目的地看代码效果很差,一般建议先阅读一些与代码相关的文章(例如官方 blog 或其它bloger的解读)后,带着问题再深入代码,比方说:为什么 react-data-grid 要把状态管理和渲染逻辑分开?

想象一个紧耦合的实现:

function Cell({ row, col, isEditing, editorType, value, onChange }) {
  // 显示逻辑和编辑逻辑混在一起
  if (isEditing) {
    if (editorType === 'select') {
      return <select value={value} onChange={onChange}>...</select>;
    } else if (editorType === 'date') {
      return <DatePicker value={value} onChange={onChange} />;
    }
    // editorType 越多,if-else 越堆越长
  }
  return <span>{value}</span>;
}

问题很明显:每加一种编辑器类型,就要改 Cell 组件本身。组件的职责不单一,扩展和复用都很困难。

react-data-grid 的解法是,让 Cell 组件只关心”什么时候该显示编辑器”,而不关心”编辑器长什么样”

interface CellRendererProps<TRow, TSummaryRow> {
  column: CalculatedColumn<TRow, TSummaryRow>;
  row: TRow;
  rowIdx: number;
  isCellActive: boolean;
  onRowChange: (row: TRow, commitChanges?: boolean) => void;
  // ...
}

只要编辑器遵循这个接口约定,就可以自由替换。Cell 组件不需要知道具体是哪个编辑器。

这就是合理设计接口带来的好处,它定义了渲染器和 DataGrid 之间的通信协议。

EditorContainer

EditorContainer 是 react-data-grid 里最复杂的组件之一。它的核心职责是:管理编辑状态的生命周期

外部点击检测

编辑器常见的一个场景,用户点击编辑器外部时,应该自动提交编辑。

这个功能看似简单,实现起来却很 tricky:

// EditCell.tsx
function EditCell({ editor, onCommit, onCancel }) {
  useEffect(() => {
    function handleOutsideClick(event: MouseEvent) {
      // 检测点击是否发生在编辑器外部
      if (!editorRef.current?.contains(event.target as Node)) {
        onCommit();
      }
    }

    // 监听 capture 阶段,而非 bubble 阶段
    window.addEventListener('mousedown', handleOutsideClick, true);
    return () => window.removeEventListener('mousedown', handleOutsideClick, true);
  }, [onCommit]);
}

为什么要用 capture 阶段?编辑器关闭的关键交互:用户点击编辑器外部时,应该自动提交编辑。

实现这个功能需要理解 DOM 事件的传播顺序。当用户点击页面上的任意元素时,事件会经过两个阶段:

  1. Capture 阶段:事件从 window 一路向下传播到目标元素

  2. Bubble 阶段:事件从目标元素向上冒泡回到 window

如果在 bubble 阶段(默认)监听,事件已经到达了目标元素。比如用户点击编辑器外的 input,input 已经获得了焦点。如果此时才去判断”点击是否在编辑器外部”,时序上会有些微妙——编辑器刚判断完要关闭,另一个单元格已经获得了焦点。

如果在 capture 阶段监听,事件在到达目标之前就能被拦截。这意味着可以先执行”关闭编辑器”的逻辑,再让新元素获得焦点,焦点变化的时序更干净,不会有重叠的中间状态。

另一个 tricky 的地方是任务生命周期的管理。外部点击检测通过 commitOnOutsideMouseDown() 这个回调函数来执行,但如果用户的编辑会话结束了(比如按 Escape 取消),这个 pending 的任务仍然可能继续执行,导致不该有的提交。

AbortController可以解决这个问题:当编辑状态改变(组件 re-render)时,通过 abort() 取消所有之前调度的任务,确保”旧的”提交逻辑不会在”新的”编辑会话中执行。

// 使用 AbortController 管理生命周期
useEffect(() => {
  const controller = new AbortController();
  const { signal } = controller;

  if (canUsePostTask) {
    scheduler.postTask(() => commitOnOutsideMouseDown(), {
      priority: 'user-blocking',
      signal
    });
  } else {
    requestAnimationFrame(commitOnOutsideMouseDown);
  }

  return () => controller.abort();
}, []);

postTask 是浏览器的新 API,可以指定任务的优先级。user-blocking 优先级确保编辑器关闭的逻辑足够快,同时 AbortController 能在用户取消时优雅地中止任务。

活动位置归一化

传统的编辑状态管理可能是这样的:

const [editingCell, setEditingCell] = useState<{ rowIdx: number; colIdx: number } | null>(null);
const [isEditing, setIsEditing] = useState(false);

但 react-data-grid 用了一个更统一的模式:活动位置(Active Position)

interface Position {
  rowIdx: number;
  colIdx: number;
}

interface ActivePosition extends Position {
  readonly mode: 'ACTIVE';  // 选中状态
}

interface EditPosition<R> extends Position {
  readonly mode: 'EDIT';    // 编辑状态
  readonly row: R;          // 正在编辑的行数据
  readonly originalRow: R;  // 编辑前的原始数据
}

两种状态共用同一个位置对象,通过 mode 字段区分。这种设计的好处是:状态描述的是”在哪里”,而不是”在做什么”

导航逻辑不需要关心当前是编辑还是选中,只需要知道”要去哪里”:

function getNextActivePosition(position: Position, key: string): Position | null {
  switch (key) {
    case 'ArrowDown':
      return { ...position, rowIdx: position.rowIdx + 1 };
    case 'ArrowRight':
      return { ...position, colIdx: position.colIdx + 1 };
    // ...
  }
}

编辑状态的切换也变得更简单——只需要把 mode'ACTIVE' 变成 'EDIT'

function startEdit(position: Position, row: TRow) {
  setActivePosition({
    ...position,
    mode: 'EDIT',
    row,
    originalRow: row
  });
}

function commitEdit() {
  setActivePosition({
    rowIdx: activePosition.rowIdx,
    colIdx: activePosition.colIdx,
    mode: 'ACTIVE'
  });
}

编辑器生命周期管理

一次编辑会话的完整流程:

  1. 激活:用户点击或按键进入编辑模式
  2. 编辑:用户修改数据
  3. 提交/取消:用户点击外部、按 Enter/Tab(提交)或 Escape(取消)
  4. 退出:返回选中状态

EditorContainer 需要处理很多边界情况:

  • 外部修改了行数据(比如其他地方更新了这条记录),应该自动退出编辑
  • 编辑失败时(比如验证不通过),应该保持编辑状态并显示错误
  • 失去焦点后再次获得焦点,应该恢复编辑状态
// 当外部修改了正在编辑的行时,自动取消编辑
useEffect(() => {
  if (activePosition.mode === 'EDIT' && isRowModified) {
    onCancel();
  }
}, [rows, activePosition]);

Renderers 模式

接口设计

包含了三个层级的信息:

第一层:基础信息

row: TRow;      // 行数据
column: CalculatedColumn<TRow, TSummaryRow>;  // 列配置
rowIdx: number; // 行索引

这些是渲染需要的最少数据。

第二层:状态信息

isCellActive: boolean;  // 是否被选中
isDraggedOver: boolean; // 是否被拖拽覆盖

让渲染器可以根据状态调整显示样式。

第三层:操作回调

onRowChange: (row: TRow, commitChanges?: boolean) => void;
setActivePosition: (position: Position) => void;

让渲染器可以触发状态变化,而不需要知道状态管理是怎么实现的。

这种设计的核心原则是:让组件知道”在哪里”,但不知道”发生了什么”。CellRenderer 不需要知道为什么它被激活,只需要知道”我现在是激活的”,然后可以选择不同的渲染方式。

Controlled vs Uncontrolled

和表单组件一样,表格也有 controlled 和 uncontrolled 两种用法。

// uncontrolled:组件自己管理状态
<DataGrid
  rows={data}
  columns={columns}
/>

// controlled:外部管理状态
<DataGrid
  rows={data}
  columns={columns}
  selectedRows={selectedRows}
  onSelectedRowsChange={setSelectedRows}
/>

react-data-grid 通过检查 props 是否传入来判断模式:

const isSelectedRowsControlled =
  selectedRows != null && onSelectedRowsChange != null;

const selectedRows = isSelectedRowsControlled
  ? selectedRows
  : internalSelectedRows;

这种模式的好处是:组件内部逻辑保持不变,只是数据来源不同。对于使用者来说,可以在任何时候切换 controlled 和 uncontrolled 模式,不需要修改其他代码。

默认渲染器链

react-data-grid 内部有一个默认渲染器链:

const renderCell = renderers?.renderCell
  ?? defaultRenderers?.renderCell
  ?? defaultRenderCell;

三个优先级:用户自定义渲染器 > 默认渲染器 > 内置渲染器。

大多数场景下,内置的默认渲染器已经够用:

function defaultRenderCell({ row, column }) {
  const value = row[column.key];
  return <span>{value}</span>;
}

如果想做自定义格式化,只需要提供 renderCell:

const columns = [
  {
    key: 'amount',
    name: '金额',
    renderCell: ({ row }) => (
      <span>¥{row.amount.toLocaleString()}</span>
    )
  }
];

而不需要覆盖整个单元格逻辑。最小知识原则在这里体现得很好:你只需要覆盖你想自定义的部分,其他保持默认。

一次编辑的完整生命周期

理解了 EditorContainer 和 Renderers 之后,更容里理解编辑行为的逻辑交互。

sequenceDiagram
    participant User as 用户
    participant Cell as Cell 组件
    participant Container as EditorContainer
    participant Grid as DataGrid
    participant Store as 状态管理

    User->>Cell: 点击单元格
    Cell->>Grid: setActivePosition({ rowIdx, colIdx, mode: 'EDIT', row, originalRow })
    Grid->>Container: 渲染编辑器
    Container->>Container: 注册外部点击检测
    User->>Container: 输入内容
    User->>Cell: 点击外部
    Container->>Grid: onCellCommit({ rowIdx, colIdx, value })
    Grid->>Store: 更新 rows
    Store-->>Grid: 新 rows
    Grid->>Cell: 重新渲染

关键点是:状态变化是单向的,渲染器只负责展示

用户的输入 → EditorContainer 捕获 → 提交到 DataGrid → DataGrid 更新 rows → Cell 收到新的 row 数据 → renderCell 重新渲染

这条链路清晰且可预测。每个环节只关注自己的职责,不需要担心状态同步的问题。

演进

从零做一款编辑器时,一般在初始阶段对编辑器类型、格式兼容没有很高的要求,通常会有比较明显的复杂度和可维护性的迭代。

比如 mvp 版本可能要求能用就行,耦合也能用,因为整体代码复杂度可能比较低,大概会这么写:

function Cell({ value, type, onChange, isEditing }) {
  if (isEditing) {
    if (type === 'text') {
      return <input value={value} onChange={(e) => onChange(e.target.value)} />;
    } else if (type === 'select') {
      return (
        <select value={value} onChange={(e) => onChange(e.target.value)}>
          <option value="a">A</option>
          <option value="b">B</option>
        </select>
      );
    } else if (type === 'date') {
      return <DatePicker value={value} onChange={onChange} />;
    }
    // 类型越多,if-else 越堆越长
  }
  return <span>{value}</span>;
}

问题很明显:类型越多,代码越难维护。新加一个类型要改 Cell 组件本身,显示、编辑等不同状态的样式处理也会比较麻烦。

那么如果尝试把编辑器和显示分开,通过外部传入编辑器:

function Cell({ value, editor: Editor, onChange, isEditing }) {
  if (isEditing && Editor) {
    return <Editor value={value} onChange={onChange} />;
  }
  return <span>{value}</span>;
}

好一点了,但问题是:Editor 的接口不统一,有的用 value prop,有的用 defaultValueonChange 的参数形式也各不一样,维护成本依然高。

此时迭代为 renderers 模式,让列定义自己决定渲染逻辑:

const columns = [
  {
    key: 'name',
    name: '名称',
    renderCell: ({ row }) => <span>{row.name}</span>,
    renderEditCell: ({ row, onRowChange }) => (
      <input
        value={row.name}
        onChange={(e) => onRowChange({ ...row, name: e.target.value }, true)}
      />
    )
  },
  {
    key: 'status',
    name: '状态',
    renderCell: ({ row }) => <StatusBadge status={row.status} />,
    renderEditCell: ({ row, onRowChange }) => (
      <select
        value={row.status}
        onChange={(e) => onRowChange({ ...row, status: e.target.value }, true)}
      >
        <option value="pending">待处理</option>
        <option value="active">进行中</option>
        <option value="done">已完成</option>
      </select>
    )
  }
];
  • 每种列类型独立定义自己的渲染器
  • 编辑器和显示器的接口完全一致(onRowChange
  • 新增列类型只需要加一个对象,不需要改 Cell 组件
  • 渲染器可以复用,可以在不同列里用同一个渲染器

🤔

回过头看 react-data-grid 的设计,有几个值得借鉴的点。

状态归一化。EditorContainer 把”在哪里”和”在做什么”统一成一个 Position 对象,导航逻辑和编辑逻辑不再耦合。这比传统的 isEditing + editingCell 的写法更清晰。

渲染器链。Controlled/uncontrolled 的自动检测、默认渲染器的 fallback——这些机制让组件既灵活又可控。使用者可以完全自定义,也可以什么都不配,组件都能正常工作。

单向数据流。EditorContainer 捕获输入 → 提交到 DataGrid → DataGrid 更新 rows → 渲染器响应变化。这条链路是单向的,每个环节只关注自己的职责。

接口设计决定扩展性。CellRendererProps 的设计是精心考虑过的:必要的、状态相关的、操作回调——三个层级清晰分离。接口一旦确定,就很难再改,所以设计接口的时候要想清楚。

工具是相通的——编辑器容器、渲染器模式、状态归一化——这些思想不只适用于表格,任何复杂表单、任何需要”状态管理和渲染分离”的场景都可以借鉴。

理解了”为什么这样设计”,再去用现成的轮子,或者自己造轮子,都会更得心应手一些。

参考