Appearance
无限滚动 infinite
配置 infinite 并给一个取数实现,滚到底部就自动加载下一批。
分工
库负责「何时该取数、取数期间的状态、取回后怎么并进虚拟滚动」;你负责「怎么取数」(fetch / axios / GraphQL / TanStack Query 都行)。 因此这里没有缓存、重试策略、HTTP 包装 —— 只有编排。
两个入口,同一内核
糖层 loadData —— 返回 Promise,库替你接进来:
ts
const table = new VirtTable(el, {
list: [],
columns,
itemKey: 'id',
estimatedSize: 40,
fixedSize: true,
infinite: { pageSize: 50 },
async loadData(req) {
const res = await fetch(`/api/rows?offset=${req.offset}&limit=${req.pageSize}`);
const { rows, total } = await res.json();
return { rows, total }; // total 用于判断是否还有更多
},
});受控层 onLoadMore —— 你自己发请求,用 ctx.done / ctx.fail 交付(想接管请求库时用它):
ts
{
infinite: { pageSize: 50 },
onLoadMore: async (ctx) => {
try {
const { rows, nextCursor } = await api.list({ cursor: ctx.cursor, limit: ctx.pageSize, signal: ctx.signal });
ctx.done(rows, { cursor: nextCursor }); // cursor === null 即到底
} catch (e) {
ctx.fail(e); // 底部条切错误态 + 重试按钮
}
},
}ctx.done() 不带数据时表示「仅解锁」——适合你已经自行 setList 的场景。
库替你处理掉的事
| 问题 | 处理方式 |
|---|---|
toBottom 每次向下滚动都会触发,会连发十几个请求 | 在途加锁 + hasMore 门禁,重复触发直接忽略 |
| 用户连点筛选/排序,先发的响应后到,把新数据覆盖了 | 代次 + 序号双重校验,旧代响应一律作废,并 abort 旧请求 |
| 追加数据后滚动位置乱跳 | 追加保留行 DOM 池,inViewBegin 不动;前插做偏移补偿 |
| 到底了还继续请求 | hasMore 推导:显式 > 游标 > 总数 > 满页启发式 |
| 加载中把已有数据遮住 | 首屏/换参用全屏遮罩,追加只亮底部状态条 |
| 请求失败后数据丢了 | 保留已加载数据,状态条给重试入口(retryLoad() 复用同一参数) |
配置
ts
infinite?: {
enabled?: boolean; // 默认 true
pageSize?: number; // 每批条数,默认 50
distance?: number; // 距底/距顶多少 px 触发,默认 200(写入内核 edgeThreshold)
autoLoadFirst?: boolean; // 首屏自动取第一批;默认「没有初始 list 就取」
manual?: boolean; // 只显示「加载更多」按钮,不自动触发
direction?: 'down' | 'up' | 'both'; // 加载方向,默认 down
showNoMore?: boolean; // 到底后显示「没有更多」,默认 true
}响应元信息
ts
interface DataResponse<T> {
rows: T[];
total?: number; // 总条数:驱动分页条与 hasMore
cursor?: string | null; // 向下游标,返回 null 表示到底
prevCursor?: string | null;// 向上游标
hasMore?: boolean; // 显式声明(优先级最高)
hasPrev?: boolean;
footerData?: string[][]; // 服务端聚合行,直接接管表尾
}游标与页码不必二选一:请求里 page / offset / cursor 同时下发,后端认哪个用哪个;状态机内部只维护「已加载多少 / 还有没有更多」,没有两条代码路径。
向上加载(历史消息式)
direction: 'up' | 'both' 时到顶触发 onLoadPrev / loadData(req.channel === 'prev'),前插数据后表格把滚动位置往下推回等量距离,视口内容不跳。
向上/向下是两条独立通道,可并发、各自加锁、各自维护游标与「是否还有更多」:
ts
{
infinite: { pageSize: 50, direction: 'both' },
async loadData(req) {
if (req.channel === 'prev') {
const { rows, prevCursor } = await api.older({ before: req.prevCursor, limit: req.pageSize });
return { rows, prevCursor }; // prevCursor === null → 向上到头
}
const { rows, cursor } = await api.newer({ after: req.cursor, limit: req.pageSize });
return { rows, cursor }; // cursor === null → 向下到底
},
}补偿是逐帧收敛的:新行首帧按 estimatedSize 占位,ResizeObserver 随后写入实测高度(每行零点几 px 的差累积成十几 px),因此表格会跟着总高变化持续校正,直到连续几帧稳定为止(硬上限约 650ms)。校正期间用户一滚动就立刻让位,不会把人拽回去。
给准确的 estimatedSize(或开 fixedSize: true)能让首帧就基本对齐,减少收敛过程中的细微位移。
状态条常驻占位
上下状态条用 visibility 而非 display 隐藏——它常驻占位(约 34px)。因为出现/消失会改变滚动容器高度,虚拟滚动会跟着抖一下,scrollTop 也会被浏览器就近夹取。要调整高度覆盖 .vt-infinite-bar 的 min-height / padding 即可。
手动管理数据
不想用库的取数编排,只想要状态条与触发时机:
ts
{
infinite: { manual: true },
// 不给 loadData / onLoadMore
}
// 自己拿到数据后:
table.appendRows(rows);
table.setHasMore(rows.length === pageSize);方法
| 方法 | 说明 |
|---|---|
loadMore() / loadPrev() | 手动触发下一批 / 更早一批 |
reload() | 清空已加载数据,重新取第一批 |
refresh() | 一次性重拉当前已加载区间,保留滚动位置 |
retryLoad() | 重试上次失败的请求(复用同一批参数) |
getRemoteState() | 读取 { page, pageSize, total, cursor, hasMore, hasPrev, loading, loadingMore, loadingPrev, error, loadedCount, initialized } |
appendRows() / prependRows() | 手动追加 / 前插(前插含滚动补偿) |
setHasMore(bool, dir?) / setCursor(cursor, dir?) | 手动模式下控制闸门与游标 |
状态条文案通过 locale.infinite 定制(loadMore / loading / noMore / error / retry),随 setLocale 即时切换。
限制
- 与
pagination互斥:分页是页视图(replace,当前页是唯一状态),无限滚动是累积流(append,页码只表示取到第几批)。同时给出时以pagination为准、忽略infinite并在控制台告警。 - 合并单元格(
merges)与无限滚动不兼容:merges基于扁平行索引,追加后索引语义漂移。需要合并时请改用分页。 showSummary的本地聚合在服务端模式下只覆盖已加载数据。要全量口径请让服务端返回footerData。- 导出 / 打印同理,只覆盖已加载数据。
示例
微应用尚未挂载。
源码
点击查看源码
ts
import { faker } from '@faker-js/faker';
import { VirtTable, type VirtTableColumn, type DataRequest, type DataResponse } from '@virt-table/vanilla';
interface Row { id: number; name: string; dept: string; qty: number; price: number }
/**
* 无限滚动示例:`infinite` + `loadData` 糖层。
*
* 表格负责编排:滚到底自动取下一批、在途去重(toBottom 每次滚动都会触发)、
* 竞态作废、底部状态条四态(加载中 / 加载更多 / 没有更多 / 失败重试)。
* 用户只负责 `loadData` 里怎么发请求。
*
* 这里用 3000 条本地数据 + 400ms 延迟模拟服务端;「注入故障」勾上后下一次请求会失败,
* 可以看到底部条切到错误态并给出重试按钮(已加载的数据不会丢)。
*/
export function bootstrapTableInfinite(root: HTMLElement): () => void {
const depts = ['工程部', '设计部', '市场部', '财务部'];
const columns: VirtTableColumn<Row>[] = [
{ key: 'id', title: 'ID', width: 80 },
{ key: 'name', title: '姓名', width: 220 },
{ key: 'dept', title: '部门', width: 160 },
{ key: 'qty', title: '数量', width: 140, align: 'right' },
{ key: 'price', title: '单价', width: 140, align: 'right' },
];
// 模拟服务端数据(真实场景中在后端)
const TOTAL = 3000;
const db: Row[] = Array.from({ length: TOTAL }, (_, i) => ({
id: i + 1,
name: faker.person.fullName(),
dept: depts[i % depts.length],
qty: faker.number.int({ min: 1, max: 20 }),
price: faker.number.int({ min: 5, max: 500 }),
}));
let failNext = false;
let reqSeq = 0;
root.innerHTML = `
<div class="demo-hint">滚到底部自动加载下一批(每批 50 条,共 3000 条)。表格内部已做在途去重与竞态处理,底部状态条自动展示加载中/没有更多/失败重试。</div>
<div class="virt-table-controls">
<label><input type="checkbox" id="manual" /> 手动模式(只显示「加载更多」按钮)</label>
<label><input type="checkbox" id="both" /> 双向加载(从第 1500 条开始,可向上加载更早数据)</label>
<label><input type="checkbox" id="fail" /> 注入故障(下一次请求失败)</label>
<button id="reload">重新加载</button>
<span id="stat" class="demo-note"></span>
</div>
<div style="width:820px;height:460px;" class="demo-container" id="c"></div>
<div id="log" class="status-text" style="margin-top:12px;white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;"></div>`;
const container = root.querySelector('#c') as HTMLElement;
const manualEl = root.querySelector('#manual') as HTMLInputElement;
const bothEl = root.querySelector('#both') as HTMLInputElement;
const failEl = root.querySelector('#fail') as HTMLInputElement;
const reloadEl = root.querySelector('#reload') as HTMLButtonElement;
const statEl = root.querySelector('#stat') as HTMLElement;
const logEl = root.querySelector('#log') as HTMLElement;
const logs: string[] = [];
const log = (msg: string): void => {
logs.unshift(msg);
logEl.textContent = logs.slice(0, 6).join('\n');
};
/**
* 模拟服务端:延迟 + 可注入失败。
*
* 双向模式用**游标**表达位置(真实的向上加载场景通常也是游标):
* cursor / prevCursor 为「数据库里的绝对下标」,返回 null 即该方向到头。
*/
const START = 1500; // 双向模式的起始位置(db 中间)
let downAt = START; // 已向下取到哪
let upAt = START; // 已向上取到哪
const loadData = (req: DataRequest): Promise<DataResponse<Row>> => {
const seq = ++reqSeq;
const both = bothEl.checked;
log(`#${seq} ${req.reason} offset=${req.offset} pageSize=${req.pageSize}${both ? ` cursor=${req.cursor} prevCursor=${req.prevCursor}` : ''}`);
return new Promise((resolve, reject) => {
setTimeout(() => {
if (failNext) {
failNext = false;
failEl.checked = false;
log(`#${seq} ✗ 失败(可点重试)`);
reject(new Error('mock network error'));
return;
}
if (!both) {
const rows = db.slice(req.offset, req.offset + req.pageSize);
log(`#${seq} ✓ 返回 ${rows.length} 条`);
// 返回 total,表格据此判定 hasMore(也可以只返回 cursor 或什么都不返回)
resolve({ rows, total: TOTAL });
return;
}
// ---- 双向(游标)模式 ----
if (req.channel === 'prev') {
const end = upAt;
const start = Math.max(0, end - req.pageSize);
upAt = start;
const rows = db.slice(start, end);
log(`#${seq} ✓ 向上返回 ${rows.length} 条 [${start}, ${end})`);
resolve({ rows, prevCursor: start > 0 ? String(start) : null });
return;
}
if (req.channel === 'more') {
const start = downAt;
const end = Math.min(TOTAL, start + req.pageSize);
downAt = end;
const rows = db.slice(start, end);
log(`#${seq} ✓ 向下返回 ${rows.length} 条 [${start}, ${end})`);
resolve({ rows, cursor: end < TOTAL ? String(end) : null });
return;
}
// 首屏:从中间取一批,两个方向都还有数据
downAt = START + req.pageSize;
upAt = START;
const rows = db.slice(START, downAt);
log(`#${seq} ✓ 首屏返回 ${rows.length} 条 [${START}, ${downAt})`);
resolve({ rows, cursor: String(downAt), prevCursor: String(START) });
}, 400);
});
};
let table: VirtTable<Row> | null = null;
const create = (): void => {
table?.destroy();
table = new VirtTable<Row>(container, {
list: [],
columns,
itemKey: 'id',
estimatedSize: 40,
fixedSize: true, // 固定行高:追加时滚动条不会因为重新测量而抖动
buffer: 6,
border: true,
infinite: {
pageSize: 50,
distance: 200,
manual: manualEl.checked,
direction: bothEl.checked ? 'both' : 'down',
},
loadData,
onRemoteStateChange: (st) => {
const total = st.total < 0 ? '?' : st.total;
statEl.textContent =
`已加载 ${st.loadedCount}/${total} 条${st.hasMore ? '' : ' · 向下到底'}` +
(bothEl.checked ? `${st.hasPrev ? '' : ' · 向上到头'}` : ` · 第 ${st.page} 批`);
},
onLoadError: (err) => log(`onLoadError: ${(err as Error).message}`),
});
};
create();
manualEl.addEventListener('change', create);
bothEl.addEventListener('change', () => {
downAt = START;
upAt = START;
create();
});
reloadEl.addEventListener('click', () => table?.reload());
failEl.addEventListener('change', () => {
failNext = failEl.checked;
});
return () => {
table?.destroy();
table = null;
root.innerHTML = '';
};
}