Konvajs实现虚拟表格

287 阅读3分钟

这是一个专栏 从零实现多维表格,此专栏将带你一步步实现一个多维表格,缓慢更新中

本文涉及的代码

虚拟表格

虚拟表格(Virtual Table) 是一种优化技术,用于处理大量数据时的性能问题。它只渲染当前可见区域(视口)内的表格单元格,而不是渲染整个表格的所有数据。

实现原理

一个简单的虚拟表格实现主要包括以下两点(注:一个完善的虚拟表格需要关注的方面更多,这里只讨论核心实现,后续的优化项会在本专栏的后续文章中实现)

  1. 按需渲染:只创建和渲染用户当前能看到的数据行和列
  2. 滚动监听:监听容器滚动事件,动态计算新的可见范围

代码大纲

基于上述原理,我们可以写出如下代码:

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

测试

我们让 ai 为我们生成一份2000*20的测试数据

const genColumnsOptimized = () => {
    return Array.from({ length: 20 }, (_, index) => ({
      title: `列 ${index + 1}`,
      width: 180,
    }));
  };

  const genRealisticDataSource = () => {
    const firstNames = [
      "张",
      "李",
      "王",
      "刘",
      "陈",
      "杨",
      "赵",
      "黄",
      "周",
      "吴",
    ];
    const lastNames = [
      "明",
      "华",
      "强",
      "伟",
      "芳",
      "娜",
      "磊",
      "军",
      "杰",
      "静",
    ];
    const departments = [
      "技术部",
      "销售部",
      "市场部",
      "人事部",
      "财务部",
      "研发部",
    ];

    return Array.from({ length: 2000 }, (_, rowIndex) => [
      `EMP${String(rowIndex + 1).padStart(5, "0")}`, // 员工编号
      `${firstNames[rowIndex % firstNames.length]}${
        lastNames[rowIndex % lastNames.length]
      }`, // 姓名
      `${20 + (rowIndex % 40)}岁`, // 年龄
      departments[rowIndex % departments.length], // 部门
      `${(3000 + (rowIndex % 7000)).toLocaleString()}元`, // 薪资
      `${(rowIndex % 5) + 1}年`, // 工龄
      `项目${(rowIndex % 10) + 1}`, // 当前项目
      `${80 + (rowIndex % 20)}%`, // 绩效
      rowIndex % 3 === 0 ? "在职" : rowIndex % 3 === 1 ? "休假" : "离职", // 状态
      `level-${(rowIndex % 7) + 1}`, // 职级
      `team-${(rowIndex % 8) + 1}`, // 团队
      `${90 + (rowIndex % 10)}分`, // 评分
      `skill-${(rowIndex % 6) + 1}`, // 技能
      `city-${(rowIndex % 10) + 1}`, // 城市
      `202${rowIndex % 4}-${String((rowIndex % 12) + 1).padStart(
        2,
        "0"
      )}-${String((rowIndex % 28) + 1).padStart(2, "0")}`, // 入职日期
      `phone-${13000000000 + rowIndex}`, // 电话
      `email${rowIndex}@company.com`, // 邮箱
      `addr-${(rowIndex % 20) + 1}`, // 地址
      `note-${rowIndex}`, // 备注
      `ext-${rowIndex}`, // 扩展信息
    ]);
  };

new VirtualTable({
    container: document.querySelector('.table'),
    columns: genColumnsOptimized(),
    dataSource: genRealisticDataSource(),
});

20251201094859.gif

本文涉及的代码