uni-app小程序Canvas转盘从零实现:绘制、概率控制与组件封装

发布时间:2026/10/23 15:56:12
uni-app小程序Canvas转盘从零实现:绘制、概率控制与组件封装

简介:这是一款基于 uni-app 与纯 canvas 实现的跨端转盘项目,面向微信小程序、支付宝小程序、抖音小程序等全端开发场景,帮助开发者免去从零造轮子,直接获得可运行的自定义抽奖/选择转盘。压缩包共13个文件,包含vue页面与组件、json工程配置、js业务逻辑、scss样式、png图标及readme说明文档,整体仅19KB,使用 HbuilderX 导入即可运行,文件分工明确、结构轻量,便于快速定位和二次修改。功能方面,支持轮盘转动、自定义转盘选项、按权重抽奖、转速减速模拟真实转动,以及选项颜色设置;自定义选项支持任意数量的内容录入,权重设置可灵活调整中奖概率,内容也可自由替换,既能用于线上抽奖、抓阄,也能用于日常生活中的“今天吃什么”“周末去哪玩”等选择场景。readme 中附有基础使用说明,可帮助你理解配置和扩展方式,方便快速集成到现有小程序项目。对于想学习 uni-app 跨端开发或 canvas 绘图的中初级开发者,这是一套易于阅读和改造的实用模板,也可作为毕业设计或功能模块的参考起点。目前已有319人学习下载。

1. 纯canvas转盘:uni-app小程序里最不该用插件的地方

做小程序抽奖转盘,很多人第一反应是去插件市场找现成的,但实际接进来才发现:插件体积大、样式和业务耦合深,想改个配色、加个概率控制都得看别人脸色。更重要的是,转盘的核心逻辑其实就两件事——画扇形和转动画,用canvas手写两百行足够,而且概率可以做到完全可控。这篇笔记要拆的就是uni-app + 小程序原生canvas从零实现转盘的完整过程,包含绘制、动画、概率分配、真机兼容性排查,以及封装成组件的进阶做法。适合那些想自己掌控抽奖逻辑、不想被第三方插件绑定的开发者,也适合刚接触canvas但想直接落地一个实战项目的从业者。

2. 转盘绘制与概率模型:先把canvas坐标系和份额算清楚

2.1 从角度到弧长:canvas绘制转盘的最小几何体系

canvas的坐标系原点在左上角,x轴向右,y轴向下。这对绘制转盘二维圆形没有影响,只要你把圆心定位在画布中心(r, r),一切围绕圆心转。真正需要费心的是角度:canvas的arc方法用的是弧度制,一个完整圆是2π,而不是360。很多从css动画转过来的同学会在这里卡住,习惯性地用360 / 奖品数量,结果画出来要么缺了一块,要么扇区重叠。

转盘的每个扇区本质上是一个从startAngle到endAngle的圆弧封口扇形。假如你有 N 个奖品,每个奖品对应的扇区角度就是该奖品权重占总权重的比例乘以2π。假设奖品数组为:

const prizes = [ { name: '一等奖', weight: 1 }, { name: '二等奖', weight: 2 }, { name: '三等奖', weight: 3 }, { name: '谢谢参与', weight: 4 } ]

那么总权重是1 + 2 + 3 + 4 = 10,“谢谢参与”的扇区角度就是4 / 10 * 2π = 0.4 * 2π。这个比例直接决定了指针停在它上面的概率,不要再去写一堆if (random < 0.1)之类的判断,全部交给权重换算即可。

绘制扇区时使用ctx.arc(centerX, centerY, radius, startAngle, endAngle)。但要注意,arc的角度默认是从三点钟方向开始,也就是0弧度对应正右方。而转盘的指针通常在正上方,所以需要把起始角度整体偏移-π/2,让第一个扇区从十二点钟方向开始。这个偏移是canvas转盘最容易忽略的细节,我见过不止一个开发者因为少了这个偏移,导致转盘视觉上旋转了一个象限。

2.2 权重与概率:让每个奖品的中奖率严格可控

实现可控概率有两种思路:一种是先随机生成角度,再判断这个角度落在哪个扇区;另一种是先按权重抽出奖品,再反推转盘要停的位置。后者更常用,因为你提前知道中奖结果,可以避免前端随机数导致“一等奖连续中了三次”这种尴尬情况。

先按权重抽奖:

function getPrizeIndex() { const totalWeight = prizes.reduce((sum, item) => sum + item.weight, 0) let random = Math.random() * totalWeight for (let i = 0; i < prizes.length; i++) { random -= prizes[i].weight if (random <= 0) return i } return prizes.length - 1 }

逻辑很直白:把总权重放在一条数轴上,每次减去当前奖品的权重,减到小于等于零时,说明随机数落在当前奖品的区间。这样每个奖品的中奖概率严格等于weight / totalWeight。

抽中索引后,要计算转盘最终停在哪个角度。假设指针固定在正上方,即画布坐标系里的-π/2弧度。那么,为了让第i个扇区的中心恰好对准指针,需要的整体旋转角度等于:

const perAngle = 2 * Math.PI / totalWeight const centerAngle = -Math.PI / 2 + (sumWeightBefore + prizes[i].weight / 2) * perAngle // 让转盘额外转几圈,视觉上更自然 const extraCircles = 5 + Math.floor(Math.random() * 4) const targetAngle = -centerAngle + extraCircles * 2 * Math.PI

这里的sumWeightBefore是当前奖品之前所有奖品的权重之和。把centerAngle取负,是因为旋转的累计值要抵消扇区原本的角度,指数针最终指向扇区的中心。extraCircles指定额外转动的圈数,通常在 5 到 8 圈之间,太少显得没劲,太多会让人等待烦躁。

这一步做完,你就已经握住了转盘的“物理引擎”。剩下的动画,只不过是把当前角度从 0 平滑地过渡到targetAngle而已。

3. uni-app里从零实现转盘:绘制、动画、回调用完整代码拆给你

3.1 在uni-app中创建canvas并适配不同屏幕

uni-app 在小程序端会编译成原生 canvas 组件,所以在模板里使用<canvas canvas-id="prizeCanvas">而不是 H5 的<canvas id="...">。这意味着不能通过document.getElementById获取,必须使用uni.createCanvasContext(canvasId, this)。第二个参数this在 Vue 组件中不可省略,否则在组件内会找不到 canvas,这是一个需要留意的小细节。

canvas 的默认尺寸是 300x150,若不做适配,转盘会被压扁。我会在data里声明canvasSize,并在onReady中根据屏幕宽度动态计算:

<canvas canvas-id="prizeCanvas" :style="'width:' + canvasSize + 'px;height:' + canvasSize + 'px'"></canvas>
data() { return { canvasSize: 300, ctx: null, isDrawing: false } }, onReady() { const { windowWidth } = uni.getSystemInfoSync() this.canvasSize = Math.floor(windowWidth * 0.8) this.ctx = uni.createCanvasContext('prizeCanvas', this) this.drawWheel(0) }

canvasSize取屏幕宽度的 80%,是因为转盘周围往往还要放按钮和文案,留一点安全边距。如果你在 PC 端浏览器调试,windowWidth可能大于 750,转盘会大得离谱,这时可以加一个Math.min(width, 600)限制最大尺寸。绘图坐标系直接使用这个canvasSize,而把它作为圆心计算基准。

drawWheel(0)是首次绘制,传入0表示当前累计旋转角度为零。注意:canvas 绘制是异步的,uni.createCanvasContext获取的上下文在onReady里通常已经可用,但如果遇到偶发获取不到的情况,可以用setTimeout包一层,或者调用uni.nextTick。

3.2 绘制扇区与文字:循环里的坐标系变换是关键

绘制转盘的核心函数是drawWheel,它接受一个rotation参数,表示当前累计旋转了多少弧度。所有扇区的起始角度都要加上这个旋转量,这样才能让整个转盘看起来在转动。

drawWheel(rotation) { const ctx = this.ctx const r = this.canvasSize / 2 ctx.clearRect(0, 0, this.canvasSize, this.canvasSize) const totalWeight = this.prizes.reduce((sum, j) => sum + j.weight, 0) let startAngle = -Math.PI / 2 + rotation this.prizes.forEach((item, index) => { const sliceAngle = (item.weight / totalWeight) * 2 * Math.PI const endAngle = startAngle + sliceAngle // 绘制扇形区域 ctx.beginPath() ctx.moveTo(r, r) ctx.arc(r, r, r - 10, startAngle, endAngle) ctx.closePath() ctx.fillStyle = item.color ctx.fill() // 在扇区中央绘制文字 ctx.save() ctx.translate(r, r) ctx.rotate(startAngle + sliceAngle / 2) ctx.textAlign = 'right' ctx.fillStyle = '#fff' ctx.font = '14px sans-serif' ctx.fillText(item.name, r - 20, 5) ctx.restore() startAngle = endAngle }) // 绘制中心点缀圆 ctx.beginPath() ctx.arc(r, r, 18, 0, 2 * Math.PI) ctx.fillStyle = '#fff' ctx.fill() ctx.draw() }

这里有两个关键点需要说明。

一是扇区绘制:moveTo(r, r)让路径从圆心开始,arc绘制从圆心到圆弧的边界,closePath闭合形成扇形。r - 10是扇区的绘制半径,留出 10px 给转盘外圈或描边,避免扇区顶到 canvas 边缘。如果你希望圆盘外圈有边框,可以额外再arc描边。

二是文字绘制:save保存当前状态,translate(r, r)把坐标系原点移到圆心,rotate旋转到扇区的中心角。这时fillText的基准点在圆心,通过textAlign = 'right'让文字在半径方向向外对齐。fillText(name, r - 20, 5)中的r - 20是文字距离圆心的长度,20 可以保证文字不贴着外边缘;5是垂直偏移,用于微调文字基线。如果你的奖品名称较长,比如六个字以上,可以把r - 20改成r * 0.65,或者手动传入文字大小。

注意,startAngle在循环中被赋值成endAngle,这是为了让每个扇区连续衔接。如果某个产品经理临时要求奖品间有分隔线,可以在每两个扇区之间加一个ctx.strokeStyle = '#fff'; ctx.stroke(),但那样需要在arc后不closePath,直接描边路径,这个细节可以根据实际需求调整。

3.3 转盘旋转动画:用缓动函数控制速度和圈数

动画的目标是让rotation从 0 平滑增长到targetAngle。直接线性递增会显得机械,真实的转盘应该是先快后慢,所以我采用一个简单的缓动函数:easeOutQuart,它的表达式是1 - Math.pow(1 - progress, 4)。这个函数的曲线是前期速度快、后期速度慢,非常接近常见抽奖转盘的减速效果。

startDraw() { if (this.isDrawing) return this.isDrawing = true const prizeIndex = this.getPrizeIndex() this.targetRotation = this.calcTargetRotation(prizeIndex) this.currentRotation = 0 this.animationFrame = 0 this.totalFrames = 60 this.timer = setInterval(() => { this.animationFrame++ const progress = Math.min(this.animationFrame / this.totalFrames, 1) const eased = 1 - Math.pow(1 - progress, 4) const currentRotation = this.targetRotation * eased this.drawWheel(currentRotation) if (progress >= 1) { clearInterval(this.timer) this.isDrawing = false const prize = this.prizes[prizeIndex] uni.showToast({ title: prize.name, icon: 'none' }) } }, 16) }

这里有几个细节值得注意。

首先,totalFrames控制动画总时长。每帧间隔 16ms,totalFrames = 60时总时长约为 960ms,加上前后偏差基本在一秒左右。如果你的产品经理要求转得更久,直接把totalFrames加大到 90 或 120,同时记得targetRotation中也要有足够的整圈数来支撑这么长的时长。

其次,currentRotation每次都从 0 重新计算,而不是在上一帧基础上累加。这是因为eased在末期增长非常缓慢,如果用currentRotation += step的方式,容易出现最后停留角度轻微抖动。而使用targetRotation * eased可以保证最后一帧恰好等于targetRotation,不会出现因为浮点误差导致指针偏离的问题。

第三,setInterval的间隔 16ms 在大部分设备上接近 60fps,但小程序动画帧算不上精确,如果遇到卡顿可以看第 4 章的优化。停止动画的条件是progress >= 1,此时立即清除定时器,并回调结果。在实际项目中,你多半要把uni.showToast换成自定义弹窗,并把这个回调暴露给业务层。

3.4 抽奖事件与结果回调:把指针位置翻译成奖品

抽奖按钮的click事件里调用startDraw,同时要加锁避免连点。上面的代码已经用this.isDrawing做了判断,但还有一个边界:如果用户在动画还没结束时再次点击,会被直接return。这虽然解决了并发问题,但更好的交互是按钮置灰或显示“旋转中”状态。我会在data里增加一个disabled状态,在动画开始时设为true,结束后再置回false。

结果回调不一定都用uni.showToast,更优雅的方式是让startDraw接收一个回调函数,或者通过this.$emit抛给父组件。例如:

startDraw() { // ... if (progress >= 1) { clearInterval(this.timer) this.isDrawing = false this.$emit('draw-end', prize) } }

这样页面里只需要监听@draw-end="handleDrawEnd"就能拿到中奖奖品。注意,如果转盘组件里的奖品配置是异步从后端接口获取的,那么绘制函数必须等待数据到达后再调用drawWheel。也就是说onReady时先检查prizes是否为空,空则只绘制一个空白圆环;接口返回后重新赋值并再次drawWheel(0),否则会出现“看得到按钮但转盘空白”的僵尸状态。

此外,有关概率计算的代码可以单独抽成一个模块,而不写在组件内部。因为不同活动对奖品概率的要求可能完全不同,有的需要后端强制校验,有的只要前端本地随机。把getPrizeIndex和calcTargetRotation放到纯函数文件里,单元测试也会更容易写。

4. 避坑手册:canvas转盘在小程序端最容易翻车的五个细节

4.1 现象:真机上报错“canvas is not defined”或“Cannot read property 'draw' of undefined”

模拟器和开发工具上转盘正常,但真机报错,多半是ctx获取的时机不对。uni-app 在某些安卓机型上,onReady时 canvas 组件尚未完全挂载,立刻createCanvasContext返回的上下文是不完整的。

原因:canvas 是原生组件,渲染优先级与 webview 不同,开发者工具里因为同步渲染所以没问题,但真机上原生 canvas 的初始化有异步延迟。

解决:不要迷信onReady。稳妥的做法是把首次绘制放到setTimeout中,比如延迟 100ms 再初始化:

onReady() { setTimeout(() => { this.ctx = uni.createCanvasContext('prizeCanvas', this) this.drawWheel(0) }, 100) }

如果 100ms 后仍然失败,再排查 canvas-id 是否和页面中其他 canvas 重名。多个页面或组件共用同一个 canvas 组件的实例时,canvas-id 必须全局唯一,不能只在页面内唯一。

4.2 现象:绘制文字后字体变模糊,尤其iPhone真机

字体模糊的原因不是 canvas 的问题,而是ctx.font里直接用'14px sans-serif'在小程序端可能被缩放。iPhone 6 及以后机型都使用高倍屏,如果没有把 canvas 的物理像素对齐到屏幕像素,绘制出来的文字就会发虚。

原因:canvas 组件的 css 尺寸和画布内部实际像素没有匹配。小程序旧版 canvas 默认把画布尺寸设置成 css 尺寸乘以devicePixelRatio,但如果你通过createCanvasContext使用其中一套坐标系,就会出现模糊。

解决:不要在大尺寸转盘上用 14px 这么小的字,尽量把文字加大到 16px 以上。同时,在设置 canvas 样式尺寸时,可以在canvasSize上乘以pixelRatio并给 canvas 标签设置width="canvasSize * pixelRatio"、height="...",但这样做对旧版 canvas 反而容易出问题。我实践后最省心的方法是:不要追求物理像素完美,直接投资在字号和字重上,比如ctx.font = 'bold 18px sans-serif',这样即使有轻微缩放,视觉上依然是清晰的。

如果你坚持要高清适配,建议改用新版 canvas 2D 接口(type="2d"),但那会带来更多兼容问题,本章后面再讨论。

4.3 现象:旋转动画在部分安卓机上卡顿,甚至闪屏

动画卡顿通常不是因为复杂绘制,而是setInterval在小程序后台或低端机上不稳定。安卓低端机的setInterval实际间隔可能超过 30ms,导致动画掉帧。

原因:原生 canvas 每次ctx.draw()都会触发渲染,动画期间每秒约 60 次重绘,如果代码里还有uni.createSelectorQuery之类的频繁调用,主线程会被占满。

解决:去掉动画帧里的无关操作,例如不要在drawWheel内每次重新计算this.prizes的总权重,把它提前到data初始化时算好并缓存。同时,把setInterval换成requestAnimationFrame的封装,uni-app 全局没有标准的requestAnimationFrame,但可以通过nextFrame实现:

function nextFrame(callback) { setTimeout(callback, 16) }

然后在动画循环里递归调用nextFrame,而不是setInterval。这样在页面不可见时,自动暂停递归调用,减少无意义的消耗。

4.4 现象:转盘停在了按钮的正下方,而不是指针位置

这是角度偏移导致的经典问题。指针固定在正上方,但旋转目标角度计算时少减了一个-Math.PI / 2的基准偏移,导致最终停的位置刚好差 90 度。

原因:绘制时已经加了-Math.PI / 2作为起始偏移,但计算centerAngle时没有考虑这个偏移,或者反过来重复计算。

解决:统一用“指针指向正上方”作为唯一的基准。我通常会封装一个函数计算某个奖品中心的目标旋转角:

calcTargetRotation(index) { const totalWeight = this.totalWeight let sumBefore = 0 for (let i = 0; i < index; i++) { sumBefore += this.prizes[i].weight } const centerAngle = -Math.PI / 2 + (sumBefore + this.prizes[index].weight / 2) * (2 * Math.PI / totalWeight) const extraCircles = 5 + Math.floor(Math.random() * 4) return -centerAngle + extraCircles * 2 * Math.PI }

注意centerAngle以-Math.PI / 2开头,最后取负后,负负得正,这样targetRotation永远是正数,表示顺时针转动。如果你希望逆时针旋转,直接改成正的centerAngle取负逻辑即可,但记住扇区绘制时的角度方向也随之变化。

4.5 现象:修改奖品列表后canvas没有刷新

转盘的奖品列表如果是动态的,比如用户参与前从后端拉取名称和权重,那么绘制时必须重置startAngle到-Math.PI / 2,然后用新的奖品数组重新计算。常见错误是直接调用drawWheel(this.currentRotation),但currentRotation可能还保留了旧动画的旋转角度,导致两套奖品叠加在一起。

原因:canvas 绘制是即时的,如果你不清空,旧的扇区形状还在画布上。clearRect虽然清空了,但下一次循环如果没有从初始角度开始,就会在上一次扇区结束角度基础上继续画,扇区角度越积越多。

解决:在setPrizes(newPrizes)方法里,强制重置:

setPrizes(newPrizes) { this.prizes = newPrizes const totalWeight = newPrizes.reduce((sum, item) => sum + item.weight, 0) this.totalWeight = totalWeight this.currentRotation = 0 this.drawWheel(0) }

并且要把totalWeight缓存起来,不要在drawWheel每次计算。养成这个习惯后,再遇到数据动态更新,只要调用setPrizes,转盘就会干净地重绘。

5. 进阶:把转盘封装成可复用组件,顺便解决主题定制与性能

5.1 组件化设计:参数驱动绘制

转盘做一次容易,但在多个活动页里复用就麻烦了。我会把它封装成一个CanvasWheel组件,通过props接收奖品列表、指针位置、旋转动画时长,通过events回传中奖结果。这样业务页面里只需要维护一个奖品数组,不用关心 canvas 的绘制细节。

组件内部的核心逻辑是:监听prizes的深度变化,自动调用setPrizes。特别注意的是,不要把prizes的默认值写成[]然后直接内联修改,务必通过watch监听:

props: { prizes: { type: Array, required: true } }, watch: { prizes: { handler(newVal) { this.setPrizes(newVal) }, deep: true, immediate: true } }

immediate: true让组件在创建时就执行第一次绘制,不用等onReady手动调用。配合第 3 章的setTimeout初始化,就能保证按钮出现前画布已经就绪。

5.2 自定义品牌色与奖品图片

默认的纯色扇区太单调。常见需求是扇区背景使用渐变色或品牌 logo 图片。小程序 canvas 同样支持ctx.createLinearGradient,但背景图片需要先用uni.getImageInfo拿到本地路径,再用ctx.drawImage绘制。要注意绘制图片时如果图片尚未缓存,drawImage会静默失败,所以我会把图片预加载和扇形绘制分开:

async preloadImages(list) { const images = [] for (const item of list) { if (item.image) { const info = await uni.getImageInfo({ src: item.image }) images.push(info.path) } else { images.push('') } } return images }

绘制时先填充色作为底色,再在扇区边界内裁剪并绘制图片。这里需要ctx.save()+ctx.arc()+ctx.clip()的组合,让图片只出现在当前扇区内,否则图片会溢出到相邻扇区。这个裁剪操作比直接画图片更可控,也是自定义转盘的高频技巧。

5.3 性能优化:离屏绘制与动画节流

如果转盘奖品多、图片多,每帧都重新绘制所有扇区和图片,低端机会卡。常见优化是把静止层(扇区、文字、背景图)先绘制到离屏 canvas 上,动画时只旋转整个离屏画布,而不是重新绘制扇区。小程序对离屏 canvas 的支持不如 H5 完整,但可以使用同一个canvas-id配合setTransform实现类似效果,或者在动画期间把drawWheel的重绘频率降低到每秒 30 次。

我个人常用的做法:动画前把静态扇区绘制到单独一张 canvas 并缓存离屏数据,动画时用ctx.drawImage把缓存绘制到主 canvas,再在主 canvas 上绘制指针。这样每帧只需要绘制一次图片和指针,绘制成本大幅下降。不过这个优化有前提——奖品数不能变化,扇区角度固定。一旦奖品权重变化,离屏缓存必须重建。

有一次我在一个抽奖活动里偷懒,没有做缓动,直接用线性动画,结果产品经理反馈“转盘停下来的那一瞬间特别生硬”。后来我把easeOutQuart换成了更陡的easeOutQuint,效果才自然。从那以后我每次做转盘都强制走一遍概率计算和动画缓动验证,确认落点准确再交付。希望这些踩坑经验能帮你少走弯路,如果你也准备在 uni-app 小程序里落地转盘,不妨先把第 2 章的角度模型在草稿纸上推一遍,再开始码代码,多花十分钟,后面能省你好几个小时。

本文还有配套的精品资源,点击获取