Skip to content

服务端数据 server data

把排序 / 筛选 / 分页交给服务端:表格不再本地排序、筛选、切片,只把这些状态收进一次请求。

三个细粒度开关

真实项目常见混合口径(服务端分页 + 前端排已加载的那一页),所以按维度分开控制:

ts
{
  manualSorting?: boolean;    // 排序交给服务端
  manualFiltering?: boolean;  // 筛选交给服务端(列头筛选 + 高级筛选)
  manualPagination?: boolean; // 分页/加载交给服务端
  dataMode?: 'client' | 'server';  // 快捷方式:'server' 等价于上面三个全开
}

开启后对应的本地管线阶段被跳过(数据已由服务端排好/筛好,本地再算一遍既冗余又会与服务端口径打架),状态变化则触发重新取数并回到第 1 页

完整示例

ts
const table = new VirtTable(el, {
  list: [],
  columns,          // 列上照常写 sortable / filters / filterType
  itemKey: 'id',
  estimatedSize: 40,
  dataMode: 'server',
  sortMode: 'multiple',
  pagination: { total: 0, pageSize: 20, pageSizes: [10, 20, 50] },
  async loadData(req) {
    const res = await fetch('/api/rows?' + new URLSearchParams({
      page: String(req.page),
      pageSize: String(req.pageSize),
      // req.sort: [{ colKey, order }];req.filters: { colKey: values[] }
      sort: JSON.stringify(req.sort),
      filters: JSON.stringify(req.filters),
      filterModel: JSON.stringify(req.filterModel),
    }), { signal: req.signal });
    const { rows, total } = await res.json();
    return { rows, total };
  },
});

请求参数

ts
interface DataRequest {
  page: number;        // 目标页码(1 基)
  pageSize: number;
  offset: number;      // main 为 (page-1)*pageSize,追加时为已加载条数
  cursor: string | null;      // 向下游标(append 时带上)
  prevCursor: string | null;
  loadedCount: number;
  sort: SortSpec[];               // [{ colKey, order }]
  filters: Record<string, unknown[]>;  // 列头筛选
  filterModel: FilterModel;            // 高级筛选条件树(AND/OR)
  search: string;
  reason: 'init' | 'reload' | 'refresh' | 'page' | 'sort' | 'filter' | 'append' | 'prepend' | 'retry';
  channel: 'main' | 'more' | 'prev';
  signal: AbortSignal;   // 被新请求抢占 / 表格销毁时中断
}

reason 让你能区分「用户换了筛选」和「滚动追加」,据此决定要不要带上完整过滤条件、要不要打埋点。

竞态与抢占

  • 换排序/筛选/翻页走 main 通道:会 abort 掉所有在途请求并作废其响应,只采用最后一次。连点筛选不会出现旧数据覆盖新数据。
  • 滚动追加走 more 通道、向上加载走 prev 通道:两者可并发,各自加锁。
  • 请求失败保留已加载数据,retryLoad() 用同一批参数重试。

分页与无限滚动是二选一

pagination页视图(每次取数 replace,「当前第几页」是唯一状态);infinite累积流(append,页码只表示取到第几批)。同时启用会让「当前第几页」有两个互相打架的含义,因此表格以 pagination 为准、忽略 infinite 并在控制台告警。

按场景选一个:需要精确跳页、可分享「第 N 页」→ 分页;数据流式浏览、不关心页边界 → 无限滚动。

没有 loadData / onLoadMore 时,分页条退回原有行为:只派发 onPageChange,由你自己取数 + setList + setTotal

服务端聚合与总数

ts
return {
  rows,
  total,                       // 分页条总数 + hasMore 判定
  footerData: [['合计', '', '', String(sumQty), String(sumPrice)]],  // 直接接管表尾
};

服务端模式下本地 showSummary 只能算已加载数据,全量口径请用 footerData 注入。

示例

微应用尚未挂载。

源码

点击查看源码
ts
import { faker } from '@faker-js/faker';
import {
  VirtTable,
  type VirtTableColumn,
  type DataRequest,
  type DataResponse,
} from '@virt-table/vanilla';
import {
  vtColumnFilter,
} from '@virt-table/vanilla/plugins';

interface Row { id: number; name: string; dept: string; qty: number; price: number }

/**
 * 服务端数据示例:`dataMode: 'server'` + `pagination` + `loadData`。
 *
 * 表头点排序、列头筛选、翻页/切换每页条数**全部交给服务端**:表格不再本地
 * 排序/筛选/切片,只把状态收进一次请求(`req.sort` / `req.filters` / `req.page`),
 * 并处理竞态(连点筛选只采用最后一次响应)。
 *
 * 这里用 400 条本地数据模拟服务端查询;右下角日志显示每次实际下发的请求参数。
 */
export function bootstrapTableServerData(root: HTMLElement): () => void {
  const depts = ['工程部', '设计部', '市场部', '财务部'];
  const columns: VirtTableColumn<Row>[] = [
    { key: 'id', title: 'ID', width: 80, sortable: true },
    { key: 'name', title: '姓名', width: 200, filterType: 'text' },
    {
      key: 'dept',
      title: '部门',
      width: 160,
      filters: depts.map((d) => ({ label: d, value: d })),
      filterMultiple: true,
    },
    { key: 'qty', title: '数量', width: 130, align: 'right', sortable: true },
    { key: 'price', title: '单价', width: 130, align: 'right', sortable: true },
  ];

  // 模拟服务端数据(真实场景中在后端)
  const TOTAL = 400;
  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 }),
  }));

  const initialPageSize = 20;
  let reqSeq = 0;

  root.innerHTML = `
    <div class="demo-hint">服务端模式:排序、筛选、翻页都通过一次请求完成(表格不做本地排序/筛选/切片)。切排序或筛选会自动回到第 1 页重新取数。</div>
    <div class="virt-table-controls">
      <button id="refresh">refresh()(保留滚动位置重拉)</button>
      <button id="reload">reload()(回到第 1 页)</button>
      <span id="stat" class="demo-note"></span>
    </div>
    <div style="width:800px;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 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');
  };

  /** 模拟服务端查询:筛选 → 排序 → 分页切片 */
  const loadData = (req: DataRequest): Promise<DataResponse<Row>> => {
    const seq = ++reqSeq;
    const sortDesc = req.sort.map((s) => `${s.colKey} ${s.order}`).join(', ') || '-';
    const filterDesc = Object.keys(req.filters).length ? JSON.stringify(req.filters) : '-';
    log(`#${seq} ${req.reason} page=${req.page} size=${req.pageSize} sort=[${sortDesc}] filters=${filterDesc}`);

    return new Promise((resolve) => {
      setTimeout(() => {
        let rows = db;

        // 筛选(服务端语义:部门多选取并集,姓名关键字模糊匹配)
        for (const [key, values] of Object.entries(req.filters)) {
          if (!values.length) continue;
          if (key === 'name') {
            const kw = String(values[0]).toLowerCase();
            rows = rows.filter((r) => r.name.toLowerCase().includes(kw));
          } else {
            rows = rows.filter((r) => values.includes(r[key as keyof Row] as never));
          }
        }

        // 排序(多列:按 sort 数组顺序依次比较)
        if (req.sort.length) {
          rows = rows.slice().sort((a, b) => {
            for (const { colKey, order } of req.sort) {
              const av = a[colKey as keyof Row];
              const bv = b[colKey as keyof Row];
              if (av === bv) continue;
              const r = typeof av === 'number' && typeof bv === 'number'
                ? av - bv
                : String(av) < String(bv) ? -1 : 1;
              return order === 'desc' ? -r : r;
            }
            return 0;
          });
        }

        const total = rows.length;
        const page = rows.slice(req.offset, req.offset + req.pageSize);
        log(`#${seq} ✓ 返回 ${page.length} 条 / 命中 ${total} 条`);
        resolve({ rows: page, total });
      }, 350);
    });
  };

  const table = new VirtTable<Row>(container, {
    plugins: [vtColumnFilter()],
    list: [],
    columns,
    itemKey: 'id',
    estimatedSize: 40,
    buffer: 6,
    border: true,
    // 排序 / 筛选 / 分页全部交给服务端(等价于 manualSorting + manualFiltering + manualPagination)
    dataMode: 'server',
    sortMode: 'multiple',
    pagination: {
      total: 0,
      pageSize: initialPageSize,
      pageSizes: [10, 20, 50],
    },
    loadData,
    onRemoteStateChange: (st) => {
      statEl.textContent = `第 ${st.page} 页 · 共 ${st.total} 条${st.loading ? ' · 请求中' : ''}`;
    },
  });

  (root.querySelector('#refresh') as HTMLButtonElement).addEventListener('click', () => table.refresh());
  (root.querySelector('#reload') as HTMLButtonElement).addEventListener('click', () => table.reload());

  return () => {
    table.destroy();
    root.innerHTML = '';
  };
}