基於 Konva.js 實現的高性能「多維表格系統」,支持大規模渲染、分組管理、篩選與排序等複雜功能,旨在構建類似騰訊文檔 / 飛書多維表格的交互體驗。
本項目使用 Konva.js 實現一個高性能的二維/多維表格系統,支持:
該系統適用於「任務管理」「資源調度」「項目需求規劃」等複雜表格場景。
| 功能模塊 | 描述 |
|---|---|
| 表格渲染 | 基於 Konva Layer + Group 分層渲染,實現單元格、邊框、背景高效繪製 |
| 分組管理 | 支持按分類分組顯示,組可展開/折疊 |
| 篩選功能 | 支持多列字段條件過濾(文本、數值、狀態) |
| 排序功能 | 支持單列與多列排序邏輯 |
| 單元格類型 | 文本、圖片、狀態標籤、自定義渲染器 |
| 交互操作 | 選中、框選、多選、懸浮提示、滾動同步 |
| 虛擬滾動優化 | 僅渲染視口內元素,提升渲染性能 |
| 動態佈局 | 自適應行高、列寬及容器尺寸變化 |
| 批量更新機制 | 使用 requestAnimationFrame 實現批量繪製,避免重複渲染 |

## 🎯 主控制器
- **table**
- 圖層系統
- `backgroundLayer` - 背景層
- `bodyLayer` - 主體層
- `featureLayer` - 特性層
- 分組系統
- `topLeft Group` - 左上凍結區域
- `topRight Group` - 右上凍結區域
- `bottomLeft Group` - 左下凍結區域
- `bottomRight Group` - 右下凍結區域
- 數據管理
- **LinearRowsManager**
- `linearRows: ILinearRow[]` - 線性行數據
- `buildLinearRows()` - 構建行數據
- `toggleGroup()` - 切換分組狀態
-佈局系統
- **CellLayout** (抽象基類)
- `GroupTabLayout` - 分組行佈局
- `RecordRowLayout` - 數據行佈局
- `AddRowLayout` - 添加行佈局
- `BlankRowLayout` - 空白行佈局
- `headerLayout` - 表頭佈局
- 工具類
- **VirtualTableHelpers**
- `getItemMetadata()` - 獲取項目元數據
- `findNearestItem()` - 查找最近項目
- `...` - 其他輔助方法
- 事件處理
- `setupEvents()` - 初始化事件
- `scroll()` - 滾動處理
- `handleCellClick()` - 單元格點擊
- `...` - 其他事件
負責表格的可視化渲染邏輯:
Konva.Layer 管理背景層、內容層、交互層Konva.Group 表示Konva.Groupprivate _waitingForDraw = false;
private animQueue: Function[] = [];
public batchDraw() {
if (!this._waitingForDraw) {
this._waitingForDraw = true;
requestAnimationFrame(() => {
this.animQueue.forEach(fn => fn());
this.animQueue = [];
this._waitingForDraw = false;
});
}
}
// 繪製表格
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 10; col++) {
// 創建每個單元格
const rect = new Konva.Rect({
x: col * cellSize,
y: row * cellSize,
width: cellSize,
height: cellSize,
fill: 'lightgrey',
stroke: 'black',
strokeWidth: 1
});
// 將矩形添加到圖層
layer.add(rect);
}
}
VirtualTableHelpers 這個類中提供了一系列的函數,如下圖。透過offsetY和offsetX 以及 rowHeight colWidth 我們可以計算出當前可視區域 應該渲染哪些行 和 列。
getVisibleRowRange(frozenRowsHeight: number): { start: number; end: number } {
const rowCount = this.linearRowsManager.getRowCount();
const viewportHeight = this.visibleHeight - frozenRowsHeight - this.scrollBarSize;
// 使用二分查找找到起始行(O(log n))
const startRow = VirtualTableHelpers.getRowStartIndexForOffset({
itemType: "row",
rowHeight: this.getRowHeight,
columnWidth: this.getColumnWidth,
rowCount: rowCount,
columnCount: this.cols,
instanceProps: this.instanceProps,
offset: this.scrollY
});
// 基於起始行計算結束行(增量計算)
const endRow = VirtualTableHelpers.getRowStopIndexForStartIndex({
startIndex: Math.max(this.frozenRows, startRow),
rowCount: rowCount,
rowHeight: this.getRowHeight,
columnWidth: this.getColumnWidth,
scrollTop: this.scrollY,
containerHeight: viewportHeight,
instanceProps: this.instanceProps
});
// 添加緩衝區(預渲染上下各 2 行)
const buffer = 2;
return {
start: Math.max(this.frozenRows, startRow - buffer),
end: Math.min(rowCount - 1, endRow + buffer)
};
}
我們定義一個 Canvas 類專門處理畫布,首先創建畫布
setupLayers() {
this.backgroundLayer = new Konva.Layer({ name: 'backgroundLayer' });
this.bodyLayer = new Konva.Layer({ name: 'bodyLayer' });
this.featureLayer = new Konva.Layer({ name: 'featureLayer' });
this.stage.add(this.backgroundLayer, this.bodyLayer, this.featureLayer);
}

// 創建四個分組
this.groups = {
topLeft: new Konva.Group(),
topRight: new Konva.Group(),
bottomLeft: new Konva.Group(),
bottomRight: new Konva.Group(),
};this.bodyLayer.add(...Object.values(this.groups))
- 裁剪一下實現凍結的效果
```javascript
setClipping() {
const frozenColsWidth = this.core.getFrozenColsWidth() + 1;
const frozenRowsHeight = this.core.getFrozenRowsHeight();
// 為每個Group設置裁剪
this.groups.topRight.clipFunc((ctx) => {
ctx.rect(
frozenColsWidth,
0,
this.visibleWidth - frozenColsWidth,
frozenRowsHeight
);
});
this.groups.bottomLeft.clipFunc((ctx) => {
ctx.rect(
0,
frozenRowsHeight,
frozenColsWidth,
this.visibleHeight - frozenRowsHeight
);
});
this.groups.bottomRight.clipFunc((ctx) => {
ctx.rect(
frozenColsWidth,
frozenRowsHeight,
this.visibleWidth - frozenColsWidth,
this.visibleHeight - frozenRowsHeight
);
});
}
滾動條不需要再講解了 在之前實現騰訊文檔甘特圖時已說過實現。定義一個HorizontalBarScrollbar類來測試一下 , 創建滾動條 , 並且註冊dragmove事件 更新offsetX從而來確定 顯示的行列以及 offsetX 渲染起點。測試結果
createScrollBars() {
const { canvas } = this;
// 創建滾動條背景和滑塊
this.hScrollBg = new Konva.Rect({
fill: "#f0f0f0",
stroke: "#e0e0e0",
strokeWidth: 1,
});
this.hScrollThumb = new Konva.Rect({
fill: "#cccecf",
cornerRadius: 4,
draggable: true,
});
// 添加到主圖層
canvas.bodyLayer.add(this.hScrollBg, this.hScrollThumb);
this.setupScrollBarEvents();
}

requestAnimationFrame 和隊列機制將多個繪製請求合併,確保在同一幀內只執行一次實際的繪製操作,從而避免了不必要的多次渲染,提升了性能
private _waitingForDraw = false;
private animQueue = [] as Array<Function>;public batchDraw() {
if (!this._waitingForDraw) {
this._waitingForDraw = true;
this.requestAnimFrame(() => {
this.render();
this._waitingForDraw = false;
});
}
}
private requestAnimFrame(callback: Function) {
this.animQueue.push(callback);
if (this.animQueue.length === 1) {
req(() => {
const queue = this.animQueue;
this.animQueue = [];
queue.forEach(function (cb) {
cb();
});
});
}
}
render() {
this.renderContent();
this.core.updateScrollBars();
}
---
## 七、性能優化
- cell單元格的值: 可能會在公式 引用之類的大數量計算,這裡我使用web worker。
- 數據統計和篩選,排序,查找這些使用 異步分片來實現。
- 多維表格中的 icon及image的快取與重用等等...
example:在單元格渲染時計算複雜計算時 使用web worker來計算 然後`textNode.text(result)`實現單個單元格更新
```javascript
// cacl(40)
if (textContext && textContext.includes('cacl')) {
this.alloyWorker.cookie.exportStaion(Math.random() * 10 >= 5 ? 40 : 39).then((result) => {
const groups = this.groups.bottomRight.find(`#${row}-${col}`) as Group[];
if (groups.length) {
const textNode = groups[0].children[1] as Konva.Text;
textNode.text(result);
}
});
textContext = '計算中...';
}

還有很多需要繼續去實現和擴展,高亮 選區 列類型生成不同的單元格 等等...

再舉個擴展的例子:用戶選區

實現起來也很簡單
export class SelectionNodeManager {
public topRect: Konva.Rect;
public rightRect: Konva.Rect;
public bottomRect: Konva.Rect;
public leftRect: Konva.Rect;
public selectionBorder: Konva.Rect;
public activeCellBorder: Konva.Rect;
constructor() {
const fillConfig = {
fill: "rgba(0, 123, 255, 0.1)",
visible: false,
listening: false,
};
this.topRect = new Konva.Rect(fillConfig);
this.rightRect = new Konva.Rect(fillConfig);
this.bottomRect = new Konva.Rect(fillConfig);
this.leftRect = new Konva.Rect(fillConfig);
this.selectionBorder = new ThinBorderRect({
fill: "transparent",
stroke: "#1e6fff",
strokeWidth: 1,
visible: false,
listening: false,
}) as any;
this.activeCellBorder = new Konva.Rect({
fill: "transparent",
stroke: "#1e6fff",
strokeWidth: 2,
visible: false,
listening: false,
});
}
update({
selectionX,
selectionY,
selectionWidth,
selectionHeight,
activeCellX,
activeCellY,
activeCellWidth,
activeCellHeight
}) {
// 計算相對位置
const relX = activeCellX - selectionX;
const relY = activeCellY - selectionY;
// 1. 上方區域
this.topRect.setAttrs({
x: selectionX,
y: selectionY,
width: selectionWidth,
height: relY,
visible: relY > 0
});
// 2. 右側區域
const rightWidth = selectionWidth - (relX + activeCellWidth);
this.rightRect.setAttrs({
x: activeCellX + activeCellWidth,
y: activeCellY,
width: rightWidth,
height: activeCellHeight,
visible: rightWidth > 0
});
// 3. 下方區域
const bottomHeight = selectionHeight - (relY + activeCellHeight);
this.bottomRect.setAttrs({
x: selectionX,
y: activeCellY + activeCellHeight,
width: selectionWidth,
height: bottomHeight,
visible: bottomHeight > 0
});
// 4. 左側區域
this.leftRect.setAttrs({
x: selectionX,
y: activeCellY,
width: relX,
height: activeCellHeight,
visible: relX > 0
});
// 5. 整體邊框
this.selectionBorder.setAttrs({
x: selectionX,
y: selectionY,
width: selectionWidth,
height: selectionHeight,
visible: true
});
// 6. 活動單元格邊框
this.activeCellBorder.setAttrs({
x: activeCellX + 1,
y: activeCellY + 1,
width: activeCellWidth - 1,
height: activeCellHeight - 1,
visible: true
});
}
hide() {
this.topRect.visible(false);
this.rightRect.visible(false);
this.bottomRect.visible(false);
this.leftRect.visible(false);
this.selectionBorder.visible(false);
this.activeCellBorder.visible(false);
}
}
生成選區節點 監聽事件並且更新選區即可。還有一個邊界就不細說了

存在疑問的可以留言,還有許多功能需要開發 打磨,有進展了再發文章。 這篇文章主要幫大家打開思路, 一步一步地解決一個困難功能的實現。