协同表格 react-data-grid 的基本框架
react-data-grid 是 Comcast 开源的 React 表格库,被众多企业用于生产环境,它的设计思路对理解协同表格很有帮助。
表格组件的常见交互问题
表格组件,尤其是协同表格,存在比较复杂的场景、交互和边界问题,这里简单列举一下用户交互层面的常见场景和问题:
- 编辑器
类型复杂:例如普通文本、长文本(富文本)、数字、日期、时间、单选、多选、链接等
编辑状态切换:点击交互、悬浮交互、焦点/失焦交互等
定位:单元格内编辑(这个不用考虑定位问题),弹出层编辑(需要考虑在哪儿弹出以及边界情况)
校验:输入类型限制、脱敏等,不同编辑器有个字的验证规则
- 状态管理
编辑状态:哪个单元格正在被编辑?
选中状态:哪个、哪些被选中,是 shift 多选还是拖拽框选?
行列变化:新增/删除/重排/拖拽/过滤/分组等
撤销/重做:快捷键操作、历史记录、有效期等
- 用户交互
键盘导航:方向键切换选中单元格、tab 跳转等 a11y 问题
快捷键:符合用户直觉的复制、粘贴、全选、撤销等
拖拽:下拉填充、规则定义、列宽调整、行拖拽等
多选:shift 点击范围多选、control/command 点击单个多选、鼠标拖拽框选等
- 性能问题
数据加载:大量数据如何加载?
重渲染:编辑状态变化如何避免整表刷新?
虚拟滚动:只渲染可视区域和缓冲区的行/列
而对于协同表格,还需要考虑到数据同步问题以及在界面上,如何友好地更新数据和状态,这也是一个较为复杂的议题,这里先不考虑数据同步的场景,单从用户界面侧,学习一下 react-data-grid 的设计实现。
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 是 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:实时通信