Skip to content

树形懒加载 lazy tree

树形数据按需取子节点:首屏只给根节点,首次展开某节点时才去请求它的 children

loadChildren 即启用(配合 type: 'tree' 列)。

ts
const table = new VirtTable(el, {
  list: rootRows,          // 只给根节点
  columns: [{ key: 'name', title: '名称', width: 320, type: 'tree' }, /* … */],
  itemKey: 'id',
  estimatedSize: 40,
  // 未加载时靠它决定要不要显示展开箭头;默认读 row.hasChildren
  hasChildren: (row) => !!row.hasChildren,
  async loadChildren(row, ctx) {
    const res = await fetch(`/api/children?parent=${row.id}`, { signal: ctx.signal });
    return res.json();     // 返回的行会写进 row.children
  },
  onChildrenLoaded: (row, children) => {},
  onChildrenLoadError: (row, err) => {},
});

行为约定

场景行为
未加载但「可能有子节点」照常显示展开箭头(由 hasChildren 判定,默认读 row.hasChildren
取数期间该节点箭头变小 spinner;点击被忽略(见下)
同一节点重复触发在途只请求一次;已加载过不再请求(走缓存)
返回空数组记为「确实没有子节点」,箭头变叶子,不会重复请求
取数失败回到折叠态并触发 onChildrenLoadError再点一次即重试
表格销毁中断所有在途请求

加载中为什么忽略点击

若允许翻转,连点会按奇偶决定最终折叠态,数据到达时展开与否就变得随机;而此时箭头已经是 spinner(cursor: progress),本就不该可点。

defaultExpandAll / expandAll() 的关系

两者只作用于已加载的节点——否则初始化就会把整棵树递归拉下来,与懒加载的意图相反。懒节点的初始状态恒为折叠。

需要预展开某条路径时,逐级调用 loadChildrenFor(rowKey)(它会取数并在数据到位后展开)。

方法

方法说明
loadChildrenFor(rowKey)主动取某节点的子节点(等价于用户首次展开)
resetLazyNode(rowKey, clearChildren = true)清掉该节点的「已加载」标记(并可选清空 children),下次展开重新取数——用于刷新子树

与其他能力的关系

  • 子节点数据写进 row.children,因此筛选/排序/合计只覆盖已加载的部分(未展开的子树对表格不可见)。
  • groupConfig(分组)叠加时,懒加载只作用于树层级。
  • 与无限滚动是两件独立的事,可同时使用:外层滚动追加根节点,展开时懒取子节点。

示例

微应用尚未挂载。

源码

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

interface Row extends Record<string, unknown> {
  id: string;
  name: string;
  role: string;
  count: number;
  /** 服务端告知「可能有子节点」,决定未加载时是否显示展开箭头 */
  hasChildren?: boolean;
  children?: Row[];
}

/**
 * 树形子节点懒加载:`loadChildren` + `hasChildren`。
 *
 * 首屏只给根节点;首次展开某节点时才去取它的子节点,取数期间该节点箭头变 spinner。
 * 同一节点在途只请求一次;返回空数组记为「确实没有子节点」,箭头变叶子且不再请求。
 * 失败会回到折叠态,再点一次即重试。
 */
export function bootstrapTableLazyTree(root: HTMLElement): () => void {
  const columns: VirtTableColumn<Row>[] = [
    { key: 'name', title: '名称', width: 320, type: 'tree' },
    { key: 'role', title: '类型', width: 160 },
    { key: 'count', title: '数量', width: 120, align: 'right' },
  ];

  // 模拟服务端:按父节点 id 造子节点(3 层,第 3 层为叶子)
  const makeChildren = (parent: Row, level: number): Row[] => {
    const count = level >= 3 ? 0 : 3 + ((parent.name.length + level) % 3);
    return Array.from({ length: count }, (_, i) => ({
      id: `${parent.id}-${i + 1}`,
      name: level === 1 ? `${parent.name} / 小组 ${i + 1}` : faker.person.fullName(),
      role: level === 1 ? '小组' : '成员',
      count: faker.number.int({ min: 1, max: 99 }),
      // 第 3 层不再有子节点
      hasChildren: level < 2,
    }));
  };

  const rootRows: Row[] = ['工程部', '设计部', '市场部', '财务部'].map((d, i) => ({
    id: `d${i + 1}`,
    name: d,
    role: '部门',
    count: faker.number.int({ min: 10, max: 99 }),
    hasChildren: true,
  }));

  let failNext = false;
  let reqSeq = 0;

  root.innerHTML = `
    <div class="demo-hint">首屏只加载 4 个根节点。点箭头展开时才去取子节点(箭头位置显示 spinner),同一节点在途只请求一次;返回空数组的节点会变成叶子。</div>
    <div class="virt-table-controls">
      <label><input type="checkbox" id="fail" /> 注入故障(下一次展开失败)</label>
      <button id="reset">重置「工程部」缓存(下次展开重新取数)</button>
      <span id="stat" class="demo-note"></span>
    </div>
    <div style="width:760px;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 failEl = root.querySelector('#fail') as HTMLInputElement;
  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');
  };

  let loadedNodes = 0;

  const table = new VirtTable<Row>(container, {
    list: rootRows,
    columns,
    itemKey: 'id',
    estimatedSize: 40,
    buffer: 6,
    border: true,
    // 未加载时靠 hasChildren 判断要不要给箭头(默认就是读 row.hasChildren,这里显式写出)
    hasChildren: (row) => !!row.hasChildren,
    loadChildren: (row, ctx) => {
      const seq = ++reqSeq;
      log(`#${seq} loadChildren(${row.name}) level=${ctx.level}`);
      return new Promise<Row[]>((resolve, reject) => {
        setTimeout(() => {
          if (failNext) {
            failNext = false;
            failEl.checked = false;
            log(`#${seq} ✗ 失败(再点一次即重试)`);
            reject(new Error('mock network error'));
            return;
          }
          const kids = makeChildren(row, ctx.level);
          log(`#${seq} ✓ 返回 ${kids.length} 个子节点`);
          resolve(kids);
        }, 500);
      });
    },
    onChildrenLoaded: (row, children) => {
      loadedNodes += 1;
      statEl.textContent = `已展开取数 ${loadedNodes} 个节点 · 最近:${row.name}(${children.length} 个子节点)`;
    },
    onChildrenLoadError: (row, err) => {
      statEl.textContent = `「${row.name}」取子节点失败:${(err as Error).message}`;
    },
  });

  failEl.addEventListener('change', () => {
    failNext = failEl.checked;
  });
  (root.querySelector('#reset') as HTMLButtonElement).addEventListener('click', () => {
    table.resetLazyNode('d1');
    log('resetLazyNode(d1):已清缓存与 children');
  });

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