虚拟表格支持固定表头和固定列

111 阅读4分钟

这是一个专栏: 从零实现多维表格,将带你一步步实现一个多维表格,持续更新中。

本文涉及的代码

冻结行列的实现

在上一篇文章 Konva.js 实现虚拟表格中,我们实现了虚拟滚动。但作为一个完整的表格,当表格数据行列过多时,应该支持冻结行、列功能以方便用户查看和编辑数据。

要实现冻结功能,我们首先需要将表格进行分区。经过分析,可以将表格分成四个独立的区域:

  1. 表头 + 冻结列区域(红色):无论表格如何滚动,该部分都保持不动
  2. 表头 + 非冻结列区域(绿色):仅在水平滚动时更新
  3. 非表头 + 冻结列区域(蓝色):仅在垂直滚动时更新
  4. 内容区域(黄色):在水平和垂直滚动时都需要更新

image.png

核心实现

1. 创建四个 Group 来表示表格的四个区域

首先定义 Groups 类型并创建四个 Group 实例来分别管理表格的四个区域:

type Groups = {
  topLeft: Konva.Group;
  topRight: Konva.Group;
  bottomLeft: Konva.Group;
  bottomRight: Konva.Group;
};

this.groups = {
  topLeft: new Konva.Group(),
  topRight: new Konva.Group(),
  bottomLeft: new Konva.Group(),
  bottomRight: new Konva.Group(),
};

2. 初始化 Groups 并设置裁剪区域

/**
 * 初始化四个区域的 Group
 * 按照从下到上的顺序添加,确保冻结区域在上层
 */
initGroup() {
  this.layer.add(this.groups.bottomRight);
  this.layer.add(this.groups.topRight);
  this.layer.add(this.groups.bottomLeft);
  this.layer.add(this.groups.topLeft);
  this.setClipping();
}

/**
 * 获取冻结列的总宽度(包括第一个冻结列)
 */
getFrozenColsWidth(): number {
  let frozenIndex = this.columns.findIndex((item) => item.lock);
  if (frozenIndex === -1) {
    frozenIndex = 0;
  }

  let frozenColsWidth = 0;
  this.columns
    .filter((_, index) => index <= frozenIndex)
    .forEach((item) => (frozenColsWidth += item.width));

  return frozenColsWidth;
}

/**
 * 获取冻结行的高度
 */
getFrozenRowsHeight(): number {
  return ROW_HEIGHT;
}

/**
 * 获取所有列的总宽度
 */
getAllColWidth(): number {
  let w = 0;
  this.columns.forEach((column) => {
    w += column.width || CELL_DEFAULT_WIDTH;
  });
  return w;
}

/**
 * 为四个区域设置 Canvas 裁剪,确保每个区域只显示其对应的内容
 */
setClipping(): void {
  const frozenColsWidth = this.getFrozenColsWidth() + 1;
  const frozenRowsHeight = this.getFrozenRowsHeight();
  const width = this.getAllColWidth();
  const height = this.rows * ROW_HEIGHT;

  // topLeft:冻结列的表头
  this.groups.topLeft.clipFunc((ctx: CanvasRenderingContext2D) => {
    ctx.rect(0, 0, frozenColsWidth, frozenRowsHeight);
  });

  // topRight:非冻结列的表头
  this.groups.topRight.clipFunc((ctx: CanvasRenderingContext2D) => {
    ctx.rect(frozenColsWidth, 0, width - frozenColsWidth, frozenRowsHeight);
  });

  // bottomLeft:冻结列的数据区
  this.groups.bottomLeft.clipFunc((ctx: CanvasRenderingContext2D) => {
    ctx.rect(0, frozenRowsHeight, frozenColsWidth, height - frozenRowsHeight);
  });

  // bottomRight:非冻结列的数据区
  this.groups.bottomRight.clipFunc((ctx: CanvasRenderingContext2D) => {
    ctx.rect(
      frozenColsWidth,
      frozenRowsHeight,
      width - frozenColsWidth,
      height - frozenRowsHeight
    );
  });
}

3. 滚动时更新各区域的位置

当用户滚动表格时,不同的区域需要以不同的方式移动,以实现冻结效果:

/**
 * 更新各区域的偏移量,实现冻结行列的滚动效果
 */
updateGroups(): void {
  // 对滚动位置进行边界值校验,防止超出范围
  const clampedScrollLeft = Math.max(
    0,
    Math.min(this.scrollLeft, this.maxScrollLeft)
  );
  const clampedScrollTop = Math.max(
    0,
    Math.min(this.scrollTop, this.maxScrollTop)
  );

  // topLeft:冻结列 + 表头(不动)
  this.groups.topLeft.offsetX(0);
  this.groups.topLeft.offsetY(0);

  // topRight:表头(仅水平滚动)
  this.groups.topRight.offsetX(clampedScrollLeft);
  this.groups.topRight.offsetY(0);

  // bottomLeft:冻结列(仅垂直滚动)
  this.groups.bottomLeft.offsetX(0);
  this.groups.bottomLeft.offsetY(clampedScrollTop);

  // bottomRight:内容区(水平和垂直同时滚动)
  this.groups.bottomRight.offsetX(clampedScrollLeft);
  this.groups.bottomRight.offsetY(clampedScrollTop);

  // 触发 Canvas 重绘
  this.layer.batchDraw();
}

/**
 * 处理滚动事件
 */
handleScroll(deltaX: number, deltaY: number): void {
  // 更新滚动位置
  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();

  // 更新各区域位置
  this.updateGroups();
}

4. 单元格渲染器(CellRenderer)

创建一个独立的单元格渲染器类,负责单个单元格的绘制:

// cell/index.ts
import Konva from "konva";
import { ROW_HEIGHT } from "../../constants";

interface CellConfig {
  x: number;
  y: number;
  width: number;
  height: number;
  value: string;
}

/**
 * 单元格渲染器
 * 负责绘制单个表格单元格(包括边框和文本)
 */
class CellRenderer {
  x: number;
  y: number;
  width: number;
  height: number;
  value: string;
  group: Konva.Group;
  rect: Konva.Rect;
  text: Konva.Text;

  constructor(config: CellConfig) {
    const { x, y, width, height, value } = config;
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
    this.value = value;

    // 创建 Group 用于组织单元格的所有图形元素
    this.group = new Konva.Group({
      x,
      y,
    });

    // 创建单元格背景矩形
    this.rect = new Konva.Rect({
      x: 0,
      y: 0,
      width: width,
      height: ROW_HEIGHT,
      fill: "#FFF",
      stroke: "#ccc",
      strokeWidth: 1,
    });

    // 创建单元格文本
    this.text = new Konva.Text({
      x: 8,
      y: 8,
      width: width - 16,
      height: 16,
      text: value,
      fontSize: 14,
      fill: "#000",
      align: "left",
      verticalAlign: "middle",
      ellipsis: true,
    });

    // 将矩形和文本添加到 Group
    this.group.add(this.rect, this.text);
  }

  /**
   * 返回渲染后的 Group
   */
  render(): Konva.Group {
    return this.group;
  }
}

export default CellRenderer;

5. 修改 renderCells 方法

/**
 * 渲染可见范围内的所有单元格
 * 清空四个区域的旧内容,然后重新渲染
 */
renderCells(): void {
  // 清空所有区域的旧单元格
  this.groups.topLeft.destroyChildren();
  this.groups.topRight.destroyChildren();
  this.groups.bottomLeft.destroyChildren();
  this.groups.bottomRight.destroyChildren();

  // 渲染所有列的表头
  for (let colIndex = 0; colIndex < this.columns.length; colIndex++) {
    this.renderCell(0, colIndex);
  }

  // 渲染数据行
  for (
    let rowIndex = this.visibleRows.start;
    rowIndex <= this.visibleRows.end;
    rowIndex++
  ) {
    if (rowIndex === 0) continue; // 跳过已渲染的表头行

    // 渲染冻结列的数据单元格
    if (this.frozenColIndex >= 0) {
      for (
        let colIndex = 0;
        colIndex <= this.frozenColIndex && colIndex < this.columns.length;
        colIndex++
      ) {
        this.renderCell(rowIndex, colIndex);
      }
    }

    // 渲染可见的非冻结列单元格
    for (
      let colIndex = this.visibleCols.start;
      colIndex <= this.visibleCols.end;
      colIndex++
    ) {
      // 跳过已渲染的冻结列
      if (this.frozenColIndex >= 0 && colIndex <= this.frozenColIndex) {
        continue;
      }
      this.renderCell(rowIndex, colIndex);
    }
  }

  // 触发 Canvas 重绘
  this.layer.draw();
}

6. 修改 renderCell 方法

/**
 * 渲染单个单元格
 * @param rowIndex - 行索引
 * @param colIndex - 列索引
 */
renderCell(rowIndex: number, colIndex: number): void {
  const column = this.columns[colIndex];
  if (!column) return;

  // 计算单元格坐标(使用绝对坐标,不减去滚动偏移)
  // 滚动偏移由各 Group 的 offsetX/offsetY 处理
  const x = this.getColX(colIndex);
  const y = this.getRowY(rowIndex);

  // 确定单元格内容(表头或数据)
  const cellValue =
    rowIndex === 0 ? column.title : this.dataSource[rowIndex][colIndex];

  // 使用 CellRenderer 创建单元格
  const cell = new CellRenderer({
    x,
    y,
    height: ROW_HEIGHT,
    width: column.width,
    value: cellValue,
  });

  const cellGroup = cell.render();
  const isFrozenCol = this.isColFrozen(colIndex);

  // 设置冻结列的 zIndex,使其在非冻结列之上
  if (isFrozenCol) {
    cellGroup.zIndex(100);
  } else {
    cellGroup.zIndex(1);
  }

  // 将单元格添加到对应的 Group
  const group = this.getCellGroup(rowIndex, colIndex);
  group.add(cellGroup);
}

总结

通过将表格分为四个独立的区域并分别管理它们的滚动,我们实现了高效的冻结行列功能。关键点包括:

  • ✅ 使用四个 Group 分别管理四个区域,便于独立控制
  • ✅ 通过 clipFunc 进行 Canvas 裁剪,确保内容不超出区域边界
  • ✅ 使用 offsetX/offsetY 实现各区域的独立滚动
  • ✅ 虚拟渲染技术确保只渲染可见区域,提升性能