Appearance
vtAIStream 流式表格
一边收数据一边渲染,列自己长出来。
现有表格库都隐含两条假设:列是预先定义好的、数据一次性给全。AI 应用的输出恰好两条都 违反 —— agent 抓回来的数据事前不知道 schema,模型在逐行往外吐,量还可能很大。这个插件补的 就是这两件事。
三个缺口
| 缺口 | 处理 |
|---|---|
逐行 appendRows = 逐行走一遍数据管线 | 攒进缓冲,一帧只落一次(flushInterval 可改成定时器) |
| 收到新行时视口该不该跟着走 | stick-to-bottom:在底部就跟随,用户一上滚就脱离,滚回底部自动恢复 |
| 列不知道长什么样 | 首批数据到达时 inferColumns() 推断类型、标题、宽度、对齐 |
快速上手
ts
import { VirtTable } from '@virt-table/vanilla';
import { vtAIStream } from '@virt-table/vanilla/plugins';
import '@virt-table/vanilla/core.css';
import '@virt-table/vanilla/plugins/ai-stream.css';
const table = new VirtTable(el, {
list: [],
columns: [], // 留空 —— 列由首批数据推断
itemKey: 'id',
plugins: [vtAIStream()],
});
// 吃任何 AsyncIterable:SSE、ReadableStream、异步生成器都行
await table.consumeAIStream(rowsFromSSE('/api/rows'));consumeAIStream 的每个 yield 是一行或一批,两种都接受。手动推也可以:
ts
table.startAIStream();
table.pushAIStreamRows([row1, row2]); // 攒到下一帧
table.pushAIStreamRows(row3); // 单行不必自己包数组
table.endAIStream(); // 落掉缓冲里的尾巴接 SSE
ts
async function* rowsFromSSE(url: string) {
const res = await fetch(url);
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
const lines = buf.split('\n');
buf = lines.pop()!; // 最后一段可能是半行,留到下一轮
for (const line of lines) {
if (line.startsWith('data: ')) yield JSON.parse(line.slice(6));
}
}
}列推断
inferColumns() 是纯逻辑,也能单独引(@virt-table/vanilla/infer-columns,零 CSS 零 DOM), 所以服务端可以先把列算好随数据一起下发:
ts
import { inferColumns } from '@virt-table/vanilla/infer-columns';
const columns = inferColumns(rows, { itemKey: 'id' });推断规则(按多数票判定,不看第一行 —— agent 数据的第一行常常是 null 或异常值):
| 值的样子 | 推断结果 |
|---|---|
number | 右对齐 + cellType: 'number'(千分位)+ 可排序 |
boolean | 居中,宽 90 |
ISO 日期串 / Date | 可排序(不右对齐) |
图片 URL(.png / .jpg …) | cellType: 'image',宽 80 |
| 普通 URL | 按文本,不可排序 |
| 超过 60 字符 | textOverflow: 'tooltip' |
| 纯数字字符串 | 按文本 —— 它可能是订单号 / 手机号 / 邮编,右对齐加千分位反而是错的 |
- 字段集合取所有采样行的并集(不同来源拼起来的数据键可能不齐),列序跟随字段首次出现的顺序。
_开头的字段与itemKey不建列。- 列宽取内容 P90 显示宽度与标题宽度的较大者(用 P90 而不是最大值:一两行超长内容不该把整列撑到 320px,那种情况交给 tooltip)。中文按双宽计。
- 标题走
humanizeKey:createdAt→Created At,user_name→User Name,SKU_CODE原样。要中文标题传titleOf。
推断是尽力而为,产出的是「能直接用的起点」而不是最终答案 —— 拿到结果再改就行。
Options
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
flushInterval | 'raf' | number | 'raf' | 缓冲落地节奏。一帧一次已经够 —— 模型吐得再快,人眼也只看得见 60fps |
maxRowsPerFlush | number | 500 | 单次落地上限,超出的留到下一次;防止一批两万行把一帧撑爆 |
autoColumns | boolean | 'ifEmpty' | 'ifEmpty' | 'ifEmpty' = 表格当前没有列才推断;true = 每轮 startAIStream() 后重新推断 |
inferOptions | InferColumnsOptions | — | 透传给 inferColumns()(itemKey 自动带上表格的) |
followBottom | boolean | true | 是否自动跟随底部 |
followThreshold | number | 40 | 判定「在底部」的容差(px)。动态行高下总高会被 ResizeObserver 持续修正,判定得留余量 |
indicator | boolean | true | 右下角的接收指示 |
onFlush | (rows, state) => void | — | 每次落地后触发 |
onStateChange | (state) => void | — | 状态变化时触发 |
方法
| 方法 | 说明 |
|---|---|
consumeAIStream(source, opts?) | 消费一个 AsyncIterable;opts.signal 可取消,opts.autoEnd 默认 true |
startAIStream() | 开始新一轮,重置计数与跟随态(不清数据,清不清由你 setList([]) 决定) |
pushAIStreamRows(rows) | 推入一行或一批 |
endAIStream() | 结束接收,同步落掉尾巴 |
abortAIStream(err?) | 中断;已收到的行保留(半截数据也是数据) |
getAIStreamState() | { status, received, buffered, following, error } |
isAIStreamFollowingBottom() / setAIStreamFollowBottom(on) | 跟随态读写 |
两点说明
- 没显式
startAIStream()就直接pushAIStreamRows()也算开始,少一步仪式。 - 源抛错时
consumeAIStream会 reject 并把状态置为error,但不回滚已落地的行。
示例
微应用尚未挂载。
关于这个 demo
表格初始 columns: [],列是首批数据到达时推断出来的 —— 注意金额右对齐带千分位、结清居中、 而订单号虽然是数字串却按文本左对齐。接收中往上滚会脱离底部跟随(指示器上的点停止呼吸), 滚回底部自动恢复。「灌 2 万行」验证 maxRowsPerFlush 会自动分批,不会把一帧撑爆。
源码
点击查看源码
ts
import { VirtTable } from '@virt-table/vanilla';
import { vtAIStream } from '@virt-table/vanilla/plugins';
/**
* vtAIStream:一边收数据一边渲染,列自己长出来。
*
* 这个 demo 模拟的是「agent 跑完一段任务,把结果一行一行吐回来」——
* 表格初始**没有任何列配置**(`columns: []`),首批数据到达时由
* `inferColumns()` 推断出列(类型、标题、宽度、对齐都是算出来的)。
*
* 换成真实数据源只需替换这个生成器,比如吃 SSE:
*
* ```ts
* async function* rowsFromSSE(url: string) {
* const res = await fetch(url);
* const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
* let buf = '';
* for (;;) {
* const { value, done } = await reader.read();
* if (done) break;
* buf += value;
* const lines = buf.split('\n');
* buf = lines.pop()!;
* for (const line of lines) {
* if (line.startsWith('data: ')) yield JSON.parse(line.slice(6));
* }
* }
* }
* await table.consumeAIStream(rowsFromSSE('/api/rows'));
* ```
*/
const FIRST_NAMES = ['林', '陈', '王', '李', '张', '刘', '黄', '周', '吴', '徐'];
const GIVEN = ['明', '静', '磊', '娜', '强', '敏', '杰', '芳', '涛', '燕'];
const CITIES = ['上海', '北京', '深圳', '杭州', '成都', '广州', '武汉', '西安'];
const CHANNELS = ['官网', '小程序', '门店', '电话', '第三方平台'];
const STATUS = ['已完成', '处理中', '待审核', '已取消'];
/** 一条「agent 抓回来的」记录。注意 key 是 camelCase,标题由 humanizeKey 推出来 */
function makeRow(i: number) {
const rnd = (n: number) => Math.floor(Math.random() * n);
return {
id: i,
orderNo: `SO${String(202600000 + i)}`, // 纯数字串:不该被当成数字右对齐
customerName: FIRST_NAMES[rnd(FIRST_NAMES.length)] + GIVEN[rnd(GIVEN.length)],
city: CITIES[rnd(CITIES.length)],
channel: CHANNELS[rnd(CHANNELS.length)],
amount: rnd(980000) + 2000, // 数字:应当右对齐 + 千分位
settled: Math.random() > 0.4, // 布尔:应当居中
createdAt: new Date(Date.now() - rnd(86400000 * 90)).toISOString().slice(0, 10), // 日期
status: STATUS[rnd(STATUS.length)],
};
}
export function bootstrapTableAIStream(root: HTMLElement): () => void {
root.innerHTML = `
<div class="demo-toolbar" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px;">
<button type="button" id="btnStart" style="cursor:pointer;font-size:13px;padding:4px 14px;border-radius:6px;border:1px solid var(--vp-c-divider,#ddd);background:transparent;color:inherit;">开始接收</button>
<button type="button" id="btnStop" style="cursor:pointer;font-size:13px;padding:4px 14px;border-radius:6px;border:1px solid var(--vp-c-divider,#ddd);background:transparent;color:inherit;">停止</button>
<button type="button" id="btnBurst" style="cursor:pointer;font-size:13px;padding:4px 14px;border-radius:6px;border:1px solid var(--vp-c-divider,#ddd);background:transparent;color:inherit;">灌 2 万行</button>
<button type="button" id="btnReset" style="cursor:pointer;font-size:13px;padding:4px 14px;border-radius:6px;border:1px solid var(--vp-c-divider,#ddd);background:transparent;color:inherit;">清空重来</button>
<span id="streamStat" style="font-size:12px;opacity:.7;margin-left:4px;font-variant-numeric:tabular-nums;"></span>
</div>
<div style="font-size:12px;opacity:.6;margin-bottom:8px;">
表格初始<strong>没有列配置</strong>,列是首批数据到达时推断出来的(金额右对齐 + 千分位、结清居中、
订单号虽是数字串但不右对齐)。接收中往上滚会脱离底部跟随,滚回底部自动恢复。
</div>
<div style="width:100%;height:520px;" class="demo-container" id="tableContainer"></div>
`;
const container = root.querySelector('#tableContainer') as HTMLElement;
const statEl = root.querySelector('#streamStat') as HTMLElement;
const table = new VirtTable(container, {
list: [],
columns: [], // 故意留空 —— 列由 vtAIStream 推断
itemKey: 'id',
estimatedSize: 40,
buffer: 6,
border: true,
emptyText: '等待数据…点上方「开始接收」',
plugins: [
vtAIStream({
// 给数字列配上千分位(inferColumns 推出 cellType: 'number' 后由核心渲染)
inferOptions: { minWidth: 96 },
onStateChange(state) {
statEl.textContent =
state.status === 'idle'
? ''
: `${state.status} · 已落地 ${state.received.toLocaleString()} 行` +
(state.buffered > 0 ? ` · 缓冲 ${state.buffered}` : '') +
(state.following ? '' : ' · 已脱离跟随');
},
}),
],
});
let seq = 0;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
/** 模拟「模型逐条吐结果」:每 40ms 来 1~6 行,节奏不均匀 */
const startTrickle = () => {
stopped = false;
table.startAIStream();
const step = () => {
if (stopped) return;
const n = 1 + Math.floor(Math.random() * 6);
table.pushAIStreamRows(Array.from({ length: n }, () => makeRow(seq++)));
timer = setTimeout(step, 30 + Math.random() * 60);
};
step();
};
const stop = () => {
stopped = true;
if (timer) clearTimeout(timer);
timer = null;
table.endAIStream();
};
root.querySelector('#btnStart')!.addEventListener('click', () => {
if (!stopped && timer) return;
startTrickle();
});
root.querySelector('#btnStop')!.addEventListener('click', stop);
// 一次灌很多:验证批量落地不会把一帧撑爆(maxRowsPerFlush 会分批)
root.querySelector('#btnBurst')!.addEventListener('click', () => {
table.pushAIStreamRows(Array.from({ length: 20000 }, () => makeRow(seq++)));
});
root.querySelector('#btnReset')!.addEventListener('click', () => {
stop();
seq = 0;
table.setList([]);
table.setColumns([]);
table.startAIStream();
statEl.textContent = '';
});
return () => {
stopped = true;
if (timer) clearTimeout(timer);
table.destroy();
root.innerHTML = '';
};
}