
不废话直接进入正题。转盘抽奖这个需求做前端的应该都不陌生官网拉新、公众号涨粉、活动页面抽奖几乎每个月都会被业务方拎出来一遍。我最近一次用纯 JavaScript 从零实现转盘游戏时原以为就是个画扇形加转圈的活真正动手才发现里面全是角度计算、动画时序、概率控制这些细节踩了几个坑之后才彻底理顺。这篇文章就把整个实现过程完整拆开讲清楚从技术方案选型、Canvas 绘制扇区、转动动画与概率控制到最终的移动端适配和防连点处理全部基于原生 JavaScript、无任何第三方库。打算做活动页的开发者、或者想拿转盘游戏练手巩固前端基础的同学可以直接照抄思路和代码。1. 转盘游戏的功能拆解与技术选型为什么用Canvas而不是CSS拼图1.1 一个看似简单的转盘拆开其实是四件事动手之前先别急着写代码把需求拆清楚。一个标准转盘游戏无论界面多花哨核心逻辑就四块奖品数据配置有哪些奖品、每个奖品长什么样、中奖概率是多少转盘视觉呈现圆盘、扇形区域、奖品文字、中心指针转动动画点击按钮后转盘加速、匀速、减速最终停下结果回调停下来之后把抽中了什么交给上层业务弹窗、发奖、埋点都走这里。每件事单独拎出来都不难但组合在一起容易互相影响。比如你想让转盘转得更顺滑结果发现动画和奖品判断耦合在一起导致最后停的位置差一格你想改奖品数量结果发现绘制逻辑写死了 8 个格子改 6 个就要重写。这些问题的根源大多出在第一步的技术选型上。1.2 三种实现方案对比别再一上来就找插件把转盘做出来常见路子有三条。我先给个对比表格再讲我的选择理由。方案实现成本灵活性体积适用场景CSS 扇形拼接低但改格数要重构低零依赖奖品固定、数量固定做一次不再动Canvas 动态绘制中掌握扇形数学后很自由高零依赖奖品可配置、需要定制视觉推荐第三方抽奖插件最低低改样式经常要读插件源码大不在乎定制、快速上线我最后选了 Canvas 动态绘制核心原因是奖品数据驱动图形。做活动页时奖品格数和奖品名称大概率会被业务方改来改去今天 8 格、下个月 10 格用 Canvas 画的话只需要改数组数据绘制函数自动适配。而 CSS 拼接方案写死 8 块之后想改成 6 块要去调整这一堆扇形的 border 角度和位移相当痛苦。另外 Canvas 的自由度足够支撑后期美化。想给扇区加个渐变色、加个粒子扫过效果Canvas 都有成熟的绘制 API想在扇形边缘加描边、想在内圈加个装饰环也只是多画几笔的事。用插件反而容易被封装限制住改一个样式得翻源码成本更高。还有一点容易被忽略Canvas 只负责绘制静态画面旋转动画完全可以让浏览器来加速。我的做法是每一帧重绘整个转盘让当前旋转角度作为绘制时的整体偏移。这种做法逻辑上最顺后面讲动画时你会看到它和概率控制天然配合。2. 转盘绘制核心奖品扇区的数学计算与Canvas渲染细节2.1 第一步最容易栽的地方Canvas 角度原点在 3 点钟方向画转盘之前先得和 Canvas 的角度体系达成共识。ctx.arc()的起始角度是从 3 点钟方向开始算的角度增大方向是顺时针。但一个正常转盘指针通常放在正上方 12 点钟方向也就是 -90°弧度制 -Math.PI / 2。这个错位如果不处理画出来的第一个格子会歪在圆弧的右侧而不是顶部。解决办法是给所有扇区的起始角度统一加一个偏移量const offset -Math.PI / 2; // 把 0 度点从 3 点钟方向移到 12 点钟方向你可以把这个 offset 理解成校准。就像钟表里的指针刻度都是从 12 点开始Canvas 默认却从 3 点起算我们要手动对齐习惯。所有扇形绘制、文字定位、角度计算都要基于这个 offset 来做否则后面计算停在哪一格时会全部错位。绘制单个扇区的核心逻辑如下function drawSlice(ctx, centerX, centerY, radius, startAngle, endAngle, color) { ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle color; ctx.fill(); // 扇区之间的白色分隔线 ctx.strokeStyle #fff; ctx.lineWidth 2; ctx.stroke(); }绘制整个转盘时遍历奖品数组每个奖品占用的角度是2 * Math.PI / prizes.length起始角度从 offset 开始挨个累加const PRIZES [ { label: 一等奖, color: #E74C3C }, { label: 二等奖, color: #F39C12 }, { label: 三等奖, color: #2ECC71 }, { label: 四等奖, color: #3498DB }, { label: 五等奖, color: #9B59B6 }, { label: 六等奖, color: #1ABC9C }, { label: 谢谢参与, color: #BDC3C7 }, { label: 再接再厉, color: #E67E22 } ]; const sliceAngle (Math.PI * 2) / PRIZES.length; function drawWheel(ctx, centerX, centerY, radius, currentAngle) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(currentAngle); // 整体旋转动画时这一帧偏转了多少就画多少 ctx.translate(-centerX, -centerY); for (let i 0; i PRIZES.length; i) { const startAngle offset i * sliceAngle; const endAngle startAngle sliceAngle; drawSlice(ctx, centerX, centerY, radius, startAngle, endAngle, PRIZES[i].color); } ctx.restore(); }这段代码把图片渲染和旋转动画统一了每次动画帧更新currentAngle然后重绘整个转盘。每一帧都是静态绘制但连续起来就是平滑旋转逻辑非常直白。2.2 奖品文字方向与位置让文字在扇区里立正扇形画完只是第一步奖品名称怎么写、写在哪直接影响观感。我测试过两种方案这里直接给结论。第一种是文字沿半径方向排列也就是从圆心朝外辐射适合两个字以内、所有扇区文字少的情况。实现时需要把画笔平移到扇区中间位置再旋转到对应的半角角度function drawLabel(ctx, centerX, centerY, radius, index, text) { const middleAngle offset index * sliceAngle sliceAngle / 2; const textRadius radius * 0.65; ctx.save(); ctx.translate( centerX Math.cos(middleAngle) * textRadius, centerY Math.sin(middleAngle) * textRadius ); ctx.rotate(middleAngle Math.PI / 2); ctx.fillStyle #fff; ctx.font bold 16px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(text, 0, 0); ctx.restore(); }这里有个细节需要注意rotate之后文字方向跟随扇区方向但会出现有的字头朝圆心、有的字头朝外的情况。要不要统一方向取决于你的设计稿。我的处理是保持统一让可读性更好但需要额外的方向判断。直接在代码里加一个判定if (middleAngle Math.PI / 2 middleAngle Math.PI * 1.5) { ctx.rotate(Math.PI); // 翻转 180 度让文字正立 }第二种是水平居中写文字也就是不管扇区角度怎么偏文字始终保持水平。这种方式适合谢谢参与这类四个字的文案阅读起来最舒服。实现上反而更简单算好扇区中心点坐标直接 fillText 即可不需要 rotate。代价是文字数量多的时候扇区边缘可能会显得拥挤。我的建议是6 格以内用沿半径方向排列字体看着舒展8 格或更多时每格角度变小沿半径排列容易相邻扇区文字打架优先考虑水平居中或者缩短文案。做活动页时奖品名称经常后来才定为了避免返工最好在做之前问清楚最长文案有几个字。2.3 高清屏适配不处理这一步手机上转盘是糊的如果只在电脑浏览器上调试你可能根本不会发现这个问题。但活动页绝大多数流量在手机上而手机屏幕通常有 2 倍甚至 3 倍设备像素比。Canvas 默认的绘制尺寸和 CSS 尺寸如果不做处理画出来的转盘在 Retina 屏幕上就是模糊的一团。适配方式是在初始化时同步 canvas 的物理尺寸和 CSS 尺寸const size 320; // 逻辑尺寸 const dpr window.devicePixelRatio || 1; canvas.width size * dpr; canvas.height size * dpr; canvas.style.width size px; canvas.style.height size px; canvas.getContext(2d).scale(dpr, dpr);一句话解释原理canvas.width设置的是画布实际像素数canvas.style.width设置的是它在页面上占据的 CSS 尺寸。如果不放大前者的像素数Retina 屏会用 1 个物理像素去显示 1 个 CSS 像素对应的图像看起来自然是虚的。把实际像素数乘以 dpr再用ctx.scale(dpr, dpr)把坐标系拉回逻辑尺寸后续绘制代码不用改但屏幕上会清晰很多。3. 转动动画与概率控制先定结果再表演转动的完整思路3.1 业务上最大的误区转完之后才判断中了什么这是我见过最普遍、也是最容易埋雷的做法动画随机转 N 圈停下来之后根据当前旋转角度反推落在哪个扇区再查询该扇区对应什么奖品。这个方案看起来没毛病实际上有三个问题。第一角度反推很容易差一格。转盘连续旋转多次后旋转角度的正负、取模、累加都会引入偏差一旦有一两度的误差反推索引时就可能落到相邻扇区上。第二无法精确控制概率。业务方经常说一等奖概率要 5%、二等奖 10%均匀随机旋转根本没法落到这些数字上只能靠调圈数碰运气。第三动画和结果之间可能不一致。高速旋转时如果转盘还带点弹性效果或者用户快速连点容易出现指针明明停在格 A程序却告诉你中奖的是格 B的尴尬。正确的思路是把顺序反过来先通过概率抽签确定中奖索引再反推出为了让它停在指针处需要转多少角度最后播放动画。动画只是把已经确定的结果演出来而不是决定结果。3.2 扇区中心对准指针的角度换算假设指针固定在页面上的正上方12 点钟方向Canvas 绘制时我们已经统一了角度体系offset 是 -Math.PI / 2指针位置对应的就是 -Math.PI / 2 这个角度。某个奖品索引index的扇区中心角度是const centerAngle offset index * sliceAngle sliceAngle / 2;要让这个中心点旋转到指针位置需要旋转的角度是function getTargetAngle(index, rounds) { const centerAngle offset index * sliceAngle sliceAngle / 2; let delta (-Math.PI / 2 - centerAngle) % (Math.PI * 2); // 取正数角度保证转盘始终向同一方向转不倒转 delta (delta Math.PI * 2) % (Math.PI * 2); return delta rounds * Math.PI * 2; }rounds是额外加上的整圈数比如 3 圈或 5 圈。没有这个整圈数的话delta 很可能只有几十度动画还没开始加速就结束了视觉上很假。圈数越大动画越有转了很多圈的效果但也要控制时长别让用户等太久。这里再强调一次方向问题Canvas 的旋转正方向是顺时针和ctx.rotate接受正角度时一致。上面计算出的targetAngle是累积旋转值动画从当前角度一直加到 targetAngle 即可。3.3 用 requestAnimationFrame 实现加速—匀速—减速动画实现方式有两种CSS transition 和requestAnimationFrame。我最终选了后者。CSS transition 的问题在于它只描述从 A 点到 B 点中间过程虽然可以用贝塞尔曲线缓动但你要实现加速-匀速-减速这种分段效果就得写多段关键帧动画或者串联多个 transition控制起来很别扭。更麻烦的是转盘旋转一圈后角度需要取模CSS 对超过 360° 的 transform 值在部分浏览器上有精度问题连续转几十次之后终点的偏差会肉眼可见。requestAnimationFrame则完全可控。物理上一块转盘从受力到停止最接近的减速曲线是easeOutQuint一开始快速旋转末端急剧减速非常像真实的刹车过程。动画核心代码function animateTo(targetAngle, duration) { const startAngle currentAngle; const delta targetAngle - startAngle; const startTime performance.now(); function step(now) { const progress Math.min((now - startTime) / duration, 1); const eased easeOutQuint(progress); currentAngle startAngle delta * eased; drawWheel(ctx, centerX, centerY, radius, currentAngle); if (progress 1) { requestAnimationFrame(step); } else { finishSpin(); } } requestAnimationFrame(step); } function easeOutQuint(t) { return 1 - Math.pow(1 - t, 5); }缓动函数不同手感差别很大。我给你一组对比参考缓动函数表达式效果线性t匀速转没有真实感easeOutQuad1 - (1-t)^2减速快适合小转盘easeOutQuart1 - (1-t)^4常用刹车感适中easeOutQuint1 - (1-t)^5末端急停最像真实转盘转盘场景我固定用easeOutQuint。你可以在本地把这几个函数都跑一遍直观感受一下选定一个手感最好的。3.4 概率控制权重数组而不是均匀随机转盘抽奖在业务上几乎总是要控制概率的所以这里必须用权重方案。每个奖品配一个weight字段抽签时先求总权重再取一个[0, total)的随机数逐项累减。function pickIndex() { 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; // 兜底 }代码是直观的讲一下为什么这样写而不是Math.floor(Math.random() * PRIZES.length)。均匀随机只能保证每个格子被抽中的概率相同但业务方要的是谢谢参与占 40%、一等奖占 1%这类非均匀配置。权重算法的本质是把总权重画成一条线段随机数落在哪个奖品区间就抽中哪个完全符合概率定义。还有一个容易被忽略的点中奖索引要等用户点击之后、动画播放之前就确定不要在动画播放中途才去计算。这样做一是保证概率稳定二是避免动画时间影响抽签结果。4. 可直接运行的完整示例HTML、Canvas与动画逻辑一次讲清理论讲完直接给一份可运行的完整代码。这个示例组合了前面所有逻辑8 个奖品、Canvas 绘制、权重概率、requestAnimationFrame 动画、结果回调。复制到本地打开浏览器就能玩。4.1 HTML 结构与基础样式!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleJavaScript 实现转盘游戏/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; background: #f5f6fa; font-family: sans-serif; } .wheel-wrap { position: relative; width: 320px; height: 320px; margin: 20px auto; } canvas { display: block; width: 100%; height: 100%; } /* 指针一个纯 CSS 三角形固定在顶部中间 */ .pointer { position: absolute; top: -10px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 14px solid transparent; border-right: 14px solid transparent; border-top: 42px solid #e74c3c; z-index: 10; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); pointer-events: none; } #spinBtn { margin-top: 24px; padding: 12px 48px; border: none; border-radius: 30px; background: #e74c3c; color: #fff; font-size: 18px; cursor: pointer; transition: background 0.2s; } #spinBtn:disabled { background: #b2bec3; cursor: not-allowed; } #result { margin-top: 16px; font-size: 18px; min-height: 28px; color: #2d3436; } /style /head body div classwheel-wrap div classpointer/div canvas idwheelCanvas/canvas /div button idspinBtn开始抽奖/button div idresult/div script srcwheel.js/script /body /html样式上唯一要特殊说明的是指针我用了一个 CSS 三角形定位在 canvas 正上方中央。由于wheel-wrap是相对定位指针可以精确覆盖在转盘顶部圆心位置。指针本身pointer-events: none避免它挡住按钮点击。实际项目中你完全可以把指针换成一张图片或者更复杂的 DOM 元素不影响后续逻辑。4.2 JavaScript 主逻辑绘制、转动、回调全流程// wheel.js const PRIZES [ { label: 一等奖, color: #E74C3C, weight: 5 }, { label: 二等奖, color: #F39C12, weight: 10 }, { label: 三等奖, color: #2ECC71, weight: 15 }, { label: 四等奖, color: #3498DB, weight: 20 }, { label: 五等奖, color: #9B59B6, weight: 10 }, { label: 六等奖, color: #1ABC9C, weight: 15 }, { label: 谢谢参与, color: #BDC3C7, weight: 15 }, { label: 再接再厉, color: #E67E22, weight: 10 } ]; const canvas document.getElementById(wheelCanvas); const spinBtn document.getElementById(spinBtn); const resultEl document.getElementById(result); const size 320; const dpr window.devicePixelRatio || 1; canvas.width size * dpr; canvas.height size * dpr; canvas.style.width size px; canvas.style.height size px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); const centerX size / 2; const centerY size / 2; const radius size / 2 - 4; const offset -Math.PI / 2; const sliceAngle (Math.PI * 2) / PRIZES.length; let currentAngle 0; let isSpinning false; function drawWheel(angle) { ctx.clearRect(0, 0, size, size); ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(angle); ctx.translate(-centerX, -centerY); for (let i 0; i PRIZES.length; i) { const startAngle offset i * sliceAngle; const endAngle startAngle sliceAngle; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle PRIZES[i].color; ctx.fill(); ctx.strokeStyle #fff; ctx.lineWidth 2; ctx.stroke(); // 画文字 const middleAngle startAngle sliceAngle / 2; const textRadius radius * 0.65; ctx.save(); ctx.translate( centerX Math.cos(middleAngle) * textRadius, centerY Math.sin(middleAngle) * textRadius ); ctx.rotate(middleAngle Math.PI / 2); ctx.fillStyle #fff; ctx.font bold 16px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(PRIZES[i].label, 0, 0); ctx.restore(); } ctx.restore(); } function pickIndex() { 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; } function getTargetAngle(index, rounds) { const centerAngle offset index * sliceAngle sliceAngle / 2; let delta (-Math.PI / 2 - centerAngle) % (Math.PI * 2); delta (delta Math.PI * 2) % (Math.PI * 2); return delta rounds * Math.PI * 2; } function easeOutQuint(t) { return 1 - Math.pow(1 - t, 5); } function spin() { if (isSpinning) return; isSpinning true; spinBtn.disabled true; resultEl.textContent ; const targetIndex pickIndex(); const rounds 4 Math.floor(Math.random() * 2); // 随机 4~5 圈 const targetAngle getTargetAngle(targetIndex, rounds); const startAngle currentAngle; const totalDelta targetAngle - startAngle; const duration 4000; const startTime performance.now(); function step(now) { const progress Math.min((now - startTime) / duration, 1); const eased easeOutQuint(progress); currentAngle startAngle totalDelta * eased; drawWheel(currentAngle); if (progress 1) { requestAnimationFrame(step); } else { isSpinning false; spinBtn.disabled false; resultEl.textContent 抽中了 PRIZES[targetIndex].label; } } requestAnimationFrame(step); } spinBtn.addEventListener(click, spin); drawWheel(currentAngle);4.3 关键函数调用关系与状态管理这份代码的执行链路是页面加载后先drawWheel(0)画初始画面用户点击按钮触发spinspin内部先确认isSpinning状态防止连点然后抽签拿到targetIndex算出目标角度再通过requestAnimationFrame逐帧更新currentAngle重绘转盘动画结束后恢复按钮可用状态并显示结果。整个项目唯一的全局状态就是currentAngle和isSpinning。前者记录转盘当前转到的角度后者防止重复触发。你可以在finish回调里加自己的业务逻辑比如调用后端接口确认奖品、发送埋点数据而不是像我这样直接在前端显示文案。注意一点真实生产环境下抽奖结果一定不能只靠前端Math.random决定那样用户可以打开控制台直接改概率必须由后端接口返回中奖索引。前端只负责根据接口结果播放动画。5. 实战复盘转动偏移、防连点与移动端适配的坑5.1 连续旋转多次后的角度漂移问题如果你用 CSS transform 旋转 Canvas 元素而不是像我这样重绘那种方案在转很多次之后会出现角度漂移transform: rotate(1080deg)和rotate(1080.000001deg)浏览器的处理精度不同连续操作几十次后指针指向会慢慢偏离真正的扇区中心。我采用的重绘方案没有这个问题因为currentAngle只是我们内存里的一个数字每次取模或不取模都精确传给 Canvas 绘制不存在样式值精度损耗。但要注意currentAngle会一直累加这个值可能越来越大。如果后续代码需要反推当前停在哪个扇区或者要做角度归一化可以用下面的方式取模function normalizeAngle(angle) { const twoPi Math.PI * 2; return ((angle % twoPi) twoPi) % twoPi; }这样无论转了多少圈角度都会落在[0, 2π)区间方便判断位置。5.2 防连点不只是加个禁用状态初版最容易犯的错是只在按钮点击函数开头加一句if (isSpinning) return;但忘了同时禁用按钮的视觉效果。用户在 4 秒动画期间按钮仍然保持高亮可点状态如果快速点了三四下虽然只有第一次生效但每次点击都会触发浏览器对应的 UI 反馈观感上像是抽奖卡住了很容易被用户投诉。我的处理是双保险逻辑上isSpinning守卫视觉上spinBtn.disabled true置灰按钮并配合 CSS 的:disabled样式把 cursor 改成not-allowed。还有一点动画结束后要把按钮状态恢复回去我在finish分支里同时做了这两件事。如果抽奖结果需要请求后端接口建议在请求发出时就把按钮禁用等接口返回后再恢复避免用户在等待期间反复点击。对于接口超时或失败的情况记得在catch或finally里恢复按钮状态否则页面会变成按钮永远点不了的死状态。5.3 移动端适配与清晰度坑移动端和桌面端的差异除了前面说的设备像素比还有一个是转盘在页面上的实际尺寸。320px 这个逻辑尺寸是安全的下限手机上显示基本合适。但如果你的活动页需要在 iPad 或超大屏上展示最好让转盘尺寸随视口宽度自适应const size Math.min(320, window.innerWidth - 40);这个值在初始化时计算一次即可不要放在绘制函数里每次都算因为 resize 时调整 canvas 尺寸会清空画布逻辑会变得复杂。项目初期就定一个合适的固定尺寸比动态响应式要省心很多。移动端还有个容易忽略的点是字体渲染。Canvas 的fillText在部分安卓机型上对中文的渲染和桌面端略有差异文字在缩放尺寸后可能被裁剪或偏移。保险做法是给文字周边留足 paddingtextRadius不要太靠近扇区内外边缘我开始用radius * 0.7在 8 格转盘上看起来偏挤降到0.65才舒适。5.4 如果奖品格数不是 8 格怎么办代码里PRIZES.length处处驱动角度计算所以奖品格数改成 6、10 或 12 都无需改动逻辑。自然变动的有两处一是文字字号格数越多每格角度越小文字要相应调小二是sliceAngle自动变小后文字排列紧凑样式就需要微调。实际项目中我建议把文字字号也做成奖品数量动态计算const fontSize Math.floor(16 * (8 / PRIZES.length)) 10;这样 8 格时是 16px10 格时约 14px6 格时约 19px大致保持可读性。总之记住一点不要在绘制函数里硬编码任何依赖格数的常量全部从PRIZES.length推导。转盘这种小项目最值得练的不是某一项高深技术而是把角度计算、动画时序、状态管理、概率控制这些前端基础能力在一个完整场景里串起来。做完之后你会发现处理先定结果再表演过程这类思路迁移性极强老虎机、翻牌、刮刮卡都是同一个套路。我在实际项目里做第二个类似抽奖组件时几乎复用了这套逻辑的骨架只换了视觉层所以这次把踩过的坑都记下来希望你能少走点弯路。