Appearance
多级分组表头 / 表尾
给列配置 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-label(position: 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 = '';
};
}