🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

Konva.js 實現 騰訊文檔 多維表格

Konva.js 多維表格系統

基於 Konva.js 實現的高性能「多維表格系統」,支持大規模渲染、分組管理、篩選與排序等複雜功能,旨在構建類似騰訊文檔 / 飛書多維表格的交互體驗。


目錄


一、項目概述

本項目使用 Konva.js 實現一個高性能的二維/多維表格系統,支持:

  • 大規模表格渲染(行列可達數百萬級)
  • 按分組管理的數據展示
  • 多維度篩選與排序
  • 支持圖片、文本、狀態標籤等多類型單元格
  • 響應式佈局與虛擬滾動優化

該系統適用於「任務管理」「資源調度」「項目需求規劃」等複雜表格場景。


二、功能清單

功能模塊 描述
表格渲染 基於 Konva Layer + Group 分層渲染,實現單元格、邊框、背景高效繪製
分組管理 支持按分類分組顯示,組可展開/折疊
篩選功能 支持多列字段條件過濾(文本、數值、狀態)
排序功能 支持單列與多列排序邏輯
單元格類型 文本、圖片、狀態標籤、自定義渲染器
交互操作 選中、框選、多選、懸浮提示、滾動同步
虛擬滾動優化 僅渲染視口內元素,提升渲染性能
動態佈局 自適應行高、列寬及容器尺寸變化
批量更新機制 使用 requestAnimationFrame 實現批量繪製,避免重複渲染

三、核心架構設計

image.png

## 🎯 主控制器
- **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()` - 單元格點擊
    - `...` - 其他事件

四、模塊說明

1. Renderer 模塊

負責表格的可視化渲染邏輯:

  • 使用 Konva.Layer 管理背景層、內容層、交互層
  • 每一行或一組單元格使用 Konva.Group 表示
  • 支持增量渲染與批量更新
  • 透過矩陣坐標快速定位渲染區域

2. Model 模塊

  • 提供數據源抽象
  • 支持篩選、排序、分組聚合與動態更新
  • 透過觀察者模式與渲染層同步數據變更

3. Controller 模塊

  • 監聽用戶輸入事件(鼠標、滾動、拖拽)
  • 控制渲染隊列與更新節奏
  • 管理當前選中狀態與焦點單元格
  • 與 Model 層進行數據同步

五、關鍵機制

1. 分組渲染(Group Rendering)

  • 每個分組獨立使用一個 Konva.Group
  • 折疊後僅渲染組頭
  • 展開時批量加載子節點
  • 支持懶加載以優化性能

2. 虛擬滾動(Virtual Scrolling)

  • 計算可視區域內應渲染的行列
  • 減少內存佔用與重繪次數
  • 支持橫向與縱向滾動同步

3. 批量繪製(Batch Draw)

private _waitingForDraw = false;
private animQueue: Function[] = [];

public batchDraw() {
  if (!this._waitingForDraw) {
    this._waitingForDraw = true;
    requestAnimationFrame(() => {
      this.animQueue.forEach(fn => fn());
      this.animQueue = [];
      this._waitingForDraw = false;
    });
  }
}

六、實現過程

1. konva.js實現一個簡單的表格繪製 但是這樣太過於簡單了 如果行列數量巨大且需要擴展顯示 卡頓的比較嚴重。

// 繪製表格
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);
  }
}

2. 使用輔助類 VirtualTableHelpers 這個類中提供了一系列的函數,如下圖。透過offsetY和offsetX 以及 rowHeight colWidth 我們可以計算出當前可視區域 應該渲染哪些行 和 列。

image.png

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)
  };
}

3. 畫布分層 以及 兼容凍結行列。(相對有點難度)

  • 使用 bodyLayer 更新不那麼頻繁且渲染成本較高, 渲染 靜態表格數據。 使用featureLayer 渲染 用戶選區,橫縱滾動條,高亮等等用戶交互。
  • 為什麼這樣做 : bodyLayer 更新不那麼頻繁且渲染成本較高,featureLayer 更新渲染非常頻繁,bodyLayer 不受影響。
  • 我們定義一個 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);
    }
  • 接下來就是處理凍結行列,凍結行列我計劃分成四塊區域 | 或者說四個組 來渲染,如圖,區域 | 組可以使用layer | group來劃分,這裡我採用konva.group來劃分,因為官網說了不建議過多的layer。
    image.png
  • 固定行凍結為一列 列假設設置成4列 計算出凍結區域 這樣我們就為每一個group 確定了應有的寬高,拖動行列滾動條的時候 就只需要更新 右下角的group 就可以了,也需要重新計算bottomRight可視區域內的行列起始行列,為什麼不需要處理其他三個區域呢 因為凍結不能凍結超出螢幕以外的行列。
    
    // 創建四個分組
    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();
    }

    image.png

  • 這裡有兩個小點需要注意一下
    1. dragmove的時候我們只需要更新 groupRight 內的行列即可 不需要針對整個畫布, 優化渲染。
    2. dragmove會頻繁調用render去更新 groupRight內容, 這裡需要優化一下
    3. 透過 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 = '計算中...';
}
  • 統計之類的使用異步分片 ,可以動態控制fps的變化 來控制處理數據量 。

image.png


八、擴展性

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

image.png

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

實現起來也很簡單

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);
  }
}

生成選區節點 監聽事件並且更新選區即可。還有一個邊界就不細說了

image.png


九、結語

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


原文出處:https://juejin.cn/post/7563248345118097427


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝28   💬4   ❤️7
840
🥈
我愛JS
📝2   💬8   ❤️2
113
🥉
御魂
💬1  
4
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付