Skip to content

React 流式表格

vtAIStream:一边收数据一边渲染,列自己长出来。表格初始没有任何列配置(columns: []),首批数据到达时由 inferColumns() 推断出列——类型、标题、宽度、对齐都是算出来的。

接收中往上滚会脱离底部跟随,滚回底部自动恢复。

使用的 API

tsx
import { VirtTableReact, vtAIStream, type AIStreamState } from '@virt-table/react';

const columns: ReactTableColumn[] = [];  // 故意留空 —— 列由 vtAIStream 推断

const plugins = React.useMemo(
  () => [
    vtAIStream({
      inferOptions: { minWidth: 96 },
      onStateChange(state: AIStreamState) {
        // state.status / received / buffered / following
      },
    }),
  ],
  [],
);

// 实例方法(ref 上调用)
tableRef.current?.startAIStream();
tableRef.current?.pushAIStreamRows(rows);   // 攒到下一帧统一落地
tableRef.current?.endAIStream();
// 或者直接吃一个 AsyncIterable(SSE / ReadableStream / 异步生成器)
tableRef.current?.consumeAIStream(rowsFromSSE('/api/rows'));

完整语义(帧调度与 maxRowsPerFlush、列推断规则、跟随态、中断与错误)见 Vanilla · 流式表格

示例

微应用尚未挂载。

源码

点击查看源码
tsx
import React from 'react';
import {
  VirtTableReact,
  type ReactTableColumn,
  type VirtTableRef,
  type AIStreamState,
  vtAIStream,
} from '@virt-table/react';

/**
 * vtAIStream:一边收数据一边渲染,列自己长出来。
 *
 * 这个 demo 模拟的是「agent 跑完一段任务,把结果一行一行吐回来」——
 * 表格初始**没有任何列配置**(`columns: []`),首批数据到达时由
 * `inferColumns()` 推断出列(类型、标题、宽度、对齐都是算出来的)。
 *
 * 换成真实数据源只需替换这个生成器(吃任何 `AsyncIterable`,SSE /
 * `ReadableStream` / 异步生成器都行),再交给 `consumeAIStream()`。
 */
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)]!,
  };
}

// 故意留空 —— 列由 vtAIStream 推断
const columns: ReactTableColumn[] = [];

export default function StreamTable() {
  const tableRef = React.useRef<VirtTableRef>(null);
  const [stat, setStat] = React.useState('');
  const seqRef = React.useRef(0);
  const stoppedRef = React.useRef(true);
  const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);

  const plugins = React.useMemo(
    () => [
      vtAIStream({
        inferOptions: { minWidth: 96 },
        onStateChange(state: AIStreamState) {
          setStat(
            state.status === 'idle'
              ? ''
              : `${state.status} · 已落地 ${state.received.toLocaleString()} 行` +
                  (state.buffered > 0 ? ` · 缓冲 ${state.buffered}` : '') +
                  (state.following ? '' : ' · 已脱离跟随'),
          );
        },
      }),
    ],
    [],
  );

  const stop = React.useCallback(() => {
    stoppedRef.current = true;
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = null;
    tableRef.current?.endAIStream();
  }, []);

  /** 模拟「模型逐条吐结果」:每 30~90ms 来 1~6 行,节奏不均匀 */
  const start = (): void => {
    if (!stoppedRef.current && timerRef.current) return;
    stoppedRef.current = false;
    tableRef.current?.startAIStream();
    const step = (): void => {
      if (stoppedRef.current) return;
      const n = 1 + Math.floor(Math.random() * 6);
      tableRef.current?.pushAIStreamRows(Array.from({ length: n }, () => makeRow(seqRef.current++)));
      timerRef.current = setTimeout(step, 30 + Math.random() * 60);
    };
    step();
  };

  React.useEffect(() => {
    return () => {
      stoppedRef.current = true;
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, []);

  return (
    <div className="demo-wrapper">
      <h3 className="demo-title">React 流式表格</h3>
      <div className="virt-table-controls">
        <button type="button" className="virt-table-btn virt-table-btn-primary" onClick={start}>
          开始接收
        </button>
        <button type="button" className="virt-table-btn" onClick={stop}>
          停止
        </button>
        <button
          type="button"
          className="virt-table-btn"
          // 一次灌很多:验证批量落地不会把一帧撑爆(maxRowsPerFlush 会分批)
          onClick={() =>
            tableRef.current?.pushAIStreamRows(
              Array.from({ length: 20000 }, () => makeRow(seqRef.current++)),
            )
          }
        >
          灌 2 万行
        </button>
        <button
          type="button"
          className="virt-table-btn"
          onClick={() => {
            stop();
            seqRef.current = 0;
            tableRef.current?.setList([]);
            tableRef.current?.setColumns([]);
            tableRef.current?.startAIStream();
            setStat('');
          }}
        >
          清空重来
        </button>
        <span className="demo-note" style={{ fontVariantNumeric: 'tabular-nums' }}>
          {stat}
        </span>
      </div>
      <div className="demo-hint">
        表格初始<strong>没有列配置</strong>,列是首批数据到达时推断出来的(金额右对齐 +
        千分位、结清居中、订单号虽是数字串但不右对齐)。接收中往上滚会脱离底部跟随,滚回底部自动恢复。
      </div>
      <div style={{ width: '100%', height: 520 }} className="demo-container">
        <VirtTableReact
          ref={tableRef}
          columns={columns}
          options={{
            list: [],
            itemKey: 'id',
            estimatedSize: 40,
            buffer: 6,
            border: true,
            emptyText: '等待数据…点上方「开始接收」',
            plugins,
          }}
        />
      </div>
    </div>
  );
}