Appearance
固定行 pinned
通过 pinnedTop / pinnedBottom 设置置顶 / 置底固定行,冻结在表头下方 / 表尾上方,不参与虚拟滚动,随横向滚动同步。setPinnedRows() 可动态更新。
与固定表头的区别
固定表头冻结的是列的说明,固定行冻结的是某几条数据。两者互不替代,通常同时存在。
| 维度 | 固定表头 | 固定行 pinned |
|---|---|---|
| 冻结对象 | 列名(列的元信息) | 具体的数据行 |
| 内容来源 | columns[].title / headerData | pinnedTop / pinnedBottom 传入的数据数组 |
| 行数 | 固定 1 行(多级表头为 N 行),不由业务决定 | 由业务决定,可动态增删 |
| 是否随数据变 | 静态,滚动 / 筛选 / 排序都不变 | 动态,setPinnedRows() 随时替换 |
| 位置 | 顶部 | 顶部(表头下方)和 / 或底部(表尾上方) |
| 如何开启 | 默认已开启,仅 showHeader: false 可关闭 | 需显式传入 pinnedTop / pinnedBottom |
固定表头不需要配置
表头/表尾各住在一个独立的 <table> 里(.vt-header / .vt-footer),容器自带 position: sticky,只要出现竖向滚动就自动冻结。固定行在实现上挂进 thead 尾部 / tfoot 头部,因此天然跟着一起冻结,并与固定列(column.fixed)同步横向偏移。
之所以不把表头留在表体那个 <table> 里:<table> 是整体的布局上下文,虚拟滚动每帧改表体就会让表头一起失效、重新光栅化,主线程繁忙时表头会整条闪白(实测空白帧 41% → 0%)。表头容器仍在滚动容器内,所以横向滚动由合成器带着走,零 JS 同步、零滞后。
使用场景怎么区分
固定表头解决「这个格子是什么」,固定行解决「这行跟其他行比怎么样」。
固定表头:只要数据多到需要竖向滚动就应该保留,它是可用性底线而非可选功能。唯一会关掉的情况是表格被当作纯展示的数据块使用(卡片里的两三行明细、导出预览片段),此时用 showHeader: false(见无表头表格)。
固定行:仅在需要一个恒定参照物或恒定关注对象时才用。典型场景:
- 基准 / 参照行:行业均值、去年同期、目标值置顶,滚到第 800 行时仍可对比。
- 「我」这一行:排行榜中把当前用户 / 当前部门置底,无论滚到哪都看得见自己。
- 用户勾选关注的少量记录:从长列表里挑出几条反复对比,用
setPinnedRows()提到顶部。 - 正在新增 / 编辑的临时行:置顶,边填写边参照下方已有数据。
以下场景不要用固定行,各有更合适的 API:
| 需求 | 应该用 |
|---|---|
| 常规聚合合计(和 / 均值) | showSummary + 列上的 summary,见自动合计行 |
| 静态表尾说明文本 | footerData |
| 横向滚动时冻结左右列 | column.fixed: 'left' | 'right',见冻结列与合并 |
注意事项
- 控制数量在个位数。固定行不参与虚拟滚动,每行都是真实 DOM,且占用可视高度;固定十几行会明显挤压表体,抵消虚拟滚动的收益。
- 数据独立于
list。固定行不是从list中「抽取」出来的,而是独立数组。若同一条数据同时存在于list和pinnedTop,它会在表头下方和表体中各渲染一次。需要「置顶后从列表移除」的效果,得自行过滤list。 - 与
showSummary/footerData共存时,pinnedBottom位于合计行上方。
示例
微应用尚未挂载。
源码
点击查看源码
ts
import { faker } from '@faker-js/faker';
import { VirtTable, type VirtTableColumn } from '@virt-table/vanilla';
interface Row { id: number | string; name: string; qty: number; amount: number }
export function bootstrapTablePinned(root: HTMLElement): () => void {
const columns: VirtTableColumn<Row>[] = [
{ key: 'id', title: 'ID', width: 120 },
{ key: 'name', title: '名称', width: 220 },
{ key: 'qty', title: '数量', width: 160, align: 'right' },
{ key: 'amount', title: '金额', width: 160, align: 'right' },
];
const list: Row[] = Array.from({ length: 1000 }, (_, i) => ({
id: i + 1,
name: faker.commerce.productName(),
qty: faker.number.int({ min: 1, max: 100 }),
amount: faker.number.int({ min: 100, max: 9999 }),
}));
const pinnedTop: Row[] = [{ id: '★', name: '【置顶】重点商品', qty: 999, amount: 99999 }];
const pinnedBottom: Row[] = [{ id: '∑', name: '【置底】统计说明', qty: 0, amount: 0 }];
root.innerHTML = `
<div class="demo-hint">首行冻结在表头下方、末行冻结在表尾上方,均不参与虚拟滚动,随横向滚动同步。</div>
<div style="width:680px;height:460px;" class="demo-container" id="c"></div>`;
const container = root.querySelector('#c') as HTMLElement;
const table = new VirtTable<Row>(container, {
list,
columns,
itemKey: 'id',
estimatedSize: 40,
buffer: 6,
border: true,
pinnedTop,
pinnedBottom,
});
return () => {
table.destroy();
root.innerHTML = '';
};
}