先说个结论:真正经得起推敲的技能盘点,不是简历里那几个形容词,而是能被数据、图表和具体案例反复验证的证据。我做了一个叫 skills 的小工具,把个人技能拆成等级、年限、标签三个维度,实时渲染成雷达图和技能卡片,还能一键导出分享图。这篇文章会从需求拆解讲到页面实现,再到部署和排坑,基本还原了我从零到一做完这个项目的完整过程。适合想把技能结构化展示出来的开发者、设计师,也适合带团队做能力盘点的同学。
为什么要盯住“技能可视化”这个点?因为这些年不管是在个人主页、作品集还是团队汇报里,我见过太多“精通某某技术”的写法,写的人心虚,看的人也没法判断。skills 就是想用一张图说明白:你到底在哪个技能上花了多少时间,当前水平大概在什么位置。项目本身不复杂,但设计思路和数据建模很值得聊。
1. 项目定位与整体设计思路
1.1 需求拆解:一个技能展示工具到底要解决什么问题
最开始我只有一句话想法:做一个页面,把我的技能“画”出来。但“画出来”这三个字需要拆成三个具体问题。
第一个问题是技能列表该怎么呈现。传统简历的写法是“熟练掌握 A、B、C”,这种写法最大的毛病是缺少层次感。同样是“会用 Python”,写脚本的人和数据科学家对外都叫“会用”,实际能力天差地别。所以我需要一种能同时表达“广度和深度”的图形化载体,雷达图是最合适的选择,它天然适合多维对比。
第二个问题是技能的评分标准。技能高低如果只靠一个人拍脑袋打分,那这个项目做出来就是自欺欺人。我需要给每个等级一个可验证的描述,比如“能在没有人指导的情况下独立完成模块开发”和“遇到疑难杂症时能给出合理的排查思路”是两种完全不同的水平。有了这种描述锚点,打分才不会太飘。
第三个问题是数据要能反复被编辑。技能这个东西是动态的,今天刚学了一个新框架,下个月可能又换方向了。如果每次修改都要改代码、改 JSON,维护成本太高。所以数据必须做到可视化编辑,甚至能导出成文件保存起来。
实际使用场景比我想象的还要广。一位做后端的朋友想给团队成员做能力盘点,他直接用我这个页面的数据格式,把每个人的技能数据整理成 JSON,然后用同一套雷达图引擎生成了团队技术全景图。还有一次,一位准备转行的设计师想把自己的设计技能和前端技能放在同一张图里对比,这等于把两个不同方向的能力放到一起看,比单独列两段文字直观得多。
1.2 技术选型:为什么用静态页面加本地存储
技术选型阶段,其实有几种方案:
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 纯静态页面 + localStorage | 部署简单、无需后端、隐私好 | 无法多设备同步 | 首选 |
| 静态页面 + 云数据库 | 支持同步、多人协作 | 引入鉴权、成本高 | 过度 |
| 全栈应用(前端+后端) | 功能扩展性强 | 维护成本高,偏离初衷 | 杀鸡用牛刀 |
我的选择是第一项。原因有四条。第一,技能数据是个人数据,一个月可能也就改几次,频率很低,不需要实时的云端同步。第二,数据存在浏览器本地可以避免隐私问题,用户自己的技能数据不想传给第三方服务。第三,静态页面可以丢到任何静态托管平台上,没有服务器运维负担。第四,本地存储能保证离线可用,网络不好时照样能打开看。
图表方案这里也稍微提一下。雷达图完全可以自己用 Canvas 或 SVG 画,没必要非得引一个图表库,因为雷达图的坐标计算逻辑并不复杂,核心就是极坐标转平面坐标,后面我会把公式拆开讲。当然如果你不想自己画,找一个成熟的开源图表库也行,但那样你就得多背上几十 KB 的依赖。
1.3 数据模型:技能 JSON 怎么设计才不后悔
这是整个项目里我觉得最有价值的部分。当初设计数据结构时,我把技能定义成下面这种 JSON 格式:
{ "skills": [ { "id": "py-core", "name": "Python 开发", "category": "后端", "level": 4, "years": 5, "tags": ["自动化脚本", "数据处理", "接口开发"] }, { "id": "frontend-vue", "name": "Vue 前端", "category": "前端", "level": 3, "years": 2, "tags": ["组件封装", "状态管理", "性能优化"] } ] }每个技能对象重点字段有五个。
id是用来做唯一的标识,渲染列表时给元素加 key,雷达图里做维度定位,删改数据时也靠它来索引。不要偷懒用 name 当 id,因为技能名称很容易改名,乱了以后很难处理。
name是展示名称,尽量用大家都能看懂的短词,比如“Python 开发”而不是“基于 Python 语言的高级服务端架构与异步编程实践”,雷达图区域有限,标签太长会重叠。
category是分类字段,后续按类别筛选、分组对比都靠它。没有这个字段,后面想按前端、后端、设计等维度聚类时就会很被动。
level是技能等级 0 到 5 的整数,代表当前掌握程度。具体锚定标准见第二章。
years是投入年限,它是 level 的重要佐证。一个人说自己做前端 5 年,但 level 只有 2,这个数据就比“前端精通”四个字更有说服力,因为年限和等级互相验证。
tags是自由标签,用来描述技能的具体方向或者项目案例关键词。但也有个数限制,不过我建议最多三个就够,太多会挤占展示空间。
这个数据模型的取舍经验是:字段宁少勿多。最初我加过confidence、interest、lastUsedAt这些字段,后来发现雷达图和卡片上根本展示不了这么多信息,反而增加了数据维护负担,砍掉以后清爽很多。
2. 核心实现:从数据到可视化的关键环节
2.1 雷达图绘制核心原理:多边形坐标计算
先讲清楚雷达图的工作原理,因为后面所有代码都基于这个逻辑。雷达图本质上是一个极坐标系里画多边形的问题。
假设我们有 n 个技能维度,整张图就是一个 n 边形。每个技能对应从圆心往外延伸的一条轴,轴之间的夹角是 360° 除以 n。某个技能的 level 值决定它在轴上距离圆心的比例,所有技能连起来就形成一个不规则多边形。
坐标计算公式很简单。对于第 i 个维度,角度 angle = -90° + (360° / n) * i,也就是从正上方开始,顺时针旋转均匀分布。对应平面坐标是:
x = centerX + radius * cos(angle) y = centerY + radius * sin(angle)这里 radius 等于“该维度的 value 除以最大等级”再乘以图表半径。如果把最大等级设成 5,level 是 4,那么半径就是图表半径的五分之四。
理解了这个公式,画雷达图就不难了。先画背景网格:每一层网格就是一个按相同比例缩小的多边形。再画轴线,就是圆心到每个顶点的直线。最后画数据多边形,把所有维度坐标用线段连起来,填充半透明的颜色。这就是完整雷达图。
我建议绘制部分用 Canvas 而不是 SVG。Canvas 在重绘几十个维度时性能更好,而且导出 PNG 图片非常方便,直接 toDataURL 就能拿到图片数据。SVG 在交互上更方便,比如每个轴单独绑定事件,但数据量大时 DOM 节点会变多,拖慢渲染。
2.2 评分标准化:技能等级怎么定量
这是 skills 项目最有门槛的部分,也是我踩坑最深的部分。
一开始我直接用 1 到 100 的数值让用户打分,结果发现没人能准确定义“85 分”和“90 分”的区别。后来我改成 0 到 5 分,并且给每个分值写死了行为描述,让用户照着标准自评。这套描述也是我后来给团队做测评时一直沿用的。
| 等级 | 行为描述锚点 | 典型特征 |
|---|---|---|
| 0 | 接触过概念,没做过实际项目 | 能说出名字,不知道接入方式 |
| 1 | 跟着教程完成过小示例 | 能复现教程代码,改参数就抓瞎 |
| 2 | 独立完成过小功能模块 | 能在别人搭好的框架里填代码 |
| 3 | 独立负责过完整模块或子项目 | 能独立设计接口、结构,无需总问别人 |
| 4 | 深入理解底层原理,带过新人 | 能讲清某个机制为什么这样设计 |
| 5 | 有自己总结的方法论,能输出内容 | 社区有文章/分享,能解决疑难场景 |
用这套锚点后,评分就变得有据可依。比如“Python 开发”给我自己打 4 分,理由是我不仅能独立写接口,还能给同事讲清楚垃圾回收机制和并发场景下的常见坑,同时带过两个新人。
另一个建议:自评时先找一个“基准技能”。比如你觉得自己最擅长的技术是某个框架,那先把它的 level 定为 4。其他技能都跟它比较,比你最擅长的弱就降一级,差很多就降两级。这样至少能保证整张图内部的相对排序是一致的,不会出现所有技能都打 5 分的“满级图”。
2.3 数据编辑与导入导出
数据可视化只是展示层,数据的可维护性才是长期用好这个系统的关键。我做了三个入口:页面上的表单编辑、JSON 文本直接编辑、以及文件导入导出。
表单编辑适合日常小改,比如把某个技能的 level 从 3 调到 4。表单提交后直接更新内存对象,然后调用渲染函数刷新页面。
JSON 文本编辑适合大批量导入,比如第一次录入一套技能数据。我会在页面上放一个隐藏的 textarea,把当前 JSON 数据灌进去,改完以后点击“应用”再校验格式。校验逻辑也很简单,JSON.parse 加字段完整性检查。如果解析失败,就给出错误提示,不让脏数据覆盖当前数据。
导入导出用文件流方式。导出就是生成一个 JavaScript Blob 对象,然后触发浏览器下载,文件命名带上日期,比如skills-2025-03-01.json。导入就是通过一个 input file 读取文件内容,解析成功后写入 localStorage。
localStorage 这一层也有讲究。我的 key 固定为skills_data_v1,为什么带_v1版本号?因为以后数据结构可能升级,比如从 level 改成新的字段,至少还能通过版本号判断新旧格式,做迁移。
const STORAGE_KEY = 'skills_data_v1'; function loadSkills() { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (e) { console.error('数据解析失败,已自动重置', e); return []; } } function saveSkills(list) { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); }这段代码麻雀虽小,防坑细节不少。首先是 Array.isArray 校验,防止数据结构不对时页面直接白屏。其次是 try catch,因为 localStorage 在隐私模式下可能写入失败,不能让它影响页面主流程。
3. 实操过程:完整还原一次 skills 页面搭建
3.1 页面骨架和布局
整个页面结构就是三块区域:顶部一个操作栏,中间左边是技能卡片列表,中间右边是雷达图画布。移动端通过媒体查询改为上下堆叠。
HTML 骨架核心部分大概长这样:
<div class="app"> <header class="toolbar"> <h1>技能雷达</h1> <button id="btn-export">导出图片</button> <button id="btn-backup">备份数据</button> </header> <main class="layout"> <section class="skill-list" id="skill-list"></section> <section class="chart-panel"> <canvas id="radar-chart"></canvas> </section> </main> </div>布局上用 CSS Grid 最合适,两列比例可以设成 5:7,左边列表容易变得过长时自动滚动,右边画布区域始终占大头。
设计上的小心得是浅色背景加卡片阴影会让雷达图更突出。我试过深黑背景,雷达图配荧光色确实酷,但技能标签的中文小字在深色底上可读性差,后来就回归到浅色系了。
3.2 数据初始化与技能卡片渲染
页面加载时要做两件事:读取已有数据,没有数据就用默认示例数据填充并保存。默认数据一定要给,要不然新用户打开页面只能看到空白。
渲染技能卡片时,我用原生 JavaScript 操作 DOM,没有引入框架。为什么不用 Vue 或 React?因为页面交互复杂度低,核心操作就是“点击卡片高亮雷达图对应维度”和“表单编辑”,原生 DOM 拼接字符串就够用了,省掉打包编译一堆事。
function renderSkillList(list) { const container = document.getElementById('skill-list'); container.innerHTML = list.map(skill => ` <div class="skill-card">function drawRadar(skills) { const canvas = document.getElementById('radar-chart'); const ctx = canvas.getContext('2d'); const w = canvas.width; const h = canvas.height; const cx = w / 2; const cy = h / 2; const maxRadius = Math.min(w, h) * 0.4; const maxLevel = 5; const count = skills.length; if (count < 3) return; // 维度太少没必要画雷达图 ctx.clearRect(0, 0, w, h); // 画网格层 for (let ring = 1; ring <= 4; ring++) { ctx.beginPath(); for (let i = 0; i <= count; i++) { const angle = -Math.PI / 2 + (Math.PI * 2 / count) * (i % count); const r = maxRadius * (ring / 4); const x = cx + r * Math.cos(angle); const y = cy + r * Math.sin(angle); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.closePath(); ctx.strokeStyle = 'rgba(120,130,150,0.25)'; ctx.stroke(); } // 画数据多边形 ctx.beginPath(); for (let i = 0; i <= count; i++) { const skill = skills[i % count]; const angle = -Math.PI / 2 + (Math.PI * 2 / count) * (i % count); const r = maxRadius * (skill.level / maxLevel); const x = cx + r * Math.cos(angle); const y = cy + r * Math.sin(angle); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.closePath(); ctx.fillStyle = 'rgba(66, 133, 244, 0.25)'; ctx.fill(); ctx.strokeStyle = '#2b6de8'; ctx.lineWidth = 2; ctx.stroke(); }交互方面我做了两个轻量的功能。鼠标移动时判断当前坐标是否落在雷达图的某个顶点附近,如果是就把这个技能的信息显示在角落的浮层里。第二个是点击左侧技能卡片时,对应维度的轴线和顶点会高亮成橙色,其他部分变灰,这样看的人能快速定位某个技能在全图中的位置。
响应式适配是另一个坑。如果 canvas 的宽度是动态的,但绘图坐标系没有跟着变,图就会变形或者画到画面外。我的做法是每次容器宽度变化时,重新读取 canvas 的 CSS 尺寸,按 devicePixelRatio 放大物理像素,再重新调用 drawRadar。监听窗口变化用防抖函数,避免拖拽窗口时频繁重绘导致卡顿。
3.4 导出分享图和发布部署
导出分享图是我用得最多的功能。之前每次想发技能图到朋友圈或者放进作品集,都得截图裁剪,很麻烦。现在做成了一个按钮,逻辑分三步。
第一步给页面加一个exporting类,这个类会把工具栏、操作按钮这些不相关的元素隐藏掉。第二步重新绘制一张更大尺寸的雷达图,我的经验是导出图的宽高设为 1200 和 900,这样在社交平台上传不会被压得太厉害。第三步执行 canvas.toDataURL('image/png'),再触发一个 a 标签下载。
这里有一个必须处理的细节:清除图层。如果你为了好看给页面加了背景渐变,导出时如果背景没带上,图片就是透明底的,放到白色页面里还好,放到深色底色上会很难看。我后来导出的 canvas 会先填充一层白色背景,保证图片直接可用。
部署就更简单了。构建出来的就是一个静态目录,html、css、js 三件套,扔到任意一个支持静态托管的平台就能跑。我甚至把它放到了个人域名的子路径下面,和博客共用一个域名,访问地址很清爽。因为没有后端,部署时不需要配数据库,也不需要环境变量,上传即用,这个优势只有维护过完整后端项目的人才能体会。
4. 常见问题与排查技巧实录
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 雷达图变形、扁扁的 | canvas 物理尺寸和 CSS 尺寸不一致 | 根据容器宽度动态重设 canvas.width/height |
| 高分辨率屏幕下图表模糊 | 没有考虑 devicePixelRatio | canvas.width 乘以 dpr,ctx.scale(dpr, dpr) |
| 修改数据后图表不刷新 | 数据改了没调用渲染函数 | 封装 render() 统一流程,暴露给外部调用 |
| localStorage 保存失败 | 隐私模式或配额已满 | 捕获异常,提示用户导出备份数据 |
| 导出图片没有数据多边形 | canvas 被 CSS 缩放,绘制坐标没同步 | 导出前重置 canvas 尺寸并重绘 |
| 技能标签文字重叠 | 技能数量太多或文字太长 | 减少维度过多的分组,限制标签长度 |
| 移动端点击无反应 | 事件绑定在 DOM 结构变化后被移除 | 使用事件委托绑定在父容器上 |
我自己踩得最深的坑就是高分辨率适配。刚完成时在普通笔记本上效果正常,拿女朋友的 4K 屏一打开,雷达图的边线是糊的。原因就是 canvas 的物理像素没跟上屏幕的像素密度。后来加了这么一段,问题彻底消失:
function setupCanvas(canvas, containerWidth, containerHeight) { const dpr = window.devicePixelRatio || 1; canvas.width = containerWidth * dpr; canvas.height = containerHeight * dpr; canvas.style.width = containerWidth + 'px'; canvas.style.height = containerHeight + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return ctx; }还有一个容易忽略的问题:雷达图的维度数量。技能数少于 3 的时候雷达图没有意义,大于 10 的时候图会变成一个毛线球,所有标签挤成一团。实践下来 4 到 7 个技能维度视觉效果最舒服。如果技能真的很多,我会按 category 分组,分别画两张雷达图,比如“前端技能图”“后端技能图”。
另外一个非常值得注意的体验细节是编辑后的“即时反馈”。我第一次做的时候,改了数据还要点一个“刷新”按钮才更新雷达图,自己用了两天就烦了。后来改成所有修改操作都走同一个syncAndRender()函数,改完自动保存、自动重绘,页面永远保持“所见即所得”的状态,用起来就顺手多了。
5. 扩展想法:从个人技能面板到团队技能地图
5.1 从单人数据到多人对比
skills 做到后期,很自然想把它扩展成对比工具。团队负责人可以把团队成员的数据收集上来,每个人一个 JSON 文件,然后在页面上选择任意两个人或更多人做雷达图叠加对比。
实现叠加画图和我上面给出的 drawRadar 逻辑几乎一样,只是把“画一个多边形”改成“循环画多个多边形”,再给每个人的多边形分配不同颜色和一个半透明填充。叠加时一定要注意透明度,否则后画的图会把先画的完全盖住,我用的是每个多边形填充色 alpha 设为 0.15,线条 opacity 不变,就能同时看到多人的轮廓。
这种对比图在团队里的价值比个人展示更大。它能快速看出团队的技能短板,比如前端三个人都很强,但都集中在界面开发,没有人深入掌握可视化或性能调优。这个结论用表格列出来需要看半天,雷达图叠加一眼就能发现哪条轴线短了一截。
5.2 引入时间维度做学习规划
数据模型的另一个扩展方向是记录历史快照。目前每份 JSON 只代表某个时间点的技能状态,如果每三个月备份一次,就会积累多个 JSON 快照。给 JSON 文件名加上日期,然后用一个时间轴组件展示快照之间的变化,技能成长曲线就出来了。
你可能会说这个直接用 Excel 也能做,但 Excel 的图表很难跟雷达图这种多维度展示结合。我当时做了一个实验,把同一个人的两份快照数据放在同一张雷达图上,因为半年后他新增了一项技能,维度数量从 5 变成 6,多边形形状发生了明显变化。这个效果用来做学习复盘很有仪式感,看着自己从“全图半径只有一半”变成“半径接近满格”,会非常有动力。
时间维度的实现难点在于历史数据的存储。localStorage 只能存当前一份快照,历史快照建议全部存成 JSON 文件在本地管理,页面上只负责“导入快照并渲染”,不需要内置一个完整的版本管理模块。
5.3 把 skills 变成可分享的组件
我个人实际用下来的最大体会是,skills 的核心价值不在图表本身有多华丽,而在于它把你脑子里的模糊判断逼成了结构化数据。每次更新数据时,我都要认真想一遍:这个技能我到底几年没碰了?当前水平是不是被高估了?这个过程本身就是对技能资产的重新审视。
如果要把 skills 分享给其他人用,还可以考虑变成 Web Component 或者一个 npm 包的形式。这样别人不需要懂整个项目结构,只需要传入一个 JSON 数据源,渲染出一个雷达图,再把导出图功能直接继承过去。把这个公共能力抽出来以后,同一套雷达图引擎就能在个人主页、团队后台、招聘评估工具里复用了。
最后说一个我在导出图时学会的小技巧:导出前先临时把页面 body 的 overflow 改成 hidden,并且用 requestAnimationFrame 等一帧再触发导出,可以避开浏览器还在渲染滚动条时导致截图边缘出现白条的尴尬。每次发布新版本之后,我都习惯先在真实浏览器里走一遍完整流程:打开页面、修改数据、刷新、导出图片。这五分钟的流程能帮我及时发现八成的基础问题,比写多少单元测试都顶用。
skill 这东西,终究是越用越实在的积累。做这个项目最大的收获不是学会画雷达图,而是真正弄懂了自己的技能长短板在哪儿,哪些该继续深挖,哪些其实只是“看起来会了”。从输出角度说,这个工具把一个模糊的自我认知变成了每季度都能回顾的数据快照,我觉得这是它最值钱的地方。