Skip to content

多级分组表头 / 表尾

给列配置 children 即可得到多级分组表头:带 children 的节点是分组节点(自身不承载数据,只在表头占一格并横跨其全部叶子后代),不带 children 的叶子才是真正的数据列。嵌套层数不限,表头行数 = 列树深度。

开了 showSummary(或给节点配 footerValue / renderFooter)后,表尾也按同一棵列树渲染成多级分组表尾

表头、表尾与表体一起做横向虚拟化:只渲染视口内的叶子列,跨越视口边界的分组格会被裁剪到视口内,视口外的整段列折叠成一个贯通表头高度的留白格。因此几十上百列的分组表头滚动依然流畅。

要点

  • fixed 写在分组上,会强制下发给所有后代(保证分组格与它的叶子落在同一冻结区段);不要在分组内的单个子列上单独设 fixed
  • 数据相关配置写在叶子列上render / sortable / filters / resizable / type 等。分组节点只需要 title(或 renderHeader)。
  • 分组节点的 width 可以写 0:它的宽度恒等于可见子列宽度之和。
  • 与手写多行表头(headerData / headerMerges互斥——配置了 children 时后者被忽略并给出告警。
  • 列显隐按树生效:隐藏叶子会让父分组自动收窄,隐藏分组等于隐藏其全部叶子;某分组的子列全被隐藏时整组消失。
  • 分组格恒为单行white-space: nowrap),超出部分裁掉、完整内容挂在原生 title 上。原因:横向虚拟化会把跨视口的分组格 colspan 裁窄,若允许折行则行高会随滚动变化,sticky 的表头/表尾就会抖动。
  • 分组标签横向跟随:中间段的分组格把内容放进一层 .vt-group-labelposition: sticky)。分组比可见区宽时标签被钉在可见区并居中、滚动中完全不动;分组窄于可见区时退化为格内居中。由 CSS sticky 在合成器逐帧完成,主线程零开销。左右冻结段的分组格本身已冻结在视口内,不套此内层。
  • 列拖拽排序(columnDraggable)在分组表头下只允许同一父分组内换位,跨分组拖拽会被忽略。

使用的 API

ts
type Column = {
  key: string;  // 列标识(必填)
  title: string;  // 列标题(必填)
  width: number;  // 列宽(必填;分组节点可填 0,宽度由子列求和)
  children?: Column[];  // 子列 —— 配置它即成为分组节点
  fixed?: 'left' | 'right';  // 冻结;写在分组上会下发给全部子列
  align?: 'left' | 'center' | 'right';  // 单元格水平对齐
  headerAlign?: 'left' | 'center' | 'right';  // 表头水平对齐(分组默认居中)
  type?: 'index' | 'checkbox' | 'expand' | 'tree' | 'radio' | 'drag';  // 功能列(仅叶子)
  sortable?: boolean;  // 可排序(仅叶子)
  resizable?: boolean;  // 可拖拽调整列宽(仅叶子)
}

type Options = {
  list: T[];  // 数据列表
  columns: Column[];  // 列树
  itemKey: string;  // 行唯一标识字段名
  estimatedSize: number;  // 行预估高度(px)
  buffer: number;  // 纵向缓冲区行数
  colBuffer: number;  // 横向缓冲区列数
  border: boolean;  // 是否显示边框
  stripe: boolean;  // 是否显示斑马纹
}

命令式 API:

方法说明
getVisibleColumns()当前可见的叶子列(数据列,不含分组节点)
getLeafColumns()全量叶子列(含隐藏)
getAllColumns()全量列树(含隐藏列与分组节点)
getHeaderDepth()表头行数(= 列树深度)
setColumnVisible(key, visible)传分组节点的 key 即整组显隐

多级分组表尾

表尾与表头同序(最外层分组在上、叶子小计在下),网格与表头逐行一致,因此复用同一份表头网格与同一套横向虚拟化裁剪规则——列很多时表尾也只渲染视口内的列。

启用条件:列树里有分组节点,并且表尾有内容来源——开了 showSummary,或任一节点配了 footerValue / renderFooter。否则退回 footerData / footerMerges 的扁平表尾(两者互斥)。

每格内容优先级renderFooter > footerValue > summary/summaryMethod 自动聚合 > 合计标签(仅整表首列的叶子格)> 空。

分组格聚合语义:把该格覆盖的全部叶子列 × 全部行的原始值摊平成一维 values,再交给 summaryMethod(优先)或内置 summary。因此 count 在分组格上统计的是摊平后的单元格数而非行数。聚合始终基于当前视图(筛选/排序后),数据变化自动重算。

ts
{ key: 'y2024', title: '2024 年', width: 0,
  summary: 'sum',                      // 分组:聚合旗下全部叶子列
  children: [
    { key: 'q1_rev', title: 'Q1 营收', width: 110, summary: 'sum' },
    { key: 'q1_rate', title: 'Q1 毛利率', width: 110, footerValue: '—' },
  ]}

示例

微应用尚未挂载。

源码

点击查看源码
ts
import { VirtTable, type VirtTableColumn } from '@virt-table/vanilla';
import { vtColumnPanel } from '@virt-table/vanilla/plugins';

/**
 * 多级分组表头 / 表尾 + 横向虚拟列表。
 *
 * 列配置写成「列树」:带 children 的节点是分组节点(只在表头占一格、横跨其全部叶子),
 * 叶子才是真正的数据列。列很多时表头与表体一样只渲染视口内的列——
 * 跨越视口边界的分组格会被裁剪,窗口外整段折叠成一个留白格。
 *
 * 开了 showSummary 后表尾也按同一棵列树渲染成多级分组表尾(与表头同序:
 * 最外层分组在上、叶子小计在下),分组格的聚合范围是它旗下全部叶子列。
 */

/** 12 个季度分组 × 4 个指标 = 48 个叶子列,足够触发横向虚拟化 */
const QUARTERS = Array.from({ length: 12 }, (_, i) => {
  const year = 2023 + Math.floor(i / 4);
  return { year, q: (i % 4) + 1 };
});

const METRICS = [
  { key: 'revenue', title: '营收' },
  { key: 'cost', title: '成本' },
  { key: 'profit', title: '利润' },
  { key: 'rate', title: '毛利率' },
];

const ROW_COUNT = 2000;

function buildColumns(): VirtTableColumn[] {
  const yearMap = new Map<number, VirtTableColumn>();
  for (const { year, q } of QUARTERS) {
    let yearCol = yearMap.get(year);
    if (!yearCol) {
      // 分组节点上的 summary 会聚合它旗下全部叶子列 × 全部行
      yearCol = { key: `y_${year}`, title: `${year} 年`, width: 0, summary: 'sum', children: [] };
      yearMap.set(year, yearCol);
    }
    yearCol.children!.push({
      key: `y${year}q${q}`,
      title: `Q${q}`,
      width: 0,
      // 季度小计只统计营收,用 summaryMethod 从摊平值里挑(每行 4 个指标,营收在第 0 个)
      summaryMethod: (values) => {
        let sum = 0;
        for (let i = 0; i < values.length; i += METRICS.length) sum += Number(values[i]) || 0;
        return `营收 ${sum}`;
      },
      children: METRICS.map((m) => ({
        key: `y${year}q${q}_${m.key}`,
        title: m.title,
        width: 110,
        align: 'right' as const,
        headerAlign: 'center' as const,
        // 毛利率是百分比字符串,不做求和
        ...(m.key === 'rate' ? { footerValue: '—' } : { summary: 'sum' as const }),
      })),
    });
  }

  return [
    // 冻结列同样支持分组:fixed 写在分组上,会自动下发给所有子列
    {
      key: 'g_base',
      title: '基础信息',
      width: 0,
      fixed: 'left',
      children: [
        // 首列叶子格默认自动放合计标签;这里显式给空值把标签让给下一列(序号列太窄会折行)
        { key: 'index', title: '#', width: 56, type: 'index', footerValue: '' },
        { key: 'name', title: '门店', width: 140, sortable: true, footerValue: '合计' },
      ],
    },
    ...yearMap.values(),
    {
      key: 'g_total',
      title: '汇总',
      width: 0,
      fixed: 'right',
      // 分组格用 renderFooter 自定义:可以拿到 rows / leafColumns / 自动聚合结果
      renderFooter: ({ rows }) => `<b>${rows.length} 家</b>`,
      children: [
        { key: 'total', title: '合计营收', width: 130, align: 'right', sortable: true, summary: 'sum' },
      ],
    },
  ];
}

export function bootstrapTableHeaderGroup(root: HTMLElement): () => void {
  const columns = buildColumns();

  const list = Array.from({ length: ROW_COUNT }, (_, i) => {
    const row: Record<string, any> = { id: i, name: `门店 ${i + 1}` };
    let total = 0;
    for (const { year, q } of QUARTERS) {
      const revenue = 200 + ((i * 37 + year + q * 13) % 800);
      const cost = Math.round(revenue * (0.45 + ((i + q) % 30) / 100));
      row[`y${year}q${q}_revenue`] = revenue;
      row[`y${year}q${q}_cost`] = cost;
      row[`y${year}q${q}_profit`] = revenue - cost;
      row[`y${year}q${q}_rate`] = `${(((revenue - cost) / revenue) * 100).toFixed(1)}%`;
      total += revenue;
    }
    row.total = total;
    return row;
  });

  root.innerHTML = `
    <div class="virt-table-controls">
      <button class="virt-table-btn virt-table-btn-primary" id="btnCols">列设置</button>
      <button class="virt-table-btn" id="btnScrollMid">横向滚到中间</button>
      <button class="virt-table-btn" id="btnScrollEnd">横向滚到最右</button>
      <button class="virt-table-btn" id="btnHalf">只留一半数据(看表尾联动)</button>
    </div>
    <div id="status" class="status-text"></div>
    <div style="width:900px;height:600px;" class="demo-container" id="tableContainer"></div>
  `;

  const container = root.querySelector('#tableContainer') as HTMLElement;
  const status = root.querySelector('#status') as HTMLElement;

  const table = new VirtTable(container, {
    list,
    columns,
    itemKey: 'id',
    estimatedSize: 40,
    buffer: 4,
    colBuffer: 2,
    border: true,
    stripe: true,
    // 开启后表尾按同一棵列树渲染成多级分组表尾
    showSummary: true,
    plugins: [vtColumnPanel()],
  });

  const leafCount = table.getVisibleColumns().length;
  const depth = table.getHeaderDepth();
  status.textContent =
    `多级分组表头 + 分组表尾:${list.length} 行 × ${leafCount} 个叶子列,表头/表尾各 ${depth} 层;` +
    '两者与表体一同做横向虚拟化(滚动时只渲染视口内的列)。';

  const btnCols = root.querySelector('#btnCols') as HTMLElement;
  const btnMid = root.querySelector('#btnScrollMid') as HTMLElement;
  const btnEnd = root.querySelector('#btnScrollEnd') as HTMLElement;
  const btnHalf = root.querySelector('#btnHalf') as HTMLElement;
  let halved = false;
  const onCols = () => table.toggleColumnPanel(btnCols);
  const onMid = () => table.scrollToCell(0, Math.floor(leafCount / 2));
  const onEnd = () => table.scrollToCell(0, leafCount - 1);
  const onHalf = () => {
    halved = !halved;
    table.setList(halved ? list.slice(0, Math.floor(list.length / 2)) : list);
    status.textContent = halved
      ? '数据减半 —— 表尾各级小计随当前视图重算'
      : '数据已恢复 —— 表尾各级小计随当前视图重算';
  };
  btnCols.addEventListener('click', onCols);
  btnMid.addEventListener('click', onMid);
  btnEnd.addEventListener('click', onEnd);
  btnHalf.addEventListener('click', onHalf);

  return () => {
    btnCols.removeEventListener('click', onCols);
    btnMid.removeEventListener('click', onMid);
    btnEnd.removeEventListener('click', onEnd);
    btnHalf.removeEventListener('click', onHalf);
    table.destroy();
    root.innerHTML = '';
  };
}