Skip to content

React 多级分组表头 / 表尾

给列配置 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 在合成器逐帧完成,主线程零开销。左右冻结段的分组格本身已冻结在视口内,不套此内层。

使用的 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;  // 可排序(仅叶子)
}

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

ref 上新增:getLeafColumns() 取全量叶子列,getHeaderDepth() 取表头行数。

多级分组表尾

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

启用条件:列树里有分组节点,并且表尾有内容来源——开了 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: '—' },
  ]}

示例

微应用尚未挂载。

源码

点击查看源码
tsx
import React from 'react';
import { VirtTableReact, type ReactTableColumn, type VirtTableRef } from '@virt-table/react';

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

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

const rowCount = 2000;

function buildColumns(): ReactTableColumn[] {
  const yearMap = new Map<number, ReactTableColumn>();
  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' },
      ],
    },
  ];
}

const columns = buildColumns();
const leafCount = quarters.length * metrics.length + 3;

const list = Array.from({ length: rowCount }, (_, i) => {
  const row: Record<string, unknown> = { 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;
});

export default function HeaderGroupTable() {
  const tableRef = React.useRef<VirtTableRef>(null);
  const [status, setStatus] = React.useState(
    `多级分组表头 + 分组表尾:${rowCount} 行 × ${leafCount} 个叶子列,表头/表尾各 3 层;两者与表体一同横向虚拟化`,
  );
  return (
    <div className="demo-wrapper">
      <h3 className="demo-title">React 多级分组表头</h3>
      <div className="virt-table-controls">
        <button
          type="button"
          onClick={() => {
            tableRef.current?.scrollToCell(0, Math.floor(leafCount / 2));
            setStatus('已横向滚动到中间列');
          }}
        >
          横向滚到中间
        </button>
        <button
          type="button"
          onClick={() => {
            tableRef.current?.scrollToCell(0, leafCount - 1);
            setStatus('已横向滚动到最右列');
          }}
        >
          横向滚到最右
        </button>
        <button
          type="button"
          onClick={() => {
            const index = Math.floor(Math.random() * rowCount);
            tableRef.current?.scrollToIndex(index);
            setStatus(`随机滚动到第 ${index + 1} 行`);
          }}
        >
          随机滚动行
        </button>
      </div>
      <div className="status-text">{status}</div>
      <div style={{ width: 900, height: 600 }} className="demo-container">
        <VirtTableReact
          ref={tableRef}
          columns={columns}
          options={{
            list,
            itemKey: 'id',
            estimatedSize: 40,
            buffer: 4,
            colBuffer: 2,
            border: true,
            stripe: true,
            // 开启后表尾按同一棵列树渲染成多级分组表尾
            showSummary: true,
          }}
        />
      </div>
    </div>
  );
}