商会系统里有个模块是组织架构展示。商会的组织不是平的——上面有联合会,下面挂着商会,商会下面有专委会,专委会下面才是会员。层级能到四五层,节点数量几百到几千。
做成树形结构展示,看起来是个常见需求,真做起来有几个点要处理:层级深了展开收起要顺滑、节点多了渲染会卡、要能搜到深层节点、搜到之后要自动展开。这篇把实现过程记一下。
一、为什么不用递归组件
第一版我写的是递归组件——组件内部调用自己,渲染子节点。写法很自然,代码也短。
<!-- OrgTreeNode.vue -->
<template>
<div class="node">
<div class="label" @click="toggle">
{{ node.name }}
</div>
<div v-if="expanded" class="children">
<OrgTreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
/>
</div>
</div>
</template>
这个写法在节点少的时候没问题,节点多了会卡。原因是每个节点都是一个组件实例,几千个节点就是几千个组件实例,创建和销毁的开销都很大。
具体的性能差距我们测过。同样是三千个节点,递归组件的首次渲染耗时在 800 毫秒上下,展开收起时因为要递归销毁和重建子树,单次操作也有明显的卡顿感。改成扁平列表之后,首次渲染降到 200 毫秒以内,展开收起只是数组过滤,操作是瞬时的。
差距的来源是组件实例的数量。递归组件里,一个节点对应一个组件实例,实例要创建、要挂载、要维护响应式依赖;扁平列表里,一个节点只是数组里的一条数据,渲染出来就是一个 div,开销差了一个数量级。
当然递归组件也不是不能用。如果树的规模确定在一两百个节点以内,递归写法的可读性更好,维护起来也更直观。判断标准就是节点数量——超过五百个,就别用递归了。
二、数据扁平化
更合适的做法是先把树拍平,再用 v-for 渲染一个列表。拍平之后,列表里每一项带层级信息和父级信息,渲染时用缩进模拟层级。
interface OrgNode {
id: string;
name: string;
parentId: string | null;
children?: OrgNode[];
}
interface FlatNode {
id: string;
name: string;
level: number;
parentId: string | null;
hasChildren: boolean;
}
function flattenTree(
tree: OrgNode[],
level = 0,
parentId: string | null = null,
result: FlatNode[] = []
): FlatNode[] {
for (const node of tree) {
result.push({
id: node.id,
name: node.name,
level,
parentId,
hasChildren: Boolean(node.children?.length)
});
if (node.children?.length) {
flattenTree(node.children, level + 1, node.id, result);
}
}
return result;
}
拍平之后还要处理展开收起。维护一个展开节点的 id 集合,渲染时只输出"所有祖先都处于展开状态"的节点。
function getVisibleNodes(
flat: FlatNode[],
expandedIds: Set<string>
): FlatNode[] {
const parentMap = new Map<string, string | null>();
flat.forEach((n) => parentMap.set(n.id, n.parentId));
function allAncestorsExpanded(id: string): boolean {
let pid = parentMap.get(id);
while (pid) {
if (!expandedIds.has(pid)) return false;
pid = parentMap.get(pid);
}
return true;
}
return flat.filter((n) => allAncestorsExpanded(n.id));
}
三、虚拟滚动
拍平加过滤之后,渲染表现好不少,但节点上万的时候还是会卡。这时候引入虚拟滚动——只渲染可视区域内的行。
<template>
<div class="viewport" @scroll="onScroll">
<div class="spacer" :style="{ height: totalHeight + 'px' }">
<div class="row-wrap" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="node in visibleRows"
:key="node.id"
class="row"
:style="{ paddingLeft: node.level * 20 + 'px' }"
>
<span class="arrow" @click="toggle(node.id)">
{{ node.hasChildren ? (expanded.has(node.id) ? '▾' : '▸') : '' }}
</span>
<span v-html="highlight(node.name)"></span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = defineProps<{ nodes: FlatNode[]; keyword: string }>();
const expanded = ref<Set<string>>(new Set());
const scrollTop = ref(0);
const rowHeight = 36;
const shown = computed(() => getVisibleNodes(props.nodes, expanded.value));
const totalHeight = computed(() => shown.value.length * rowHeight);
const startIndex = computed(() => Math.floor(scrollTop.value / rowHeight));
const visibleCount = computed(() => 20);
const visibleRows = computed(() =>
shown.value.slice(startIndex.value, startIndex.value + visibleCount.value)
);
const offsetY = computed(() => startIndex.value * rowHeight);
function onScroll(e: Event) {
scrollTop.value = (e.target as HTMLElement).scrollTop;
}
function toggle(id: string) {
if (expanded.value.has(id)) expanded.value.delete(id);
else expanded.value.add(id);
}
</script>
四、搜索高亮与自动展开
搜索有两个要求:一是匹配到的文字要高亮,二是匹配到的节点如果在深层折叠状态,要自动把它的祖先展开。
高亮用正则替换,注意要做转义,避免关键词里有正则特殊字符导致报错。
function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlight(text: string) {
const kw = props.keyword.trim();
if (!kw) return text;
const re = new RegExp(`(${escapeRegExp(kw)})`, 'gi');
return text.replace(re, '<em class="hl">$1</em>');
}
自动展开是在搜索关键词变化时,找出所有匹配的节点,把它们的父链全部加进展开集合。
watch(
() => props.keyword,
(kw) => {
if (!kw.trim()) return;
const matched = props.nodes.filter((n) =>
n.name.toLowerCase().includes(kw.trim().toLowerCase())
);
const parentMap = new Map(props.nodes.map((n) => [n.id, n.parentId]));
matched.forEach((node) => {
let pid = parentMap.get(node.id) ?? null;
while (pid) {
expanded.value.add(pid);
pid = parentMap.get(pid) ?? null;
}
});
}
);
五、几个细节
一是行高要固定。虚拟滚动依赖行高计算偏移,行高不固定的话计算会错位。如果要支持不同高度的行,得改成动态测量,复杂度上去了。
二是缩进用 padding 不用 margin。margin 会影响行高的计算,padding 不会。
三是搜索要做防抖。输入每个字符都重新计算匹配和展开,输入快了会卡。加个 200 毫秒的防抖。
四是有孩子的节点才显示箭头。没有子节点的会员节点,前面留空占位,保持对齐。
五是键盘导航。组织树用得多了,用户会希望能用键盘上下移动、回车展开。这个需求不复杂——维护一个当前焦点索引,监听 keydown 事件,上下箭头改索引,回车切换展开状态,同时把焦点行滚动到可视区域。
六是空状态。搜索没有匹配结果的时候,要给一个明确的提示,不能是一片空白。我们会在空状态里放一句"没有找到匹配的组织",再加一个清空搜索词的按钮。
整个组件做下来,核心思路就两条:把树拍平成列表,把渲染范围收窄到可视区。这两条做完,几千个节点的组织树就能跑得很顺。剩下的搜索高亮、自动展开、键盘导航都是体验层面的补充,做不做取决于业务要求。
有一点提醒一下:如果组织层级是固定的三层,比如联合会、商会、专委会,其实不用做成通用树组件,写死三层用三个列表联动反而更简单。通用树组件的价值在于层级不确定的场景,如果层级确定,别过度设计。
这个组件我们前后迭代了三版才稳定下来,第一版就是递归组件,被性能教训了才改成现在的方案。
我们这边在用的商会管理系统叫未来漫城·商会互联平台,上面这个组织树组件现在跑在联合会看板里,一个联合会下面十几家商会、几百个专委会节点,展开搜索都不卡。