资讯动态

uni-app小程序纯Canvas转盘绘制与抽奖动画实现指南

发布时间:2026/10/14 1:23:43 来源:尧图企业网站定制
简介这是一份基于uni-app与uni-ui实现的小程序转盘组件源码采用纯canvas绘制面向需要快速集成抽奖、轮盘或做选择功能的小程序开发者支持微信、支付宝、抖音等多端部署。资源共13个文件压缩包仅19KB代码量精简主体包含3个vue页面文件、3个json配置、2个js逻辑文件另有scss样式、png图标、readme说明等目录结构清晰便于直接阅读和二次修改。功能涵盖自定义转盘选项、设置选项权重进行按概率抽奖、转速减速模拟真实转动以及选项颜色配置可直接通过HbuilderX导入使用。无论是抽奖、抓阄还是纠结今天吃什么、周末去哪玩只要修改选项数据即可快速复用同时也很适合学习canvas动画与uni-app跨端开发思路。目前已有318人学习下载作为轻量实用的canvas转盘示例具备较高的参考价值尤其适合需要快速上线抽奖功能的中小型项目。1. 为什么我要用纯 Canvas 在 uni-app 小程序里写转盘先把选型理由说透做了几个小程序营销组件之后我越来越倾向把这类交互从图片方案里摘出来。uni-app 加小程序加纯 Canvas 做转盘听起来比直接贴一张底图复杂度高但换来的是尺寸自适应、奖品可动态配置、抽奖动画完全可控。之前用 gif 或序列帧做转盘改一个奖品名要重新出图转起来还偶发卡顿后来换成 Canvas 手绘之后这些问题基本消失。这篇笔记会把完整的绘制、旋转、判定和跨端适配过程拆开新手能照着抄熟手可以对比参数和边界条件。如果你正被“转盘在真机上模糊”“抽奖结果对不上指针”这类问题困扰直接跳到对应章节。2. 纯 Canvas 绘制转盘坐标系、扇形与文字样式的可靠写法2.1 画布初始化与坐标系理解先不碰动画把静态转盘画出来。Canvas 的坐标系原点是左上角x 轴向右y 轴向下角度从三点钟方向开始顺时针递增。转盘要绕圆心旋转所以第一步是把圆心移到画布中心换算坐标后再画每一个扇形。在 uni-app 里初始化一张画布常见做法是给 canvas 绑定一个 id然后在 onReady 里创建上下文。旧版写法是 uni.createCanvasContext新版小程序基础库推荐 type2d 的方式直接拿标准 CanvasRenderingContext2D。下面这段代码同时兼容两种方式先探测是否支持 node 接口拿不到就退回旧版上下文。// 模板里 // canvas type2d idwheelCanvas classwheel-canvas/canvas // canvas canvas-idwheelCanvas idwheelCanvasLegacy classwheel-canvas/canvas initCanvas() { // 优先走 type2d 的新接口拿到的 ctx 是标准 CanvasRenderingContext2D const query uni.createSelectorQuery().in(this) query.select(#wheelCanvas).fields({ node: true, size: true }).exec((res) { if (res res[0] res[0].node) { const canvas res[0].node const ctx canvas.getContext(2d) this.canvas canvas this.ctx ctx // 这里的宽高来自 CSS 渲染后的实际尺寸后面会结合像素比再做一次高清适配 this.canvasWidth res[0].width this.canvasHeight res[0].height this.drawWheel() } else { // 低版本基础库的降级方案注意旧版 ctx 需要调 draw() 才会真正上屏 const ctx uni.createCanvasContext(wheelCanvasLegacy, this) this.ctx ctx this.ctxLegacy true this.drawWheelLegacy() } }) }参数说明select 的 fields 配置里node 表示获取 canvas 节点实例size 表示拿渲染尺寸。旧版 createCanvasContext 的第一个参数需要和模板里 canvas-id 保持一致。初始化的结果需要存到 this 上后续绘制和动画都会用到。这里有个容易忽略的点type2d 的 canvas 在模板里写 style 的宽高但画布内部的分辨率是 canvas.width 和 canvas.height 两个属性控制的。如果不做像素比适配直接按 CSS 尺寸绘制真机上会发虚第 4 章会单独讲。2.2 扇形绘制moveTo、arc 与分区算法绘制一个扇形核心是三个步骤把笔移动到圆心画一段圆弧再闭合路径。给每个奖品分配一个扇区扇区角度是 360 度除以奖品数量。颜色和文案都从奖品数组里取这样后台上配置奖品列表时前端不需要改任何绘制代码。/** * 绘制单个扇形 * param {Object} ctx - canvas 上下文 * param {number} cx - 圆心 x * param {number} cy - 圆心 y * param {number} radius - 扇区半径 * param {number} startAngle - 起始角度弧度制 * param {number} endAngle - 结束角度弧度制 * param {string} color - 填充色 */ function drawSector(ctx, cx, cy, radius, startAngle, endAngle, color) { ctx.beginPath() ctx.moveTo(cx, cy) // 第三个参数 false 表示顺时针绘制方向要与转盘旋转方向保持一致 ctx.arc(cx, cy, radius, startAngle, endAngle, false) ctx.closePath() ctx.fillStyle color ctx.fill() // 加描边可以让扇区边界清晰这里只描边一次避免相邻两个扇区产生双重描边 ctx.strokeStyle #ffffff ctx.lineWidth 1 ctx.stroke() }调用时startAngle 从上一块扇区的末尾开始累加形成一个环形。奖品数组的每一项包含 name 和 color颜色交替排列让视觉上能明显区分每个分区。如果把 arc 的最后一个参数改成 true会变成逆时针绘制转盘转动方向、指针判定、动画加速方向全部要跟着反容易乱我一般固定用 false。分区数量这块有个边界如果奖品只有 1 个整个圆就只有一个扇区如果奖品有 12 个以上文字会非常拥挤通常会把文字改成两行或者缩小字号。小程序转盘的合理取值范围是 4 到 10 个奖品这个区间内每个扇区的弧度足够容纳 4 到 6 个汉字。2.3 分区文字绘制save/restore 与 textAlign文字画在扇形中间如果直接计算文字的绝对坐标涉及三角函数稍麻烦一点。更省事的办法是先把坐标原点移到圆心旋转到某个扇区的中间角度然后沿半径方向画文字。这样每个扇区的文字绘制逻辑完全一样唯一变化的是 rotate 的角度。/** * 在指定扇区绘制奖品文字 * param {Object} ctx - canvas 上下文 * param {number} cx - 圆心 x * param {number} cy - 圆心 y * param {number} radius - 扇区半径 * param {number} angle - 扇区中心角度 * param {string} text - 奖品名称 */ function drawSectorText(ctx, cx, cy, radius, angle, text) { // save 和 restore 一定成对出现save 记录当前状态restore 恢复避免旋转状态互相污染 ctx.save() ctx.translate(cx, cy) ctx.rotate(angle) // 文字右对齐fillText 的 x 坐标从扇形外边向内缩 20px文字不会越界 ctx.textAlign right ctx.fillStyle #3c2e1f ctx.font 14px sans-serif ctx.fillText(text, radius - 20, 6) ctx.restore() }rotate 之后坐标系跟着旋转x 轴方向仍然是从圆心向外延伸。textAlign 用 rightfillText 里的 x 传 radius - 20意思是文字的最右端到圆心的距离是 radius - 20这样文字自动向左展开不会超出画的圆。y 轴方向画出来会偏上传 6 是一个经验补偿值让文字视觉上垂直居中。这里最容易踩的坑是忘写 restore导致下一次 drawSector 的 fillStyle 或者旋转角度被篡改。如果发现第二个扇区的颜色染到了第三个扇区、或者文字方向越转越歪先怀疑 save/restore 配对有没有写全。文字是否需要跟随转盘旋转取决于产品需求抽奖转盘的视觉习惯是奖品文字跟着扇区转动所以每次绘制时按当前扇区中心角度旋转是合理的。3. 转盘动画与抽奖判定旋转模型、缓动函数与点击锁3.1 旋转模型围绕圆心的旋转与角度记录静态转盘画好之后动画的本质是让整个图形围绕圆心不断改变角度。实现方式有两种选择一是每一次都重绘所有扇区和文字把角度作为偏移参数传入绘制函数二是绘制到一个离屏画布上旋转时只做 rotate 和 drawImage。先看不做离屏缓存的直接方案维护一个全局角度变量 currentAngle每一次绘制时所有扇形和文字的角度都加上这个偏移量。绘制扇区的代码基本不用改只要在绘制前把画布整体旋转一下。function drawWheelWithAngle(ctx, cx, cy, items, radius, currentAngle) { // 整体旋转视觉上看就是转盘在转动 ctx.save() ctx.translate(cx, cy) ctx.rotate(currentAngle) ctx.translate(-cx, -cy) // 计算每个扇区起始角度和结束角度都基于 0 度叠加 const sectorAngle (2 * Math.PI) / items.length for (let i 0; i items.length; i) { const start i * sectorAngle const end start sectorAngle drawSector(ctx, cx, cy, radius, start, end, items[i].color) } // 文字也跟随整体旋转角度基准与扇区一致 for (let i 0; i items.length; i) { const midAngle i * sectorAngle sectorAngle / 2 drawSectorText(ctx, cx, cy, radius - 10, midAngle, items[i].name) } ctx.restore() // 中间指针画在最上层不跟随旋转指针固定指向上方 drawPointer(ctx, cx, cy) }这个方案的优点是容易理解缺点是每次动画帧都要重新执行路径计算和 fill如果奖品数量多、画布尺寸大低端机的帧率会掉。对于 8 个以内的奖品、400px 左右的画布实测表现足够顺滑。如果你想要更高性能把静态扇区绘制缓存到离屏画布每帧只旋转图片这个写法在第 6 章展开。3.2 缓动函数从 easeOut 到可控时长的帧循环转盘不能匀速转匀速运动会显得很机械而且用户看不清奖品落在哪个位置。常规做法是用 easeOut 缓动让转盘先快后慢最后平滑停下来。缓动函数的作用是把时间进度 t从 0 到 1映射成角度进度的比例。这里我没有用 requestAnimationFrame因为小程序里的 requestAnimationFrame 支持并不统一兼容做法是用 setTimeout 实现帧循环每次计算当前应该转到的绝对角度然后重绘。// easeOutCubic 缓动函数t 从 0 到 1返回值也是 0 到 1先快后慢 function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3) } startSpin() { // 如果正在转直接返回这是最简单的防重复触发锁 if (this.spinning) return this.spinning true const duration 4000 // 动画时长 4 秒 const startAngle this.currentAngle // 当前角度作为起点 const totalRotate Math.PI * 2 * 5 // 至少转 5 圈制造仪式感 const targetOffset this.getTargetOffset() // 计算命中某个奖品需要转到的偏移角度 const finalAngle startAngle totalRotate targetOffset // 最终绝对角度 const startTime Date.now() // 用 setTimeout 模拟帧循环每 16ms 约一帧 const tick () { const elapsed Date.now() - startTime const progress Math.min(elapsed / duration, 1) const eased easeOutCubic(progress) // 当前角度 起点 总旋转量 * 缓动进度 this.currentAngle startAngle (finalAngle - startAngle) * eased // 重绘整个画布 this.drawWheelWithAngle(this.ctx, this.cx, this.cy, this.items, this.radius, this.currentAngle) if (progress 1) { // 动画未结束继续下一帧 this.animationTimer setTimeout(tick, 16) } else { // 动画结束落定到最终角度关闭动画锁 this.currentAngle finalAngle this.spinning false // 回调中奖结果由外部传入 onFinish this.onFinish this.onFinish(this.targetItem) } } tick() }参数说明duration 控制整个动画时间4 秒是比较常见的设置想要更干脆可以缩短到 3 秒。totalRotate 决定最少转几圈圈数太少会显得奖品是硬甩过去的圈数太多则用户等待时间过长。finalAngle 是一个绝对角度它不需要缓存模 2π因为绘制时 Canvas 的 rotate 可以接受任意大的弧度角度累积不会影响结果这一点比直接操作 DOM 的 CSS transform 要省心。要注意 progress 到达 1 之前缓动函数输出的 eased 值不可能完全等于 1所以最后一帧要手动把 currentAngle 赋成 finalAngle否则可能出现停下来的瞬间角度轻微回退视觉上像抖了一下。3.3 命中判定按奖品倒推目标角度抽奖的核心问题是要让指针停在哪个扇区。最容易出错的地方是先随机一个角度再去查这个角度属于哪个奖品。这样做的问题在于如果扇区的角度边界计算有一点误差最终停下的位置会偏向相邻扇区用户较真起来很麻烦。更稳的做法是先随机决定中奖奖品再根据奖品索引反推目标角度。这样可以保证目标角度必然落在该扇区的中心区域留出足够的容错空间。// 计算命中某个奖品需要的偏移角度 getTargetOffset() { const items this.items const sectorAngle (2 * Math.PI) / items.length // 随机抽取一个奖品索引这里用 Math.random 实现等概率抽奖 // 注意真实业务通常由后端返回中奖结果前端只负责动画表现 const randomIndex Math.floor(Math.random() * items.length) this.targetItem items[randomIndex] // 扇区中心角度 索引 * 扇区大小 扇区大小的一半 const targetSectorCenter randomIndex * sectorAngle sectorAngle / 2 // 关键指针固定指向 12 点钟方向。绘制时 0 度在 3 点钟方向 // 因此需要把目标角度换算成相对于 12 点钟方向的偏移。 // 这里统一约定转盘旋转角度的起点让第一个扇区起始边位于 12 点钟方向。 // 所以目标偏移 扇区中心角度 - Math.PI / 2 let targetOffset targetSectorCenter - Math.PI / 2 // 转盘先正转到目标偏移再叠加 5 圈旋转就是最终绝对角度 return targetOffset }这个函数里的核心换算不复杂先确定随机奖品索引再找扇区中心。扇区中心角度按顺时间从 0 度开始计算由于绘制指针时指针在 12 点位置实际绘制 0 度在 3 点位置所以需要减去 π/2。如果你把指针画到了右边就不用减这个值而是加 π/2 或者做别的换算。这里的角度基准一旦定下来必须和绘制指针的位置保持一致画指针的代码见上节 drawPointer。关于随机策略有一个后端交互场景值得提一下如果抽奖结果由后端下发前端拿到的是一个奖品 id那么这里不应该用 Math.random而应该根据后端返回的索引调用 getTargetOffsetById(index)。把这个逻辑从 getTargetOffset 里抽出去动画部分保持不变只替换命中索引的来源这样前端与后端对接时改动面最小。在动画结束的回调里把 this.targetItem 传给 onFinish业务层可以通过 uni.showToast 或弹窗给用户展示结果同时把抽奖次数、库存等状态更新到服务端。注意不要在动画结束前就弹出结果否则用户看到弹窗和转盘停稳不一致会产生“造假”的负面体验。4. uni-app 跨端适配从 Context 到 Canvas 2D 的迁移与像素比处理4.1 旧版 canvasContext 与 type2d 的差异uni-app 同时支持两种 Canvas 上下文早期项目大多用 uni.createCanvasContext 创建的 canvasContext新项目可以直接上 type2d 的标准接口。这两者的差距不只是 API 名字不同调用方式和渲染效果都有实质区别。对比项canvas-id 旧版上下文type2d 新接口获取方式uni.createCanvasContext(id, this)canvas.getContext(2d)提交方式调用 ctx.draw() 后批量渲染同步写入直接显示动画性能每次整块重绘更接近浏览器 Canvas 2D 标准原生支持更好基础库要求几乎没有限制基础库 2.9.0 以上代码复用度小程序的 wx 接口没法用于 App 端标准 Canvas 接口H5 和 App 端更接近旧版接口在 uni-app 里最大的问题是 draw() 是异步的绘制结束后需要回调才能继续下一帧对动画循环很不友好。而且旧版的 ctx 上很多方法名与浏览器标准略有不同比如调用 ctx.setFillStyle 而不是 ctx.fillStyle代码切到 H5 端时容易踩坑。如果你现在新建项目建议直接用 type2d如果你维护的是老项目旧版学起来也不亏很多开源组件仍是旧版写法。一个工程里可以同时保留两种上下文初始化时先探测新接口拿到 node 走新逻辑拿不到自动降级到旧接口。第 2 章 initCanvas 那段代码已经演示了降级流程。4.2 像素比适配让转盘在真机上不发虚Canvas 发虚问题在真机上几乎必现原因在于物理像素和 CSS 像素不是一比一。画布内部分辨率的默认值通常是 300x150而 CSS 尺寸可能是 350x350浏览器会把 300x150 的内容拉伸显示到 350x350 的容器里结果是边缘模糊、文字发虚。解决办法是获取设备的 pixelRatio把 canvas 的内部宽高乘以像素比再用 ctx.scale 放大坐标系这样绘制时所有尺寸按 CSS 像素来写实际渲染按物理像素输出。setupCanvasDpr(canvas, ctx, cssWidth, cssHeight) { // uni.getSystemInfoSync 可以取到当前设备的像素比iPhone 一般是 2 或 3 const dpr uni.getSystemInfoSync().pixelRatio || 2 // 内部分辨率设置为 CSS 尺寸乘以 dpr canvas.width cssWidth * dpr canvas.height cssHeight * dpr // 缩放坐标系绘制时仍然用 CSS 像素尺寸 ctx.scale(dpr, dpr) return dpr }注意这个函数要在 getContext 之后、第一次绘制之前调用并且只需要调用一次。如果后续改变了容器的宽高比如屏幕旋转或者窗口变化需要重新执行一次 setup 逻辑。ctx.scale 是一个持久状态如果后面还要做离屏绘制记得在恢复尺寸的 save/restore 里包一层或者单独建一个离屏画布实例不要复用同一个 ctx。还有一个细节在 type2d 模式下canvas 节点的宽度是 CSS 像素但分辨率由 canvas.width 控制。很多教程里直接用 canvas.width style.width * dpr这里的 style.width 需要通过 SelectorQuery 的 size 字段获取不能直接读 canvas.style因为小程序里 canvas 的 style 是字符串。没有拿到准确尺寸就直接乘 dpr会导致绘制内容被截断或位置偏移。4.3 事件与触摸坐标换算小程序转盘如果只是点击一次转一下直接绑定 tap 事件就行不需要处理触摸坐标。但如果要做“手指滑动转盘持续旋转”的交互就需要把触摸坐标换算成相对于圆心的角度。tap 事件拿到的 event.detail.x 和 event.detail.y 是相对页面左上角的坐标。要换算成相对圆心的坐标需要先拿到画布在页面中的位置然后做一次偏移计算。onCanvasTap(e) { // 获取画布相对页面左上角的位置信息 const query uni.createSelectorQuery().in(this) query.select(#wheelCanvas).boundingClientRect((rect) { // 触摸点换算成相对画布左上角的坐标 const touchX e.detail.x - rect.left const touchY e.detail.y - rect.top // 再换算成相对圆心的坐标圆心在画布中心 const centerX rect.width / 2 const centerY rect.height / 2 const relativeX touchX - centerX const relativeY touchY - centerY // 计算当前触摸点相对圆心的夹角 // Math.atan2 返回的是 -π 到 π 之间的弧度需要转成 0 到 2π let angle Math.atan2(relativeY, relativeX) if (angle 0) { angle Math.PI * 2 } this.lastTouchAngle angle // 在这里可以选择立即旋转转盘或者仅记录角度供滑动旋转使用 }).exec() }参数说明atan2 比 atan 更适合这种场景因为它能根据 x 和 y 的符号正确判断象限返回的角度范围可以覆盖整个圆周。换算出的 angle 如果用于滑动旋转需要和 currentAngle 建立差值关系每次 touchmove 时计算手指角度的变化量把差值加到 currentAngle 上。这里有一个纵向坐标的问题需要额外注意Canvas 坐标系 y 轴向下Math.atan2 的 y 参数在 Canvas 里应该直接使用相对坐标不需要手动翻转符号翻转之后旋转方向会反过来。做滑动旋转时还需要控制一下灵敏度。初始记录 lastTouchAngle每次 touchmove 用新角度减去旧角度得到 deltaAngle加到 currentAngle。为了减速停止可以给一个摩擦系数比如每次位移后的角度增量乘以 0.95让手指离开后转盘还能余转一小段。5. 转盘项目排查与避坑记录5 个高频问题与解决路径5.1 现象一转完指针停在两个扇区边界中间做第一版的时候我测试了几十次发现有几次指针正好压在两个扇区的交界线上用户看了肯定会觉得抽奖结果有黑幕。原因目标角度取到了扇区的起始角度没有加上扇区半角偏移导致目标角度的实际落点就在分界线上。此外如果扇区起始角度计算时的索引从 0 开始而视觉上第一个扇区对应的是 0 度很容易把起始角度误当成中心角度。解决把中奖目标角度改为扇区起始角度加上半个扇区角度。这个半角偏移让指针停在扇区的视觉中心附近而不是边缘。我还会在最终角度上再叠加一个很小的常量偏移比如 0.02 弧度避免因为浮点精度问题压线。5.2 现象二转盘在真机上边缘发虚、文字带毛边我发现模拟器上预览效果还行在几台安卓真机上跑文字全是虚的边缘有明显的锯齿。原因没有按像素比设置 canvas 内部分辨率系统把低分辨率画布拉伸到了高清屏导致渲染发虚。部分机型对 canvas 的默认分辨率处理方式不同有些安卓机的默认宽度只有 300px拉伸到 350px 之后就出现锯齿。解决统一在初始化时调用 setupCanvasDpr把 canvas.width 和 canvas.height 乘以设备像素比并对 ctx 做 scale。这个问题需要让美工或测试在至少三台不同像素比的机器上看效果iPhone 和安卓的高端机、低端机各看一遍。5.3 现象三快速点击会连续触发两次抽奖转盘还在旋转的过程中用户再次点击按钮发现抽奖接口被调了两次后端出现了两笔抽奖记录。原因抽奖锁的置位时机不对。我一开始把 spinning 置 true 放在了动画真正开始的回调里但 tap 事件和动画启动之间有一些同步代码用户在这段时间内可以再次点击锁还没来得及生效。解决把 isSpinning 的判断放在 tap 事件处理函数的第一行并且在进入函数后立刻置为 true不留给任何异步代码空间。动画结束回调里再置回 false。这样处理之后快速点击最多只会触发一次抽奖。5.4 现象四旋转时文字位置错乱、颜色互相污染转盘转起来之后原本应该固定在扇区里的文字出现了一个扇区的文字串到另一个扇区的情况甚至每个扇区颜色都不对。原因文字绘制函数里的 save/restore 没配对或者扇区绘制和文字绘制之间共用了一个 ctx 状态。具体来说绘制文字时 rotate 改变了坐标系没有在 restore 里恢复下一个扇区继续以错误的角度偏移绘制。解决把绘制文字封装成独立函数在函数开头 ctx.save在结尾 ctx.restore。扇区绘制的 fillStyle 和 strokeStyle 也尽量放在各自函数内部不要依赖外部传入的全局状态。如果发现相邻扇区颜色串色优先检查是否有某个扇区绘制后忘记重设 fillStyle。5.5 现象五type2d 在小程序低版本上拿不到节点在部分安卓机上初始化 Canvas 时SelectorQuery 的 fields({ node: true }) 返回的 node 是 undefined转盘直接白屏。原因type2d 的基础库要求是 2.9.0低版本基础库不支持 node 接口或者返回的节点结构不同。用户微信版本偏旧时这类问题偶发。解决初始化时通过 res[0].node 判断是否存在不存在则降级使用 uni.createCanvasContext 创建旧版上下文。这里注意旧版上下文需要调用 draw() 才能上屏动画函数里需要把每次重绘后的 draw() 调用补上。如果业务要求兼容很老的版本只能整体退回到旧版 canvas 接口新接口拿不到节点时没有太好的补救办法。6. 进阶技巧帧循环调度、离屏缓存与命中验证这一章把性能优化和可验证性做一下收尾。先说你一定会遇到的帧循环调度问题。小程序端没有标准的 requestAnimationFrame直接用 setTimeout 模拟 16ms 一帧会造成一个副作用页面切到后台再切回来时setTimeout 的时间线被打断动画会出现短暂跳变。解决办法是不要依赖帧数累加而是每次 tick 都使用绝对时间戳计算 progress。我在第 3 章的 startSpin 里已经用了 Date.now() 做差值这就是正确的做法。如果你想封装成可以复用的调度器可以写这样一个小函数。// 一个轻量的 requestAnimationFrame 兼容封装便于在多个 Canvas 动画里复用 function raf(callback) { // 小程序里 setTimeout 返回的是 numberclearTimeout 接受这个 id return setTimeout(() { callback(Date.now()) }, 16) }这个封装不复杂核心价值是统一了 h5、App、小程序的帧循环入口。后续如果要引入第三方动画库也可以把 raf 作为 polyfill 传入降低改造量。再说离屏缓存。当转盘奖品有 8 个甚至更多时每帧全部重绘扇区和文字的开销并不小。可以先把不会变化的扇区底图绘制到一个离屏 canvas 上旋转时只做 rotate 和 drawImage。实现思路是创建一个新的 canvas 节点设置和主画布相同的宽高把扇区和文字画上去然后主循环里用 ctx.rotate 旋转并 drawImage 这张底图指针重新画在最上层。这样每帧只有一次 drawImage 加一次指针绘制开销降低得很明显。最后是命中验证。写一套可回放的命中测试逻辑能帮你省下大量联调时间。每一次抽奖结束后不要只回调结果把本次的 finalAngle、targetItem、targetOffset 打印到 console 里或者上报到一个调试接口。这样出现“指针没指向中奖奖品”的投诉时你只需要带着这次的角度日志按相同参数复现动画就能立刻判断问题是出在角度换算还是随机数生成。从那以后我每次做 Canvas 动画都强制自己走一遍流程先确认坐标基准再定绘制函数和状态隔离然后写帧循环调度最后加上命中日志。这个流程帮我避开了大多数转盘类组件的坑希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑