Skip to content
实时预览虚拟渲染
virtual

虚拟渲染

实时预览和 pnpm play 的“大数据虚拟渲染”共用同一个数据生成器:默认稳定生成 12,000 个节点,叶子随机分布在 2~6 层。开启 virtual 后,组件只渲染可视区域及缓冲区内的行。

数据使用固定种子的伪随机算法,而不是每次刷新都变化的真随机算法,因此两处示例的数据结构、节点 key 和定位目标始终一致,便于复现和比较性能。

实时预览对应代码

下面直接引入右侧实时预览使用的 Vue 组件源码,文档代码与实际运行内容保持同源:

vue
<template>
  <view class="docs-demo-stack">
    <view class="docs-demo-metric">
      <view class="docs-demo-metric__item">
        <view class="docs-demo-metric__value">{{ nodeCount }}</view>
        <view class="docs-demo-metric__label">总节点数</view>
      </view>
      <view class="docs-demo-metric__item">
        <view class="docs-demo-metric__value">{{ depthText }}</view>
        <view class="docs-demo-metric__label">随机层级</view>
      </view>
      <view class="docs-demo-metric__item">
        <view class="docs-demo-metric__value">{{ selectedText }}</view>
        <view class="docs-demo-metric__label">当前已选</view>
      </view>
    </view>

    <view class="docs-demo-toolbar docs-demo-toolbar--space">
      <text class="docs-demo-caption">固定种子 · 随机分支 · 可视区渲染</text>
      <button class="docs-demo-chip is-active" @click="locateTarget">
        定位 6 级节点
      </button>
    </view>

    <view class="docs-demo-card">
      <view class="docs-demo-card__header">
        <text class="docs-demo-card__title">万级区域树</text>
        <view class="docs-demo-card__meta">
          <view class="docs-demo-card__meta-dot"></view>
          virtual
        </view>
      </view>
      <uni-tree-view
        ref="treeRef"
        v-model="checkedValue"
        selectable
        multiple
        check-on-click-node
        virtual
        default-expand-all
        :virtual-height="320"
        :virtual-item-height="36"
        :virtual-overscan="12"
        :data="treeData"
        theme-color="#299764"></uni-tree-view>
    </view>

    <view class="docs-demo-status">
      <view class="docs-demo-status__icon"></view>
      <view class="docs-demo-status__content">
        <text class="docs-demo-status__title">{{ locateMessage }}</text>
        <text class="docs-demo-status__detail">{{ locateDetail }}</text>
      </view>
    </view>

    <view class="docs-demo-section-label">
      <text>虚拟渲染 + 懒加载</text>
      <text class="docs-demo-section-label__value">80 个异步根节点</text>
    </view>

    <view class="docs-demo-card">
      <view class="docs-demo-card__header">
        <text class="docs-demo-card__title">按需加载网点</text>
        <view class="docs-demo-card__meta">
          <view class="docs-demo-card__meta-dot"></view>
          virtual + load-mode
        </view>
      </view>
      <uni-tree-view
        v-model="lazyCheckedValue"
        selectable
        multiple
        check-on-click-node
        virtual
        load-mode
        :virtual-height="240"
        :virtual-item-height="36"
        :virtual-overscan="8"
        :data="lazyTreeData"
        :load-api="lazyLoader.load"
        theme-color="#299764"
        @load="handleLazyLoad"
        @load-error="handleLazyLoadError"></uni-tree-view>
    </view>

    <view class="docs-demo-status">
      <view class="docs-demo-status__icon"></view>
      <view class="docs-demo-status__content">
        <text class="docs-demo-status__title">{{ lazyMessage }}</text>
        <text class="docs-demo-status__detail">
          “异步区域 1”首次请求会失败,再次点击箭头即可重试;其他节点直接加载。
        </text>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import UniTreeView from "uni-tree-view";
import type {
  TreeKey,
  TreeLoadErrorPayload,
  TreeLoadPayload,
  UniTreeViewExposed
} from "uni-tree-view";
import { computed, shallowRef } from "vue";
import { createLargeTreeData } from "@/utils/largeTreeData";
import {
  createVirtualLazyLoader,
  createVirtualLazyRootData
} from "@/utils/lazyVirtualTreeData";

const largeTree = createLargeTreeData();
const treeRef = shallowRef<UniTreeViewExposed | null>(null);
const checkedValue = shallowRef<TreeKey[]>([]);
const treeData = shallowRef(largeTree.data);
const nodeCount = largeTree.count.toLocaleString();
const depthText = `${largeTree.minDepth}-${largeTree.maxDepth} 层`;
const locateMessage = shallowRef(`可定位目标:第 6 层「${largeTree.targetLabel}」`);
const locateDetail = shallowRef(`节点名里的「${largeTree.targetLabel.split(" ")[1]}」是从根节点数下来的逐层序号`);
const selectedText = computed(() => checkedValue.value.length ? `${checkedValue.value.length} 项` : "0 项");

const lazyTreeData = shallowRef(createVirtualLazyRootData());
const lazyCheckedValue = shallowRef<TreeKey[]>([]);
const lazyLoader = createVirtualLazyLoader();
const lazyMessage = shallowRef("展开任一异步区域,仅加载该节点的子级");

async function locateTarget() {
  const located = await treeRef.value?.scrollToKey(largeTree.targetKey, { expandParents: true });
  locateMessage.value = located
    ? `已定位:第 6 层「${largeTree.targetLabel}」`
    : "目标节点定位失败";
  locateDetail.value = located
    ? "序号路径逐层对应「第 1 个区域 → 第 1 个城市 → …」,因此该节点稳定存在"
    : "目标 key 不在当前状态树中,scrollToKey 返回 false";
}

function handleLazyLoad(payload: TreeLoadPayload) {
  lazyMessage.value = `已加载「${payload.node.label}」的 ${payload.children.length} 个子节点`;
}

function handleLazyLoadError(payload: TreeLoadErrorPayload) {
  lazyMessage.value = `「${payload.node.label}」加载失败,再次点击箭头重试`;
}
</script>

<style lang="scss">
@use "./demo.scss";
</style>

共享的万级数据生成器

实时预览和 playground 首页都调用下面同一个 createLargeTreeData()

ts
export interface LargeTreeNode {
  id: string;
  label: string;
  children?: LargeTreeNode[];
}

export interface LargeTreeDataOptions {
  total?: number;
  seed?: number;
}

export interface LargeTreeDataResult {
  data: LargeTreeNode[];
  count: number;
  minDepth: number;
  maxDepth: number;
  targetKey: string;
  targetLabel: string;
}

export const LARGE_TREE_DEFAULTS = {
  total: 12_000,
  seed: 20_260_728,
  minDepth: 2,
  maxDepth: 6
} as const;

const ROOT_COUNT = 18;
const LEVEL_NAMES = ["区域", "城市", "片区", "街道", "社区", "网格"];

interface TreeBranch {
  node: LargeTreeNode;
  depth: number;
  path: string;
}

function createSeededRandom(seed: number) {
  let state = seed >>> 0;

  return () => {
    state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;
    return state / 4_294_967_296;
  };
}

function randomInteger(random: () => number, min: number, max: number) {
  return Math.floor(random() * (max - min + 1)) + min;
}

function takeBranch(branches: TreeBranch[], index: number) {
  const branch = branches[index];
  const lastBranch = branches[branches.length - 1];

  if (!branch || !lastBranch) {
    throw new Error("Unable to select a large-tree branch");
  }

  branches.pop();

  if (index < branches.length) {
    branches[index] = lastBranch;
  }

  return branch;
}

export function createLargeTreeData(options: LargeTreeDataOptions = {}): LargeTreeDataResult {
  const total = options.total ?? LARGE_TREE_DEFAULTS.total;
  const seed = options.seed ?? LARGE_TREE_DEFAULTS.seed;

  if (!Number.isInteger(total) || total < 1_000) {
    throw new Error("Large-tree total must be an integer greater than or equal to 1,000");
  }

  const random = createSeededRandom(seed);
  const data: LargeTreeNode[] = [];
  const expandableBranches: TreeBranch[] = [];
  let count = 0;

  function createNode(depth: number, path: string): TreeBranch {
    count += 1;

    return {
      depth,
      path,
      node: {
        id: `area-${path}`,
        label: `${LEVEL_NAMES[depth - 1]} ${path}`
      }
    };
  }

  function appendChildren(parent: TreeBranch, childCount: number) {
    const childDepth = parent.depth + 1;
    const children = Array.from({ length: childCount }, (_, index) => {
      return createNode(childDepth, `${parent.path}-${index + 1}`);
    });

    parent.node.children = children.map((child) => child.node);

    if (childDepth < LARGE_TREE_DEFAULTS.maxDepth) {
      expandableBranches.push(...children);
    }

    return children;
  }

  for (let rootIndex = 0; rootIndex < ROOT_COUNT; rootIndex += 1) {
    const root = createNode(1, String(rootIndex + 1));
    data.push(root.node);
    appendChildren(root, randomInteger(random, 3, 5));
  }

  let targetBranch = expandableBranches[0];

  if (!targetBranch) {
    throw new Error("Unable to create the large-tree target branch");
  }

  while (targetBranch.depth < LARGE_TREE_DEFAULTS.maxDepth) {
    const targetIndex = expandableBranches.indexOf(targetBranch);
    takeBranch(expandableBranches, targetIndex);
    [targetBranch] = appendChildren(targetBranch, randomInteger(random, 2, 4));
  }

  while (true) {
    if (count >= total) {
      break;
    }

    if (!expandableBranches.length) {
      throw new Error(`Unable to generate ${total} nodes within ${LARGE_TREE_DEFAULTS.maxDepth} levels`);
    }

    const branchIndex = randomInteger(random, 0, expandableBranches.length - 1);
    const branch = takeBranch(expandableBranches, branchIndex);
    const remaining = total - count;
    const childCount = Math.min(randomInteger(random, 2, 7), remaining);
    appendChildren(branch, childCount);
  }

  return {
    data,
    count,
    minDepth: LARGE_TREE_DEFAULTS.minDepth,
    maxDepth: LARGE_TREE_DEFAULTS.maxDepth,
    targetKey: targetBranch.node.id,
    targetLabel: targetBranch.node.label
  };
}

默认参数:

参数默认值说明
total12000生成的总节点数,默认不少于一万
seed20260728固定随机种子,相同种子生成相同结构
层级2~6根节点为第 1 层,叶子随机分布在第 2~6 层

节点按层级依次命名为「区域 / 城市 / 片区 / 街道 / 社区 / 网格」,名称中的编号是从根节点数下来的逐层序号路径。例如 网格 1-1-1-1-1-1 表示:第 1 个区域 → 其第 1 个城市 → … → 其第 1 个网格,是一个稳定存在的第 6 层节点;对应节点 key 为 area-1-1-1-1-1-1

如需构造不同但仍可复现的数据,可以传入另一个种子:

ts
const { data, count } = createLargeTreeData({
  total: 15_000,
  seed: 9527
});

关键参数

参数说明
virtual-height滚动视口高度,只接受数值,单位固定为 px;默认 400
virtual-item-height每行高度,只接受数值,单位固定为 px;组件会据此固定虚拟行高度
virtual-overscan可视区外额外渲染的行数,滚动越快可适当调大

virtual-height 当前不能传 rpx%vhcalc()。虚拟列表需要用明确的像素高度计算起止索引;小程序端也请传换算后的 px 数值。

节点较少、内容不足 virtual-height 时,组件会渲染全部节点,但仍保留固定高度视口,剩余区域留空。若页面需要随内容高度自适应,请关闭 virtual

与懒加载组合

virtualload-mode 可以同时开启,两者职责不同:懒加载控制“数据何时进入状态树”,虚拟渲染控制“当前可见节点中哪些行进入视图”。懒加载完成后,可见列表和虚拟窗口会自动重新计算。

vue
<uni-tree-view
  virtual
  load-mode
  :virtual-height="320"
  :virtual-item-height="36"
  :data="rootData"
  :load-api="loadChildren"
/>

右侧实时预览包含两个案例:

  1. 万级区域树:12,000 个静态节点的虚拟渲染
  2. 按需加载网点(向下滚动可见):虚拟渲染 + 懒加载组合

懒加载案例说明(pnpm play 首页同样包含):

  • 首屏提供 80 个异步根节点(标签为「异步区域 N」)
  • 每次展开按需加载 16 个子节点
  • 「异步区域 1」首次展开会故意失败,再次点击箭头即可验证重试功能
  • 其他节点正常加载

预览中两个树下方都有状态提示,会实时显示操作结果。共用数据和加载函数如下:

ts
import type { TreeDataItem, TreeKey, TreeNode } from "uni-tree-view";

export const VIRTUAL_LAZY_ROOT_COUNT = 80;
export const VIRTUAL_LAZY_CHILD_COUNT = 16;
export const VIRTUAL_LAZY_FAILURE_KEY = "lazy-region-1";

export function createVirtualLazyRootData(): TreeDataItem[] {
  return Array.from({ length: VIRTUAL_LAZY_ROOT_COUNT }, (_, index) => ({
    id: `lazy-region-${index + 1}`,
    label: `异步区域 ${index + 1}`,
    append: `${VIRTUAL_LAZY_CHILD_COUNT} 个网点`,
    leaf: false
  }));
}

export function createVirtualLazyLoader(options: {
  delay?: number;
  failFirstKey?: TreeKey | false;
} = {}) {
  const delay = options.delay ?? 240;
  const failFirstKey = options.failFirstKey ?? VIRTUAL_LAZY_FAILURE_KEY;
  const attempts = new Map<TreeKey, number>();

  async function load(node: TreeNode): Promise<TreeDataItem[]> {
    await wait(delay);
    const attempt = (attempts.get(node.id) ?? 0) + 1;
    attempts.set(node.id, attempt);

    if (failFirstKey !== false && node.id === failFirstKey && attempt === 1) {
      throw new Error(`${node.label} 模拟首次加载失败`);
    }

    return Array.from({ length: VIRTUAL_LAZY_CHILD_COUNT }, (_, index) => ({
      id: `${String(node.id)}-site-${index + 1}`,
      label: `${node.label} · 网点 ${index + 1}`,
      leaf: true
    }));
  }

  return { attempts, load };
}

function wait(delay: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, delay));
}

需要注意:scrollToKey 只能定位已经加载进状态树的 key,未知后代不会因为定位操作而自动请求。懒加载的完整约定见懒加载

滚动到指定节点

生成器会返回一个稳定存在的 6 级节点 targetKey(即实时预览状态栏中显示的「可定位目标」)。实时预览和 playground 都使用同一个目标执行定位:

ts
const largeTree = createLargeTreeData();

async function locateTarget() {
  const located = await treeRef.value?.scrollToKey(largeTree.targetKey, {
    expandParents: true
  });

  if (!located) {
    uni.showToast({ title: "节点不存在", icon: "none" });
  }
}

注意事项

行高必须固定

虚拟模式按 virtual-item-height 计算并固定内置节点行高。若插槽内容高度超过该值,内容可能溢出;请同步调大 virtual-item-height,且不要使用可变行高内容。

虚拟渲染不等于免数据计算

virtual 主要减少实际渲染的节点行数;万级树的数据生成、扁平化、索引和选择状态计算仍然会发生。生产环境应按需生成或请求大数据,不要为了展示而无条件初始化。

何时开启

  • 可见节点 < 500:无需开启,直接渲染更简单
  • 可见节点 ≥ 1000:建议开启
  • default-expand-all + 大数据:强烈建议开启

Released under the MIT License.