
前阵子接了个需求做企业内部的员工能力评估小程序。需求方丢过来一张设计稿——五个维度的能力雷达图专业能力、沟通协作、执行效率、学习成长、责任心每项画一条从圆心发散的轴数据点连成一个多边形。看到图的瞬间我第一反应是找现成图表库翻了一圈 ECharts 的小程序版本包体一装进去主包就告急想调个描边颜色还得跟一堆底层封装搏斗。折腾半天最后还是回到最原始的路子——自己用 canvas 把这张五维图一笔一笔画出来反而最省事定制起来也最听话。这篇就把整个落地过程拆开讲。五维图在小程序里其实就是雷达图的一个特例轴数是 5每条轴之间 72 度。我会从选型、坐标推导、分层绘制、动画过渡一路讲到真机踩坑代码都能直接抄。适合两类人看一类是第一次在小程序里用 canvas 画图形、被旧接口和 DPR 搞懵的另一类是已经画出来了但图模糊、更新卡顿、标签压线的可以对着后面几节排查。1. 选型这一步就决定了后面顺不顺Canvas 2D 与旧 Context 的分岔口在动手写第一行绘制代码之前得先把用哪套 canvas 接口定下来。小程序里同时存在两套老的wx.createCanvasContext加canvas canvas-idxxx和新的canvas type2d配合节点查询拿到的真实 canvas 对象。这个选择不是习惯问题它直接决定了你后面能不能做动画、图会不会糊、能不能拿到真实的绘制上下文。1.1 组件库图表在小程序里的体积与定制困局先说为什么不用现成的图表库。像 ECharts 这类完整方案核心包加依赖轻松过几百 KB小程序主包总共就 2MB 的预算塞一张图进去剩下的业务页面就没法活了。就算分包加载canvas 渲染层本身也不会因为分包而变轻。更麻烦的是定制成本。五维图这种图产品要的是顶点画小圆点、数据面用半透明蓝、标签跟着轴的方向对齐——这些细节要么靠配置项的某个角落去碰运气要么就得改到图表库内部。到最后你会发现为了省下自己画的那两百行代码反而要读两千行别人的源码。五维图这种图形本身结构简单五个顶点、一层蛛网、一条闭合折线自己写反而可控改一个颜色就是改一个字符串。1.2 wx.createCanvasContext 为什么在真机上越用越别扭旧接口最典型的问题就是它不是一个真正的 canvas 上下文。wx.createCanvasContext返回的对象只提供了一套仿造的绘制 API像setFillStyle、setStrokeStyle这类写法和标准 canvas 的ctx.fillStyle xxx完全是两套语言。你在网上抄到的任何一段标准 canvas 雷达图代码搬过来都得先翻译一遍。它还有两个让人难受的点。第一是不支持canvas.requestAnimationFrame想做逐帧动画只能用setTimeout或setInterval硬凑帧率不稳掉帧也没法避免。第二是异步绘制模型所有绘制指令要等ctx.draw()一次性提交ctx.draw()里的回调有时候在真机上触发的时机和开发者工具完全不一样排查问题特别费劲。新项目基本没有理由再选它除非要兼容非常老的基础库版本。1.3 Canvas 2D 的节点获取与 DPR 缩放必须一次做对新接口的核心变化是canvas 变成了一个真实的组件节点你要先用SelectQuery把它捞出来拿到 node 和真实的 2D 上下文。canvas type2d idradar stylewidth: 620rpx; height: 620rpx; /canvas// 页面或组件内 const query wx.createSelectorQuery().in(this) query .select(#radar) .fields({ node: true, size: true }) .exec((res) { const info res[0] if (!info || !info.node) return const canvas info.node const ctx canvas.getContext(2d) // 关键按设备像素比放大画布分辨率 const dpr wx.getWindowInfo().pixelRatio canvas.width info.width * dpr canvas.height info.height * dpr ctx.scale(dpr, dpr) this.canvas canvas this.ctx ctx this.cw info.width // 逻辑宽度 this.ch info.height // 逻辑高度 this.render() })这里的ctx.scale(dpr, dpr)是整段代码里最重要的一行。画布的物理像素被放大到逻辑尺寸 × dpr然后你把坐标系整体缩放回去后面所有绘制坐标就可以继续按逻辑尺寸写不用手动乘 dpr。少了这一步图在 iPhone 这类高倍屏上就是一层毛边。注意wx.getSystemInfoSync()在新版基础库已经不再推荐使用取像素比、窗口尺寸这类信息优先用wx.getWindowInfo()它更轻量也不会有老接口那种同步阻塞的顾虑。顺便说一句如果你用的是自定义组件节点查询一定要加.in(this)把查询范围限制在当前组件实例里。不加的话页面里如果恰好有两个 id 相同的 canvas你捞到的可能是别人家那个然后就会陷入我明明改了颜色怎么没反应的困局。2. 五个顶点不是画出来的是算出来的极坐标到画布坐标的完整推导很多人第一次画雷达图会卡在同一个地方我知道圆心知道半径但五个顶点到底在哪这其实是一道初中的三角函数题只是要把它翻译成循环。2.1 起手角度定在正上方顺时针还是逆时针雷达图有个约定俗成的视觉习惯第一个维度从正上方开始然后顺时针依次排开。为什么从正上方开始因为人的视线天然先落在顶部第一维度往往是权重最高的那个放顶上最容易被读到。canvas 里的坐标系和数学习惯不一样x 轴向右为正y 轴向下为正角度 0 指向右方三点钟方向。所以正上方对应的角度不是 0而是-Math.PI / 2也就是 -90 度。这一点如果记错整张图会整体旋转 90 度标签全部错位。五个维度平分一周每个维度之间的夹角是step 2π / 5 72°第 i 个维度所在的角度就是-π/2 i × 2π/5。顺时针方向在这里天然成立因为 canvas 的 y 轴向下角度递增就是顺时针转动刚好符合我们要的视觉顺序。2.2 极坐标换算的通用函数与五等分角度把上面的推导封装成一个函数后面所有绘制都靠它包括蛛网、轴线、数据点、标签const START_ANGLE -Math.PI / 2 const TOTAL 5 /** * 极坐标转画布坐标 * param {number} cx 圆心 x * param {number} cy 圆心 y * param {number} radius 当前半径 * param {number} index 第几个维度从 0 开始 * param {number} total 维度总数五维图传 5 */ function polarToCartesian(cx, cy, radius, index, total TOTAL) { const angle START_ANGLE (index * Math.PI * 2) / total return { x: cx radius * Math.cos(angle), y: cy radius * Math.sin(angle), angle } }这个函数的好处是它不关心你传进来的半径是外圈还是某个中间层。画蛛网的时候传radius * level / levels画数据点的时候传radius * ratio同一套坐标逻辑复用到底代码不会重复。写的时候有个细节值得注意不要提前把total写死成 5。虽然这个需求是五维图但你只要把它做成参数将来产品说我们想改成六维或者某个指标拆成八维你只需要改一个数字其余绘制逻辑一行都不用动。我吃过这个亏——第一版把 5 硬编码在七八个地方后来需求加了一个维度改漏了一处蛛网是五边形但数据面是六边形看着特别诡异。2.3 量纲归一化五组不同最大值的维度如何落在同一个半径上五个维度的原始数据往往不是同一个量纲。比如专业能力是百分制打分满分 100协作次数可能是次数满分 30学习时长是小时满分 50。它们要落在同一个半径上就必须先归一化。归一化的公式很简单ratio value / max但有两个坑必须处理。第一max不能是 0。如果某个维度的满分配置漏填或者算出来是 0value / 0就是Infinitycanvas 拿到这个坐标会直接把整个多边形画崩甚至出现整张图跑出可视区的现象。所以要先判空const ratio item.max 0 ? Math.min(item.value / item.max, 1) : 0第二Math.min(..., 1)这层夹紧不能省。实际业务里数据超过满分是常有的事比如某个人的执行力评分因为加权算错变成了 108 分如果直接算 1.08那个顶点就会戳出蛛网外圈看着像是渲染错误。夹紧到 1 至少保证视觉上不越界同时在数据层面把这个异常暴露给你去查。归一化之后每个维度的绘制半径就是外圈半径 × ratio。五组数据各走各的半径但角度是固定的所以它们永远落在自己的那条轴上不会错位。3. 分层绘制顺序表蛛网、轴线、数据面、顶点与标签的落地细节坐标算清楚了接下来是绘制顺序问题。canvas 没有图层概念后画的覆盖先画的。所以顺序必须是先铺背景网格再画轴线然后画数据面最后画顶点和文字。顺序一乱数据面就被网格线盖住或者文字被多边形糊掉。3.1 蛛网网格层数与描边颜色该怎么定蛛网的本质是一组同心正五边形。层数建议 4 层或 5 层层数太少看不出刻度感太多又会让图变脏。四层对应的其实是 25%、50%、75%、100% 四个档位读图的时候很容易换算所以我一般固定用 4 层。drawGrid(ctx, cx, cy, radius, total, levels) { ctx.save() ctx.strokeStyle #E3E8F0 ctx.lineWidth 1 // 同心多边形 for (let l 1; l levels; l) { const r (radius * l) / levels ctx.beginPath() for (let i 0; i total; i) { const p polarToCartesian(cx, cy, r, i, total) i 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y) } ctx.closePath() ctx.stroke() } // 从圆心发散的轴线 for (let i 0; i total; i) { const p polarToCartesian(cx, cy, radius, i, total) ctx.beginPath() ctx.moveTo(cx, cy) ctx.lineTo(p.x, p.y) ctx.stroke() } ctx.restore() }描边颜色选浅灰蓝这类低饱和度的别用纯灰。纯灰在白色背景上会显得脏浅灰蓝比如#E3E8F0和主色系是同一个色相家族看着更整体。还有一点网格线宽度固定 1px 就好逻辑坐标下 1px 经过 dpr 缩放之后在高倍屏上是 2 到 3 个物理像素正合适。3.2 数据面填充透明度、描边与顶点圆点数据面是整张图的主角它的视觉重量必须压过网格。填充色用主色的低透明度版本描边用主色的实色这样多边形既有面又有轮廓。drawDataArea(ctx, cx, cy, radius, axes) { const points axes.map((item, i) { const ratio item.max 0 ? Math.min(item.value / item.max, 1) : 0 return polarToCartesian(cx, cy, radius * ratio, i, axes.length) }) // 数据面包围区域 ctx.beginPath() points.forEach((p, i) { i 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y) }) ctx.closePath() ctx.fillStyle rgba(64, 128, 255, 0.25) ctx.fill() ctx.strokeStyle #4080FF ctx.lineWidth 2 ctx.lineJoin round ctx.stroke() // 顶点圆点 points.forEach((p) { ctx.beginPath() ctx.arc(p.x, p.y, 4, 0, Math.PI * 2) ctx.fillStyle #4080FF ctx.fill() ctx.beginPath() ctx.arc(p.x, p.y, 4, 0, Math.PI * 2) ctx.strokeStyle #FFFFFF ctx.lineWidth 2 ctx.stroke() }) }填充透明度这里我给的是 0.25。太低了数据面就飘太高了又会盖住后面的蛛网读者没法判断某个点的数值大概落在哪一层。0.2 到 0.3 这个区间实测最舒服。顶点圆点是提升可读性的关键一招。一个 4px 的实心圆加一圈 2px 白色描边视觉上就像给每个数据点做了个白色垫底即使数据面和顶点正好落在网格线上圆点依然清晰。这是很多手写雷达图容易忽略的细节加上之后质感立刻不一样。3.3 文本标签的锚点与超长文案避让标签是最容易翻车的一环。它要放在轴的外侧还要根据所在方向自动调整对齐方式否则左边和右边的字会往外撑、顶到画布边缘。核心逻辑是根据角度判断文字锚点drawLabels(ctx, cx, cy, radius, axes) { ctx.font 12px sans-serif ctx.fillStyle #5A6472 axes.forEach((item, i) { const p polarToCartesian(cx, cy, radius, i, axes.length) const offset 16 const lx p.x Math.cos(p.angle) * offset const ly p.y Math.sin(p.angle) * offset // 横向锚点 const cos Math.cos(p.angle) if (cos 0.3) ctx.textAlign left else if (cos -0.3) ctx.textAlign right else ctx.textAlign center // 纵向基线 const sin Math.sin(p.angle) if (sin 0.7) ctx.textBaseline top else if (sin -0.7) ctx.textBaseline bottom else ctx.textBaseline middle ctx.fillText(item.label, lx, ly) }) }offset这个偏移量是沿着轴的方向往外推的这样不管标签在哪个角度它和顶点之间都保持固定距离不会出现有的贴着顶点、有的离得老远。超长文案要提前留白。canvas 没有自动换行fillText遇到长字符串就是一路画出去直接出画布。稳妥的做法有两条一是画布周围预留足够的 padding我一般给半径留 40 到 48 逻辑像素的边距二是给标签做一次截断超过 6 个字就砍掉加省略号或者干脆在数据层就把标签裁短。如果是那种必须要显示全称的场景可以用一小段居中的短标签加一行小字数值的组合把长文本挪到图外的列表里。4. 数据一变就重画太生硬逐帧补间与小程序的帧调度静态图画完了接下来是体验问题。当用户切换评估对象、或者数据异步加载回来的时候如果直接把图重画一遍视觉上是一闪很廉价。加一段从 0 长到目标值的动画整个页面的观感会提升一个档次。4.1 canvas.requestAnimationFrame 的正确用法这是新版 Canvas 2D 相对旧接口最大的优势之一。它挂载在 canvas 节点对象上不是全局的animate() { const canvas this.canvas const duration 600 const startTime Date.now() const tick () { const elapsed Date.now() - startTime const t Math.min(elapsed / duration, 1) const eased 1 - Math.pow(1 - t, 3) // easeOutCubic this.render(eased) if (t 1) { this.frameId canvas.requestAnimationFrame(tick) } else { this.frameId null } } this.frameId canvas.requestAnimationFrame(tick) }注意这里保存了frameId。这是为了在页面隐藏、组件销毁、或者用户快速切换数据时能把上一帧的动画掐掉。如果不取消两次数据更新会叠加成两条并行的动画图会开始抽搐。onUnload() { if (this.frameId this.canvas) { this.canvas.cancelAnimationFrame(this.frameId) this.frameId null } }canvas.cancelAnimationFrame同样是节点上的方法别误写成全局的。4.2 缓动函数与数值插值缓动函数决定了动画的手感。线性运动t直接用看起来像机器人在匀速推图很生硬。easeOutCubic是先快后慢收尾柔和最适合雷达图这种数据生长的语义。// 几种常见缓动 const easing { linear: (t) t, easeOutCubic: (t) 1 - Math.pow(1 - t, 3), easeInOutQuad: (t) t 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t 2, 2) / 2 }插值的思路是render接收一个进度参数p0 到 1所有半径都乘上它。render(progress 1) { const { ctx, cw, ch } this ctx.clearRect(0, 0, cw, ch) const cx cw / 2 const cy ch / 2 const radius Math.min(cw, ch) / 2 - 48 this.drawGrid(ctx, cx, cy, radius, this.axes.length, 4) this.drawDataArea(ctx, cx, cy, radius, this.axes, progress) this.drawLabels(ctx, cx, cy, radius, this.axes) }drawDataArea里把radius * ratio改成radius * ratio * progress就行。这样一行改动就让整个动画跑起来了网格和标签保持静止不动只有数据面在生长。这个视觉设计比全图一起缩放要沉稳得多。4.3 组件化封装时 properties 监听与首帧时序实际项目里这张图基本不会只出现一次最好封装成一个自定义组件对外暴露axes属性。这时候有个典型时序问题properties变化时你可能想立刻重绘但此时 canvas 节点可能还没查出来。Component({ properties: { axes: { type: Array, value: [], observer(newVal) { if (!this.canvas) { this.pendingData newVal return } this.axes newVal this.animate() } } }, lifetimes: { ready() { this.initCanvas().then(() { if (this.pendingData) { this.axes this.pendingData this.pendingData null this.animate() } }) }, detached() { if (this.frameId this.canvas) { this.canvas.cancelAnimationFrame(this.frameId) } } } })关键就是pendingData这个缓冲。数据先于节点到达时先存起来等initCanvas完成后补一次渲染。这个模式我用在很多 canvas 组件里能避免掉一大类首次渲染空白的问题。另外ready生命周期比attached更合适做节点查询。attached的时候节点还在构建中查询有时拿不到 size返回的宽高是 0画布尺寸算出来的半径就是负数图直接消失。5. 真机日志里才会暴露的四个问题从模糊到内存代码写完跑在开发者工具里一切正常扔到真机上就开始出幺蛾子。下面这几个是我在实际项目里挨个踩过的。5.1 高清屏上和开发者工具表现不一致的模糊开发者工具默认的模拟设备像素比是 2而且它的渲染方式和真机不完全一样所以有时候你明明写了ctx.scale(dpr, dpr)工具里很清楚真机 iPhone 上还是有点糊。常见的三个原因第一canvas.width和canvas.height设置在了ctx.scale之后缩放基准变了。第二你设的是物理像素尺寸但没有同步更新style的显示尺寸导致画布被拉伸。第三fields查询时没带size: true拿到的宽高是 undefinedcanvas.width被赋值成NaN画布尺寸回落到默认值。// 正确顺序先设物理尺寸后缩放 canvas.width info.width * dpr canvas.height info.height * dpr ctx.scale(dpr, dpr)提示如果你在小程序开发者工具里看到图很清楚但真机上糊先检查fields({ node: true, size: true })这两个参数是否都带上了这是最高频的原因。5.2 节点查询失败的两种典型时序createSelectorQuery().exec()的回调拿不到 node通常有两种情况。一种是画布被包在wx:if里条件为 false 的时候节点不存在等条件变 true 之后你没重新查询。这种情况要在条件切换后重新调用initCanvas或者干脆用hidden代替wx:if让节点一直存在只是不可见。另一种是在自定义组件里忘了.in(this)。这种情况下select(#radar)会在整个页面范围内查找如果你的组件被复用了两次两个 canvas 同 id查询结果就不确定。加上.in(this)之后查询范围被限制在组件内部问题消失。5.3 频繁重绘与 canvas.requestAnimationFrame 泄漏动画帧回调里唯一要做的事就是算进度、重绘、决定要不要继续。千万不要在回调里做数据请求、setData大对象这类重操作。我见过一个实现每帧都调一次this.setData({ progress })结果图没画动页面先卡死了。进度是纯渲染状态存在this上就够了不需要走setData。cancelAnimationFrame必须在三个地方调用页面onHide、页面onUnload、组件detached。尤其是当用户快速切换 Tab 的时候如果旧页面的动画没停它还在后台按 60fps 重绘耗电又掉帧。症状常见原因处理方式图形模糊有毛边未按 dpr 放大画布设物理尺寸后ctx.scale(dpr, dpr)节点查询返回 null用了 wx:if 或漏了.in(this)改用 hidden查询范围限定组件首帧空白数据先于节点到达用 pendingData 做缓冲补渲切换后图在抽搐动画帧未取消onHide/detached 里 cancelAnimationFrame数据超过满分顶点外凸未夹紧比率Math.min(value / max, 1)标签顶出画布半径未预留边距半径减 48长文案截断5.4 安卓与 iOS 的字体基线、圆角差异最后说两个不大但很影响精致度的问题。同样是12px sans-serif安卓和 iOS 实际渲染出来的字形宽度不一样安卓普遍偏宽一点点导致标签的宽度估算在两端不一致。如果你的标签是居中锚点且靠近画布边缘安卓上可能就压线了。解决办法是给边距再多留 4 到 6 像素的余量别卡着算。另一个是lineJoin。数据面的折线在顶点处如果角度很尖默认的 miter 连接在两端设备的呈现也会有细微差异尖角可能被削掉。统一设成ctx.lineJoin round两端表现一致视觉上也更柔和。还有个小细节ctx.arc画的顶点圆点在某些安卓机型上如果半径小于 3 逻辑像素会被抗锯齿抹得几乎看不见。所以圆点半径不要低于 3.5我一般用 4稳。整体走下来一张五维图的代码量其实不大核心就两个函数极坐标换算和归一化。真正花时间的地方在于那些不写在文档里的东西——DPR 缩放、节点查询时序、动画帧的清理。把这些处理干净这张图就能直接复用到六维、八维甚至做成一个通用能力画像组件。我现在做类似评估类页面都是直接把这个组件搬过去改一下axes的数据结构和主色剩下的不用动。