· 6 min read
协同表格 react-data-grid 的基本框架
React
协同表格需要解决:多种编辑器类型、复杂的编辑状态管理、单元格之间的联动、键盘导航、粘贴复制、性能优化……本文以 react-data-grid 为例,介绍可协同表格的架构设计与技术要点。
react-data-grid 是 Comcast 开源的 React 表格库,被众多企业用于生产环境,它的设计思路对理解协同表格很有帮助。
协同表格的技术挑战
在分析 react-data-grid 之前,先看看做一个协同表格需要面对哪些问题:
编辑器复杂性
| 挑战 | 说明 |
|---|---|
| 多种编辑器类型 | 文本、数字、日期、下拉、多选、关联…… |
| 编辑状态切换 | 点击编辑 → 失去焦点保存 / 手动保存 |
| 编辑器定位 | 单元格内编辑 vs 弹出层编辑 |
| 验证逻辑 | 每个编辑器有自己的验证规则 |
状态管理
| 挑战 | 说明 |
|---|---|
| 编辑状态 | 哪个单元格正在编辑? |
| 选中状态 | 哪个单元格被选中? |
| 行/列变化 | 新增行、删除列 |
| 撤销/重做 | 每次编辑需要记录历史 |
用户交互
| 挑战 | 说明 |
|---|---|
| 键盘导航 | 方向键切换单元格、Tab 跳转 |
| 快捷键 | Ctrl+C 复制、Ctrl+V 粘贴 |
| 拖拽 | 拖拽填充、拖拽调整列宽 |
| 多选 | Shift 点击、Ctrl 点击 |
性能问题
| 挑战 | 说明 |
|---|---|
| 大数据量 | 十万级数据如何渲染? |
| 频繁重渲染 | 编辑状态变化如何避免整表刷新? |
| 虚拟滚动 | 只渲染可视区域的行和列 |
react-data-grid 基本架构
设计理念
react-data-grid 的设计围绕几个核心原则:
- 配置驱动:通过列定义配置所有行为
- 渲染器模式:用自定义组件处理渲染和编辑
- 统一状态管理:编辑状态由 DataGrid 统一管理
- 虚拟化:高性能渲染大量数据
组件层级
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
Renderer 是 react-data-grid 的核心概念:用自定义组件处理单元格的渲染和编辑。
const columns = [
{
key: 'name',
name: '姓名',
renderer: <CustomCellRenderer />,
editor: <CustomEditor />,
}
];
这种方式把”怎么显示”和”怎么编辑”完全交给开发者,库本身只负责框架。
Renderer 的分类
| 类型 | 用途 | 接口 |
|---|---|---|
| cellRenderer | 单元格渲染 | { row, column, isCellSelected } |
| headerRenderer | 表头渲染 | { column, sortDirection } |
| rowRenderer | 行渲染 | { row, renderers } |
| summaryRowRenderer | 汇总行渲染 | { row, isCellSelected } |
CellRenderer 示例
// 自定义单元格渲染器
const CustomCellRenderer = ({ row, column, isCellSelected }) => {
const value = row[column.key];
return (
<div
className={`cell ${isCellSelected ? 'selected' : ''}`}
style={{ backgroundColor: isCellSelected ? '#e3f2fd' : 'transparent' }}
>
{value}
</div>
);
};
为什么用 renderer 模式?
优点:
- 灵活性高:任何 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 |
老生常谈的状态管理
DataGrid 状态
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 时自动提交还是取消? |
虚拟滚动
为什么需要虚拟滚动
当数据量达到数万行时,一次性渲染所有 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>
);
}
}
虚拟化的挑战
| 挑战 | 说明 |
|---|---|
| 行高不确定 | 动态行高如何处理? |
| 滚动位置同步 | 虚拟滚动与实际滚动如何同步? |
| 粘性列 | 固定列如何在虚拟滚动中保持? |
| 性能 | 滚动时如何避免频繁计算? |
类似产品
特色
| 产品 | 特色 |
|---|---|
| Airtable | 丰富的字段类型、视图、自动化 |
| Notion | 块式编辑、数据库关联 |
| Miro | 无限画布、实时协作 |
差异
| react-data-grid | Airtable/Notion | |
|---|---|---|
| 实时协作 | 无 | WebSocket/CRDT |
| 字段类型 | 基础 | 丰富(关联、公式等) |
| 视图切换 | 无 | 多视图(看板、日历等) |
| 离线支持 | 有限 | 强 |
实时协作
flowchart LR
A[用户 A] --> B[本地状态]
C[用户 B] --> D[本地状态]
B --> E[协同服务器]
D --> E
E --> F[CRDT 算法]
F --> G[冲突解决]
G --> H[同步状态]
- CRDT:无冲突复制数据类型,用于解决并发编辑
- OT:操作转换,另一个主流方案
- WebSocket:实时通信
总结
react-data-grid 的架构亮点
- Renderer 模式:灵活性与结构性的平衡
- EditorContainer:统一的编辑状态管理
- 虚拟滚动:解决大数据量性能问题
- 键盘导航:完整的无障碍支持
协同表格的进阶挑战
| 挑战 | 难度 | 说明 |
|---|---|---|
| 多种编辑器类型 | 中 | 配置化可解决 |
| 编辑状态管理 | 中 | 状态机可解决 |
| 键盘导航 | 高 | 需要完善的事件处理 |
| 虚拟滚动 | 高 | 需要精确的位置计算 |
| 实时协作 | 极高 | 需要 CRDT/OT 等复杂算法 |