抽奖转盘这需求听起来真是再简单不过——“做个转盘能转就行”。但凡是接过这种话术的人都知道越说是“简易版”越不能做得太垮盘子要好看、转速要舒服、停的位置要准、连点不能刷屏最后还得一本正经地告诉用户“恭喜你抽中了”。我做完这个项目的最大感受是它把前端的基本功串了个遍Canvas 绘制、弧度换算、CSS 动画、事件防抖、加权随机一个都不少但每一步的难度又刚好控制在“一个晚上能写完”的范围。这篇文章我把完整思路、可直接复制的代码和踩过的坑都拿出来讲适合刚学完前端基础想找个练手项目的人也适合活动页里需要快速集成一个抽奖组件、又不想引入重型插件的同学。1. 先想清楚转盘转的到底是什么1.1 简易版的核心需求拆解动手写代码之前先把“抽奖转盘”拆成三层你会发现它其实就三件事画盘子、转盘子、告诉用户中没中。画盘子是把奖品名称按扇形分布在圆盘上扇区颜色要区分、文字要清晰。转盘子是用户点击抽奖后圆盘旋转若干圈并减速停下。告诉用户中没中则是在动画结束后弹出结果。这个顺序非常重要很多人第一次做会写反先转动画等动画停再随机选结果。听起来没差别但动画停在那一刻才去算结果低速设备上很容易出现指针已经到位、弹窗却卡了半拍的情况观感很差。正确的顺序是点击瞬间先随机选中奖项再根据中奖项计算目标角度最后触发动画。动画只是把已经确定的结局“演”出来。这个思路在真实业务里同样成立——服务端返回中奖结果前端负责表演两端各司其职。1.2 技术方案怎么选Canvas、CSS渐变还是SVG画转盘的方法我认真对比过三种各有各的适用场景。Canvas 方案最灵活扇区数量、颜色、文案全部由数据驱动。奖品列表一变重绘一遍就行不需要动 DOM。缺点是代码稍微多一点涉及弧度和坐标换算。CSSconic-gradient()方案最省事。四个扇区的色块用一段渐变就能画完甚至不用写 JS 绘制逻辑。但文字没法直接画进渐变里得额外用绝对定位元素去摆扇区一多还要动态生成样式反而绕。SVG 的path画扇区也很标准对熟悉 SVG 的人很友好但路径命令对零基础同学的学习成本比arc()高不少。简易版我最终选了 Canvas。理由很朴素后面你想加第八个扇区、加盘面花纹、加外圈装饰改配置数据重绘即可不用推翻架构。这算是对“简易版”最划算的投资——一开始多花十分钟后面省一小时。2. 动手画转盘每一度都要算明白2.1 角度与扇区从弧度说起Canvas 的arc()方法用的是弧度制一圈是2π。默认 0 弧度在三点钟方向也就是画布右边。但转盘的指针通常放在正上方也就是十二点钟方向所以第一根扇区边界必须从-Math.PI / 2开始画这样第一个扇区才从顶部起笔。假设奖品有 n 个每个扇区跨越的弧度就是2π / n第 i 个扇区的起始角度是start -Math.PI / 2 i * (2 * Math.PI / n)这个公式是整个绘制的基础也是最容易翻车的地方。我见过不少新手忘记减Math.PI / 2结果转盘从右侧开始画后续指针怎么对都对不上扇区中心调试时非常怀疑人生。扇区画法本身很机械moveTo到圆心arc画出扇形外弧closePath闭合路径然后fill填充颜色、stroke描边。重复 n 次就是完整转盘。这里有个细节arc()的角度是顺时针方向增长的因为 Canvas 的 y 轴向下跟我们平时数学课上的坐标系方向相反。只要记住“从顶部起步、顺时针画圈”这一点整个绘制逻辑就顺了。2.2 文字方向与居中最容易被忽略的细节把文字画进扇区常规做法是translate到圆心再rotate到扇区中线的角度然后沿着 x 轴往外写文字。配合textAlign right文字右边缘落在radius - 22的位置字体会自然朝向盘面外沿视觉上就像“站”在扇区里一样。ctx.save(); ctx.translate(center, center); ctx.rotate(start step / 2); ctx.textAlign right; ctx.fillStyle #ffffff; ctx.font bold 15px Microsoft YaHei, Arial, sans-serif; ctx.fillText(prize.name, radius - 22, 5); ctx.restore();这里的rotate(start step / 2)是关键让文字坐标系旋转到扇区中心线上再沿 x 轴往外排版。save和restore必须成对出现否则旋转状态会叠加到下一个扇区文字全乱。这个方法有个经典缺陷下半部分的扇区文字会倒过来因为画笔旋转了 135 度或 225 度之后文字的“上方”已经指向盘心了。如果在意这个细节可以在 rotate 之后判断中线角度是否落在Math.PI / 2到Math.PI * 3 / 2之间也就是画布下半部分如果是就把文字再旋转Math.PI并改成textAlign left文字就能正过来。简易版里如果奖品都集中在几条固定文案很多项目会直接忽略这个瑕疵。2.3 指针和中心装饰提升手感的低成本操作转盘本体是圆形画布但指针和中心按钮绝对不能跟着圆盘一起转否则指针指到哪里全凭运气。正确做法是把它们放在 Canvas 的外层容器里Canvas 负责转指针和按钮保持不动。指针我直接用 CSS 边框三角实现30px 的border-top加两侧透明边框三角形尖朝下视觉上正好压在盘面上方。比贴一张 PNG 图片省事颜色还能直接用border-top-color控制。中心那个“抽奖”按钮既是装饰也是交互入口做成绝对定位、z-index高于 Canvas 即可。这里有个经验按钮不要画进 Canvas 里否则点击区域判定非常麻烦你用 DOM 按钮天然就支持点击事件、禁用状态、悬浮样式省下大量脑细胞。3. 转动与抽奖逻辑从“转得爽”到“中得准”3.1 目标角度计算公式推导这是整个项目最核心的数学值得单独说清楚。我们的目标是让中奖扇区的中心最终停在指针——也就是正上方。扇区 i 的中心相对正上方的顺时针角度是i * degStep degStep / 2其中degStep 360 / prizes.length。要让这个中心点回到正上方圆盘需要再旋转一个相反的角度取模 360 得到正的目标角度desiredMod (360 - (i * degStep degStep / 2) % 360) % 360如果只转一个desiredMod的小角度动画会非常生硬所以必须加上若干整圈。同时不能每次从 0 度开始转否则第二次抽奖时转盘会“倒着回退”一下非常违和。这就要维护一个累积的currentRotation每次在旧值基础上增加function getTargetRotation(index) { const degStep 360 / prizes.length; const currentMod currentRotation % 360; const desiredMod (360 - (index * degStep degStep / 2)) % 360; const extraTurns 5 * 360; const delta extraTurns ((desiredMod - currentMod) % 360 360) % 360; return currentRotation delta; }这里((desiredMod - currentMod) % 360 360) % 360可能看着绕其实就是为了取一个 0 到 360 之间的正值避免出现负增量。这个方法不仅适用转盘任何“旋转后停在指定位置”的动画都可以套用比如仪表盘指针、轮播指示器。3.2 动画曲线与转几圈更自然圆盘旋转我用 CSStransition加transform: rotate()实现。两个关键点决定了动画质感。第一必须用transform去转不能改left或top。transform只触发合成器不触发布局和重绘性能开销最小改left会导致浏览器每帧重新布局转盘稍大一点就掉帧明显。第二缓动曲线要选先快后慢的。默认的ease也行但太“平均”了没有转盘那种冲刺后慢慢减速的悬念感。我用的曲线是cubic-bezier(0.17, 0.67, 0.15, 0.95)前段快速加速后段长尾减速停下来之前那半秒特别有“要停但又不知道停哪”的效果。转几圈也是试出来的。3 圈太短像敷衍6 圈又显得磨蹭。5 圈、4 秒是我调下来多数场景都比较舒服的组合。想让效果更不机械可以把圈数在上限之间随机比如每次都转 5 到 8 圈用户体感上不会觉得“又是同样的转法”。3.3 概率控制真随机与加权随机抽奖结果我用Math.random()加权随机实现。思路是奖品列表里每一项配一个权重权重越大中奖概率越高。先求总权重再随机一个 0 到总和的数依次减去各项权重减到小于等于零的那一项就是中奖项。function getRandomIndex() { 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; }这个算法有个好处奖品数量变了、权重改了代码一行不用动。加权随机是“均匀随机”的自然延伸——Math.random()在大量抽样下是均匀的给每个结果分配一段长度正比于权重的区间命中概率就自然正比于权重。但有件事必须说清楚纯前端抽奖没有保底机制连续抽到“谢谢参与”完全可能。这在 demo 里没问题正式业务一定要服务端生成结果、前端只播动画。不然用户打开控制台改一下权重数组你的一等奖一天能发出去三千个。4. 完整代码实现复制粘贴就能跑的简易版4.1 HTML骨架与CSS样式先看整体结构一个外层容器包住画布、指针、中心按钮。这里容器是相对定位的舞台Canvas 的包裹层负责旋转指针和按钮固定在容器上。div classwheel-wrapper div classpointer/div div classwheel canvas idwheelCanvas/canvas /div div classcenter-dot/div button classspin-btn idspinBtn抽奖/button /divCSS 部分最需要注意的是层级关系和定位基准。.wheel-wrapper { position: relative; width: 360px; height: 360px; margin: 40px auto 0; } .wheel { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; will-change: transform; } #wheelCanvas { display: block; width: 100%; height: auto; border-radius: 50%; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); } .pointer { position: absolute; top: -12px; left: 50%; transform: translateX(-50%); z-index: 10; width: 0; height: 0; border-left: 18px solid transparent; border-right: 18px solid transparent; border-top: 32px solid #ffd700; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.3)); } .center-dot { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 56px; height: 56px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #ffffff, #e0e0e0); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); z-index: 5; } .spin-btn { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 40px; height: 40px; border: none; border-radius: 50%; background: #ffd700; color: #333333; font-weight: bold; font-size: 12px; cursor: pointer; z-index: 6; } .spin-btn:disabled { opacity: 0.6; cursor: not-allowed; }注意.center-dot和.spin-btn都定位在容器中心但按钮的z-index必须比中心装饰高否则按钮被盖住就点不到了。这个顺序我第一次就搞反了调试了半天才发现按钮根本没在响应点击。4.2 Canvas绘制核心代码奖品配置和数据驱动绘制是 Canvas 方案最舒服的地方。想换奖品改数组就行绘制代码一行不动。const prizes [ { name: 一等奖, color: #f56c6c, weight: 1 }, { name: 二等奖, color: #e6a23c, weight: 3 }, { name: 三等奖, color: #67c23a, weight: 6 }, { name: 谢谢参与, color: #409eff, weight: 10 }, ]; const canvas document.getElementById(wheelCanvas); const ctx canvas.getContext(2d); const wheel document.getElementById(wheel); const btn document.getElementById(spinBtn); const size 420; canvas.width size; canvas.height size; const center size / 2; const radius size / 2 - 6; const step (Math.PI * 2) / prizes.length; function drawWheel() { ctx.clearRect(0, 0, size, size); prizes.forEach((prize, i) { const start -Math.PI / 2 i * step; const end start step; ctx.beginPath(); ctx.moveTo(center, center); ctx.arc(center, center, radius, start, end); ctx.closePath(); ctx.fillStyle prize.color; ctx.fill(); ctx.strokeStyle #ffffff; ctx.lineWidth 2; ctx.stroke(); ctx.save(); ctx.translate(center, center); ctx.rotate(start step / 2); ctx.textAlign right; ctx.fillStyle #ffffff; ctx.font bold 15px Microsoft YaHei, Arial, sans-serif; ctx.fillText(prize.name, radius - 22, 5); ctx.restore(); }); } drawWheel();这里clearRect每次重绘前清空画布防止扇区叠影。radius减 6 是为了留出一点点边缘避免圆盘顶到画布边界显得发闷。如果以后要加外圈装饰把radius再缩一圈、多画一个环形就行了。4.3 转动与事件绑定转动逻辑的核心是状态管理记录当前累积角度、标记是否正在旋转、动画结束后再放开。let currentRotation 0; let spinning false; function getTargetRotation(index) { const degStep 360 / prizes.length; const currentMod currentRotation % 360; const desiredMod (360 - (index * degStep degStep / 2)) % 360; const extraTurns 5 * 360; const delta extraTurns ((desiredMod - currentMod) % 360 360) % 360; return currentRotation delta; } btn.addEventListener(click, () { if (spinning) return; spinning true; btn.disabled true; const index getRandomIndex(); const target getTargetRotation(index); currentRotation target; wheel.style.transition transform 4s cubic-bezier(0.17, 0.67, 0.15, 0.95); wheel.style.transform rotate(${target}deg); wheel.addEventListener(transitionend, function handler() { wheel.removeEventListener(transitionend, handler); alert(恭喜抽中${prizes[index].name}); spinning false; btn.disabled false; }); });这里用transitionend事件确定动画结束比setTimeout硬等更可靠。但要注意如果页面切到后台浏览器可能暂停动画导致transitionend延迟甚至不触发。稳妥做法是在事件回调里同时加一个兜底的setTimeout比如 5 秒后如果spinning还是 true 就强制复位。简易版可以不写但正式页面最好加上我有一次就是在快速切换浏览器标签页时用户点了抽奖就一直转不停。4.4 扩展用CSS conic-gradient替代Canvas如果你只需要四五个固定扇区、不想碰 Canvasconic-gradient()是条捷径。圆盘本身用纯 CSS 画渐变百分比对应扇区占比.wheel { border-radius: 50%; background: conic-gradient( #f56c6c 0% 25%, #e6a23c 25% 50%, #67c23a 50% 75%, #409eff 75% 100% ); }文字需要额外处理在每个扇形中心位置放一个绝对定位的span用transform: rotate(角度) translateY(-半径)摆到对应位置。对于四个扇区中心角分别是 45 度、135 度、225 度、315 度直接写死也很快。但这种方案一旦奖品数变成 6 个、8 个样式就要跟着改灵活性远不如 Canvas。我的建议是一次性活动、奖品固定用 conic-gradient 很爽奖品可配置、可能调整老老实实用 Canvas。5. 实战踩坑实录这些毛病我全遇到过5.1 转盘“滑丝”了动画没停稳现象有两种转盘转完还在轻轻晃或者卡在半路不动。前一种多半是缓动曲线末尾速度不为零cubic-bezier的末端需要接近水平我用的(0.17, 0.67, 0.15, 0.95)就是调出来的比较稳的组合。后一种十有八九是transitionend没触发原因要么是transition属性被覆盖要么是页面切后台导致动画中断。排查时先看控制台有没有报错再看wheel元素的transition是否被其他样式覆盖。我踩过一次项目全局样式里写了* { transition: all 0.3s }结果我设置 4 秒转场时被覆盖成 0.3 秒转盘瞬间就位看起来像“指针抽搐”。解决方案是给转盘单独指定transition-property并且不要在全局无差别写transition: all。5.2 指针指歪了角度基准线没对齐这是最常见的定位问题。我一开始画扇区时忘了从-Math.PI / 2起步结果第一个扇区的边界在三点钟方向算出来的desiredMod全部偏了 90 度。另一个容易漏的点是扇区中心的偏移——index * degStep算的是边界中心还要再加degStep / 2漏掉这个半扇区指针总会停在两个扇区的交界线上特别尴尬。调试技巧非常实用把transition-duration临时改成 12 秒转得足够慢就能眼睁睁看着指针最后落在哪。再配合控制台打印target % 360对比期望值一两个来回就能确认公式对不对。5.3 连点导致抽奖刷屏用户手速快或者移动端双击很容易在动画还没结束时再次触发按钮。我的解决方式是双保险spinning标志位拦截逻辑btn.disabled禁用按钮。两个缺一不可——标志位防止代码层面重复进入抽奖流程禁用按钮是为了让用户看到明确反馈“现在不能点”。动画结束后在transitionend回调里同时恢复两者。5.4 概率感觉不对劲概率这东西算法本身通常没错错的是用户感知。我一开始把一等奖权重设成 1、谢谢参与设成 10逻辑上没问题但测试连抽十次几乎全是“谢谢参与”自己都开始怀疑算法。后来调成 1 : 3 : 6 : 10体验就合理多了——能中三等奖、偶尔爆个二等奖一等奖依然稀有但不再是“永远不可能”。另外要注意前端加权随机没有记忆性前一轮抽了大奖下一轮的概率完全不变。如果业务上需要“连着抽 20 次至少中一次奖”必须上服务端做保底策略前端改不出来。5.5 高DPI屏幕下Canvas文字发虚最后说一个容易被忽略但很影响观感的细节。在高分屏比如 MacBook 的 Retina 屏上Canvas 如果只按 CSS 尺寸设置宽高绘制出来的文字和线条会发虚。解决方法是按设备像素比缩放画布分辨率const dpr window.devicePixelRatio || 1; canvas.width size * dpr; canvas.height size * dpr; ctx.scale(dpr, dpr);这样画布的逻辑坐标不变但实际像素数翻倍线条和文字都会锐利很多。这个技巧不止转盘用得上任何 Canvas 绘制都建议加上成本极低收益明显。我个人做完这个项目的体会是抽奖转盘虽然小却是少有的能把绘制、动画、数学、交互全部串起来的练习。最值钱的不是代码本身而是那套“先定结果、再算角度、后播动画”的思维模型换到仪表盘、轮播器、转盘菜单上全是同一套打法。最后再分享一个收尾小技巧正式上线前把动画时间临时调成 8 秒跑一遍全流程确认指针落位精准、弹窗时机正确再调回正常速度。这种“慢动作验收法”能帮你发现很多正常速度下肉眼根本捕捉不到的对齐问题。