ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Vue 3 中纯 CSS 实现动态鱼骨图:轻量级因果分析组件开发实践

Vue 3 中纯 CSS 实现动态鱼骨图:轻量级因果分析组件开发实践 1. 项目缘起为什么要在Vue里用纯CSS画鱼骨图最近在做一个后台管理系统产品经理甩过来一个需求要在一个项目复盘报告里展示一个“问题根因分析”的图表。他给的原型图就是一个典型的鱼骨图也叫因果图或石川图。这东西大家应该都见过一根主骨几根大骨上面再分小骨像鱼的骨架一样用来层层拆解问题的原因。我第一反应是去找现成的图表库像ECharts、AntV G2这些。但一看需求发现有点特殊首先这个鱼骨图的“骨头”数量、层级、文本内容都是动态的后端接口返回的数据结构每次都可能不一样。其次产品对样式有比较“固执”的要求比如骨头的颜色、箭头的形状、连线的虚实都指定了设计规范用图表库去覆盖默认样式调试起来可能比从头写还麻烦。最后这个页面是内嵌在Vue 3的单页面应用里需要是一个高度可复用的组件能响应数据变化并且性能要轻量。这时候我就琢磨能不能用纯CSS来实现配合Vue的响应式数据驱动和组件化能力应该能行。用CSS画图最大的好处是极致轻量零依赖渲染性能高而且样式控制权完全在自己手里想怎么调就怎么调。当然挑战也很明显CSS不是绘图工具如何用div和::before、::after这些伪元素去构建一个非矩形的、带连接线的拓扑结构网上搜了一圈现成的、高质量的纯CSS鱼骨图组件几乎没有大多还是依赖SVG或者Canvas。这反而激起了我的挑战欲。经过一番折腾最终还真用Vue 3 CSS鼓捣出来了。下面我就把这个组件的实现思路、核心代码、踩过的坑以及动态适配的技巧毫无保留地分享出来。你可以把它看作一个思路也可以直接拿走代码适配到你的项目里。2. 核心设计用CSS“拼积木”构建鱼骨骨架纯CSS实现复杂图形的核心思路就是“组合”与“变形”。我们把一条完整的鱼骨拆解成多个标准的CSS盒子然后通过定位、变形、边框等属性把它们拼接到一起。2.1 鱼骨图的HTML结构设计首先我们需要一个清晰、语义化的HTML结构来承载数据。一个鱼骨图通常包含以下部分主骨 (Spine): 中间的水平主线代表核心问题。大骨 (Main Bone): 从主骨斜向上或斜向下延伸出的主要分支代表主要原因类别如人、机、料、法、环。小骨 (Sub Bone): 从大骨上延伸出的次级分支代表更具体的原因。在Vue组件里我们使用嵌套的div结构来表示这种层级关系。数据驱动是Vue的强项所以我们用v-for来循环渲染。!-- FishboneDiagram.vue 模板部分简化示例 -- template div classfishbone-container !-- 主骨 -- div classspine/div !-- 循环渲染大骨上方和下方 -- div v-for(bone, index) in upperBones :keyupper-index classmain-bone upper div classbone-line/div div classbone-label{{ bone.category }}/div !-- 循环渲染该大骨下的小骨 -- div v-for(sub, subIndex) in bone.children :keysub-index-subIndex classsub-bone div classsub-line/div div classsub-label{{ sub.reason }}/div /div /div div v-for(bone, index) in lowerBones :keylower-index classmain-bone lower !-- 结构同上 -- /div !-- 鱼头问题描述 -- div classfish-head div classproblem-text{{ problem }}/div /div /div /template这个结构非常直观。.fishbone-container是总容器.spine是主骨.main-bone代表大骨它内部包含表示骨干的.bone-line、类别的.bone-label以及循环出来的.sub-bone小骨。2.2 纯CSS绘制“骨头”的魔法接下来是最关键的部分如何让一个普通的div看起来像一根有角度的“骨头”或一条带箭头的线1. 主骨与骨干线主骨其实就是一条粗一点的横线。我们可以用border-top或者background配合线性渐变来实现。为了简单和更好的控制我选择了用::after伪元素来画线。.spine { position: absolute; left: 120px; /* 给鱼头留位置 */ right: 0; top: 50%; height: 0; } .spine::after { content: ; position: absolute; left: 0; right: 0; top: -2px; /* 让线在容器中间 */ height: 4px; background-color: #333; /* 主骨颜色 */ border-radius: 2px; }这里用绝对定位将.spine铺满容器宽度然后用::after画了一条4px高、圆角的实线。top: 50%和top: -2px的组合确保了线在容器垂直居中。2. 大骨骨干线带箭头的斜线这是技巧性最强的一步。大骨线需要从主骨斜着延伸出去末端最好有个箭头。我们无法直接画一条斜线但可以用一个矩形旋转一定角度来模拟。.main-bone.upper { position: absolute; top: 50%; /* 水平位置通过JS计算后动态设置例如 :style{ left: bone.position px } */ } .main-bone.upper .bone-line { width: 150px; /* 大骨长度 */ height: 3px; background-color: #666; transform-origin: left center; /* 旋转围绕左端中心点 */ transform: rotate(-45deg); /* 上方大骨逆时针旋转斜向上 */ position: relative; } /* 添加箭头使用 .bone-line 的 ::after 伪元素画一个三角形 */ .main-bone.upper .bone-line::after { content: ; position: absolute; right: -8px; top: 50%; transform: translateY(-50%) rotate(-45deg); width: 0; height: 0; border-top: 6px solid transparent; border-bottom: 6px solid transparent; border-left: 10px solid #666; /* 一个指向右的三角形 */ }原理是这样的.bone-line本身是一个水平的矩形宽150px高3px。通过transform: rotate(-45deg)它就以左端为中心点transform-origin: left center逆时针旋转了45度变成了一根斜向上的线。箭头则是用CSS边框画三角形的老技巧在线的末端right: -8px附加一个三角形并调整其旋转角度使之与线方向匹配。对于下方的大骨.lower只需要将旋转角度改为rotate(45deg)箭头可能也需要调整方向例如用border-right代替border-left。3. 小骨线小骨线是从大骨线垂直延伸出去的短线。实现方式类似但旋转中心点需要调整。.main-bone .sub-bone { position: absolute; /* 小骨在大骨线上的位置通过JS计算top值动态设置 */ } .main-bone.upper .sub-bone .sub-line { width: 80px; height: 2px; background-color: #999; transform-origin: left center; transform: rotate(-90deg); /* 垂直于大骨线对于上方大骨需要再旋转-90度 */ position: relative; left: 20px; /* 从大骨线向右偏移一段距离开始画 */ }这里的关键是transform-origin和rotate的组合。小骨线初始是水平的我们让它围绕左端旋转-90度就得到了一根垂直向上的线相对于其父容器。因为父容器.sub-bone已经通过绝对定位放在了大骨线的特定位置所以这根小骨线看起来就是从大骨线上垂直长出来的。4. 鱼头问题框鱼头通常是一个矩形或圆角矩形放在主骨最左端。可以用一个普通的div加上样式即可为了美观可以加上一个指向主骨的小三角。.fish-head { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #4a6fa5; color: white; padding: 15px 25px; border-radius: 8px; font-weight: bold; box-shadow: 2px 2px 5px rgba(0,0,0,0.1); } /* 用 ::before 画一个指向右侧的三角形 */ .fish-head::before { content: ; position: absolute; right: -15px; top: 50%; transform: translateY(-50%); width: 0; height: 0; border-top: 10px solid transparent; border-bottom: 10px solid transparent; border-left: 15px solid #4a6fa5; }通过以上组合我们就用纯粹的CSS“盒子”和“边框”拼出了一个鱼骨图的视觉骨架。所有的“骨头”都是通过旋转矩形得来箭头是边框三角形连接点通过绝对定位精确定位。3. 动态化的关键Vue数据驱动与样式计算静态画出来只是第一步鱼骨图必须是动态的。大骨和小骨的数量、位置、文本内容都应由数据控制。3.1 组件数据结构设计在Vue组件的script setup里我们定义响应式数据。一个典型的数据结构如下import { ref, computed } from vue; const problem ref(项目上线延迟); // 核心问题 const boneData ref([ { id: 1, category: 人员, direction: upper, // upper 或 lower决定在上方还是下方 position: 20%, // 在主骨上的水平位置百分比或像素 children: [ { id: 11, reason: 经验不足, position: 30% }, { id: 12, reason: 沟通不畅, position: 60% }, { id: 13, reason: 临时请假, position: 80% } ] }, { id: 2, category: 机器, direction: upper, position: 50%, children: [ { id: 21, reason: 服务器性能瓶颈, position: 40% }, { id: 22, reason: 测试环境不稳定, position: 70% } ] }, { id: 3, category: 方法, direction: lower, position: 35%, children: [ { id: 31, reason: 流程冗余, position: 50% }, { id: 32, reason: 技术方案选型失误, position: 90% } ] } // ... 更多大骨 ]); // 通过计算属性将数据按上下方向分开方便模板 v-for const upperBones computed(() boneData.value.filter(b b.direction upper)); const lowerBones computed(() boneData.value.filter(b b.direction lower));这里position字段是关键。大骨的position决定了它从主骨的哪个水平位置“长”出来。小骨的position决定了它在大骨线上的哪个位置“分叉”。这个位置可以是百分比如20%也可以是具体的像素值如200px取决于你的布局逻辑。使用百分比通常更灵活能更好地适配容器宽度变化。3.2 动态样式绑定有了数据我们需要将它们映射到CSS的定位属性上。Vue的:style绑定在这里大显身手。在模板中我们需要动态计算每个大骨和小骨的位置!-- 大骨容器水平定位 -- div v-for(bone, index) in upperBones :keybone.id classmain-bone upper :style{ left: bone.position } !-- 大骨线本身不需要额外定位其旋转中心已在CSS中设为左端 -- div classbone-line/div div classbone-label{{ bone.category }}/div !-- 小骨在大骨线上的垂直定位 -- div v-forsub in bone.children :keysub.id classsub-bone :style{ top: sub.position } div classsub-line/div div classsub-label{{ sub.reason }}/div /div /div这里有两个层次的定位大骨容器.main-bone它的left样式由bone.position决定这控制了这根大骨从主骨的哪个“点”出发。小骨容器.sub-bone它的top样式由sub.position决定。注意这个top是相对于其父容器.main-bone的。因为.main-bone .bone-line是旋转的所以这里的top实际上控制的是小骨分叉点在大骨线段长度上的位置比例。例如top: 30%意味着小骨从大骨线从左端算起30%长度的位置垂直伸出。实操心得小骨位置的top值如果使用百分比其参考的是父容器.main-bone的高度。但我们的.main-bone可能只有定位作用没有明确高度。一个更可靠的做法是将小骨的位置计算为大骨线长度的百分比然后通过JS计算出具体的像素值再通过:style绑定top。或者确保.main-bone有一个明确的高度比如等于大骨线的长度这样百分比定位才准确。我在实际项目中采用了计算像素值的方式因为数据层可能更容易获取精确的偏移量。3.3 响应式与自适应鱼骨图可能需要适配不同宽度的容器。纯CSS方案的一个优势是我们可以利用CSS的calc()函数和vw单位来实现基础的响应式。例如主骨的起点给鱼头留的空间可以设为left: clamp(120px, 15vw, 200px);这样在窄屏幕上不会占用太多空间在宽屏幕上也有足够展示区域。大骨的长度也可以与容器宽度关联.bone-line { width: min(150px, 20vw); }。但是更复杂的自适应比如根据大骨数量自动调整间距避免重叠就需要JavaScript介入了。我们可以在组件的onMounted或watch数据变化时计算一个合适的position值。import { watch, ref, onMounted } from vue; const containerWidth ref(0); const bonePositions ref([]); onMounted(() { updateLayout(); window.addEventListener(resize, updateLayout); }); function updateLayout() { const container document.querySelector(.fishbone-container); if (!container) return; containerWidth.value container.offsetWidth; // 简单的等分算法示例实际逻辑可能更复杂需考虑避让 const totalBones boneData.value.length; const spacing containerWidth.value * 0.7 / (totalBones 1); // 主骨70%区域用于分布大骨 bonePositions.value boneData.value.map((bone, index) { // 为每个大骨计算一个水平位置像素值 return ${containerWidth.value * 0.15 spacing * (index 1)}px; // 从15%宽度处开始 }); }然后在模板中绑定计算出的位置:style{ left: bonePositions[index] }。踩坑记录动态计算位置时一定要在Vue的nextTick之后或onMounted生命周期中执行以确保DOM已经渲染能获取到正确的容器尺寸。同时监听window.resize事件来更新布局。对于复杂的避让算法防止骨头标签文字重叠可能需要引入更高级的布局计算但这已经超出了纯CSS的范畴属于组件的业务逻辑了。4. 样式打磨与交互增强基础骨架和动态绑定完成后我们需要让组件看起来更专业用起来更友好。4.1 细节样式优化文字排版与防重叠 大骨和小骨的标签文字可能很长。需要设置合理的white-space、word-break和max-width并绝对定位在骨头末端附近。对于小骨文字可能需要在线的左侧或右侧可以通过translateX进行微调。.bone-label { position: absolute; top: -25px; /* 放在大骨线的上方 */ left: 100%; /* 从线末端开始 */ transform: translateX(5px); white-space: nowrap; font-size: 14px; font-weight: 500; color: #333; background-color: #f8f9fa; padding: 2px 8px; border-radius: 4px; border: 1px solid #dee2e6; } .main-bone.lower .bone-label { top: auto; bottom: -25px; /* 下方大骨的标签放在线下面 */ }悬浮高亮效果 为了提高可交互性可以添加鼠标悬浮效果高亮某一条原因分支。.main-bone:hover .bone-line, .main-bone:hover .bone-label { background-color: #1890ff; /* 主色高亮 */ color: white; border-color: #1890ff; z-index: 10; /* 提高层级防止被遮挡 */ } .main-bone:hover .sub-bone .sub-line { background-color: #69c0ff; /* 子骨用稍浅的颜色 */ }通过:hover伪类当鼠标悬停在大骨上时改变其骨干线和标签的颜色同时也可以联动高亮其下所有的小骨让关联关系一目了然。动画过渡 当数据更新骨头位置或数量发生变化时可以添加平滑的过渡动画。.main-bone, .sub-bone, .bone-label, .sub-label { transition: all 0.3s ease-in-out; }这会让left、top、opacity等属性的变化有一个渐变的动画效果体验更流畅。注意transform和opacity属性的动画性能通常最好。4.2 封装为可复用Vue组件我们将所有逻辑封装到一个单一的Vue组件文件中如FishboneDiagram.vue并通过props接收外部数据。!-- FishboneDiagram.vue 完整组件示例 -- script setup import { computed } from vue; const props defineProps({ data: { type: Array, required: true, default: () [] // 结构同之前的boneData }, problem: { type: String, default: 待分析问题 }, // 可以添加更多配置项如颜色、线宽、是否显示箭头等 config: { type: Object, default: () ({}) } }); const upperBones computed(() props.data.filter(b b.direction upper)); const lowerBones computed(() props.data.filter(b b.direction lower)); // 可以在这里定义一些基于config的样式计算属性 /script template div classfishbone-diagram !-- ... 之前的模板结构将内部数据引用改为 props.problem 和 计算属性 ... -- /div /template style scoped /* 所有的CSS样式放在这里使用scoped确保样式隔离 */ .fishbone-diagram { position: relative; width: 100%; height: 500px; /* 建议设置一个默认高度或通过prop传入 */ overflow: visible; /* 确保骨头和标签超出容器也能显示 */ } /* ... 其他样式 ... */ /style这样在父组件中就可以轻松使用了template div FishboneDiagram :problemcurrentProblem :databoneList / /div /template4.3 处理内容溢出与边界情况在实际使用中你可能会遇到两个棘手的问题文本内容过长小骨的原因描述可能很长导致文字超出容器或与其他元素重叠。解决方案包括使用max-width和word-break: break-word强制换行。动态计算文本长度超过一定字符后使用...省略并配合title属性显示全文。更优雅的方案是使用Popper.js或Tippy.js之类的库在鼠标悬浮时显示完整内容的弹窗。但这会引入依赖偏离“纯CSS”的初衷可根据项目权衡。骨头数量过多导致重叠当原因类别很多时大骨可能会在主骨上挤在一起。纯CSS无法自动解决此问题。必须在数据层或组件逻辑层进行处理数据预处理后端或前端在生成数据时对position进行智能分配确保有最小间距。前端动态计算如第3.3节所述在组件内通过JS计算每个大骨的left位置实现简单的等分或更复杂的力导向布局模拟这属于轻量级的数据可视化布局算法实现起来稍复杂。个人体会纯CSS实现的核心优势是轻量和样式自由但代价是需要自己处理更多布局逻辑。对于中等复杂度、对性能有要求、且UI定制化程度高的场景这个方案非常合适。但如果你的鱼骨图数据量巨大比如超过20个大骨每个大骨下又有数十个小骨或者需要频繁动态更新、拖拽交互那么使用专业的Canvas或SVG图表库可能是更可持续的选择。它们内置了更强大的布局和渲染引擎。我们这个组件更适合作为中后台系统、报告页面中的一个轻量级、高定制化的展示模块。5. 完整代码示例与图片示意由于篇幅限制这里无法贴出上万行的完整代码但我会给出最核心的Vue单文件组件结构并附上关键CSS和实现后的效果描述。组件核心结构 (FishboneDiagram.vue):template div refcontainerRef classfishbone-diagram !-- 鱼头 -- div classfish-head{{ problem }}/div !-- 主骨 -- div classspine/div !-- 上方大骨 -- div v-for(bone, index) in computedUpperBones :keyupper-${bone.id || index} classmain-bone upper :stylegetMainBoneStyle(bone, index) mouseenterhighlightBranch(bone.id, true) mouseleavehighlightBranch(bone.id, false) div classbone-line/div div classbone-label{{ bone.category }}/div !-- 小骨 -- div v-forsub in bone.children :keysub.id classsub-bone :stylegetSubBoneStyle(sub) div classsub-line/div div classsub-label :titlesub.reason{{ truncateText(sub.reason) }}/div /div /div !-- 下方大骨 (结构类似省略) -- div v-for(bone, index) in computedLowerBones :keylower-${bone.id || index} classmain-bone lower :stylegetMainBoneStyle(bone, index) mouseenterhighlightBranch(bone.id, true) mouseleavehighlightBranch(bone.id, false) !-- ... 内部结构与上方大骨一致 ... -- /div /div /template script setup import { ref, computed, onMounted, onUnmounted, nextTick } from vue; const props defineProps({ data: Array, problem: String, config: Object }); const containerRef ref(null); const containerWidth ref(0); const computedUpperBones computed(() props.data?.filter(b !b.direction || b.direction upper) || []); const computedLowerBones computed(() props.data?.filter(b b.direction lower) || []); function getMainBoneStyle(bone, index) { // 动态计算位置这里简化处理实际应根据bone.position或等分算法计算 const left bone.position || ${(index 1) * (100 / (computedUpperBones.value.length 1))}%; return { left }; } function getSubBoneStyle(sub) { return { top: sub.position || 50% }; } function truncateText(text, maxLen 10) { return text.length maxLen ? text.substring(0, maxLen) ... : text; } function highlightBranch(boneId, isEnter) { // 实现高亮逻辑例如通过CSS类名或直接操作样式 const els document.querySelectorAll([data-bone-id${boneId}]); els.forEach(el { el.classList.toggle(highlighted, isEnter); }); } function updateContainerWidth() { if (containerRef.value) { containerWidth.value containerRef.value.offsetWidth; // 可以在这里触发更复杂的布局重计算 } } onMounted(() { updateContainerWidth(); window.addEventListener(resize, updateContainerWidth); }); onUnmounted(() { window.removeEventListener(resize, updateContainerWidth); }); /script style scoped .fishbone-diagram { position: relative; width: 100%; min-height: 400px; margin: 20px auto; font-family: sans-serif; } /* ... 所有之前提到的CSS样式包括 .fish-head, .spine, .main-bone, .bone-line, .sub-bone, .sub-line, 标签样式悬浮效果过渡动画等 ... */ /* 例如 */ .main-bone { position: absolute; top: 50%; transform: translateY(-50%); } .main-bone.upper .bone-line { width: 150px; height: 3px; background: linear-gradient(to right, #8c8c8c, #595959); transform-origin: left center; transform: rotate(-45deg); transition: all 0.3s ease; } /* ... 更多样式 ... */ .highlighted .bone-line, .highlighted .bone-label { background-color: #1890ff !important; color: white !important; border-color: #1890ff !important; } /style实现效果描述图片示例替代:由于无法直接嵌入图片我将描述一个典型的渲染效果一个水平放置的浅灰色容器中央有一条深灰色、4px粗的横线贯穿左右这是“主骨”。主骨最左端连接着一个蓝色的圆角矩形框内部写着“项目上线延迟”这是“鱼头”。从主骨的上方斜向上约45度角伸出三根较粗的灰色实线长度约150px末端有一个深灰色的实心三角形箭头。这三根线代表“人员”、“机器”、“方法”三个主要原因类别。每根线的起点与主骨连接点水平位置不同均匀分布。每根线的末端上方紧挨着一个浅灰色背景、深色边框的标签里面写着对应的类别名。在“人员”这根大骨线上从大约30%、60%、80%长度的位置垂直向上相对于大骨线伸出三根更细、颜色更浅的短线这就是“小骨”。每根小骨的末端也有一个小标签分别显示“经验不足”、“沟通不畅”、“临时请假”。当鼠标悬停在“人员”这个大骨上时这根大骨线、其箭头、类别标签以及下属的三根小骨线都会高亮变成主题蓝色其他分支则变灰交互关系非常清晰。主骨的下方同样有斜向下的“大骨”例如“环境”、“测量”等类别其结构与上方对称。整个图形完全由HTML元素和CSS样式构成没有使用任何图片资源。通过Vue的数据绑定改变props.data鱼骨图的结构会立即响应更新骨头的位置、数量、文字内容都会随之变化。这个组件现在可以直接在你的Vue 3项目中引入使用通过传递不同的data和problem就能快速生成各种因果分析图。它轻量、灵活、样式可控完美满足了产品经理那个“有点特殊”的需求。
返回列表