Vue3 + TS 给商会系统做一个组织架构树组件(递归渲染 + 虚拟滚动 + 搜索高亮)

140 阅读5分钟

商会系统里有个模块是组织架构展示。商会的组织不是平的——上面有联合会,下面挂着商会,商会下面有专委会,专委会下面才是会员。层级能到四五层,节点数量几百到几千。

做成树形结构展示,看起来是个常见需求,真做起来有几个点要处理:层级深了展开收起要顺滑、节点多了渲染会卡、要能搜到深层节点、搜到之后要自动展开。这篇把实现过程记一下。

一、为什么不用递归组件

第一版我写的是递归组件——组件内部调用自己,渲染子节点。写法很自然,代码也短。

<!-- 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 事件,上下箭头改索引,回车切换展开状态,同时把焦点行滚动到可视区域。

六是空状态。搜索没有匹配结果的时候,要给一个明确的提示,不能是一片空白。我们会在空状态里放一句"没有找到匹配的组织",再加一个清空搜索词的按钮。

整个组件做下来,核心思路就两条:把树拍平成列表,把渲染范围收窄到可视区。这两条做完,几千个节点的组织树就能跑得很顺。剩下的搜索高亮、自动展开、键盘导航都是体验层面的补充,做不做取决于业务要求。

有一点提醒一下:如果组织层级是固定的三层,比如联合会、商会、专委会,其实不用做成通用树组件,写死三层用三个列表联动反而更简单。通用树组件的价值在于层级不确定的场景,如果层级确定,别过度设计。

这个组件我们前后迭代了三版才稳定下来,第一版就是递归组件,被性能教训了才改成现在的方案。

我们这边在用的商会管理系统叫未来漫城·商会互联平台,上面这个组织树组件现在跑在联合会看板里,一个联合会下面十几家商会、几百个专委会节点,展开搜索都不卡。

8aa40c95-ebd9-4d15-acd7-0392120e1aa9.png