
1. 五维图到底在画什么先想清楚再动手小程序里做五维图第一次接这个需求的时候我以为是半小时的活结果在一个像素比上磨了一下午。所谓五维图本质就是雷达图的一个特例——把五个维度的数值映射到五条从中心射出的轴线上再把五个数据点连成一个多边形。它跟六维、八维雷达图在数学上没有任何区别只是维度恰好是五个形状是一个正五边形。小程序 canvas 五维图这个组合之所以常见是因为小程序端的测评类、能力模型类、门店对比类页面实在太多而这类页面最喜欢用五维图来一次讲清楚这个人在五个方面各是什么水平。1.1 五个维度的业务含义与数据形态落到具体业务里五维图的输入通常长这样一个长度为 5 的维度名称数组加一个长度为 5 的数值数组。维度名称一般是短语比如专业能力沟通协作执行落地学习成长创新意识控制在 2 到 6 个字最好太长了标签会挤到画布外面。数值这块就是坑最多的地方有的业务给的是 5 分制评分有的是 100 分制打分有的是 0 到 1 的百分比还有的是原始累计值比如累计答题 138 道。这些量纲混在一起直接画结果就是某个维度顶到最外圈、其他四个挤在中心糊成一团。我的处理原则是绘制层只认归一化之后的 0 到 1所有量纲转换放在数据准备阶段完成。如果五个维度都是同一套评分体系那就统一除以满分如果不同维度量纲差异明显那就各自按该维度的合理上限做归一化同时在页面上用文字说明各维度已按各自满分换算避免用户误读。这一步不做后面画得再漂亮都是错图。1.2 为什么不用现成图表库小程序生态里能画雷达图的库不少ec-canvas 那套用得最多功能也确实全。但我最后选择用原生 canvas 2d 手写理由有三个。第一是包体主包 2 MB 的限制摆在那里图表库加上依赖动辄几百 KB 到 1 MB 出头而我这张图只需要五边形、五条轴、一层渐变填充和一串标签手写大概两百行代码就够压缩后几乎不占体积。第二是交互定制需求里要点击某一维高亮出数值气泡、要支持两组数据叠加对比、要做入场生长动画用库去改这些反而要啃它的源码。第三是可控性字体、颜色、留白、动画曲线全在自己手里设计师改一版稿我改几个常量就行。代价当然也有数学得自己算像素比得自己处理真机差异得自己踩。这篇文章接下来讲的就是这些自己的部分。1.3 几何模型五边形其实就是一个极坐标函数把五维图拆开看它只有一种基本运算已知圆心、半径、角度求圆周上的点。五个顶点就是把圆周五等分每个间隔 72 度。为了让最上面的顶点正对屏幕上方起始角度取负 90 度也就是-Math.PI / 2。写成函数就是一个极坐标转直角坐标的映射function polar(cx, cy, r, index, total) { const angle -Math.PI / 2 (Math.PI * 2 * index) / total; return { x: cx r * Math.cos(angle), y: cy r * Math.sin(angle), angle }; }这四个参数里cx cy是圆心r是半径index是第几个维度total是维度总数。注意这里必须用total而不是写死 5因为之后如果你要扩展成六维、八维改一个配置就行绘制逻辑一行都不用动。网格的每一圈、轴线端点、数据点、标签位置全部复用这一个函数这也是我坚持把五维图先抽象成任意维雷达图的原因——多做一步抽象后面少改三次代码。2. 画布初始化拿节点、算像素比、躲尺寸坑小程序里画 canvas最容易被低估的就是初始化这一步。我见过太多人写完绘制逻辑真机一看图形是糊的、或者干脆一片空白最后发现全是初始化的锅。2.1 新版 Canvas 2D 取节点的正确姿势小程序有两种 canvas 接口旧的wx.createCanvasContext和新的 Canvas 2D。新旧差异很大旧接口的draw()是异步提交绘制指令动画做起来很别扭新接口的getContext(2d)拿到的就是一个标准 2D 上下文requestAnimationFrame、createRadialGradient、measureText全都能用。所以 WXML 里第一件事是加type2dcanvas type2d idradar classradar bindtouchstartonTouchStart/canvas取节点必须用选择器查询而且必须在节点渲染完成之后。放到onReady里是最稳的时机页面onLoad时节点还不存在wx.createSelectorQuery() .select(#radar) .fields({ node: true, size: true }) .exec((res) { const info res res[0]; if (!info || !info.node) { console.warn(canvas 节点还没就绪); return; } // info.node 是 canvas 实例info.width / info.height 是它的 CSS 尺寸 });这里的fields({ node: true, size: true })是关键只写node: true拿不到尺寸只写size: true拿不到实例。另外如果你把 canvas 封装在自定义组件里查询必须加.in(this)限定组件范围否则会查到别的页面节点上去。注意如果 canvas 所在容器用了wx:if控制显隐切换回来之后必须重新走一遍查询流程旧的 canvas 实例尺寸会变成 0直接在上面绘制就是白屏。2.2 devicePixelRatio 与 ctx.scale 的配合这是糊图问题的唯一元凶。canvas 有两个尺寸概念一个是它在页面上占的 CSS 尺寸比如 350 × 280 逻辑像素另一个是它的位图尺寸物理像素。如果你不设置位图尺寸canvas 默认是 300 × 150然后被浏览器拉伸到 350 × 280 显示结果就是模糊。正确做法是让位图尺寸等于 CSS 尺寸乘以设备像素比const dpr (wx.getWindowInfo ? wx.getWindowInfo().pixelRatio : wx.getSystemInfoSync().pixelRatio) || 1; canvas.width Math.floor(info.width * dpr); canvas.height Math.floor(info.height * dpr); const ctx canvas.getContext(2d); ctx.scale(dpr, dpr);举个数CSS 宽度 350dpr 取 3不少安卓旗舰是 3 甚至更高位图宽度就是 1050。设置完之后画布实际有 1050 个物理像素可用清晰度拉满。关键在于后面那句ctx.scale(dpr, dpr)。它把坐标系整体放大 dpr 倍于是你后续所有绘制代码都可以用逻辑像素来写不用到处乘 dpr。比如你画半径 100 的圆实际占 300 个物理像素正好填满 350 逻辑宽度的一半多点。如果忘了这一步画出来的图形会缩到右下角四分之一区域看着像是被裁掉了。反过来说如果你实在不想用 scale那就得把每个坐标手乘 dpr代码会变得非常难维护我不推荐。2.3 布局留白给标签留出呼吸空间五维图跟普通条形图不一样标签是围着圆心分布的上下左右都得留空间尤其是沟通协作这类四字标签宽度能有 50 逻辑像素。如果半径直接按min(width, height) / 2算标签必然被裁掉一部分。我的做法是先测量所有标签的宽度取最大值再加一点余量作为 paddingcomputePadding(ctx, dims) { const size this.options.labelSize; ctx.font ${size}px sans-serif; let max 0; dims.forEach((d) { const w ctx.measureText(d).width; if (w max) max w; }); return Math.max(max this.options.labelGap, 36); }measureText是同步返回的成本可以忽略而且它返回的是逻辑像素宽度跟ctx.scale不冲突可以直接拿来参与计算。最终的绘制半径就是const radius Math.min(cssWidth, cssHeight) / 2 - padding;这样不管标签是两个字还是六个字图都不会被挤出画布。容器高度建议给到 560rpx 以上太扁的容器里五维图会被压成椭圆感视觉上很难看。3. 分四层把五维图搭出来绘制顺序直接决定成品观感。我的习惯是固定四层网格层、数据层、标签层、高亮层。每层一个独立函数好处是调试时可以单独注释掉某一层看效果也方便后面加主题换肤。3.1 底层五边形网格与五条轴线的参数化网格是 5 层同心五边形最里面那层的半径是radius / levels最外层正好等于radius。每一层的顶点都用同一个polar函数算闭合路径用closePath收尾。这里有个细节beginPath一定要写在每一层循环里面如果写在循环外面五次描边会连成一条乱七八糟的折线这是新手最容易犯的路径污染错误。for (let l 1; l levels; l) { const r (radius * l) / levels; ctx.beginPath(); for (let i 0; i total; i) { const p polar(cx, cy, r, i, total); if (i 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); } ctx.closePath(); ctx.strokeStyle ringColor; ctx.stroke(); }轴线我习惯一次beginPath把所有 5 条线画完再统一描边因为它们的颜色和线宽完全一致合并成一次绘制调用在渲染开销上更划算。moveTo(cx, cy)是每条线的起点lineTo到顶点五条线的 moveTo 中间不需要 beginPath。网格颜色选浅灰蓝比如#E6EBF2比纯灰更有质感跟浅色背景更融洽。线宽统一 1 个逻辑像素就够了setLineDash在小程序 Canvas 2D 上可以画虚线但不同机型表现略有差异如果求稳用浅色实线代替虚线是更保险的选择。3.2 数据层归一化、渐变填充与顶点圆点数据层是整张图的主角。先把数值统一映射到 0 到 1再乘半径。这里必须做上下限钳制因为真实业务数据出现负数或者超出满分的情况一点都不罕见const points values.map((v, i) { let ratio v / max; if (ratio 0) ratio 0; if (ratio 1) ratio 1; return polar(cx, cy, radius * ratio * progress, i, total); });progress是给入场动画用的插值系数静态绘制时传 1。填充用径向渐变而不是纯色视觉层次立刻就不一样了。小程序 Canvas 2D 支持createRadialGradient从圆心到最外圈做一个由浓到淡的过渡const grad ctx.createRadialGradient(cx, cy, radius * 0.08, cx, cy, radius); grad.addColorStop(0, rgba(58,122,255,0.45)); grad.addColorStop(1, rgba(58,122,255,0.06)); ctx.fillStyle grad; ctx.fill();注意渐变的两个圆都得以圆心为基准如果内圆圆心写偏了填充会出现奇怪的方向感。描边用饱和度高一点的主题色线宽 2再把五个顶点画成白心蓝边的小圆点视觉焦点就出来了。圆点半径建议 3.5 到 4 逻辑像素太小了看不见太大了抢戏。实操心得ctx.fill()和ctx.stroke()一定要在路径构造完成之后依次调用不要在两个顶点之间穿插fill否则会出现多边形内部的三角填充痕迹。3.3 文本层标签对齐要靠三角函数判断标签对齐是五维图最考验细节的地方。五个标签分布在五个方向正上、右上、右下、左下、左上。对齐方式不能用固定值要根据顶点相对圆心的位置动态判断const cos Math.cos(p.angle); const sin Math.sin(p.angle); if (Math.abs(cos) 0.2) ctx.textAlign center; else ctx.textAlign cos 0 ? left : right; if (Math.abs(sin) 0.2) ctx.textBaseline middle; else ctx.textBaseline sin 0 ? top : bottom;为什么这么写因为 canvas 的 y 轴向下为正。正上方那个顶点角度是 -90 度sin等于 -1所以textBaseline取bottom意思是文字的底边贴在锚点上方文字自然出现在顶点正上方。正下方角度是 90 度sin等于 1取top文字出现在顶点下方。左右两侧的textAlign逻辑同理。设置ctx.font有个坑小程序里必须写完整的 font 简写${size}px sans-serif只设ctx.fontSize是无效的。字体族如果写PingFang SC安卓端没有这个字体会静默回退所以直接写sans-serif让系统自己选反而更统一。字号 11 到 13 之间比较合适再小在真机上看不清。3.4 动画层用 canvas.requestAnimationFrame 做入场入场动画是所有测评类页面的标配直接把progress从 0 补间到 1 就行。缓动函数我用三次方的 ease-out前段快后段收有种数据长出来的手感runEnterAnimation() { const canvas this.canvas; const duration this.options.duration; let startTs 0; const tick (ts) { if (!startTs) startTs ts; const raw Math.min(1, (ts - startTs) / duration); const eased 1 - Math.pow(1 - raw, 3); this.render(eased); if (raw 1) canvas.requestAnimationFrame(tick); }; canvas.requestAnimationFrame(tick); }这里有两个要点。第一用canvas.requestAnimationFrame而不是全局的requestAnimationFrame把帧调度绑定到 canvas 实例上页面切走时更容易随组件一起停掉。第二时间基准用回调传入的ts而不是Date.now()因为首帧时间戳和当前时间通常不等用Date.now()会导致第一帧就跳一大段。动画结束后不需要额外清理raw到 1 自然停止不会一直空转耗电。整个动画时长控制在 600 到 700 毫秒再长用户会觉得卡再短就看不出生长的感觉了。缓动曲线如果想要更有弹性可以换成1 - Math.pow(1 - t, 4)但不要用弹跳back曲线五维图这种正经数据图弹来弹去显得不专业。4. 交互扩展点击高亮、多组对比、导出分享图基础图形画完之后产品一般还会提三件事点一下要看详情、要能对比两组数据、要能存成图片分享出去。这三件事都不难但都有各自的细节。4.1 手指落在哪一维角度最近邻命中检测命中检测的思路很朴素算出手指点相对圆心的距离和角度距离超出半径就不响应否则用角度除以 72 度再四舍五入得到最近的维度索引。const dx x - centerX; const dy y - centerY; const dist Math.sqrt(dx * dx dy * dy); if (dist radius 20) { this.setData({ activeIndex: -1 }); return; } let angle Math.atan2(dy, dx) Math.PI / 2; if (angle 0) angle Math.PI * 2; const step (Math.PI * 2) / total; const idx Math.round(angle / step) % total;Math.atan2返回的是 -π 到 π 的弧度加Math.PI / 2是为了把零点从正右方挪到正上方跟绘制时的起始角度对齐这段偏移如果不加点正上方的维度会命中相邻的维度。最后的取模是为了处理index溢出到 5 的情况。触摸坐标要用e.touches[0].x这个坐标已经是相对 canvas 左上角的逻辑像素可以直接参与计算。但我在个别机型上遇到过x为 undefined 的情况兜底可以先拿e.touches[0].pageX减去 canvas 的boundingClientRect.leftconst rect await this.getCanvasRect(); const x touch.x ! undefined ? touch.x : touch.pageX - rect.left; const y touch.y ! undefined ? touch.y : touch.pageY - rect.top;getCanvasRect用wx.createSelectorQuery().select(#radar).boundingClientRect()拿结果可以缓存在this上不用每次触摸都查一遍。还有一个必须记住的点setData只更新了数据不会自动重绘 canvas。必须在setData之后手动调一次render否则你点半天页面毫无反应。这是我当年排查了半小时的灵异事件。4.2 两组数据对比怎么画才不糊需求从单人测评变成我和团队平均对比的时候直接叠两层半透明填充是行不通的因为交叠区域颜色会变浑浊完全看不出谁是谁。我的做法是两组都用描边为主、极淡填充为辅第一组蓝色#3A7AFF第二组橙色#FF8A3D填充透明度压到 0.12 以下只保留描边和顶点圆点的辨识度。如果两组对比是主需求还可以进一步简化完全去掉填充只留两条带圆点的折线视觉上更干净。另外在图下方加一行图例用小色块 文字说明哪条是谁别指望用户能从颜色猜出来。多组绘制的时候要注意绘制顺序先画填充面积大的那组再画小的那组这样小的那组描边不会被盖住。如果两组面积差不多那就按固定顺序画保证每次渲染结果一致不会出现动画过程中两条线跳动的情况。4.3 导出图片与分享卡片导出图片用wx.canvasToTempFilePathCanvas 2D 模式下要传 canvas 实例而不是旧的 contextwx.canvasToTempFilePath({ canvas: this.canvas, x: 0, y: 0, width: this.cssWidth, height: this.cssHeight, destWidth: this.cssWidth * dpr, destHeight: this.cssHeight * dpr, success: (res) { wx.previewImage({ urls: [res.tempFilePath] }); }, fail: (err) { console.error(导出失败, err); } });这里的destWidth和destHeight要乘 dpr否则导出图会比你屏幕上看到的小一圈分享出去糊成一片。另外导出之前要确保动画已经跑完如果用户在动画进行中点了分享导出的图就是一个残缺的多边形处理办法是导出前先render(1)强制画一帧完整状态。如果是要做成分享卡片建议单独准备一个更大的离屏画布比如 750 × 1000 CSS 像素把五维图居中画上去再加上标题、分数、二维码等元素。用同一套polar绘制函数就能复用只需要把圆心和半径参数换掉这也是前面抽象维度参数带来的好处。顺带提一句切换维度或者切换对比对象时可以用wx.setNavigationBarTitle同步更新导航栏标题比如从个人能力画像变成团队能力对比用户感知会清晰很多这个调用成本极低但体验提升明显。5. 常见问题与排查技巧实录下面这些坑我基本上一个不落地踩过一遍整理成速查表遇到问题先对号入座。5.1 模糊、白屏、拉伸三个最高频故障现象根因处理方式图形发虚、边缘有毛边没处理 dpr位图尺寸等于或小于 CSS 尺寸设置canvas.width cssW * dpr并执行ctx.scale(dpr, dpr)完全白屏什么也没有查询节点时机太早或 canvas 在wx:if里刚显示移到onReadywx:if切换后用nextTick重新查询图形被拉伸变形位图宽高比例与 CSS 比例不一致两个方向都乘同一个 dpr不要只乘宽度图形只占右下角一小块设置了位图尺寸但漏了ctx.scale补上 scale或手动给所有坐标乘 dpr每次重绘都有残影叠加没有清空画布每帧开头ctx.clearRect(0, 0, cssW, cssH)标签被切掉一半padding 不够用measureText动态计算 padding导出图比屏幕小destWidth没乘 dpr导出参数补乘 dpr重点说说残影这一条。因为它跟别的图不一样五维图的填充区域是半透明的叠加两层你未必看得出来叠加四层之后颜色就明显变深了很多人会误以为是渐变参数写错了。判断方法很简单故意把填充色改成完全不透明的纯红如果红色越来越深那就是没清画布。5.2 真机差异与兼容细节开发工具上跑得好好的真机上出问题的场景主要有三类。第一类是坐标差异。开发工具的 canvas 尺寸是真机渲染尺寸的模拟值如果你在代码里对宽度做了某些硬编码假设比如默认就是 375 宽到了 414 或 360 宽的机型上圆心位置就偏了。所以圆心必须是cssWidth / 2动态算出来的不能写死。第二类是触摸坐标缺失。前面提过个别机型touch.x可能拿不到一定要写 pageX 兜底的逻辑。另外如果 canvas 外层套了scroll-view滑动和点击会打架建议 canvas 上加catchtouchstart阻止冒泡或者干脆把画布放在不滚动的固定区域。第三类是层级问题。Canvas 2D 在较新的基础库上支持同层渲染普通的view可以覆盖在它上面。但如果你要覆盖的是cover-view或者项目基础库版本较低就可能出现覆盖元素被 canvas 挡住的情况。最保险的方案是把数值气泡直接用 canvas 画出来fillRect加圆角加文字完全不依赖原生层级我这几年都这么干从没出过岔子。5.3 性能与内存的那些小事五维图本身元素很少正常情况下不会成为性能瓶颈但有两种写法会把它拖垮。第一种是每个顶点圆点都beginPath完立刻fill一次同时每次填充都新建一个渐变对象。实测下来把渐变对象在动画循环外创建一次并缓存起来复用能省掉不少开销。因为渐变的坐标是固定的只有progress在变完全没有必要每帧重建。第二种是在动画循环里调setData。setData涉及逻辑层到视图层的通信一秒钟调六十次会让整个页面明显卡顿。我的原则是canvas 的绘制完全不走setData只有需要改变页面其他元素比如选中维度的文字详情时才调一次。这样即使动画播放过程中页面也不会有任何通信压力。内存方面页面卸载时记得把canvas引用、ctx引用、缓存的 rect 都置为 null。我遇到过在长列表页面里反复进出一百多次后内存持续上涨的情况最后定位到就是组件里持有的 canvas 引用没释放。加上onUnload里的清理逻辑之后曲线就平了。6. 一套可复用的封装与参数速查把上面的东西串起来我做成了一个可直接跑的页面把可变的都抽成配置项换业务只需要改配置和数值。6.1 完整可运行代码WXMLcanvas type2d idradar classradar bindtouchstartonTouchStart /canvasWXSS.radar { display: block; width: 100%; height: 560rpx; }JSconst DEFAULT_OPTIONS { dimensions: [专业能力, 沟通协作, 执行落地, 学习成长, 创新意识], max: 100, levels: 5, labelSize: 12, labelGap: 16, labelColor: #5A6478, ringColor: #E6EBF2, axisColor: #E6EBF2, strokeColor: #3A7AFF, fillTop: rgba(58,122,255,0.45), fillBottom: rgba(58,122,255,0.06), pointFill: #FFFFFF, duration: 640 }; function polar(cx, cy, r, index, total) { const angle -Math.PI / 2 (Math.PI * 2 * index) / total; return { x: cx r * Math.cos(angle), y: cy r * Math.sin(angle), angle }; } Page({ data: { activeIndex: -1 }, onReady() { this.options Object.assign({}, DEFAULT_OPTIONS); this.values [92, 78, 85, 70, 88]; this.queryCanvas(); }, onUnload() { this.canvas null; this.ctx null; }, queryCanvas() { wx.createSelectorQuery() .select(#radar) .fields({ node: true, size: true }) .exec((res) { const info res res[0]; if (!info || !info.node) return; const canvas info.node; const dpr (wx.getWindowInfo ? wx.getWindowInfo().pixelRatio : wx.getSystemInfoSync().pixelRatio) || 1; canvas.width Math.floor(info.width * dpr); canvas.height Math.floor(info.height * dpr); const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); this.canvas canvas; this.ctx ctx; this.dpr dpr; this.cssWidth info.width; this.cssHeight info.height; this.padding this.computePadding(ctx); this.runEnterAnimation(); }); }, computePadding(ctx) { const opt this.options; ctx.font ${opt.labelSize}px sans-serif; let max 0; opt.dimensions.forEach((d) { const w ctx.measureText(d).width; if (w max) max w; }); return Math.max(max opt.labelGap, 36); }, runEnterAnimation() { const canvas this.canvas; const duration this.options.duration; let startTs 0; const tick (ts) { if (!startTs) startTs ts; const raw Math.min(1, (ts - startTs) / duration); const eased 1 - Math.pow(1 - raw, 3); this.render(eased); if (raw 1) canvas.requestAnimationFrame(tick); }; canvas.requestAnimationFrame(tick); }, render(progress) { const ctx this.ctx; const opt this.options; const total opt.dimensions.length; const w this.cssWidth; const h this.cssHeight; ctx.clearRect(0, 0, w, h); const cx w / 2; const cy h / 2; const radius Math.min(w, h) / 2 - this.padding; this.center { cx, cy }; this.radius radius; this.drawGrid(ctx, cx, cy, radius, total); this.drawData(ctx, cx, cy, radius, total, progress); this.drawLabels(ctx, cx, cy, radius, total); this.drawHighlight(ctx, cx, cy, radius, total); }, drawGrid(ctx, cx, cy, radius, total) { const opt this.options; ctx.save(); ctx.lineWidth 1; for (let l 1; l opt.levels; l) { const r (radius * l) / opt.levels; ctx.beginPath(); for (let i 0; i total; i) { const p polar(cx, cy, r, i, total); if (i 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); } ctx.closePath(); ctx.strokeStyle opt.ringColor; ctx.stroke(); } ctx.beginPath(); for (let i 0; i total; i) { const p polar(cx, cy, radius, i, total); ctx.moveTo(cx, cy); ctx.lineTo(p.x, p.y); } ctx.strokeStyle opt.axisColor; ctx.stroke(); ctx.restore(); }, drawData(ctx, cx, cy, radius, total, progress) { const opt this.options; const points this.values.map((v, i) { let ratio v / opt.max; if (ratio 0) ratio 0; if (ratio 1) ratio 1; return polar(cx, cy, radius * ratio * progress, i, total); }); if (!this.gradient) { const grad ctx.createRadialGradient(cx, cy, radius * 0.08, cx, cy, radius); grad.addColorStop(0, opt.fillTop); grad.addColorStop(1, opt.fillBottom); this.gradient grad; } ctx.save(); ctx.beginPath(); points.forEach((p, i) { if (i 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); }); ctx.closePath(); ctx.fillStyle this.gradient; ctx.fill(); ctx.lineWidth 2; ctx.strokeStyle opt.strokeColor; ctx.stroke(); ctx.lineWidth 2; points.forEach((p) { ctx.beginPath(); ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2); ctx.fillStyle opt.pointFill; ctx.fill(); ctx.strokeStyle opt.strokeColor; ctx.stroke(); }); ctx.restore(); }, drawLabels(ctx, cx, cy, radius, total) { const opt this.options; ctx.save(); ctx.font ${opt.labelSize}px sans-serif; ctx.fillStyle opt.labelColor; for (let i 0; i total; i) { const p polar(cx, cy, radius opt.labelGap, i, total); const cos Math.cos(p.angle); const sin Math.sin(p.angle); ctx.textAlign Math.abs(cos) 0.2 ? center : cos 0 ? left : right; ctx.textBaseline Math.abs(sin) 0.2 ? middle : sin 0 ? top : bottom; ctx.fillText(opt.dimensions[i], p.x, p.y); } ctx.restore(); }, drawHighlight(ctx, cx, cy, radius, total) { const idx this.data.activeIndex; if (idx 0) return; const opt this.options; const end polar(cx, cy, radius, idx, total); const value this.values[idx]; let ratio value / opt.max; if (ratio 0) ratio 0; if (ratio 1) ratio 1; const dot polar(cx, cy, radius * ratio, idx, total); ctx.save(); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(end.x, end.y); ctx.lineWidth 2; ctx.strokeStyle opt.strokeColor; ctx.stroke(); ctx.beginPath(); ctx.arc(dot.x, dot.y, 5, 0, Math.PI * 2); ctx.fillStyle opt.strokeColor; ctx.fill(); ctx.font bold 12px sans-serif; const text String(value); const bw ctx.measureText(text).width 16; const bh 22; let bx dot.x - bw / 2; let by dot.y - bh - 10; if (bx 4) bx 4; if (bx bw this.cssWidth - 4) bx this.cssWidth - 4 - bw; if (by 4) by dot.y 12; ctx.beginPath(); if (typeof ctx.roundRect function) ctx.roundRect(bx, by, bw, bh, 6); else ctx.rect(bx, by, bw, bh); ctx.fillStyle rgba(30,38,54,0.88); ctx.fill(); ctx.fillStyle #FFFFFF; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(text, bx bw / 2, by bh / 2); ctx.restore(); }, onTouchStart(e) { const touch (e.touches e.touches[0]) || (e.changedTouches e.changedTouches[0]); if (!touch || !this.center) return; const x touch.x ! undefined ? touch.x : touch.pageX; const y touch.y ! undefined ? touch.y : touch.pageY; const { cx, cy } this.center; const total this.options.dimensions.length; const dx x - cx; const dy y - cy; if (Math.sqrt(dx * dx dy * dy) this.radius 20) { this.setData({ activeIndex: -1 }, () this.render(1)); return; } let angle Math.atan2(dy, dx) Math.PI / 2; if (angle 0) angle Math.PI * 2; const step (Math.PI * 2) / total; const idx Math.round(angle / step) % total; this.setData({ activeIndex: idx }, () this.render(1)); } });6.2 参数速查表参数作用建议取值调整影响dimensions维度名称数组5 个每个 2 到 6 字长度直接决定图形边数和标签宽度max数值上限与业务评分体系一致影响归一化结果填错图形会失真levels网格层数4 到 6越多越密超过 6 层会显得杂乱labelSize标签字号11 到 13影响 padding 计算太大会压缩图形labelGap标签与顶点的距离12 到 20太小标签贴边太大图形缩小strokeColor数据描边色主题色多组对比时需换成不同色系duration入场动画时长600 到 700 毫秒太短看不出效果太长显拖沓6.3 后续还能怎么扩展这套结构留了几个自然的扩展口子。维度数量改成 4 或 6只要改dimensions数组长度绘制逻辑自动适配不需要动一行数学代码。要加第二组对比数据把values换成二维数组在drawData外面套一层循环换一下颜色和透明度即可。要做点击后跳转到详情页就在onTouchStart里把索引抛给业务层处理。再远一点如果要做成头像挂件或者小程序码分享图可以复用同一套polar和绘制函数在一个尺寸更大的离屏 canvas 上先画背景、再画五维图、最后画文字和图形码导出成一张完整的分享卡片。我做过一版整个过程大概多写四十行代码因为核心的几何计算已经完全复用了。最后说两句我在小程序里画过不下十种图表五维图是性价比最高的一个——代码量小、视觉冲击力强、产品经理还特别爱用。真正花时间的从来不是绘制逻辑而是像素比、测量时机、真机差异这些环境问题。我的经验是把一个polar函数和一套 dpr 初始化流程先打磨稳后面无论画三角形、六边形还是十二边形都只是改一个参数的事。还有个小技巧分享给同样要在小程序里折腾 canvas 的朋友每次改完绘制代码别只看开发工具的模拟器一定要用一台安卓机和一台 iPhone 各扫一遍。我遇到过的三次开发工具正常、真机异常全部都是在这两台设备的差异里暴露出来的。多花那两分钟能省下后面几个小时的返工。