uniapp中纯CSS实现家谱世系图:从数据建模到连线渲染全解析

📅 发布时间:2026/10/15 14:09:02
uniapp中纯CSS实现家谱世系图:从数据建模到连线渲染全解析
接到“做一张家谱世系图”这个需求的时候我第一反应是去找现成的树形组件。搜了一圈要么包体太大要么用 Canvas 画完之后在手机上缩放卡顿还有的组件在小程序端直接编译报错。后来我索性把所有第三方依赖都扔掉在 uniapp 里用纯 CSS 加少量手势代码把家谱世系图完整跑通了。这条路走完之后我对“移动端展示层级关系数据”这件事算是彻底想明白了只要数据结构和 CSS 连线原理掌握住家谱、组织架构图、族谱、甚至目录树都能用同一套思路做出来。这篇文章就把这条路的完整思考过程、数据设计、连线原理和踩坑记录都写下来给同样在移动端做树形关系展示的开发者一个可参考的落地样例也顺便聊聊为什么“纯 CSS 实现”在 uniapp 这个跨端场景里反而比 Canvas 和第三方组件更省心。1. 先盘一盘需求为什么最终选了纯CSS这条“笨”路1.1 这个需求到底在问什么家谱世系图表面上看是“画一棵树”但真正落到移动端它有几个和普通树形组件不太一样的约束。首先是节点信息密度高一个人除了姓名往往还要带生卒年、配偶、排行甚至照片。其次是关系类型固定只有“父母-子女”这一种连线关系但同辈数量可能很大一个祖先下面几十个孙辈很常见。第三是交互简单但必须流畅用户要看的是整张图的全貌以及某条血缘链的走向所以缩放和拖拽比点击展开更重要。把这些约束摆出来你会发现 Canvas 和 DOM 两条路都能走但各有各的麻烦。1.2 Canvas方案的三个软肋我最初其实优先试了 Canvas。原理很简单把树拍平递归计算每个节点的坐标然后ctx.lineTo画线、ctx.fillText写字。单论“画出来”这件事Canvas 确实漂亮可一旦进入移动端真实场景问题就来了。第一个问题是文字排版。家谱节点里的生卒年、配偶名字长度不一Canvas 的fillText不会自动换行你必须自己写换行逻辑还要根据不同机型的字体宽度动态测量文本。第二个问题是命中检测。点击一个节点弹出详情需要把坐标换算回屏幕再判断落点是否落在某个矩形容器里数据一变整个 Canvas 就要重绘。第三个问题最致命当缩放比例变化时Canvas 里的文字会跟着整体模糊。家谱图用户免不了双指放大看细节Canvas 放大之后字迹发虚体验打折扣。说白了Canvas 适合节点数量几百上千、内部结构规则的图不适合家谱这种“少量高信息量节点、需要频繁读文字”的场景。1.3 第三方组件包体与定制成本再来说第三方组件。现成的树形图组件大多是给桌面端或 H5 设计的接进 uniapp 之后有几个绕不开的问题一是很多组件内部用了大量 DOM 操作和近似getBoundingClientRect的 API小程序端没有完整 DOM需要做兼容二是包体会明显变大很多组件为了支持拖拽、缩放、缩略图把前端生态里大半依赖都带进来了而小程序对主包体积卡得很死三是家谱这种“同代横向排一排”的结构和通用树形组件的折叠逻辑不完全一样改起来等于重写。所以我的结论是在 uniapp 场景里如果节点规模不大几百个以内纯 CSS 反而最可控。没有额外依赖、没有 Canvas 重绘、没有 DOM 兼容问题线怎么画、字怎么排、颜色怎么改全都在样式文件里肉眼可见。2. 数据模型设计树怎么存才能让CSS好画2.1 从半结构化家谱信息到标准树家谱原始数据通常很乱经常是“每个人有一个父母字段”或者“每个人有个 children 数组”的混合状态。我第一步永远是把它归一化成标准树结构节点结构这样定义{ id: p001, name: 某公, gender: male, birthYear: 1935, deathYear: 2012, spouse: 某氏, children: [ { id: p002, name: 长子, ... } ] }这里有一个家谱特有的点配偶不是单独节点而是和本人放在同一个卡片里。原因很简单纯 CSS 连线的核心是“每个节点只有一条向上连接线”如果配偶也作为一个独立节点那一个人的卡片会引出两条线世系关系就乱掉了。配偶信息放在卡片内部展示血缘连线只针对“本人-子女”这条主线结构才清晰。2.2 世代分组和同辈排序树结构保存好之后第二步要把它转成“按世代分组”的数组。家谱图在视觉上通常是祖先在左、后代在右或者祖先在上、后代在下本质上是把树按深度拆成若干行同一深度的人排在同一列/同一行。我这里以“祖先在上、后代在下”的纵向世系为例转出来的结果长这样const rows [ // 第1代只有根节点 [{ id: p001, name: 某公, ... }], // 第2代根节点的所有子女 [{ id: p002, name: 长子 }, { id: p003, name: 次子 }], // 第3代按父母分组 [ { id: p004, name: 长孙, parentId: p002 }, { id: p005, name: 孙女, parentId: p002 }, { id: p006, name: 次孙, parentId: p003 } ] ]注意这里我没用递归组件而是手动维护parentId。为什么后面专门说。还有一个很容易被忽略的问题同辈的排序。家谱讲究长幼顺序原始数据里 children 的顺序往往就是长幼顺序但经过数据清洗后顺序可能丢失。所以我在转 rows 之前会给每个节点补一个sort字段排序规则是sort升序同 sort 再按id字符串排序保证每次渲染结果稳定。2.3 递归组件在小程序的坑为什么我选择拍平渲染理论上树形数据最自然的渲染方式是递归组件组件内部再调自己一层层渲染下去。但 uniapp 要考虑到三端差异递归组件在 H5 端跑得很欢编译到小程序端就经常出问题。具体来说微信小程序是支持组件自引用的在组件的json文件里把自己写进usingComponents就行。但 uniapp 的编译链路经过多层转换递归组件在深层嵌套时偶尔会出现“组件实例栈溢出”或者样式失效的问题排查起来非常费劲。而且家谱这种需求每一代的节点数量可能很多递归渲染在深层嵌套时也不好做性能控制。所以我最终采用拍平方案先把树转成rows二维数组然后渲染两层循环——外层循环世代内层循环该世代的“同父分组”。这样每个节点都是平铺的不涉及深层嵌套三端都能稳定跑。代价是需要我自己维护“同一父亲的孩子属于一组”这个信息这个信息拍平后规则非常明确export function treeToRows(root) { const rows [] const levelMap new Map() // key: 世代深度, value: { parentId, nodes }[] walk(root, 1, null) function walk(node, level, parentId) { if (!levelMap.has(level)) levelMap.set(level, []) const groups levelMap.get(level) let group groups.find(g g.parentId parentId) if (!group) { group { parentId, nodes: [] } groups.push(group) } group.nodes.push({ id: node.id, name: node.name, spouse: node.spouse, gender: node.gender, birthYear: node.birthYear, deathYear: node.deathYear }) ;(node.children || []).forEach(child walk(child, level 1, node.id)) } // 按世代顺序、组顺序整理再给每组节点标注位置 return Array.from(levelMap.keys()) .sort((a, b) a - b) .map(level { const groups levelMap.get(level) return groups.map(group { group.nodes.forEach((node, index) { node.isFirst index 0 node.isLast index group.nodes.length - 1 node.isOnly group.nodes.length 1 }) return group }) }) }这样每个节点自带的isOnly、isFirst、isLast标记就是后面 CSS 连线判断边界条件的依据。不用伪类选择器渲染时直接在模板里拼 class兼容性最好。3. 纯CSS连线的核心算法伪元素画线的完整推导3.1 一个关键认知每根线都由节点自己的伪元素画出来纯 CSS 画树形连线最重要的认知是不要想着用一个全局的容器去画整条线而是让每个节点、每个分组自己负责“与自己相关的局部线段”。这样即使节点动态增删只要局部关系不变线就不会乱。具体到家谱场景连线可以拆成两类竖线从每个非根节点顶部出发向上延伸到“同父分组”的顶部相当于传统树形图中“从父节点伸向孩子的那根垂直主干”。横线在每个同父分组的顶部连接该组所有孩子节点的竖线形成一条水平分支线。竖线由.node-wrap的伪元素负责横线由.siblings-group的伪元素负责。两根线交汇在分组的顶部视觉上就形成了“一根竖干上分出几根竖支”的树杈效果。3.2 节点卡片的层级结构在 uniapp 里我用的模板结构是这样view classtree-row v-for(groups, genIndex) in rows :keygenIndex view classrow-inner view classsiblings-group :class{ is-only-group: group.nodes.length 1 } v-for(group, gIndex) in groups :keygIndex view classnode-wrap :class{ is-root: genIndex 0, is-first: node.isFirst, is-last: node.isLast, is-only: node.isOnly } v-fornode in group.nodes :keynode.id view classnode-card text classnode-name{{ node.name }}/text text classnode-sub v-ifnode.spouse配 {{ node.spouse }}/text text classnode-sub{{ node.birthYear }} - {{ node.deathYear }}/text /view /view /view /view /view最外层.tree-row负责每代之间的纵向间距.row-inner是一个收缩到内容宽度的容器保证“横线宽度只覆盖这一组节点”而不是整行都画一条通线。这是很多纯 CSS 树形图画得不对的根源横线宽度必须等于同父孩子节点的总宽度。3.3 竖线和横线的具体写法对应的核心样式我把关键部分贴出来.tree-row { display: flex; justify-content: center; position: relative; } .siblings-group { position: relative; display: inline-flex; align-items: flex-start; padding-top: 60rpx; // 给竖线留出空间 } // 横线画在同父分组顶部上方连接组内所有节点 .siblings-group::before { content: ; position: absolute; top: 30rpx; // 竖线中点的位置 left: 0; right: 0; height: 2rpx; background-color: #aaa; } .node-wrap { position: relative; margin: 0 12rpx; } // 竖线从节点顶部向上延伸到分组顶部 .node-wrap:not(.is-root)::before { content: ; position: absolute; top: -60rpx; left: 50%; width: 2rpx; height: 60rpx; background-color: #aaa; }这个设计的几何逻辑是这样的.siblings-group的padding-top: 60rpx在每一组的顶部留出一段空白竖线就画在这段空白里。竖线从.node-wrap的顶部开始向上 60rpx 延伸到分组顶部横线画在竖线中点附近top: 30rpx把组内所有竖线横向连起来。两根线交汇看起来就是父节点的线下来之后在分支点横着分给了每个孩子。3.4 边界条件根节点、独生子女、组的首尾边界条件是连线好看与否的分水岭我逐个说根节点它没有父节点所以.is-root的竖线必须隐藏。用.node-wrap.is-root::before { display: none; }。独生子女只有一个孩子的分组不需要横线因为父亲的竖线直接连到孩子的竖线视觉上就是一根直线贯穿下来。所以.is-only-group::before { display: none; }同时孩子节点的竖线照常画。组的首尾组内只有两个及以上节点时横线从组左端画到组右端覆盖第一个节点的竖线和最后一个节点的竖线没有额外需要处理的部分。但如果节点卡片宽度不一致横线左端会错位。所以我在前面强调节点卡片要统一宽度或者至少保持统一的最小宽度否则横线就会“歪”。这里补充一个我在方案迭代时发现的优化点如果希望横线和竖线交叉处看起来是“T 形”而不是“十 形”也就是横线只在竖线的一侧延伸可以把横线改成用border-top加左右各一段border-right/left的方式。但做一个家谱图T 形和十形视觉差异不大还平白增加代码复杂度我最终采用了“横线完整覆盖分组宽度”的方案简洁且不会出现断点。3.5 为什么不用经典嵌套列表方案网上一搜“纯 CSS 树形图”出来的基本都是经典方案递归的ul/li每个li用::before和::after画半段横线两个半段拼成一条完整横线。这个方案在 H5 的 DOM 结构下很成熟但放到 uniapp 拍平渲染后并不适配。因为拍平后的结构不是嵌套列表而是“行级分组”经典方案中li之间的左右拼线逻辑没法直接复用。如果你强行在拍平结构里去模拟就必须计算每个节点左右半段的宽度还不如直接用“分组横线”来得干净。这个取舍就是拍平结构和嵌套结构在 CSS 实现上的本质差异。再补充一点用::before画线线的粗细我用的是2rpx在 Retina 屏上折算成物理像素大约是 1px。如果写成1rpx部分安卓机型上会因为四舍五入变成 0px线直接消失。所以我建议用1px或者2rpx别用0.5px兼容性不可控。4. 移动端的交互横屏引导、手势缩放和拖拽4.1 家谱图天生适合横屏三端配置各不相同家谱世系图是横向结构同一代可能有几十个节点竖屏根本放不下。在 uniapp 里三端要分开配置App 端在 pages.json 对应页面的app-plus节点里配置orientation: landscape。微信小程序pages.json 里给页面加pageOrientation: landscape。H5 端没有原生的强制横屏配置通常只能通过 CSS 提示用户旋转屏幕或者页面内部用transform: rotate(90deg)做一个伪横屏。我实际测试下来微信小程序这个配置是最省心的编译过去之后用户一进页面就自动横屏根本不需要感知旋转过程。App 端也稳定。H5 端如果也要做强制横屏旋转后的布局计算会比较绕建议需求和产品沟通时把 H5 定义为“自适应缩放不强求横屏”。4.2 双指缩放的手势数学既然是纯 CSS 实现缩放最自然的做法是给整个图容器加transform: scale()。但直接 scale 有两个问题缩放中心默认是容器中心双指缩放时图会“跑”缩放后容器的滚动范围不会自动变大。所以我用一个包装层把scale和translate组合起来view classtree-viewport touchstartonTouchStart touchmoveonTouchMove view classtree-canvas :stylecanvasStyle !-- 上面的 tree-row 结构 -- /view /view对应的 data 和样式data() { return { scale: 1, baseX: 0, baseY: 0, startX: 0, startY: 0, startDist: 0, startScale: 1 } }.tree-canvas { transform-origin: 0 0; transition: none; }双指缩放的核心是记录两只手指起始距离然后在 touchmove 里用当前距离和起始距离的比值乘上起始缩放值。关键点是要让缩放中心尽量保持在两指中心附近否则用户体验极差。我用这段计算onTouchStart(e) { if (e.touches.length 2) { this.startDist Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ) this.startScale this.scale } else if (e.touches.length 1) { this.startX e.touches[0].clientX this.startY e.touches[0].clientY } } onTouchMove(e) { if (e.touches.length 2) { const dist Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ) const ratio dist / this.startDist let nextScale Math.min(Math.max(this.startScale * ratio, 0.4), 2) const centerX (e.touches[0].clientX e.touches[1].clientX) / 2 const centerY (e.touches[0].clientY e.touches[1].clientY) / 2 // 让缩放中心点尽量稳定 this.baseX centerX - (centerX - this.baseX) * (nextScale / this.scale) this.baseY centerY - (centerY - this.baseY) * (nextScale / this.scale) this.scale nextScale this.updateCanvasStyle() } else if (e.touches.length 1) { const dx e.touches[0].clientX - this.startX const dy e.touches[0].clientY - this.startY this.baseX dx this.baseY dy this.startX e.touches[0].clientX this.startY e.touches[0].clientY this.updateCanvasStyle() } } updateCanvasStyle() { this.canvasStyle transform: scale(${this.scale}) translate(${this.baseX}px, ${this.baseY}px); }这里有一个数学细节可以展开讲baseX centerX - (centerX - baseX) * (nextScale / scale)这条公式的本质是在缩放比例变化时让变换原点附近的那个点不动。如果你不做这一步只改scale那图会以左上角为原点缩放指尖下的内容会瞬间飞走。这个公式我调了很久才发现是纯 CSS 缩放手势里最关键的一行比缩放值本身的计算重要得多。4.3 单指拖拽和滚动容器的边界拖拽逻辑比较简单记录单指按下时的坐标每次 touchmove 计算位移累加到baseX/baseY。但有一个边界问题当scale很小比如 0.4且整张图小于视口时应该禁止拖拽否则用户随便一划图就跑没影了。我加了一个判断只有当前缩放状态下内容的实际尺寸超过视口时才允许拖拽。计算内容尺寸最简单的方式是给.tree-canvas一个明确宽度在 data 里维护treeWidth/treeHeight。我的做法是在 onReady 时用uni.createSelectorQuery().select(.tree-canvas).boundingClientRect()量一下然后和视口宽高对比onReady() { const query uni.createSelectorQuery() query.select(.tree-canvas).boundingClientRect() query.select(.tree-viewport).boundingClientRect() query.exec(res { this.treeWidth res[0].width this.treeHeight res[0].height this.viewWidth res[1].width this.viewHeight res[1].height }) }这样在拖拽时就能限制位移范围不会让整张图拖到视口外空荡荡的地方。缩放最小值设 0.4也是考虑到家谱图如果缩得太小字根本没法看不如直接给定一个可读性的下限。5. 我在三端实测中踩过的坑5.1 伪元素在部分安卓 WebView 上闪烁第一个坑是伪元素画的线在做缩放动画时偶尔会整条消失或者闪一下。这是因为 WebView 合成层对伪元素的 GPU 栅格化不稳定。解决方式是在.siblings-group和.node-wrap上强制创建合成层.siblings-group, .node-wrap { transform: translateZ(0); }这个 trick 听起来很玄学但实测下来确实稳定尤其是安卓端。如果加了还不行还可以把background-color画线改成border-top/border-left画线再配合will-change: transform绝大多数机型的闪烁都能消掉。5.2 touchmove 和 scroll-view 的手势冲突最初我为了页面能滚动在.tree-viewport外面套了一个scroll-view。结果发现双指缩放时scroll-view会跟手势抢事件缩放过程经常被内部滚动打断。后来我干脆在缩放手势激活时不使用scroll-view而是用普通view加catchtouchmove把 touchmove 事件全部接管。这里有个很容易踩的细节小程序里catchtouchmove可以阻止冒泡但 H5 端没有catchtouchmove。我在 uniapp 的模板里直接写了touchmove.stop编译到小程序端会转换成catchtouchmoveH5 端也会阻止默认行为三端一致。这个是 uniapp 跨端语法里很实用的一招。5.3 节点卡片高度不一致导致横线错位家谱节点里有的人没有生卒年有的人配偶名字特别长卡片高度就参差不齐。一旦同一代的人卡片高度不同下一代的.siblings-group顶部位置就会错开横线跟着歪。这就是我在 3.1 强调“节点卡片统一宽度/最小高度”的原因。我的落地做法是给.node-card设一个min-height同时用 flex 纵向布局把名字、配氏、生卒年固定在三行位置。如果某个字段缺失用空字符串占位。这样每张卡片高度一致整代底部对齐孩子们的竖线起点也就对齐了。5.4 长名字溢出和换行家谱数据里经常有很长的名字比如“某某府君之长子”这种带称谓的或者配偶栏写“继配某氏”。如果不处理文字会溢出卡片甚至把相邻节点的竖线位置顶歪。我用的方案是卡片内部三个text都设置.node-name { font-size: 26rpx; line-height: 36rpx; word-break: break-all; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }word-break: break-all在中文里其实不需要但长英文或数字 ID 会出现所以保留。-webkit-line-clamp: 2限制最多两行避免单个节点因为名字太长把整棵树撑变形。配偶和生卒年则只允许一行超长用省略号。5.5 节点超过两百个之后的性能家谱做到五百个节点时伪元素的数量会到一千个左右App 端某些低端机型开始掉帧。我做了两个优化第一按当前缩放级别动态决定是否绘制“配偶、生卒年”信息缩放值小于 0.6 时只显示姓名减少文字布局计算的复杂度第二用v-if控制连线伪元素的绘制而不是用v-show节点少的时候无所谓节点多的时候渲染差异很明显。性能优化有一个原则不要把 CSS 写入一个个节点的内联style。之前我为了高亮某些节点把背景色直接拼进style结果几百个节点渲染时全卡住。后来统一改成预定义的 class把“是否高亮”作为 class 开关性能立刻恢复。6. 从一个能跑的Demo到真正能用的组件6.1 动态增删节点之后重排算法要够快家谱图不是做完就不动的用户随时可能新增一个人、修正一段关系。数据一变rows 就要重新计算。我用的treeToRows是纯函数输入根节点、输出 rows配合 uniapp 的响应式系统数据更新后视图自动重排。这里要注意 key 的问题。v-for渲染节点时key 一定不能用index必须用node.id。我第一次用 index 当 key结果删除中间某个人时后面的节点全部重新渲染连线动画闪得厉害。换成 id 之后只有被删除的节点局部消失其他节点和连线完全不动动画稳定。6.2 高亮某个人的直系血亲链家谱图最常见的交互是“看看某个人是哪一支的”。我的做法是点击节点弹出一个底部弹层展示此人详情同时页面里高亮他的直系祖先链。实现很简单从点击的节点向上找 parents收集整条祖先链的 id 集合然后在渲染时给这些节点加.highlight-lineclass。CSS 让这些节点的卡片边框变蓝、连线变蓝其他节点全部降饱和。这个功能看似花哨其实只是 JS 层多了一次向上遍历CSS 层加一个 class对整体复杂度几乎没有影响。6.3 导出图片纯CSS展示和Canvas快照并不冲突有人会问纯 CSS 画的图怎么导出成图片分享我的做法是双轨展示用纯 CSS导出时把树的坐标数据传给一个隐形的 Canvas用 Canvas 二次绘制一张静态图然后canvas.toTempFilePath生成图片。这里 Canvas 只是“拍照”不承担交互和渲染主逻辑所以前面提到的 Canvas 缺点都不存在。导出图片倒是有一个需要注意的点Canvas 的字体大小要用物理像素不能直接拿 CSS 的 rpx 用。我一般先通过uni.getSystemInfoSync().windowWidth / 750算出 rpx 转 px 的系数再乘到 Canvas 坐标里导出图才能和屏幕上的比例一致。6.4 后续扩展的方向这套“拍平数据 分组容器 伪元素连线”的方案换个背景色、改个卡片样式就能当组织架构图用把横向世系改成纵向世系只要把行布局换成列布局、竖线横线互换就可以如果再激进一点把每组节点的连线改成虚线、圆角、变宽也都是纯 CSS 范围内的事。我在实际项目中最后把这套逻辑封装成了一个私有组件输入一颗树输出一份世系图后续再做家庭档案类的需求基本就是复制粘贴改改样式的事。树形数据展示这种问题只要突破了“数据怎么拍”和“线怎么画”这两个点剩下的就是审美和时间了。最后分享一个我在反复调试里总结出来的小技巧调试连线时不要急着美化先用醒目的红色background-color把所有竖线和横线画出来确认每条线的拓扑结构正确之后再改颜色、改粗细。这样能把“结构错误”和“样式不好看”两个问题分开解决排查效率高很多。