这是一个专栏: 从零实现多维表格,将带你一步步实现一个多维表格,持续更新中。
冻结行列的实现
在上一篇文章 Konva.js 实现虚拟表格中,我们实现了虚拟滚动。但作为一个完整的表格,当表格数据行列过多时,应该支持冻结行、列功能以方便用户查看和编辑数据。
要实现冻结功能,我们首先需要将表格进行分区。经过分析,可以将表格分成四个独立的区域:
- 表头 + 冻结列区域(红色):无论表格如何滚动,该部分都保持不动
- 表头 + 非冻结列区域(绿色):仅在水平滚动时更新
- 非表头 + 冻结列区域(蓝色):仅在垂直滚动时更新
- 内容区域(黄色):在水平和垂直滚动时都需要更新
核心实现
1. 创建四个 Group 来表示表格的四个区域
首先定义 Groups 类型并创建四个 Group 实例来分别管理表格的四个区域:
type Groups = {
topLeft: Konva.Group;
topRight: Konva.Group;
bottomLeft: Konva.Group;
bottomRight: Konva.Group;
};
this.groups = {
topLeft: new Konva.Group(),
topRight: new Konva.Group(),
bottomLeft: new Konva.Group(),
bottomRight: new Konva.Group(),
};
2. 初始化 Groups 并设置裁剪区域
/**
* 初始化四个区域的 Group
* 按照从下到上的顺序添加,确保冻结区域在上层
*/
initGroup() {
this.layer.add(this.groups.bottomRight);
this.layer.add(this.groups.topRight);
this.layer.add(this.groups.bottomLeft);
this.layer.add(this.groups.topLeft);
this.setClipping();
}
/**
* 获取冻结列的总宽度(包括第一个冻结列)
*/
getFrozenColsWidth(): number {
let frozenIndex = this.columns.findIndex((item) => item.lock);
if (frozenIndex === -1) {
frozenIndex = 0;
}
let frozenColsWidth = 0;
this.columns
.filter((_, index) => index <= frozenIndex)
.forEach((item) => (frozenColsWidth += item.width));
return frozenColsWidth;
}
/**
* 获取冻结行的高度
*/
getFrozenRowsHeight(): number {
return ROW_HEIGHT;
}
/**
* 获取所有列的总宽度
*/
getAllColWidth(): number {
let w = 0;
this.columns.forEach((column) => {
w += column.width || CELL_DEFAULT_WIDTH;
});
return w;
}
/**
* 为四个区域设置 Canvas 裁剪,确保每个区域只显示其对应的内容
*/
setClipping(): void {
const frozenColsWidth = this.getFrozenColsWidth() + 1;
const frozenRowsHeight = this.getFrozenRowsHeight();
const width = this.getAllColWidth();
const height = this.rows * ROW_HEIGHT;
// topLeft:冻结列的表头
this.groups.topLeft.clipFunc((ctx: CanvasRenderingContext2D) => {
ctx.rect(0, 0, frozenColsWidth, frozenRowsHeight);
});
// topRight:非冻结列的表头
this.groups.topRight.clipFunc((ctx: CanvasRenderingContext2D) => {
ctx.rect(frozenColsWidth, 0, width - frozenColsWidth, frozenRowsHeight);
});
// bottomLeft:冻结列的数据区
this.groups.bottomLeft.clipFunc((ctx: CanvasRenderingContext2D) => {
ctx.rect(0, frozenRowsHeight, frozenColsWidth, height - frozenRowsHeight);
});
// bottomRight:非冻结列的数据区
this.groups.bottomRight.clipFunc((ctx: CanvasRenderingContext2D) => {
ctx.rect(
frozenColsWidth,
frozenRowsHeight,
width - frozenColsWidth,
height - frozenRowsHeight
);
});
}
3. 滚动时更新各区域的位置
当用户滚动表格时,不同的区域需要以不同的方式移动,以实现冻结效果:
/**
* 更新各区域的偏移量,实现冻结行列的滚动效果
*/
updateGroups(): void {
// 对滚动位置进行边界值校验,防止超出范围
const clampedScrollLeft = Math.max(
0,
Math.min(this.scrollLeft, this.maxScrollLeft)
);
const clampedScrollTop = Math.max(
0,
Math.min(this.scrollTop, this.maxScrollTop)
);
// topLeft:冻结列 + 表头(不动)
this.groups.topLeft.offsetX(0);
this.groups.topLeft.offsetY(0);
// topRight:表头(仅水平滚动)
this.groups.topRight.offsetX(clampedScrollLeft);
this.groups.topRight.offsetY(0);
// bottomLeft:冻结列(仅垂直滚动)
this.groups.bottomLeft.offsetX(0);
this.groups.bottomLeft.offsetY(clampedScrollTop);
// bottomRight:内容区(水平和垂直同时滚动)
this.groups.bottomRight.offsetX(clampedScrollLeft);
this.groups.bottomRight.offsetY(clampedScrollTop);
// 触发 Canvas 重绘
this.layer.batchDraw();
}
/**
* 处理滚动事件
*/
handleScroll(deltaX: number, deltaY: number): void {
// 更新滚动位置
this.scrollTop = Math.max(
0,
Math.min(this.scrollTop + deltaY, this.maxScrollTop)
);
this.scrollLeft = Math.max(
0,
Math.min(this.scrollLeft + deltaX, this.maxScrollLeft)
);
// 更新可见范围
this.updateVisibleRange();
// 重新渲染单元格
this.renderCells();
// 更新各区域位置
this.updateGroups();
}
4. 单元格渲染器(CellRenderer)
创建一个独立的单元格渲染器类,负责单个单元格的绘制:
// cell/index.ts
import Konva from "konva";
import { ROW_HEIGHT } from "../../constants";
interface CellConfig {
x: number;
y: number;
width: number;
height: number;
value: string;
}
/**
* 单元格渲染器
* 负责绘制单个表格单元格(包括边框和文本)
*/
class CellRenderer {
x: number;
y: number;
width: number;
height: number;
value: string;
group: Konva.Group;
rect: Konva.Rect;
text: Konva.Text;
constructor(config: CellConfig) {
const { x, y, width, height, value } = config;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.value = value;
// 创建 Group 用于组织单元格的所有图形元素
this.group = new Konva.Group({
x,
y,
});
// 创建单元格背景矩形
this.rect = new Konva.Rect({
x: 0,
y: 0,
width: width,
height: ROW_HEIGHT,
fill: "#FFF",
stroke: "#ccc",
strokeWidth: 1,
});
// 创建单元格文本
this.text = new Konva.Text({
x: 8,
y: 8,
width: width - 16,
height: 16,
text: value,
fontSize: 14,
fill: "#000",
align: "left",
verticalAlign: "middle",
ellipsis: true,
});
// 将矩形和文本添加到 Group
this.group.add(this.rect, this.text);
}
/**
* 返回渲染后的 Group
*/
render(): Konva.Group {
return this.group;
}
}
export default CellRenderer;
5. 修改 renderCells 方法
/**
* 渲染可见范围内的所有单元格
* 清空四个区域的旧内容,然后重新渲染
*/
renderCells(): void {
// 清空所有区域的旧单元格
this.groups.topLeft.destroyChildren();
this.groups.topRight.destroyChildren();
this.groups.bottomLeft.destroyChildren();
this.groups.bottomRight.destroyChildren();
// 渲染所有列的表头
for (let colIndex = 0; colIndex < this.columns.length; colIndex++) {
this.renderCell(0, colIndex);
}
// 渲染数据行
for (
let rowIndex = this.visibleRows.start;
rowIndex <= this.visibleRows.end;
rowIndex++
) {
if (rowIndex === 0) continue; // 跳过已渲染的表头行
// 渲染冻结列的数据单元格
if (this.frozenColIndex >= 0) {
for (
let colIndex = 0;
colIndex <= this.frozenColIndex && colIndex < this.columns.length;
colIndex++
) {
this.renderCell(rowIndex, colIndex);
}
}
// 渲染可见的非冻结列单元格
for (
let colIndex = this.visibleCols.start;
colIndex <= this.visibleCols.end;
colIndex++
) {
// 跳过已渲染的冻结列
if (this.frozenColIndex >= 0 && colIndex <= this.frozenColIndex) {
continue;
}
this.renderCell(rowIndex, colIndex);
}
}
// 触发 Canvas 重绘
this.layer.draw();
}
6. 修改 renderCell 方法
/**
* 渲染单个单元格
* @param rowIndex - 行索引
* @param colIndex - 列索引
*/
renderCell(rowIndex: number, colIndex: number): void {
const column = this.columns[colIndex];
if (!column) return;
// 计算单元格坐标(使用绝对坐标,不减去滚动偏移)
// 滚动偏移由各 Group 的 offsetX/offsetY 处理
const x = this.getColX(colIndex);
const y = this.getRowY(rowIndex);
// 确定单元格内容(表头或数据)
const cellValue =
rowIndex === 0 ? column.title : this.dataSource[rowIndex][colIndex];
// 使用 CellRenderer 创建单元格
const cell = new CellRenderer({
x,
y,
height: ROW_HEIGHT,
width: column.width,
value: cellValue,
});
const cellGroup = cell.render();
const isFrozenCol = this.isColFrozen(colIndex);
// 设置冻结列的 zIndex,使其在非冻结列之上
if (isFrozenCol) {
cellGroup.zIndex(100);
} else {
cellGroup.zIndex(1);
}
// 将单元格添加到对应的 Group
const group = this.getCellGroup(rowIndex, colIndex);
group.add(cellGroup);
}
总结
通过将表格分为四个独立的区域并分别管理它们的滚动,我们实现了高效的冻结行列功能。关键点包括:
- ✅ 使用四个 Group 分别管理四个区域,便于独立控制
- ✅ 通过
clipFunc进行 Canvas 裁剪,确保内容不超出区域边界 - ✅ 使用
offsetX/offsetY实现各区域的独立滚动 - ✅ 虚拟渲染技术确保只渲染可见区域,提升性能