Skip to content

React 无限滚动

配置 infinite 并给一个 loadData,滚到底部就自动加载下一批。表格负责在途去重、竞态作废、状态条四态与滚动位置维持;你只负责发请求。

使用的 API

ts
type Options = {
  list: T[];             // 无限滚动时传 [],数据由表格内部累积
  itemKey: string;       // 行唯一标识字段名
  estimatedSize: number;   // 行预估高度(px)
  fixed?: boolean;       // 固定行高:追加时滚动条不因重新测量而抖动
  infinite?: {
    pageSize?: number;   // 每批条数,默认 50
    distance?: number;   // 距底触发阈值(px),默认 200
    manual?: boolean;    // 只显示「加载更多」按钮,不自动触发
    direction?: 'down' | 'up' | 'both';  // 加载方向,默认 down
    showNoMore?: boolean;// 到底后显示「没有更多」,默认 true
  };
  loadData?: (req: DataRequest) => Promise<DataResponse<T>>;   // 取数糖层
  onLoadMore?: (ctx: LoadMoreContext<T>) => void | Promise<void>; // 受控层(优先)
  onLoad?: (res, req) => void;
  onLoadError?: (err, req) => void;
  onRemoteStateChange?: (state: RemoteState) => void;
}

ref 方法(VirtTableRef):reload() / refresh() / loadMore() / loadPrev() / retryLoad() / getRemoteState() / setLoadData() / appendRows() / prependRows() / setHasMore() / setCursor()

关键写法

tsx
const tableRef = useRef<VirtTableRef>(null);

<VirtTableReact
  ref={tableRef}
  columns={columns}
  options={{
    list: [],
    itemKey: 'id',
    estimatedSize: 40,
    fixedSize: true,
    infinite: { pageSize: 50 },
    // 不需要 useCallback:组件内部用 ref 转发,函数身份变化不会重建表格
    async loadData(req: DataRequest): Promise<DataResponse<Row>> {
      const res = await fetch(`/api/rows?offset=${req.offset}&limit=${req.pageSize}`, { signal: req.signal });
      const { rows, total } = await res.json();
      return { rows, total };
    },
    onRemoteStateChange: setRemote,
  }}
/>

取数回调不用 useCallback,但要读最新 state 时用 ref

表格只在 mount 时创建一次。组件内部已把取数回调换成读 ref 的稳定包装函数,因此函数身份变化不会重建表格,也不会触发重新取数,同时闭包里能拿到最新的 props。

loadData 内部若要读某个会变的 state(如示例里的「注入故障」开关),仍要自己用 ref 承接——因为那份闭包是渲染时创建的:

tsx
const failRef = useRef(failNext);
failRef.current = failNext;   // 每次渲染同步

「是否启用」由首屏是否提供决定:首屏没传 loadData,之后要补上请用 ref.current.setLoadData(fn)

远程模式下 list 只是初始值

内联写在 JSX 上的 options 对象(含 list: [])每次渲染都是新引用。提供 loadData / onLoadMore 后,组件对 options.list 的同步会自动跳过,因此不会把累积数据清空——传 [] 即可,数据归表格管。

另外 infinite 配置在构造时生效,示例里切换「手动模式」是靠 key 重建组件实现的。

完整语义(竞态处理、hasMore 推导、向上加载补偿、与 pagination 互斥、限制)见 Vanilla · 无限滚动

示例

微应用尚未挂载。

源码

点击查看源码
tsx
import React from 'react';
import {
  VirtTableReact,
  type ReactTableColumn,
  type VirtTableRef,
  type DataRequest,
  type DataResponse,
  type RemoteState,
} from '@virt-table/react';
import { faker } from '@faker-js/faker';

interface Row extends Record<string, unknown> {
  id: number;
  name: string;
  dept: string;
  qty: number;
}

const depts = ['工程部', '设计部', '市场部', '财务部'];
const TOTAL = 3000;
// 模拟服务端数据(真实场景在后端)。懒生成:playground 会 import 全部示例模块,
// 顶层就造几千条 faker 数据会拖慢整个 demo 站的首屏。
let _db: Row[] | null = null;
const getDb = (): Row[] => (_db ??= 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 }),
})));

const columns: ReactTableColumn<Row>[] = [
  { key: 'id', title: 'ID', width: 80 },
  { key: 'name', title: '姓名', width: 240 },
  { key: 'dept', title: '部门', width: 160 },
  { key: 'qty', title: '数量', width: 140, align: 'right' },
];

export default function InfiniteTable() {
  const tableRef = React.useRef<VirtTableRef>(null);
  const [manual, setManual] = React.useState(false);
  const [failNext, setFailNext] = React.useState(false);
  const [status, setStatus] = React.useState('等待首屏取数…');
  const [logs, setLogs] = React.useState<string[]>([]);
  const seqRef = React.useRef(0);
  // 故障开关要被 loadData 读到最新值;loadData 本身不需要稳定化(封装内部走 ref 转发)
  const failRef = React.useRef(failNext);
  failRef.current = failNext;

  const log = (msg: string): void => setLogs((prev) => [msg, ...prev].slice(0, 8));

  const loadData = (req: DataRequest): Promise<DataResponse<Row>> => {
    const seq = ++seqRef.current;
    log(`#${seq} ${req.reason} offset=${req.offset} pageSize=${req.pageSize}`);
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (failRef.current) {
          setFailNext(false);
          log(`#${seq} ✗ 失败(可点状态条重试)`);
          reject(new Error('mock network error'));
          return;
        }
        const rows = getDb().slice(req.offset, req.offset + req.pageSize);
        log(`#${seq} ✓ 返回 ${rows.length} 条`);
        resolve({ rows, total: TOTAL });
      }, 400);
    });
  };

  return (
    <div className="demo-wrapper">
      <h3 className="demo-title">React 无限滚动</h3>
      <div className="virt-table-controls">
        <label>
          <input type="checkbox" checked={manual} onChange={(e) => setManual(e.target.checked)} />
          手动模式(只显示「加载更多」按钮)
        </label>
        <label>
          <input type="checkbox" checked={failNext} onChange={(e) => setFailNext(e.target.checked)} />
          注入故障(下一次请求失败)
        </label>
        <button type="button" onClick={() => tableRef.current?.reload()}>reload()</button>
        <button type="button" onClick={() => tableRef.current?.refresh()}>refresh()</button>
        <button type="button" onClick={() => tableRef.current?.loadMore()}>loadMore()</button>
      </div>
      <div className="status-text">{status}</div>
      <div style={{ width: 820, height: 460 }} className="demo-container">
        {/* key 让「手动模式」切换后重建表格(infinite 配置在构造时生效) */}
        <VirtTableReact
          key={manual ? 'manual' : 'auto'}
          ref={tableRef}
          columns={columns}
          options={{
            list: [],
            itemKey: 'id',
            estimatedSize: 40,
            fixedSize: true,
            buffer: 6,
            border: true,
            infinite: { pageSize: 50, distance: 200, manual },
            loadData,
            onRemoteStateChange: (st: RemoteState) =>
              setStatus(
                `已加载 ${st.loadedCount}/${st.total} 条 · 第 ${st.page} 批` +
                  (st.hasMore ? '' : ' · 已到底') +
                  (st.loadingMore ? ' · 追加中' : ''),
              ),
            onLoadError: (err: unknown) => log(`onLoadError: ${(err as Error).message}`),
          }}
        />
      </div>
      <div
        className="status-text"
        style={{ marginTop: 12, whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, Menlo, monospace' }}
      >
        {logs.slice(0, 6).join('\n')}
      </div>
    </div>
  );
}