资讯动态

Vue3+SVG自建可交互技能画像组件:数据驱动雷达图与环形进度条实战

发布时间:2026/10/21 13:50:02 来源:尧图企业网站定制
如果你跟我一样把个人主页翻来覆去改了三版最后卡在“技能展示”这一栏迟迟定不下来那你大概率也经历过这种尴尬想放一张能力雷达图结果引了一个大图表库才发现样式根本改不动想用现成的技能标签组件结果排出来的页面跟别人的主页像是同一个模子刻出来的。我最后的选择就是自己动手写了一个可交互的技能画像组件代号就叫skills。它不是什么复杂框架就是把零散的技术栈整理成“按分类切换、可点开看细节、有雷达图也有环形进度条”的能力展示模块。整套东西做成一个 Vue 3 组件喂一份 JSON 数据就能跑支持响应式布局和深浅主题切换。如果你也在搭个人主页、作品集或者想把简历里的“熟悉 Vue / 熟悉 Node.js”这种干巴巴的字眼变成能讲故事的可视化内容这篇分享应该能帮你省掉不少折腾的时间。1. 项目定位与方案选型1.1 为什么放着现成组件不用先说清楚来龙去脉。我自己的个人主页改版改到第三周其他区块都定了唯独技术能力这一段怎么放都不对劲。市面上能直接找到的现成方案我大概分了三类。第一类是纯静态的标签墙把 JavaScript、TypeScript、Vue 这些词搓成一个个彩色圆角块排成几行看着挺整齐但信息量约等于零——只会告诉访客“这个人知道这些名词”至于熟练到什么程度、在什么项目里用过、用得深不深完全看不出来。第二类是图表库生成的雷达图或能力条形图功能确实全但为了一个几十技能的小模块引入一个几十 KB 的库总觉得不太值当而且默认样式跟个人主页的风格很难统一。第三类是所谓的无头组件只提供逻辑不提供外观理论上想怎么改都行可用起来才发现抽象层太厚学习成本比从头写一个还高。我拿三个下午挨个试了一遍最大的感受是不是这些轮子不好而是这个需求本身太“刁钻”。我要的不只是十个技能名加一串百分比。我希望访客点一下“前端基础”这个分类看到的不是干巴巴的“Vue 90%”而是这个技能背后用了几年、主要用在什么场景、配套工具链有哪些我希望这个模块在手机端不塌不挤在深色模式下不刺眼还能配合整页滚动做一点自然的过渡动画。这种黏糊糊的定制需求与其去现成组件里找缝隙塞不如把边界完全握在自己手里。1.2 这个组件到底做成什么样所以我最终写了一个叫skills的小组件严格说它是一个“可交互技能画像”模块。它的核心能力可以拆成四块按分类组织技能数据每一大类比如前端基础、后端服务、基础设施是一个可切换的页签每个分类下面有一组技能条目每条技能用环形进度条展示熟练度旁边配一段简短说明点开还能看到使用年限和常用工具链标签分类页签左侧是一张 SVG 雷达图展示当前分类内各项技能的综合能力分布而不是简单地把几个百分比堆在一起整个模块响应式支持亮色和暗色主题切换对外接口只需要一份 JSON 数据。我特意把数据结构和渲染逻辑拆开。这样换一份技能数据或者把整个模块从个人主页挪到作品集页面组件本身可以原封不动继续用。说白了一开始是给我自己用的小工具后来收着收着就变成了一个比较通用的模板。这个模块适合谁参考我理解有三类人。第一类是正在做个人主页或作品集、想把手里的技术栈做得更有说服力的开发者第二类是对 Vue 组件拆分、SVG 可视化感兴趣的初级前端可以直接看代码怎么组织第三类是纯粹想找一个“数据驱动视图”案例的人——多看看这种小规模但完整的设计比自己闷头啃框架文档更有感觉。1.3 技术栈选型Vue 3 Vite 原生 SVG技术选型这件事老实说没有太多悬念。Vue 3 加 Vite 是因为我的主页本来就是这套组合搭的为了一个子模块再引另一套框架纯属给自己找麻烦。更重要的是Vue 3 的组合式 API 非常适合这种“数据驱动 多子组件通信”的场景把数据逻辑提取成组合式函数各组件之间通过 props 和事件交互不会出现复杂的状态管理需求。可视化部分我坚持用 SVG 而不是 Canvas。原因很朴素雷达图和环形条属于“少量几何图形加少量文本”的场景SVG 的节点天然支持事件绑定、CSS 过渡和文本排版代码写起来直观在浏览器开发者工具里能直接看到每个元素的属性调试体验好得多。Canvas 适合图形数量巨大、需要频繁重绘的场景在技能展示这种模块里反而要额外处理事件命中、文本测量一类的问题属于杀鸡用牛刀。用表格总结一下我当时对比过的几条路线方案优点缺点我的取舍通用图表库雷达图功能全、上手快样式定制难度高、包体大放弃静态标签墙组件简单、零依赖信息量低、不可交互放弃无头组件逻辑完整抽象层厚、学习成本高放弃原生 SVG 自绘结构清晰、易调试、易定制图形量大时性能一般采用后面所有实现都围绕一个原则数据驱动视图。组件里不写死任何技能名和百分比也不在视图层计算业务逻辑所有数据都从一份 JSON 进来组件只负责把数值映射成坐标、颜色和动画。这一点在后面设计和编码时会反复出现。2. 数据结构与核心细节解析2.1 一份 JSON 喂饱整个组件动手写视图之前我花了整整一个晚上设计数据结构。这块看似不起眼实际上决定了组件能走多远。我定义的最终结构是这样的// 技能分类 interface SkillCategory { id: string; // 唯一标识比如 frontend name: string; // 展示名比如 前端基础 icon: string; // 分类图标标识按需引入 skills: SkillItem[]; } // 单条技能 interface SkillItem { name: string; // 技能名 level: number; // 0 到 100 的整数熟练度 years: number; // 使用年限 tags: string[]; // 常用工具链或相关关键词 note: string; // 一句话说明描述应用场景或项目经验 }为什么把 level 限制成 0 到 100 的整数因为雷达图和环形条的比例计算全都依赖这个值浮点数虽然也能算但容易在 SVG 像素上留下肉眼看不出来、代码里却很难排查的误差整数还能顺便兼容未来“手动拖拽调整”之类的需求。years 和 tags 一开始觉得可加可不加后来发现正是这两个字段让整个模块从“展示”变成了“叙事”——访客看到的不只是熟练度还能感知到沉淀和方向感。举个真实数据例子。假设我的前端分类下有这样几条技能{ id: frontend, name: 前端基础, icon: code, skills: [ { name: JavaScript, level: 88, years: 6, tags: [ES2024, 异步编程], note: 日常主力语言写过中型单页应用和工具脚本 }, { name: Vue, level: 85, years: 4, tags: [Vue 3, Pinia], note: 个人主页与多个后台项目均基于 Vue 3 构建 }, { name: CSS, level: 78, years: 6, tags: [Flexbox, Grid], note: 擅长布局还原偏好设计系统与变量化样式 } ] }组件完全不关心这些数据是谁、从哪来它只认level和各项字段。这样一来以后如果我换工作、换技术栈只需要改这份数据文件所有图形会自动更新不需要碰一行业务代码。这里我还做了一层防御在组合式函数入口对数据进行校验比如检查level是否在 0 到 100 之间、skills数组是否为空。技能数小于 3 的分类雷达图会自动降级为隐藏避免画出一个只有两三个点的“怪图”。这种边界处理属于那种不做没人说、做了很显功力的细节。2.2 雷达图背后的坐标计算雷达图是组件里视觉上最唬人的部分但它的核心其实就一个函数把“角度加半径”转换成“x 加 y 坐标”。为什么雷达图要从正上方开始排维度因为人的阅读习惯里“第一项”应该在最显眼的位置也就是正上方。如果从右侧三点钟方向开始画整个图形就会“歪”着视觉上不舒服。所以坐标转换时要让角度 0° 对应正上方标准写法是这样function polarToCartesian(cx, cy, radius, angleDeg) { const rad ((angleDeg - 90) * Math.PI) / 180; return { x: cx radius * Math.cos(rad), y: cy radius * Math.sin(rad) }; }cx、cy是雷达图中心点坐标angleDeg是当前维度对应的角度从 0° 开始递增减 90 度就是让 0° 从正上方开始走。有了这个函数生成雷达图轮廓就很简单了。假设当前分类下有 n 条技能那么角度步长就是 360 / n 度循环调用polarToCartesian就能拿到所有顶点坐标。把顶点两两相连就得到了多边形网格把每条技能的level / 100乘以半径再把缩放后的顶点连起来就得到了数据多边形。这里有一个新手容易踩的坑数据点坐标只是一个一个的“点”要画成闭合多边形得先把它们拼成 SVG 的points字符串也就是“x,y x,y x,y”这种格式。我一开始直接传数组给points属性结果图形死活不显示后来才发现 SVG 的polygon接收的是字符串不是数组。这种小坑写的时候踩一次记住了就再也不会犯。标签文字也要放在图形外圈。为了让标签不被 SVG 边缘裁掉我会把标签半径设成图形半径加 14 像素左右并且用text-anchormiddle和dominant-baselinemiddle让文字居中定位。不然不同长度的技能名看起来会东倒西歪。2.3 环形进度条与 dasharray 的魔法如果说雷达图是把维度“摊开”看环形进度条就是把每项技能“收拢”成一圈。它看起来比雷达图精致原理却更简单全在 SVG 的两个属性上。在 SVG 里画一个圆它的描边默认是完整的一圈。stroke-dasharray可以把描边切成“实线加间隙”的重复序列而stroke-dashoffset可以控制这个序列从哪里开始偏移。把这两者组合起来就能实现“描边只画一部分”的效果——这就是环形进度条的核心。具体计算是这样的圆周长C 2πr如果你想让进度显示progress0 到 1那么实线长度应该等于C × progress间隙长度是C × (1 - progress)。用stroke-dasharray设成C再用stroke-dashoffset设成C × (1 - progress)实线部分就会正好对应进度条的长度。const r 34; const circumference 2 * Math.PI * r; const dashOffset circumference * (1 - item.level / 100);在模板里对应地写circle :stroke-dasharraycircumference :stroke-dashoffsetdashOffset stroke-linecapround transformrotate(-90) /这里有个关键参数transformrotate(-90)。circle 默认从右侧三点钟方向开始画弧旋转 -90 度后进度条从正上方开始观感更顺。stroke-linecapround则是把进度条两端变成圆头看起来不是那么生硬。用生活化的比喻来说stroke-dasharray就像一根绳子上预先打好了固定的绳结和空档stroke-dashoffset就是你把绳子整体往前拉了多长空档和绳结的分布位置就变了。理解这一点后环形进度条再变花样你都能心算出来。2.4 数字滚动动画的通用实现可视化组件光有静态图形不够还要有“动起来”这个过程。我最先实现的是技能数值从 0 滚到目标值的动画这是整个组件里互动感最强的一环。动画的骨架是requestAnimationFrame核心是一个缓动函数。我用了最经典的easeOutCubic曲线先快后慢符合人对“进度推进”的预期const easeOutCubic (t) 1 - Math.pow(1 - t, 3); function animateValue(start, end, duration, callback) { const startTime performance.now(); const tick (now) { const progress Math.min((now - startTime) / duration, 1); const current Math.round(start (end - start) * easeOutCubic(progress)); callback(current); if (progress 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }在 Vue 组件里我把这段逻辑封装成一个useCountUp组合式函数暴露一个start(target)方法。分类切换或者数据更新时调用这个方法触发数字滚动。动画时长我固定在 500 毫秒左右。太短了看不出过渡太长了会拖慢用户的浏览节奏。这个经验值也适用于页面中大多数“锦上添花”的动画——记住可视化动画是辅助阅读的不是用来炫技的。3. 实操过程从零搭一个 skills 组件3.1 初始化项目与目录规划假设你手上已经有一个 Vite 加 Vue 的项目那直接开始就行。没有的话用下面命令初始化npm create vitelatest skills-demo -- --template vue cd skills-demo npm install我不建议把组件代码堆进App.vue里。虽然它只是个小组件我还是按可复用组件的规格来组织目录。目录结构大概是这样的src/ components/ skills/ SkillsPanel.vue // 对外主组件负责数据接收和区块编排 SkillRadar.vue // 雷达图子组件 SkillRing.vue // 环形进度条子组件 SkillCategoryTabs.vue // 分类页签子组件 useSkillsData.js // 组合式函数封装数据校验和当前分类状态主组件只负责“接数据、分区块、调动画”具体图形交给子组件。这样每个文件都很薄出了问题能快速定位。我第一次写的时候把所有逻辑都塞在主组件里结果一个文件 600 多行改一行样式都要翻半天后来果断拆开。3.2 雷达图组件的完整实现SkillRadar.vue的核心是那两个 computed 属性。我把上一章的polarToCartesian函数搬进来然后这样组织const props defineProps({ items: { type: Array, required: true }, // 技能列表 size: { type: Number, default: 240 } // 正方形 viewBox 边长 }); const cx props.size / 2; const cy props.size / 2; const radius props.size / 2 - 24; // 给标签留空间 const angleStep computed(() 360 / props.items.length); const vertices computed(() props.items.map((_, i) polarToCartesian(cx, cy, radius, i * angleStep.value)) ); const dataPoints computed(() props.items .map((item, i) { const r (item.level / 100) * radius; const p polarToCartesian(cx, cy, r, i * angleStep.value); return ${p.x},${p.y}; }) .join( ) );模板部分就清晰了——先画网格多边形再画数据多边形最后把维度标签放到外圈合适的位置。完整代码不在此贴全但核心结构是这样svg :viewBox0 0 ${size} ${size} roleimg aria-label技能雷达图 !-- 网格画 gridLevels 层同心多边形 -- polygon v-forg in gridLevels :keyg :pointsgridPoints(g) fillnone strokevar(--skills-grid-color) / !-- 数据区域填充半透明色表示能力范围 -- polygon :pointsdataPoints fillvar(--skills-accent) fill-opacity0.15 strokevar(--skills-accent) stroke-width2 / !-- 加上顶点小圆点强化视觉锚点 -- circle v-for(p, i) in vertices :keyi :cxp.x :cyp.y r3 fillvar(--skills-accent) / /svg注意一个细节SVG 的viewBox设成和size相同的正方形整个图形才能等比缩放。不然容器宽度变了图形会被拉伸变形这在做响应式时特别重要。3.3 环形进度条组件与数字滚动SkillRing.vue就更简单了。它的职责只有一个根据item.level画一圈环形进度并配合数字动画展示当前值。template div classring-item svg :viewBox0 0 ${box} ${box} classring-svg circle :cxhalf :cyhalf :rr classring-bg / circle :cxhalf :cyhalf :rr classring-fg :stroke-dasharrayC :stroke-dashoffsetoffset transformrotate(-90) / /svg span classring-num{{ displayValue }}/span p classring-name{{ item.name }}/p p classring-note{{ item.note }}/p span v-fortag in item.tags :keytag classring-tag{{ tag }}/span /div /templateC和offset走 computed 算出避免在模板里塞公式。数字动画部分我在onMounted里调用一次useCountUp().start(item.level)并且在watch到item.level变化时重新触发。这一步有个视觉细节数字滚动的起始值应该是当前displayValue而不是每次都从 0 开始。如果用户正在看 Vue 的 85%切到 TypeScript 的 80%数字直接从 85 滚到 80 比从 0 滚到 80 自然得多。3.4 分类切换与过渡动画分类切换是整个组件里最容易写乱的地方。我的做法是用一个currentIdref 保存当前分类标识点击页签时更新currentId并通过watch监听它触发新分类下所有SkillRing重新运行动画雷达图的 SVG 节点数量不大直接更新points属性即可不需要销毁重建这样动画反而连贯。列表用了transition-group让条目在切换时有轻微错位效果。动画时长控制在 250 毫秒左右条目间加 30 毫秒的 stagger 延迟。实测超过 300 毫秒就会让用户觉得“卡”30 毫秒的错位既有层次感又不会拖沓。同时我暴露了一个current-change事件让外层页面知道当前查看的分类。这个事件可以用来同步页面上其他区块的标题也可以用来做简单的访问埋点。组件自己只管内部状态外部要做什么由外部决定。4. 常见问题与排查技巧实录4.1 雷达图在小屏上被压扁我第一次写完雷达图在手机上打开一看图形变成椭圆了文字标签挤成一团。原因很简单我在样式里给雷达图设了固定宽高而 SVG 默认会按容器尺寸拉伸。解决方案是前面提到的viewBox。把 SVG 的viewBox设成一个正方形再配preserveAspectRatioxMidYMid meet图形就会始终在容器内等比居中不会变形。但要注意正方形viewBox在窄容器上会把高度撑得很大挤压下面的环形条区域。我的处理是给雷达图外层容器设一个最大高度超过后左右留白居中而不是继续拉伸。4.2 环形进度条首屏不播动画很多组件第一次渲染到页面时进度条直接带着最终值出现看不到“从 0 转起来”的过程。原因是 Vue 挂载时会把stroke-dashoffset直接设成最终值中间没有过渡状态。我的解法是在onMounted里先用nextTick确保 DOM 更新完成再启动动画循环去修改dashoffset。注意要配合 CSS 过渡或者直接用我前面写的animateValue函数去驱动。两者任选其一就能避免“首帧即终态”的问题。4.3 快速切换分类时数字乱跳分类切换太快时上一轮数字滚动的requestAnimationFrame还没结束新一轮又启动了两个循环同时修改同一个显示值数字就会来回跳。解决方法是给每个动画一个“令牌”每次启动新动画时把旧令牌作废let token 0; function start(target) { const myToken token; const old displayValue.value; animateValue(old, target, 500, (val) { if (myToken token) displayValue.value val; }); }用令牌而不是cancelAnimationFrame是因为多个 rAF 循环可能依赖同一个帧号直接取消不一定来得及。这是我在实际调试中试出来的经验也推荐给你。4.4 深色模式下颜色发灰最初我把颜色写死在 SVG 的stroke属性里一开深色模式图形又灰又刺眼。后来把所有颜色全部抽成 CSS 变量例如var(--skills-grid-color)、var(--skills-accent)、var(--skills-text)在组件根节点定义默认值页面主题切换时覆盖这几个变量就行。我常用的主题变量对照表变量名作用亮色推荐值暗色推荐值--skills-accent高亮描边、数据区颜色#2563eb#60a5fa--skills-grid-color雷达图网格线#e2e8f0#334155--skills-text技能名、说明文字#334155#e2e8f0这样主题化的成本几乎为零组件内部完全不需要感知页面处于什么模式样式变量一变整体配色自动切换。最后说一点个人体会。写这个skills组件最大的收获不是它看起来多炫而是让我彻底理解了“先定义数据、再思考视图”的开发顺序。前端组件永远有改不完的样式和交互但把数据格式定清楚之后视觉层怎么折腾都不会伤筋动骨。如果后面继续维护我会给组件加上导出 PNG 截图的功能再把技能数据改成从远程接口拉取更新一次数据全站同步更新。不过在当下我已经用这个模块替换掉主页里原来那排静态标签访客点击率明显变高了——把“技能墙”变成“可阅读的能力简历”确实能让人更愿意停下来看几眼。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑