协同表格 react-data-grid(二)
上一篇文章 介绍了 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 事件的传播顺序。当用户点击页面上的任意元素时,事件会经过两个阶段:
-
Capture 阶段:事件从 window 一路向下传播到目标元素
-
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'
});
}
编辑器生命周期管理
一次编辑会话的完整流程:
- 激活:用户点击或按键进入编辑模式
- 编辑:用户修改数据
- 提交/取消:用户点击外部、按 Enter/Tab(提交)或 Escape(取消)
- 退出:返回选中状态
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,有的用 defaultValue,onChange 的参数形式也各不一样,维护成本依然高。
此时迭代为 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 的设计是精心考虑过的:必要的、状态相关的、操作回调——三个层级清晰分离。接口一旦确定,就很难再改,所以设计接口的时候要想清楚。
工具是相通的——编辑器容器、渲染器模式、状态归一化——这些思想不只适用于表格,任何复杂表单、任何需要”状态管理和渲染分离”的场景都可以借鉴。
理解了”为什么这样设计”,再去用现成的轮子,或者自己造轮子,都会更得心应手一些。