稀土掘金技术社区 11月05日 09:58
Konva.js 多维表格系统:高性能实现指南
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文详细介绍了如何使用 Konva.js 构建一个高性能的多维表格系统。文章从项目概述出发,列举了系统的核心功能,包括大规模渲染、分组管理、筛选排序及多种单元格类型支持。重点阐述了核心架构设计,将表格划分为图层和分组,并介绍了数据管理、布局系统和事件处理机制。文中深入探讨了分组渲染、虚拟滚动和批量绘制等关键技术,并分享了实现多维表格分组功能的具体过程和挑战,包括画布分层、冻结行列的处理以及分组逻辑的实现,最终展示了如何通过精巧的架构设计和优化,实现类似腾讯文档/飞书的交互体验。

📊 **高性能渲染与虚拟滚动**:该系统基于 Konva.js,采用分层渲染(背景、主体、特性层)和分组管理(左上、右上、左下、右下)来处理表格的视觉呈现。通过虚拟滚动技术,仅渲染视口内的元素,有效解决了大规模数据渲染时的性能瓶颈,支持数百万级别的行列渲染。

🗂️ **灵活的分组与筛选排序**:系统支持按分类对数据进行分组展示,并允许用户展开或折叠分组。同时,提供了强大的筛选功能,支持多列字段(文本、数值、状态)的条件过滤,以及单列与多列的排序逻辑,极大地增强了数据的组织和检索能力。

🎨 **多样的单元格类型与交互**:支持文本、图片、状态标签等多种单元格类型,并允许自定义渲染器。在交互方面,实现了选中、框选、多选、悬浮提示、滚动同步等功能,为用户提供了丰富且流畅的操作体验。

🚀 **核心架构与关键机制**:系统架构围绕一个主控制器,结合图层系统、分组系统、数据管理(如 LinearRowsManager)、布局系统(如 CellLayout)和事件处理。关键机制包括分组渲染(每个分组使用独立 Konva.Group)、虚拟滚动(减少内存占用)和批量绘制(通过 requestAnimationFrame 合并绘制请求),共同保障了系统的性能和响应速度。

原创 热心市民小岳 2025-11-05 08:30 重庆

点击关注公众号,技术干货及时达!

Konva.js 多维表格系统基于 「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()` - 单元格点击
        - `...` - 其他事件

    四、模块说明1. Renderer 模块负责表格的可视化渲染逻辑:

    使用 Konva.Layer 管理背景层、内容层、交互层

    每一行或一组单元格使用 Konva.Group 表示

    支持增量渲染与批量更新

    通过矩阵坐标快速定位渲染区域

    2. Model 模块提供数据源抽象

    支持筛选、排序、分组聚合与动态更新

    通过观察者模式与渲染层同步数据变更

    3. Controller 模块监听用户输入事件(鼠标、滚动、拖拽)

    控制渲染队列与更新节奏

    管理当前选中状态与焦点单元格

    与 Model 层进行数据同步

    五、关键机制1. 分组渲染(Group Rendering)每个分组独立使用一个 Konva.Group

    折叠后仅渲染组头

    展开时批量加载子节点

    支持懒加载以优化性能

    2. 虚拟滚动(Virtual Scrolling)计算可视区域内应渲染的行列

    减少内存占用与重绘次数

    支持横向与纵向滚动同步

    3. 批量绘制(Batch Draw)

      ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
      private _waitingForDraw = false;
      private animQueueFunction[] = [];

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

      六、实现过程1. konva.js实现一个简单的表格绘制 但是这样太过于简单了 如果行列数量巨大且需要扩展显示 卡顿的比较严重。

        ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
           // 绘制表格
            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',
                  strokeWidth1
                });

                // 将矩形添加到图层
                layer.add(rect);
              }
            }

        2.使用辅助类 VirtualTableHelpers 这个类中提供了一系列的函数,如下图。通过offsetY和offsetX  以及 rowHeight colWidth 我们可以计算出当前可视区域 应该渲染哪些row 和 column。

          ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
            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)
              };
            }

             getVisibleColRange(frozenColsWidth: number): { start: number; end: number } {
              const viewportWidth = this.visibleWidth - frozenColsWidth - this.scrollBarSize;
              const startCol = VirtualTableHelpers.getColumnStartIndexForOffset({
                itemType: "column",
                rowHeight: this.getRowHeight,
                columnWidth: this.getColumnWidth,
                rowCount: this.rows,
                columnCount: this.cols,
                instanceProps: this.instanceProps,
                offset: this.scrollX
              });

              const endCol = VirtualTableHelpers.getColumnStopIndexForStartIndex({
                startIndex: Math.max(this.frozenCols, startCol),
                rowHeight: this.getRowHeight,
                columnWidth: this.getColumnWidth,
                instanceProps: this.instanceProps,
                containerWidth: viewportWidth,
                scrollLeft: this.scrollX,
                columnCount: this.cols
              });

              const buffer = 2;
              return {
                start: Math.max(this.frozenCols, startCol - buffer),
                end: Math.min(this.cols - 1, endCol + buffer)
              };
            }

                //   渲染一下即可看到效果
                // 渲染单元格 - 从 leftPadding 开始
              for (let row = visibleRows.start; row <= visibleRows.end; row++) {
                const height = this.getRowHeight(row);

                if (currentY + height >= frozenRowsHeight &&
                  currentY <= this.visibleHeight - this.scrollBarSize) {
                  let currentX = this.leftPadding;
                  for (let col = 0; col < this.frozenCols; col++) {
                    const width = this.getColumnWidth(col);
                    const cell = this.createCell(row, col, currentX, currentY, width, height);
                    this.groups.bottomLeft.add(cell);
                    currentX += width;
                  }
                }

                currentY += height;
              }
            }

          填充一点数据即可 到这里一个高性能的表格也就成了 但是我们是要实现多维表格 还有段距离。3. 画布分层 以及 兼容冻结行列。(相对有点难度 )使用 bodyLayer更新不那么频繁且渲染成本较高, 渲染 静态表格数据。 使用featureLayer渲染 用户选区,横纵滚动条,高亮等等用户交互。

          为什么这样做 : bodyLayer更新不那么频繁且渲染成本较高,featureLayer更新渲染非常频繁,bodyLayer不受影响。

          我们定义一个Canvas类专门处理画布,首先创建画布

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
              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

            「固定行冻结为一列 列假设设置成4列 计算出冻结区域」 这样我们就为每一个group确定了应有的宽高,拖动行列滚动条的时候 就只需要更新 右下角的group就可以了,也需要重新计算bottomRight可视区域内的行列起始行列,为什么不需要处理其他三个区域呢 因为冻结不能冻结超出屏幕以外的行列。

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
                // 创建四个分组
                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))

                裁剪一下实现冻结的效果
               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渲染起点。测试结果

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
            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();
              }

              public setupScrollBarEvents() {
                let isDraggingHScroll = false;

                // 水平滚动条事件
                this.hScrollThumb.on("mousedown touchstart", () => {
                  isDraggingHScroll = true;
                });

                this.hScrollThumb.on("dragmove touchmove", () => {
                  if (isDraggingHScroll) {
                    const frozenColsWidth = this.core.getFrozenColsWidth();
                    const scrollableWidth =
                      this.canvas.visibleWidth - frozenColsWidth - this.config.scrollBarSize;
                    const thumbWidth = this.hScrollThumb.width();

                    let thumbX = this.hScrollThumb.x();
                    thumbX = Math.max(
                      frozenColsWidth,
                      Math.min(thumbX, frozenColsWidth + scrollableWidth - thumbWidth)
                    );
                    this.hScrollThumb.x(thumbX);
                    this.hScrollThumb.y(this.canvas.visibleHeight - this.config.scrollBarSize + 1); // 固定Y坐标

                    if (scrollableWidth > thumbWidth) {
                      const scrollRatio =
                        (thumbX - frozenColsWidth) / (scrollableWidth - thumbWidth);
                      this.scrollX = scrollRatio * this.maxScrollX;
                      this.core.render.batchDraw();
                    }
                  }
                });

                this.canvas.stage.on("mouseup touchend", () => {
                  isDraggingHScroll = false;
                  // isDraggingVScroll = false;
                });

            *   这里有两个小点需要注意一下

            *   1.  dragmove的时候我们只需要更新 groupRight 内的行列即可 不需要针对整个画布, 优化渲染。

            *   2.  dragmove会频繁调用render去更新 groupRight内容, 这里需要优化一下

            *   通过 `requestAnimationFrame` 和队列机制将多个绘制请求合并,确保在同一帧内只执行一次实际的绘制操作,从而避免了不必要的多次渲染,提升了性能

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
            private _waitingForDraw = false;
              private animQueue = [] as Array<Function>;

              public batchDraw() {
                if (!this._waitingForDraw) {
                  this._waitingForDraw = true;
                  this.requestAnimFrame(() => {
                    this.render();
                    this._waitingForDraw = false;
                  });
                }
                return this;
              }

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

            4.分组的实现 ( 难点 )1.前期准备先来调研一下腾讯文档的 我们就光靠这个效果需要分析出如何实现分组的效果

            经过苦思冥想, 想出来两套方案。最终我选择了第二种,因为我对于第二种的思路好打开一点

            ounter(lineounter(lineounter(line
              定义好嵌套数据格式
              1 通过每一个嵌套数据,一组一组的渲染,对于需要结合可视区域渲染来说 稍有难度。
              2.不要被嵌套给影响 还是一行一行的渲染 ,分组头我也按照行来渲染 只不过需要控制行缩进

            一组一组的渲染的效果

            一行一行的渲染 ( 我在使用这种的方案实现的时候 没有经过太多的困难 => 比较推荐)

            2.定义处理分组的类

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
             先定义行存在哪些类型
             export enum CellType {
              Record = 'Record',
              GroupTab = 'GroupTab',
              AddRow = 'AddRow',
              BlankRow = 'BlankRow',
              Header = 'Header'
            }

            // 处理分组的类
            export class LinearRowsManager {
              private linearRowsILinearRow[] = [];
              private rawDataany[][] = [];
              private groupConfig: { fieldstringenabledboolean } = { field''enabledfalse };


              private groupStates = new Map<stringboolean>(); // 保存分组展开状态

              constructor(dataany[][]) {
                this.rawData = data;
              }

              // 支持多级分组的构建方法
              buildLinearRows(groupFields?: string[]): ILinearRow[] {
                this.linearRows = [];
                this.groupConfig = {
                  fields: groupFields || [],
                  enabled: !!(groupFields && groupFields.length > 0)
                };

                // 表头行(始终存在)
                this.linearRows.push({
                  typeCellType.Header,
                  recordId'header-row',
                  depth0,
                  dataIndex0,
                  displayIndex0
                } as ILinearRowHeader);

                if (!this.groupConfig.enabled) {
                  // 无分组:直接映射所有数据行
                  for (let i = 1; i < this.rawData.length; i++) {
                    this.linearRows.push({
                      typeCellType.Record,
                      recordId`record-${i}`,
                      depth0,
                      dataIndex: i,
                      displayIndex: i
                    } as ILinearRowRecord);
                  }
                } else {
                  // 多级分组:递归构建分组树
                  this.buildMultiLevelGroups();
                }

                return this.linearRows;
              }

            class中还定义了一些其他方法

            经过分组class的处理 我们的数据变成了这样 我们在render中处理的时候就不要处理行列数据了 直接拿这个分组生成的数据来渲染。

            在render中开启分组 this.enableMultiLevelGrouping(['负责人', '开始时间', '状态']); 看下 groupRight如何渲染的, 核心思维就是要生成 createCell

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
              // 
              renderBottomRight() {
                console.time('renderBottomRight');
                const frozenColsWidth = this.getFrozenColsWidth();
                const frozenRowsHeight = this.getFrozenRowsHeight();

                const visibleRows = this.getVisibleRowRange(frozenRowsHeight);
                if (visibleRows.start > visibleRows.end) {
                  console.timeEnd('renderBottomRight');
                  return;
                }

                const visibleCols = this.getVisibleColRange(frozenColsWidth);
                if (visibleCols.start > visibleCols.end) {
                  console.timeEnd('renderBottomRight');
                  return;
                }

                // 先绘制背景
                this.renderLevel1GroupBackgrounds(visibleRows, frozenColsWidth, frozenRowsHeight, 'bottomRight');
                this.renderLevel2GroupBackgrounds(visibleRows, frozenColsWidth, frozenRowsHeight, 'bottomRight');

                const colPositions = this.preCalculateColPositions(visibleCols, frozenColsWidth);

                let currentY = frozenRowsHeight - this.scrollY;
                for (let row = this.frozenRows; row < visibleRows.start; row++) {
                  currentY += this.getRowHeight(row);
                }

                for (let row = visibleRows.start; row <= visibleRows.end; row++) {
                  const height = this.getRowHeight(row);

                  for (let i = 0, col = visibleCols.start; col <= visibleCols.end; col++, i++) {
                    const width = this.getColumnWidth(col);
                    const x = colPositions[i];

                    const cell = this.createCell(row, col, x, currentY, width, height);
                    this.groups.bottomRight.add(cell);
                  }

                  currentY += height;
                }

                console.timeEnd('renderBottomRight');
              }

            设定一个createCell函数 你要返回一个正确的 cell group出来,需要处理 枚举的五种类型的渲染方式。 五种类型的渲染方式各有不同 我们可以通过布局思想来定义一下,分别处理渲染。

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
             export enum CellType {
              Record = 'Record',
              GroupTab = 'GroupTab',
              AddRow = 'AddRow',
              BlankRow = 'BlankRow',
              Header = 'Header'
            }

            先定义一个基类

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
            export abstract class CellLayout {
              protected ctxCanvasRenderingContext2D;
              protected xnumber = 0;
              protected ynumber = 0;
              protected widthnumber = 0;
              protected heightnumber = 0;

              setPosition(xnumberynumberwidthnumberheightnumber) {
                this.x = x;
                this.y = y;
                this.width = width;
                this.height = height;
              }

              abstract render(rowILinearRowcolnumberisFirstColbooleanisLastColboolean): Konva.Group;
            }

            后续五种行布局也需要定义出来

            以分组行布局来举例。 主要是处理分组行的缩进 以及折叠的事件 后续还要扩展 统计之类的功能。

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
            class GroupTabLayout extends CellLayout {
              render(row: ILinearRow, col: number, isFirstColboolean, isLastColboolean): Konva.Group {
                const group = new Konva.Group({ x: this.x, y: this.y });
                const groupRow = row as ILinearRowGroup;
                const depth = row.depth;

                const bgColor = this.getGroupBackground(depth);
                const indentOffset = depth * 20; // 根据层级缩进

                if (isFirstCol) {
                  // 第一列:展开/折叠图标和分组标题
                  const bgRect = new Konva.Rect({
                    x: indentOffset,
                    y0,
                    width: this.width - indentOffset,
                    height: this.height,
                    fill: bgColor,
                    stroke'#e0e0e0',
                    strokeWidth0.5
                  });

                  // 只有非最后一层的分组才显示展开/折叠图标
                  const showExpandIcon = groupRow.children && groupRow.children.some(child =>
                    child.type === CellType.GroupTab
                  );

                  const icon = new Konva.Text({
                    x: indentOffset + 15,
                    y: this.height / 2,
                    text: showExpandIcon ? (groupRow.expanded ? '▼' '▶') : '',
                    fontSize8,
                    fill'#4a5568',
                    listening: showExpandIcon
                  });

                  const titleText = new Konva.Text({
                    x: indentOffset + (showExpandIcon ? 30 15),
                    y: this.height / 2 - 5,
                    text: `${groupRow.groupTitle} (${groupRow.recordCount})`,
                    fontSize8,
                    fill'#2d3748',
                    fontWeight'bold'
                  });

                  if (showExpandIcon) {
                    bgRect.on('click', () => {
                      bgRect.fire('toggleGroup', { groupId: groupRow.groupId });
                    });
                    icon.on('click', () => {
                      icon.fire('toggleGroup', { groupId: groupRow.groupId });
                    });
                  }

                  group.add(bgRect, icon, titleText);
                } else if (col === 1) {
                  // 第二列:分组字段信息
                  const bgRect = new Konva.Rect({
                    width: this.width,
                    height: this.height,
                    fill: bgColor,
                    stroke'#e0e0e0',
                    strokeWidth0.5
                  });

                  const fieldLabel = new Konva.Text({
                    x10,
                    y6,
                    text: `${this.getGroupFieldLabel(depth)}: ${groupRow.groupField}`,
                    fontSize7,
                    fill'#718096'
                  });

                  const valueLabel = new Konva.Text({
                    x10,
                    y: this.height / 2 + 2,
                    text: groupRow.groupTitle,
                    fontSize13,
                    fontWeight'bold',
                    fill'#2d3748'
                  });

                  group.add(bgRect, fieldLabel);
                } else {
                  // 其他列:空背景
                  const bgRect = new Konva.Rect({
                    width: this.width,
                    height: this.height,
                    fill: bgColor,
                    stroke'#e0e0e0',
                    strokeWidth0.5
                  });
                  group.add(bgRect);
                }

                return group;
              }

              private getGroupBackground(depth: number): string {
                // const colors = ['#f7fafc''#edf2f7''#e2e8f0''#cbd5e0'];
                const colors = ['#FFF''#F5F5F5''#FFF''#cbd5e0'];
                return colors[Math.min(depth, colors.length - 1)];
              }

              private getGroupFieldLabel(depth: number): string {
                const labels = ['一级分组''二级分组''三级分组'];
                return labels[Math.min(depth, labels.length - 1)] || '分组';
              }

            }

            全部定义好了 全部在 render类中实例化

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
                this.linearRowsManager = new LinearRowsManager(this.data);
                this.linearRowsManager.buildLinearRows(); // 初始无分组

                // 初始化布局渲染器
                this.groupTabLayout = new GroupTabLayout();
                this.recordRowLayout = new RecordRowLayout(
                  this.data,
                  this.colsConfig
                );
                this.addRowLayout = new AddRowLayout(); 
                this.blankRowLayout = new BlankRowLayout();
                this.headerLayout = new headerLayout(this.iconManager, this.columns);

            正确处理后 生成单元格

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
              createCell(rowIndex: number, col: number, x: number, y: number, width: number, height: number): Konva.Group {
                const row = this.linearRowsManager.getRow(rowIndex);
                if (!row) return new Konva.Group();

                const isFirstCol = col === 0;
                const headerRow = rowIndex === 0;
                const isLastCol = col === this.cols - 1;

                let layout: CellLayout;
                switch (row.type) {
                  case CellType.GroupTab:
                    layout = this.groupTabLayout;
                    break;
                  case CellType.AddRow:
                    layout = this.addRowLayout;
                    break;
                  case CellType.BlankRow:
                    layout = this.blankRowLayout;
                    break;
                  case CellType.Header:
                    layout = this.headerLayout;
                    break;
                  case CellType.Record:
                    layout = this.recordRowLayout;
                    break;
                  default:
                    layout = this.recordRowLayout;
                    break;
                }

                layout.setPosition(x, y, width, height);
                const cellGroup = layout.render(row, col, isFirstCol, isLastCol);

                // 监听分组折叠事件
                if (row.type === CellType.GroupTab) {
                  cellGroup.on('toggleGroup', (e: any) => {
                    this.linearRowsManager.toggleGroup(e.groupId);
                    this.render();
                  });
                }

                // 监听 AddRow 点击事件
                if (row.type === CellType.AddRow) {
                  cellGroup.on('addRow', (e: any) => {
                    this.handleAddRow(e.groupId);
                  });
                }

                return cellGroup;
              }

            来看下渲染结果

            七、性能优化cell单元格的值: 可能会在公式 引用之类的大数据量计算,这里我使用web worker。

            数据统计和筛选,排序,查找这些使用 异步分片来实现。

            多维表格中的 icon及image的缓存与复用等等...

            example:在单元格渲染时计算复杂计算时 使用web worker来计算 然后textNode.text(result)实现单个单元格更新

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
             // 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[1as Konva.Text;
                      textNode.text(result);
                    }
                  })
                  textContext = '计算中...';
                }

            统计之类的使用异步分片 ,可以动态控制fps的变化 来控制处理数据量 。

            八、扩展性还有很多需要继续去实现和扩展,高亮 选区 列类型生成不同的单元格 等等...

            再举个扩展的例子:用户选区

            实现起来也很简单

            ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line
            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)",
                  visiblefalse,
                  listeningfalse
                };

                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",
                  strokeWidth1,
                  visiblefalse,
                  listeningfalse,
                }) as any;

                this.activeCellBorder = new Konva.Rect({
                  fill"transparent",
                  stroke"#1e6fff",
                  strokeWidth2,
                  visiblefalse,
                  listeningfalse,
                });
              }

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

                // 6. 活动单元格边框
                this.activeCellBorder.setAttrs({
                  x: activeCellX  + 1,
                  y: activeCellY  + 1,
                  width: activeCellWidth - 1,
                  height: activeCellHeight - 1,
                  visibletrue
                });
              }

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

            生成选区节点 监听事件并且更新选区即可。还有一个边界就不细说了

            结语存在疑问的可以留言,还有许多功能需要开发 打磨,有进展了再发文章。 这篇文章主要帮大家打开思路, 一步一步地解决一个困难功能的实现。

            ""~

            阅读原文

            跳转微信打开

            Fish AI Reader

            Fish AI Reader

            AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

            FishAI

            FishAI

            鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

            联系邮箱 441953276@qq.com

            相关标签

            Konva.js 多维表格 前端开发 高性能渲染 虚拟滚动 分组管理 JavaScript UI组件 Multidimensional Table Frontend Development High-Performance Rendering Virtual Scrolling Group Management JavaScript UI Components
            相关文章