Skip to content

固定行 pinned

通过 pinnedTop / pinnedBottom 设置置顶 / 置底固定行,冻结在表头下方 / 表尾上方,不参与虚拟滚动,随横向滚动同步。setPinnedRows() 可动态更新。

与固定表头的区别

固定表头冻结的是列的说明,固定行冻结的是某几条数据。两者互不替代,通常同时存在。

维度固定表头固定行 pinned
冻结对象列名(列的元信息)具体的数据行
内容来源columns[].title / headerDatapinnedTop / 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 中「抽取」出来的,而是独立数组。若同一条数据同时存在于 listpinnedTop,它会在表头下方和表体中各渲染一次。需要「置顶后从列表移除」的效果,得自行过滤 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 = '';
  };
}