Konvajs实现虚拟表格
这是一个专栏 从零实现多维表格,此专栏将带你一步步实现一个多维表格,缓慢更新中
虚拟表格
虚拟表格(Virtual Table) 是一种优化技术,用于处理大量数据时的性能问题。它只渲染当前可见区域(视口)内的表格单元格,而不是渲染整个表格的所有数据。
实现原理
一个简单的虚拟表格实现主要包括以下两点(注:一个完善的虚拟表格需要关注的方面更多,这里只讨论核心实现,后续的优化项会在本专栏的后续文章中实现)
- 按需渲染:只创建和渲染用户当前能看到的数据行和列
- 滚动监听:监听容器滚动事件,动态计算新的可见范围
代码大纲
基于上述原理,我们可以写出如下代码:
import Konva from "konva";
import { Layer } from "konva/lib/Layer";
import { Stage } from "konva/lib/Stage";
export type Column = {
title: string;
width: number;
};
type VirtualTableConfig = {
container: HTMLDivElement;
columns: Column[];
dataSource: Record<string, any>[];
};
type Range = { start: number; end: number };
class VirtualTable {
// =========== 表格基础属性 ===========
rows: number = 20;
cols: number = 20;
columns: Column[];
stage: Stage;
layer: Layer;
dataSource: TableDataSource;
// =========== 虚拟表格实现 ===========
// 滚动相关属性
scrollTop: number = 0;
scrollLeft: number = 0;
maxScrollTop: number = 0;
maxScrollLeft: number = 0;
visibleRowCount: number = 0;
// 可见行列范围
visibleRows: Range = { start: 0, end: 0 };
visibleCols: Range = { start: 0, end: 0 };
// 表格可见宽高
visibleWidth: number;
visibleHeight: number;
constructor(config: VirtualTableConfig) {
const { container, columns, dataSource } = config;
this.columns = columns;
this.dataSource = dataSource;
this.visibleWidth = container.getBoundingClientRect().width;
this.visibleHeight = container.getBoundingClientRect().height;
this.visibleRowCount = Math.ceil(this.visibleHeight / ROW_HEIGHT);
this.maxScrollTop = Math.max(
0,
(this.rows - this.visibleRowCount) * ROW_HEIGHT
);
// 计算总列宽
const totalColWidth = this.columns.reduce((sum, col) => sum + col.width, 0);
this.maxScrollLeft = Math.max(0, totalColWidth - this.visibleWidth);
this.stage = new Konva.Stage({
container,
height: this.visibleHeight,
width: this.visibleWidth,
});
this.layer = new Konva.Layer();
this.stage.add(this.layer);
// 监听滚动事件
this.bindScrollEvent(container);
// 初始化调用
this.updateVisibleRange();
this.renderCells();
}
// 监听滚动事件
bindScrollEvent() {
this.updateVisibleRange();
this.renderCells();
}
// 计算可见行列范围
updateVisibleRange() {}
// 渲染可见范围内的 cell
renderCells() {}
}
export default VirtualTable;
计算可见行列范围
updateVisibleRange() {
// 计算可见行
const startRow = Math.floor(this.scrollTop / ROW_HEIGHT);
const endRow = Math.min(
startRow + this.visibleRowCount,
this.dataSource.length
);
this.visibleRows = { start: startRow, end: endRow };
// 计算可见列
let accumulatedWidth = 0;
let startCol = 0;
let endCol = 0;
// 计算开始列
for (let i = 0; i < this.columns.length; i++) {
const col = this.columns[i];
if (accumulatedWidth + col.width >= this.scrollLeft) {
startCol = i;
break;
}
accumulatedWidth += col.width;
}
// 计算结束列
accumulatedWidth = 0;
for (let i = startCol; i < this.columns.length; i++) {
const col = this.columns[i];
accumulatedWidth += col.width;
if (accumulatedWidth > this.visibleWidth) {
endCol = i + 1;
break;
}
}
this.visibleCols = {
start: startCol,
end: Math.min(endCol, this.columns.length),
};
}
滚动事件监听
/**
* 绑定滚动事件
*/
bindScrollEvent(container: HTMLDivElement) {
container.addEventListener("wheel", (e) => {
e.preventDefault();
this.handleScroll(e.deltaX, e.deltaY);
});
// 支持触摸滚动
let lastTouchY = 0;
let lastTouchX = 0;
container.addEventListener("touchstart", (e: TouchEvent) => {
const touch = e.touches?.[0];
if (touch) {
lastTouchY = touch.clientY;
lastTouchX = touch.clientX;
}
});
container.addEventListener("touchmove", (e: TouchEvent) => {
const touch = e.touches?.[0];
if (touch) {
const deltaY = lastTouchY - touch.clientY;
const deltaX = lastTouchX - touch.clientX;
this.handleScroll(deltaX, deltaY);
lastTouchY = touch.clientY;
lastTouchX = touch.clientX;
}
});
}
/**
* 处理滚动
*/
handleScroll(deltaX: number, deltaY: number) {
// 更新滚动位置
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();
}
单元格渲染逻辑
/**
* 获取指定行的 Y 坐标
* @param rowIndex - 行索引
* @returns Y 坐标值
*/
getRowY(rowIndex: number): number {
return rowIndex * ROW_HEIGHT;
}
/**
* 获取指定列的 X 坐标
* @param colIndex - 列索引
* @returns X 坐标值
*/
getColX(colIndex: number): number {
let x = 0;
for (let i = 0; i < colIndex; i++) {
const col = this.columns[i];
if (col) {
x += col.width;
}
}
return x;
}
renderCell(rowIndex: number, colIndex: number) {
const column = this.columns[colIndex];
if (!column) return;
// 计算坐标时考虑滚动偏移
const x = this.getColX(colIndex) - this.scrollLeft;
const y = this.getRowY(rowIndex) - this.scrollTop;
// 创建单元格
const group = new Konva.Group({
x,
y,
});
const rect = new Konva.Rect({
x: 0,
y: 0,
width: column.width,
height: ROW_HEIGHT,
fill: "#FFF",
stroke: "#ccc",
strokeWidth: 1,
});
// 创建文本
const text = new Konva.Text({
x: 8,
y: 8,
width: column.width - 16,
height: 16,
text: this.dataSource[rowIndex][colIndex],
fontSize: 14,
fill: "#000",
align: "left",
verticalAlign: "middle",
ellipsis: true,
});
group.add(rect);
group.add(text);
this.layer.add(group);
}
/**
* 渲染可见范围内的所有单元格
* 首先清除旧单元格,然后按行列重新渲染
*/
renderCells() {
this.layer.destroyChildren();
// 渲染数据行
for (
let rowIndex = this.visibleRows.start;
rowIndex <= this.visibleRows.end;
rowIndex++
) {
for (
let colIndex = this.visibleCols.start;
colIndex <= this.visibleCols.end;
colIndex++
) {
this.renderCell(rowIndex, colIndex);
}
}
}