☰
基于纯CSS的uniapp家谱世系图实现:递归组件与连接线方案
2026/10/15 13:25:05 网站建设 项目流程

接手家谱世系图这个需求,很多人第一反应是找现成的树形组件。真正做起来才发现,后台组织架构的树组件、PPT式的架构图插件,在手机端基本都水土不服。尤其项目用的是uniapp,目标端既要跑小程序又要跑H5,选型稍有不慎就是无尽的样式兼容地狱。

最后我自己采用的方案很朴素:数据交给递归组件,视觉呈现全部交给纯CSS布局,不引canvas、不引SVG、不引任何树形图表库。核心思路就一句话:把家谱当成一组嵌套的flex容器,用伪元素画连接线。这篇文章把整个实现过程拆开来讲,包括数据怎么设计、递归怎么落地、连接线怎么用CSS拼出来,以及我在真机上踩过的一些坑,给正在做同类需求的朋友一个可以少走弯路的参考。

1. 整体设计与思路拆解

1.1 为什么用纯CSS而不是js画树插件

市面上的树形组件大多面向Web后台,例如基于table的树表、基于canvas的组织架构图。这类组件在PC浏览器里表现尚可,但放进微信小程序或者其他移动端WebView里,问题非常集中:

  • 很多组件库依赖DOM操作,小程序里没有完整的DOM API,适配成本极高。
  • canvas方案虽然能画漂亮的连线,但节点点击、滚动定位、文本换行都要自己计算,开发量直接翻倍。
  • 第三方树插件往往自带一套样式体系,想改成符合手机屏幕的卡片风格,要么写一堆覆盖样式,要么干脆改不动。

纯CSS方案的优势在于,uniapp页面最终编译成小程序WXML或H5 DOM,本质上都是同一套盒模型。flex布局、伪元素、边框这些能力在两端都能用,只要不碰canvas和SVG,兼容性就稳定得多。视觉呈现完全由CSS控制,颜色、间距、卡片弧度都可以直接用变量和类名调整,产品改需求时改动成本很低。

提示:这里的“纯CSS”指的是布局和连接线全部由CSS完成,不代表完全不用JavaScript。数据转换、展开收起的状态切换仍然需要少量逻辑,只是渲染层面不依赖任何绘图库。

1.2 布局方向为什么选纵向分层

家谱世系图和普通组织架构图不一样,它有严格的辈分关系。从传统家谱的样子出发,最符合用户阅读习惯的就是:同一辈分横向排列,不同辈分从上到下纵向延伸。爷爷在最上面,儿子在中间一行,孙子在最下面,一行是一代人。

移动端屏幕宽度有限,横向树状布局往往需要左右拖动,用户很难快速定位某一个人,体验很差。纵向分层最大的好处是匹配手机竖屏滚动习惯,你只需要上下滑动就能浏览完整家谱,手指操作成本最低。另一个原因是家谱数据天然是一棵多叉树,用flex横向排列同一层级的节点是天然合理的,不需要计算坐标位置,也不存在组织架构图那种复杂的跨级连线问题。

我最终确定的布局骨架是:每个“家族单元”由父节点区和子节点区组成,父节点区放这一对的夫妻卡片,子节点区横向排列各个孩子,孩子下面再递归嵌套自己的家族单元。连接线只处理两个层级之间的事情,不跨级画线,这样每一层都能独立计算,逻辑简单清晰。

2. 数据准备与树结构设计

2.1 通用家谱树字段设计

如果直接从数据库查家谱数据,你拿到的通常是一张平铺表,每个人有id、姓名、父亲id、母亲id、配偶id、排行、出生年份这种字段。前端要做的第一件事是把这张表整理成树形结构。

我习惯给每个节点保留这些核心字段:

字段类型说明
idString/Number唯一标识
nameString显示姓名
genderString用于区分卡片样式
birthString出生年份,空则不显示
deathString去世年份,空则视为在世
orderNumber同父兄弟中的排行
spouseObject配偶节点,可能为空
childrenArray孩子节点数组
expandedBoolean是否展开后代分支

gender字段虽然看着简单,但在样式上有大用。我这边用性别决定卡片边框的颜色,男性偏冷色、女性偏暖色,这样扫一眼就能快速分辨成员关系。birth和death可以拼成类似“1932 - 2008”的生卒信息,放在姓名下方,比单独列字段更简洁。

2.2 平铺数据如何变成递归树

假设后端返回的是一个扁平数组,每个元素有parentId指向父亲,没有parentId的就是根节点。转换逻辑并不复杂,核心是两层遍历:

function buildTree(list) { const map = {}; const roots = []; list.forEach(item => { map[item.id] = { ...item, children: [], spouse: null, expanded: false }; }); list.forEach(item => { const node = map[item.id]; if (item.parentId && map[item.parentId]) { map[item.parentId].children.push(node); } else { roots.push(node); } }); // 排序:同辈兄弟按order排 Object.values(map).forEach(node => { node.children.sort((a, b) => (a.order || 0) - (b.order || 0)); }); return roots; }

这段代码里的map对象直接通过id索引到节点,第二次遍历时不需要反复查找。children里的节点是map里的同一个对象引用,所以之后修改节点的expanded状态会直接作用到树上。

需要注意的是,家谱里可能存在多个根节点,比如一个家族经过分房后,系统里同时存了几支世系。这种情况不用强行造一个总根,前端可以在根层用一个view把多个roots循环渲染出来就行。

2.3 配偶怎么处理才不乱

配偶不能简单塞进children里,否则家谱就变成了一个流程图,辈分会乱。配偶应当和本人并列展示,共同作为孩子们的父节点区。我处理配偶的方式比较简单:在主节点数据里如果有spouseId,构建树时把这个id对应的节点从map中取出来,挂到主节点的spouse字段上,同时从普通列表里标记为“已占用”,避免它被当成另一个独立根节点渲染。

在模板里,父节点区就是主节点卡片和配偶卡片水平排列,中间用一条短横线表示婚姻关系。和普通兄弟节点之间用竖线连接不同,这里的横线让人一眼能看出两个人是同辈且结为夫妻,语义清楚。

3. 纯CSS布局:连接线的核心画法

3.1 一个家族单元怎么拆

要把一棵树画出来,先不要想整棵树,只盯住一个“父亲 + 他的孩子们”的小单元。这个小单元是整个家谱图的基础画面。

我先定义这个单元的结构:

  • 最外层是一个family容器。
  • family里面第一行是parents区域,放着本人卡片和配偶卡片。
  • family里面第二行是children区域,放着所有孩子。
  • 每个孩子内部再递归一个family容器。

这样递归下去,每个人都拥有一个完整的小单元。连接线只出现在两个地方:parents区域到底部、children区域的上方。

3.2 关键代码:竖线加横线拼出连接线

直接上核心代码。先看模板部分:

<template> <view class="family"> <view class="parents"> <view class="member-card" :class="['gender-' + node.gender]"> <text class="member-name">{{ node.name }}</text> <text class="member-life" v-if="node.birth || node.death"> {{ node.birth || '?' }} - {{ node.death || '至今' }} </text> </view> <view class="spouse-card" v-if="node.spouse"> <text class="member-name">{{ node.spouse.name }}</text> </view> </view> <view class="children" v-if="node.expanded && node.children.length"> <view class="child" v-for="item in node.children" :key="item.id" > <FamilyNode :node="item" /> </view> </view> </view> </template>

再看样式,这是整个方案的重头戏:

.family { display: flex; flex-direction: column; align-items: center; } .parents { position: relative; display: flex; justify-content: center; align-items: center; padding-bottom: 40rpx; } .parents::after { content: ''; position: absolute; top: 100%; left: 50%; width: 4rpx; height: 40rpx; background-color: #c0c4cc; transform: translateX(-50%); } .children { position: relative; display: flex; } .children::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4rpx; background-color: #c0c4cc; } .child { position: relative; flex: 1; display: flex; align-items: center; justify-content: center; padding-top: 40rpx; } .child::before { content: ''; position: absolute; top: 0; left: 50%; width: 4rpx; height: 40rpx; background-color: #c0c4cc; transform: translateX(-50%); }

这套样式的核心逻辑是:parents区域的伪元素画一条从底部中心向下的竖线;children区域的伪元素画一条横贯整行的横线;每个child的伪元素再画一条从横线到自己卡片的竖线。三条线组合起来,就是一个标准的“T形”连接。

为什么这段代码能稳定衔接?因为children区域使用display: flex布局后,每个child都会按flex: 1均匀分配宽度,children::before的横线天然覆盖整个子代区域的宽度,而child::before的竖线正好定位在每个child列的中心。无论孩子数量是2个还是5个,连接线都能自动对齐,不需要任何百分比计算。

3.3 独生子女的样式修正

上面的方案在有两个以上孩子时效果很好。但家谱里经常出现独生子女的情况,这时children区域只有一个child,横线会横跨整个父节点宽度,看起来像一条很长的横线只连一个孩子,非常不协调。

我的处理办法是给children加一个动态类名,当只有一个孩子时不显示横线,只保留从父节点直接通向这个孩子的竖线:

.children.single::before { display: none; }

模板里绑定:

<view class="children" :class="{ single: node.children.length === 1 }" v-if="node.expanded && node.children.length" >

这样独生子女的树形就变成一条竖直的直线,父节点在上一代、孩子在下一代,干净利落。

3.4 配偶之间的短横线

配偶卡片之间也不要直接贴在一起,我加了一个短横线元素表示婚姻关联。这个在模板里插一个view即可:

<view class="spouse-link" v-if="node.spouse"></view>

样式:

.spouse-link { width: 48rpx; height: 4rpx; background-color: #c0c4cc; margin: 0 16rpx; }

看起来就像两个人之间连了一小段水平线。这个细节做出来之后,家谱的层次感会立刻提升,别人一眼就能辨认出夫妻关系,而不是把两个卡片当成并列的两个独立成员。

4. 实操:uniapp递归组件与展开收起

4.1 递归组件在uniapp里的正确姿势

树形结构最适合用递归组件实现。在uniapp的Vue3项目中,新建一个FamilyNode.vue,并在它的script setup里通过defineOptions声明组件名:

<script setup> defineOptions({ name: 'FamilyNode' }); const props = defineProps({ node: { type: Object, required: true } }); const toggle = () => { props.node.expanded = !props.node.expanded; }; </script>

这样FamilyNode在模板里就可以直接把自己当成普通标签来使用:<FamilyNode :node="item" />。

有一点必须提醒:在小程序端,递归组件的目录结构必须规范。我花了不少时间才搞明白,uniapp的easycom规则默认扫描components/组件名/组件名.vue,所以FamilyNode一定要放在components/FamilyNode/FamilyNode.vue,别随手放在随意目录或改成index.vue这种命名。否则H5端能跑,小程序端会报组件未注册的错误,排查起来非常浪费时间。

4.2 默认展开层级与点击切换

家谱数据如果很大,首屏全部展开直接卡死。我采用的策略是:默认只展开第一代和第二代,更深层级的节点全部收起,用户点击某个人物卡片时,再展开他的孩子分支。

在构建树的数据时,我统一给每个节点设置expanded: false,然后在页面加载完成后再单独把根节点和它的直接children的expanded改为true。这样做首屏能快速渲染,用户也能看到整棵树的轮廓。

卡片上的点击事件也很简单:

<view class="member-card" @click="toggle">

toggle函数里直接反转当前节点的expanded字段。因为子代是否渲染由v-if="node.expanded && node.children.length"控制,所以收起分支时,对应的后代节点会直接从渲染树里移除,不会占用虚拟节点数量。

4.3 大量节点场景下的性能策略

如果家谱人数超过几百人,全部数据一次性渲染到小程序里还是会有压力。我实测过,上千个节点的树展开后,真机滑动会明显掉帧。针对这种情况,我建议做“懒加载式展开”。

懒加载的思路是:列表数据全量在内存里,但渲染树只保留“用户已展开到的那部分”。简单做法是在toggle里判断,如果当前节点之前加载过就不再处理,否则手动把这个节点的children挂到渲染数据对应的节点上。这样屏幕前永远只有有限个节点,数据再大也能稳住。

小程序对页面DOM节点数量有限制,一次性渲染几千个view很容易触发性能告警。宁可让用户多点击几次展开,也不要贪图一次展示所有代际。

4.4 辈分缩进与滚动容器

家谱节点层级深了以后,即使收起子树,卡片本身也有宽度。同一辈分的节点多了,横向排列还是放不下。

我处理横向空间不足的方案是:在页面外层用竖向滚动,在每一代人数较多的地方直接让children区域允许横向滚动。uniapp里可以用scroll-view的scroll-x属性实现。简单一点的方案是给children设置宽度为max-content,让它按实际内容宽度排列,外层再滚动查看。

<scroll-view scroll-x class="row-scroll"> <view class="children" :class="{ single: node.children.length === 1 }"> <!-- 子节点循环 --> </view> </scroll-view>

要注意的是,这么处理之后children::before的横线仍然会横贯整个children区域,即使横线很长,滚动查看时依然连贯,不会断线。

5. 常见问题与排查技巧实录

5.1 伪元素在小程序端不显示或错位

这个是本方案最容易踩的坑。uniapp里普通Vue页面编译成小程序后,伪元素在大部分场景是支持的,但有几个注意点:

  • 不能给同一个元素同时使用::before和::after去画两条重叠的竖线,部分基础库会渲染异常。
  • 伪元素不能使用动态style绑定去改content,小程序里动态修改content无效。
  • App端如果编译到nvue,div组件不支持伪元素。如果你有App端需求,最好用真实view标签来画线,或者对App端单独做一套兼容。

如果你的目标平台主要是微信小程序和H5,伪元素可以直接用。要上App端的话,建议提前确认清楚,免得最后返工。

5.2 竖线高度和padding对接不上

连接线断裂最常见的原因是竖线的高度和容器间距不一致。比如.child的padding-top是40rpx,但.child::before的height写成了60rpx,竖线就会穿过卡片顶部,视觉上非常奇怪。

我的惯例是把所有相关的间距统一维护在一个地方。这里的关键参数是三个:parents的padding-bottom、child的padding-top、parents::after和child::before的height。这四个值要保持完全一样。我用一个固定的line-gap变量来维护:

$line-gap: 40rpx; $line-width: 4rpx; $line-color: #c0c4cc;

以后调间距,只改这一个变量,连接线不会断裂。

5.3 H5正常但小程序报组件未注册

这个原因我在前面已经提过,主要是目录规范问题。除了把组件放在components目录下,还要确认组件的文件名、目录名、组件标签名完全一致。例如components/FamilyNode/FamilyNode.vue,页面里写<FamilyNode />。这样easycom才能在小程序端正确注册。

我自己排查的时候还遇到过缓存问题:小程序开发者工具有时不会立刻识别新加的自定义组件,需要重新编译一次。如果你确认路径没问题但组件还是显示不出来,先重启编译,往往就能解决。

5.4 子节点过多时卡片被压缩变形

当一段分支有超过8个孩子时,flex:1会把每个孩子卡片的宽度压得很窄,姓名直接显示不下。我的解决办法比较朴素:在卡片上设置min-width,不允许卡片无限压缩。同时在children区域已经有横向滚动的情况下,等于每个孩子列保持最小宽度,多余部分通过滚动查看,不会为了适应屏幕把所有卡片挤成一条竖线。这个体验比硬塞在窄屏幕里要舒服得多。

5.5 展开收起时连接线跳动明显

如果你在toggle时直接改expanded字段,可能会出现分支一多,页面高度剧烈变化,连接线显示不稳定的情况。想优化的话,有两个方向:

  • 收起时不要立刻从DOM移除,配合一个淡出动画。uniapp里可以用transition做显隐过渡,但注意小程序里对动态高度动画支持有限。
  • 不做动画,而是固定每个family的最小高度,减少跳动感。

说实话,家谱这种信息密度高的场景,用户更在意的是快速浏览,而不是花哨动画。我个人最终去掉了动画,直接v-if渲染,反而稳定省事。

5.6 根节点不唯一时需要包一层容器

不要把roots数组直接放到页面根层循环,建议外层套一个view,并且在根节点比较多时,用纵向排列或横向滚动排列。我通常的做法是一行放两个根节点,家庭分支太多时改为一行一个,让它纵向往下排列。这样整体页面结构清晰,也不会出现根节点压缩问题。

写在最后

这个项目我前后迭代过三个版本。第一版用canvas画线,功能确实炫,但真机交互总差强人意;第二版引了一个树形插件,样式改到怀疑人生;第三版才回到CSS本身,用一套递归加伪元素的方案把问题彻底解决。回头看,最大的体会其实是:移动端家谱图并不需要一个多复杂的渲染引擎,真正关键的是把层级关系的数据结构和递归视图打通,连接线只是水到渠成的结果。

如果你正在做类似需求,建议先把树形数据的转换逻辑和递归组件跑通,再加连接线样式。千万不要一上来就调线怎么画,那是本末倒置。数据结构顺了,纯CSS画线其实只用一两个小时就能完成。

还有一个可以扩展的方向:现在这套方案只展示了血亲关系,如果后续要加入姻亲关系网、同宗不同支的关联,可以在数据模型里再加relation类型字段,布局上把连线的样式区分开,例如虚线表示姻亲、实线表示直系。这样家谱的信息含量会更强。原则不变,依然是递归组件加CSS布局,后面无论加多少种关系类型,都不会把代码复杂度推到不可控。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询