虚拟渲染
实时预览和 pnpm play 的“大数据虚拟渲染”共用同一个数据生成器:默认稳定生成 12,000 个节点,叶子随机分布在 2~6 层。开启 virtual 后,组件只渲染可视区域及缓冲区内的行。
数据使用固定种子的伪随机算法,而不是每次刷新都变化的真随机算法,因此两处示例的数据结构、节点 key 和定位目标始终一致,便于复现和比较性能。
实时预览对应代码
下面直接引入右侧实时预览使用的 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():
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
};
}默认参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
total | 12000 | 生成的总节点数,默认不少于一万 |
seed | 20260728 | 固定随机种子,相同种子生成相同结构 |
| 层级 | 2~6 | 根节点为第 1 层,叶子随机分布在第 2~6 层 |
节点按层级依次命名为「区域 / 城市 / 片区 / 街道 / 社区 / 网格」,名称中的编号是从根节点数下来的逐层序号路径。例如 网格 1-1-1-1-1-1 表示:第 1 个区域 → 其第 1 个城市 → … → 其第 1 个网格,是一个稳定存在的第 6 层节点;对应节点 key 为 area-1-1-1-1-1-1。
如需构造不同但仍可复现的数据,可以传入另一个种子:
const { data, count } = createLargeTreeData({
total: 15_000,
seed: 9527
});关键参数
| 参数 | 说明 |
|---|---|
virtual-height | 滚动视口高度,只接受数值,单位固定为 px;默认 400 |
virtual-item-height | 每行高度,只接受数值,单位固定为 px;组件会据此固定虚拟行高度 |
virtual-overscan | 可视区外额外渲染的行数,滚动越快可适当调大 |
virtual-height 当前不能传 rpx、%、vh 或 calc()。虚拟列表需要用明确的像素高度计算起止索引;小程序端也请传换算后的 px 数值。
节点较少、内容不足 virtual-height 时,组件会渲染全部节点,但仍保留固定高度视口,剩余区域留空。若页面需要随内容高度自适应,请关闭 virtual。
与懒加载组合
virtual 与 load-mode 可以同时开启,两者职责不同:懒加载控制“数据何时进入状态树”,虚拟渲染控制“当前可见节点中哪些行进入视图”。懒加载完成后,可见列表和虚拟窗口会自动重新计算。
<uni-tree-view
virtual
load-mode
:virtual-height="320"
:virtual-item-height="36"
:data="rootData"
:load-api="loadChildren"
/>右侧实时预览包含两个案例:
- 万级区域树:12,000 个静态节点的虚拟渲染
- 按需加载网点(向下滚动可见):虚拟渲染 + 懒加载组合
懒加载案例说明(pnpm play 首页同样包含):
- 首屏提供 80 个异步根节点(标签为「异步区域 N」)
- 每次展开按需加载 16 个子节点
- 「异步区域 1」首次展开会故意失败,再次点击箭头即可验证重试功能
- 其他节点正常加载
预览中两个树下方都有状态提示,会实时显示操作结果。共用数据和加载函数如下:
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 都使用同一个目标执行定位:
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+ 大数据:强烈建议开启