
转盘抽奖算是H5活动页里最经典的互动玩法了各种营销活动换个皮肤就能用。最近我接了一个偏运营向的项目要求“每期奖品不同、样式跟着设计师走、中奖结果由后端决定”简单翻了翻网上现成的Html5转盘插件要么样式写死不好改要么动画逻辑经不起推敲索性用Canvas从零写了一个。这篇就把实现过程、几个关键决策点以及上线前踩过的坑一并整理出来。如果你正打算自己写一个转盘组件或者想把手头的旧代码重构掉可以参考一下我的做法。1. 转盘这种需求我为什么绕开CSS和现成插件先聊选型。转盘看起来简单但“简单”只是对最终用户而言放在代码层面要同时满足视觉还原、动态数据、动画可控三件事方案就不是那么好选了。1.1 三种实现方式的取舍大多数新手第一反应是用切好的底图配合CSS的transform: rotate旋转。这个方案在一次性落地页里确实最快设计师出张图前端把指针和按钮叠上去transition给个时间就能转。但问题也明显每期奖品变了要重新出图奖品数量不同旋转角度就变了图片在高清屏上还得额外准备2x图而且想在中途加个“奖品动态下发”的功能整个方案基本要推翻。SVG方案比图片灵活一些路径本身就是元素可以动态增删扇区。但转盘上的文字是放射状分布的逐个操作文字节点的旋转、对齐、换行代码写起来非常啰嗦动画过程中还要频繁更新DOM属性掉帧风险高。我最后选了Canvas理由可以总结成四点绘制逻辑全在JavaScript里奖品数组怎么传画布就怎么画动态数据几乎零成本每帧重绘即可完成动画不需要操作DOM节点性能更容易把控高清屏适配很成熟按设备像素比放大画布即可不会发虚视觉自由度最高扇形颜色、文字样式、装饰元素都自己画设计师怎么改都不怕。表格对比更直观对比项图片CSSSVGCanvas静态页面开发速度快中慢动态改奖品需要重出图操作DOM节点重新绘制即可高清屏表现需要多倍图矢量清晰按dpr放大动画控制能力transform实现依赖CSS/JS混合requestAnimationFrame完全可控长期维护成本高中低1.2 什么情况下不用自己写必须说句公道话如果只是个一次性活动页下周一上线奖品也不会中途改用现成插件完全没问题没必要从零造轮子。我当时选择手写主要是因为项目后面至少还有三到四期运营活动每期都会换奖品和配色而且后端要控制中奖结果我需要一个能“按指定奖品ID转到对应位置”的组件这个需求很多现成插件并不支持或者支持得很别扭。另外提醒一句如果用了第三方插件务必确认它的旋转逻辑是基于CSS transform还是Canvas。CSS transform方案在连续多次旋转时角度值会越积越大虽然功能上不受影响但时间长了浮点数的精度问题可能会冒出来。2. 先画一个能用的转盘扇形、文案与中心按钮确定Canvas方案之后第一步就是把静态转盘画出来。这个过程不复杂但细节不少尤其是文案的方向和高清屏的处理我一个个说。2.1 建立绘制框架画转盘前先明确概念Canvas的arc方法默认从三点钟方向开始算角度顺时针递增。但我们视觉上转盘都是从十二点方向开始转的所以我习惯给所有扇区的起始角度都减去Math.PI / 2把零点偏移到顶部。const startBase -Math.PI / 2; const unit (Math.PI * 2) / prizes.length; const start startBase index * unit; const end start unit;prizes是奖品配置数组每个元素至少包含id、label、color这些字段。实际项目中可能还会有icon、weight、textColor后面用到再展开。2.2 绘制扇形扇区绘制本身很简单先移动到圆心画弧线闭合路径再填充颜色。ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, start, end); ctx.closePath(); ctx.fillStyle item.color; ctx.fill(); ctx.strokeStyle #ffffff; ctx.lineWidth 2; ctx.stroke();这里有两个容易忽略的地方。第一arc的角度是浮点数多个扇区首尾相接时理论上不会出现缝隙但抗锯齿可能会导致扇区之间有一丝背景色透出来所以给每个扇区加一条白色描边反而能在视觉上让分界更干净。第二如果奖品数量很多扇区角度很小文字很容易重叠这个后面再细说。关于底色搭配我建议直接给每个扇区配好颜色而不是用随机色。随机色生成的色板经常出现两种饱和度过高的颜色相邻观感很差。我会让设计师一次性给一套“以品牌色为基调”的色板然后放进配置里前端不做任何颜色计算。2.3 放射状文字的绘制转盘上的文字不是水平排布的而是沿着半径方向放射状排列每个文字都要旋转到扇区中心线的方向。我一般这样写ctx.save(); ctx.translate(cx, cy); ctx.rotate(start unit / 2); ctx.textAlign right; ctx.textBaseline middle; ctx.fillStyle #ffffff; ctx.font bold 14px sans-serif; ctx.fillText(item.label, radius - 16, 0); ctx.restore();解释一下这里的关键点。translate把坐标系原点移到圆心然后rotate旋转当前坐标系相当于把原来水平向右的x轴旋转到扇区中间方向。此时我在fillText里设置textAlign right文字就会从右向左绘制写出来的效果是文字贴着外圈向圆心方向排列方向呈现放射状。还有一个小细节当扇区很窄或者文字较长时文字会溢出扇区。我加了一个简单的自适应字号逻辑let fontSize 14; ctx.font bold ${fontSize}px sans-serif; const maxWidth radius * 0.55; while (ctx.measureText(item.label).width maxWidth fontSize 10) { fontSize - 1; ctx.font bold ${fontSize}px sans-serif; }measureText是Canvas提供的测量文本宽度的API循环减小字号直到文字能装下。这个方法比固定用14px或者靠人工估算长度要可靠得多。2.4 中心按钮和指针不画进Canvas转盘中央的按钮我会用DOM元素实现而不是画在Canvas里。原因很实在按钮需要响应点击事件DOM天然支持如果在Canvas里画还得自己写命中检测纯粹是给自己找麻烦。指针同理用CSS画一个三角形绝对定位到Canvas上方居中即可。div classwheel-box canvas idwheel/canvas span classpointer/span button idspinBtnGO/button /div.wheel-box { position: relative; width: 320px; height: 320px; margin: 0 auto; } #wheel { width: 100%; height: 100%; } .pointer { position: absolute; top: 0; left: 50%; transform: translateX(-50%); border-left: 12px solid transparent; border-right: 12px solid transparent; border-top: 20px solid #f59f00; z-index: 2; } #spinBtn { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 64px; height: 64px; border-radius: 50%; border: none; background: #fff; color: #f59f00; font-weight: 700; cursor: pointer; z-index: 3; }用DOM做指针还有一个好处动画过程中Canvas每帧都要重绘如果指针也在Canvas里等于每帧要重复绘制一堆静态图形没必要。DOM元素由浏览器独立渲染不参与Canvas重绘性能更好。3. 让转盘转起来缓动动画、圈数与落点计算静态转盘画完之后核心问题就是旋转动画。这一节我会把动画逻辑拆成几个部分每一步都说明为什么这么做。3.1 动画引擎选择requestAnimationFrame动画本质上是一连串的重绘最可靠的驱动方式是requestAnimationFrame。它有两个关键优点浏览器会在下一帧绘制之前调用回调动画帧率与屏幕刷新率对齐页面切到后台时自动暂停不会白白消耗CPU。很多教程喜欢用setInterval控制旋转角度我不推荐。setInterval无法保证执行频率稳定移动端浏览器为了省电经常降低定时器精度会导致转盘一顿一顿的观感非常差。3.2 用时间比例计算进度而不是每帧加固定角度新手写旋转动画经常这样每帧给当前角度加一个固定值比如currentAngle 0.02。这种方式的问题在于不同设备的帧率不同60Hz的设备一秒钟转60次30Hz的设备只转30次最终旋转的总圈数和时长完全不一样。正确做法是记录动画开始时间每帧根据当前时间与开始时间的比例计算进度let currentAngle 0; let spinning false; let rafId 0; function spinTo(targetAngle, duration 4000) { const startAngle currentAngle; const delta targetAngle - startAngle; const startTime performance.now(); spinning true; function frame(now) { const t Math.min((now - startTime) / duration, 1); const ease easeOutQuart(t); currentAngle startAngle delta * ease; drawWheel(prizes, currentAngle); if (t 1) { rafId requestAnimationFrame(frame); } else { spinning false; onSpinFinished(); } } rafId requestAnimationFrame(frame); }performance.now()返回的是高精度时间戳不受帧率波动影响。这样无论设备帧率是30还是60动画总时长都锁定在4秒视觉节奏稳定。3.3 缓动函数决定“手感”转盘的减速过程必须符合物理直觉先快速旋转然后慢慢停下来。这个过程用缓动函数实现。我推荐easeOutQuartfunction easeOutQuart(t) { return 1 - Math.pow(1 - t, 4); }这个函数的特性是开始阶段变化幅度大速度递减非常快最后阶段几乎静止视觉上非常接近真实转盘的阻尼效果。如果产品经理希望转起来更“飘”一点可以用easeOutQuint指数越高最后阶段越慢。我实测下来easeOutQuart最不容易被反馈“转得太假”。这部分有个常见误区有人会用setTimeout在动画结束后直接弹出中奖结果。但实际上动画是异步执行的弹窗逻辑应该放在onSpinFinished回调里而不是点击事件之后立刻做。否则用户会在转盘还没停下来的时候就看到弹窗非常出戏。3.4 目标角度计算让指定扇区落在指针处这是整个转盘组件里最容易算错的地方。我们需要实现的能力是给定一个奖品索引让这个奖品的扇区中心在动画结束时恰好指向顶部的指针。前面我设定过第index个扇区的初始角度是- Math.PI / 2 index * unit unit / 2要让这个中心角转到-Math.PI / 2扇区需要再旋转的角度是-(index * unit unit / 2)但这个值可能是负数我们不能让转盘逆着转。所以要先对角度做模运算把它归一化到0 ~ 2π之间然后再加上若干整圈保证动画是从当前位置正向转过去的。function getTargetAngle(prizeIndex, extraLoops 5) { const unit (Math.PI * 2) / prizes.length; const current currentAngle; let diff -(prizeIndex * unit unit / 2) - current; diff diff % (Math.PI * 2); if (diff 0) diff Math.PI * 2; return current diff extraLoops * Math.PI * 2; }extraLoops是额外转的整圈数一般在4到6之间。太少会显得很敷衍太多用户等得不耐烦我一般默认5圈。再说一个实际经验动画结束后currentAngle会变得非常大因为它累加了多圈角度浮点数误差会累积。虽然短时间内不影响使用但运行很长时间后可能影响计算精度。我通常在动画结束回调里把currentAngle做一次模运算currentAngle currentAngle % (Math.PI * 2);这样既能保持计算精度又不会影响后续角度的正确性。4. 概率控制前端随机、加权随机与后端定夺转盘动画只是个壳真正决定用户“中什么奖”的逻辑才是业务核心。这一节讲概率控制的三种层次从简单到复杂。4.1 错误的做法先随机索引再转很多初学者会这么写点击抽奖时用Math.random()随机一个奖品索引然后调用getTargetAngle让转盘转到那个扇区。问题在于这种方案没有任何概率控制能力。打个比方你画了10个扇区但运营希望“谢谢参与”的实际概率是80%“大奖”的实际概率只有0.1%。如果扇区画得一样大纯随机就无法满足需求。更严重的是前端代码是公开可读的结合控制台可以随意调用getTargetAngle理论上可以做到“指哪打哪”。只要涉及真实利益就绝对不能把中奖结果的决定权放在前端JS里。4.2 加权随机在纯前端里做到“看起来可控”如果项目确实只是想做个玩法演示奖品背后没有任何发奖逻辑那可以用加权随机。每个奖品配置一个weight字段数值越大中奖概率越高。function weightedPickIndex(prizes) { const total prizes.reduce((sum, p) sum p.weight, 0); let rand Math.random() * total; for (let i 0; i prizes.length; i) { rand - prizes[i].weight; if (rand 0) return i; } return prizes.length - 1; }原理是把所有权重加总成一个线段然后在线上随机取一个点落在哪段就中哪个。这个算法简单、可读性高适合演示场景。4.3 后端定结果更严谨的抽奖流程在正式运营项目里正确的流程是前端点击抽奖按钮之后发起请求到后端后端根据真随机算法或业务规则计算出中奖的奖品ID返回给前端前端再根据这个ID计算目标角度让转盘转到对应的位置。async function onSpinClick() { if (spinning) return; const prize await fetchPrizeResult(); const index prizes.findIndex((p) p.id prize.id); if (index -1) { console.error(后端返回了未知奖品ID); return; } const target getTargetAngle(index, 5); spinTo(target, 4000, () { showResult(prize); }); }这种情况下前端彻底变成了“展示层”算法和概率都在服务端用户无法篡改。这是任何真实发奖场景的底线。4.4 接口延迟期间不能干等后端接口通常不会瞬间返回如果点击按钮后界面保持静止等接口体验会很差。常用的优化方案是“先转起来等接口返回后再对准”。简单版本是这样点击按钮后立刻调用spinTo到一个随机角度接口返回后重新计算目标角度并“续接”动画。实现起来有个难点中途重新设置目标角度要保证动画速度看起来依然是平滑减速的。我自己用的是一种更务实的做法把等待接口的过程放在动画启动之前但接口期间给按钮一个loading态配上“正在抽奖”的提示。因为绝大多数内网接口响应时间在200ms以内用户基本感知不到延迟。如果接口偶尔慢比如超过1秒再考虑两段式动画。这个取舍要看你项目的实际情况别一上来就搞复杂的续接逻辑。4.5 扇区大小与实际概率无关但别太离谱运营经常会要求“把大奖的扇区画大一点让用户看着很诱人”。这背后有一个常见误解用户默认扇区面积等于中奖概率。前端确实可以把扇区画得很大实际概率由后端权重控制这样既满足视觉吸引力又保证运营指标。但扇区面积和概率差距如果太悬殊用户抽几次就会觉得“有黑幕”影响口碑。这个度要结合业务背景把控一般大奖扇区占比不超过整个转盘的三分之一比较安全。5. 上线前容易被喷的四个细节转盘能转起来只是完成了30%剩下70%都在各种边界情况的处理上。这块内容是我踩坑踩出来的逐条列出来。5.1 防连点抽奖进行中锁死按钮用户手滑经常连续点好几次抽奖按钮如果每次都发起请求等于用户一次活动抽了好几次奖运营成本不可控。最简单的方案是加一个spinning标志位动画过程中直接拦截事件。function onSpinClick() { if (spinning) return; // 抽奖逻辑 }更稳妥的做法是把按钮的disabled属性也设置上避免键盘或读屏器触发重复请求。注意这个锁必须在发起请求之前就生效而不是等接口返回后再锁否则高并发场景下仍然可能重复提交。5.2 高清屏适配canvas.width重设会重置状态这是Canvas开发里最经典的坑。如果直接把canvas.clientWidth赋给canvas.width在Retina屏上绘制出来的图形会发虚。正确做法是让canvas.width等于CSS像素宽度乘以devicePixelRatio再用ctx.scale(dpr, dpr)让绘图坐标系保持CSS像素。function setupCanvas(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); const width Math.floor(rect.width * dpr); const height Math.floor(rect.height * dpr); if (canvas.width ! width || canvas.height ! height) { canvas.width width; canvas.height height; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); } return canvas.getContext(2d); }注意一个隐藏问题给canvas.width赋值会清空画布同时重置所有canvas上下文状态包括scale、fillStyle、strokeStyle等。所以setupCanvas不能放在每次绘制之前调用否则每帧都会清空缩放状态。正确的做法是只在尺寸变化时重新设置绘制过程中直接使用已经创建好的上下文。另外getBoundingClientRect()返回的尺寸可能带小数和dpr相乘后可能出现非整数最好用Math.floor向下取整避免canvas内部内存分配出现非整数大小的报错。5.3 异步图片加载onload之后再重绘如果奖品带icon图片绘制顺序很重要。图片是异步加载的如果用drawImage绘制尚未加载完成的图片Canvas里就是一片空白。我的做法是加载完所有图片后再触发首次绘制function loadAllImages(prizes) { const tasks prizes.map((p) { return new Promise((resolve) { const img new Image(); img.onload () resolve({ id: p.id, img }); img.onerror () resolve({ id: p.id, img: null }); img.src p.icon; }); }); return Promise.all(tasks); }使用Promise.all等所有图片加载完成然后统一开始drawWheel。如果某张图片短时间加载不出来onerror要返回一个默认占位图或者null否则整个转盘会因为一张图挂掉。5.4 页面生命周期页面隐藏时暂停动画组件销毁时清理任务移动端用户随时可能切走页面比如接电话、切微信。如果页面在后台时requestAnimationFrame被浏览器暂停切回来后动画帧率会出现跳变观感很奇怪。针对这个问题我习惯在封装时保留rafId并在组件销毁时统一取消function destroy() { if (rafId) { cancelAnimationFrame(rafId); rafId 0; } }如果是React/Vue项目在useEffect或onUnmounted里调用destroy即可。如果是纯原生页面则需要在页面卸载事件里处理。页面隐藏导致的另一个问题是用户切回来发现动画已经结束但中奖弹窗没有弹出来。这个其实是requestAnimationFrame暂停后动画结束回调没有执行的连锁反应。比较省心的处理是监听visibilitychange事件在页面重新可见时检查一下动画是否结束。document.addEventListener(visibilitychange, () { if (!document.hidden spinning) { // 说明之前还在转现在重新回到页面视情况直接完成回调 } });不过说实话这个场景比较边缘我实际项目中并不会特意处理因为用户切走页面时动画大概率已经因为performance.now()的逻辑结束了只是回调还没触发。如果这个场景对你很重要可以在visibilitychange触发时主动完成回调保证弹窗不丢失。6. 从开发到上线的实操顺序和调试技巧最后分享一些开发流程上的心得这部分是代码之外的但能直接影响效率。我第一次写转盘组件时按“先静态、再动画、最后接接口”的顺序推进整个过程很顺。静态阶段先把扇形、文字、指针、按钮这些视觉元素全部定稿不发散动画阶段只关心旋转逻辑用写死的中奖索引去验证落点是否准确等这两步都稳定了再对接后端接口把动态数据接进来。这样做的好处是每个阶段出问题时问题边界都很清晰不会出现“到底是图片没加载完还是角度算错”这种互相纠缠的排查局面。调试落点是否准确时有一个很实用的技巧在URL后面加一个调试参数比如?target2页面初始化时直接读取这个参数把目标奖品索引强制设为2然后自动触发一次动画。这样不用每次点按钮、等接口、拼概率才能看到某个特定扇区的落点效果。const debugTarget new URLSearchParams(location.search).get(target); if (debugTarget ! null) { const index Number(debugTarget); if (!Number.isNaN(index) index 0 index prizes.length) { setTimeout(() { const target getTargetAngle(index, 5); spinTo(target, 4000); }, 500); } }这个调试开关上线前不要删留在代码里也没有任何副作用但以后换文案、换奖品数量时能帮你快速回归验证。还有一个容易被人忽视的细节中奖结果弹窗。转盘动画结束后的反馈一定要及时最好配合一点音效或者文字弹层。如果动画停了1秒之后弹窗才出现用户会觉得很卡。我把弹窗逻辑放在onSpinFinished回调里紧跟动画最后一帧实测在低端安卓机上也不会有明显的延迟感。最后说一个踩过几次的坑不要在requestAnimationFrame回调里同步弹出浏览器原生alert弹窗。原生弹窗会阻塞主线程动画循环会直接卡死明明转盘已经到位了但页面没有任何反应。所有弹窗都要用自定义DOM弹层且放在动画结束回调里执行不要放在动画循环中。这个细节在我早期版本里踩得很惨后来养成了习惯整个组件内部禁止出现任何同步阻塞方法。