简介一份面向前端开发者与活动运营者的手机端转盘抽奖互动源码基于HTML5 canvas与jQuery实现可快速部署到营销活动或H5页面解决抽奖场景中视觉表现与交互逻辑问题。压缩包共37个文件整体约452KB涵盖png图片素材、js脚本、css样式、html页面以及jsp后台逻辑文件其中js负责旋转动画与中奖计算jsp用于服务端数据交互png则提供转盘、指针等视觉元件结构清晰便于按功能修改。目前已有2304人学习下载适合希望掌握canvas绘图、jQuery动画、响应式布局与移动端触屏事件的读者。通过阅读源码可理解转盘绘制、抽奖触发、结果展示的完整流程并能参考后台代码扩展奖品管理、抽奖记录等功能提升前端综合开发能力。1. 转盘抽奖源码不只是“转个圈”从 Canvas 盘面到服务端校验要过的四道坎搜索 html5 转盘抽奖源码 的人通常不是要一份能跑的 demo而是要一个能塞进活动页、能动态换奖品、能在手机上正常转的组件。见过不少开源小样在 PC 上演示挺好换到真机就露馅画布模糊、指针总指向隔壁奖品、快速连点能刷出多次抽奖。转盘抽奖表面上是个旋转动画实际由四块组成扇区绘制、缓动旋转、目标角度计算、概率与防刷校验。这篇文章按这四块逐层拆开给出一套 Canvas 版转盘源码的实现思路与完整代码代码可以直接改奖品改颜色最后一章收在真机上最容易翻车的几个坑和进阶玩法。适合做 H5 活动页的前端、独立开发者以及想把抽奖模块做稳定的人。2. 选型定胜负为什么用 Canvas 画盘面用 transform 做旋转第一次写转盘的人最纠结的是怎么画扇形。常见方案有三个整张图片旋转、CSS 画扇形、Canvas 绘制。我先把三者的对比放出来再解释为什么这套源码选 Canvas。方案改奖品文字清晰度旧机型兼容适合场景静态图 CSS 旋转每次重新导出图片依赖图片尺寸最好奖品固定、长期不换CSS 扇形拼接改颜色和角度要改样式一般一般结构简单的固定转盘Canvas 绘制改数据重绘一次即可可按 devicePixelRatio 适配较好奖品由接口下发、活动频繁变我一般选 Canvas核心原因活动页的奖品、颜色、名称都是接口下发的Canvas 一次绘制能根据数组动态生成改一个奖品不用重出图而 CSS 方案每次改布局都要调结构奖品多了还会遇到相邻扇形边界对不齐的麻烦。2.1 盘面只画一次Canvas 绘制与高清屏适配Canvas 的常见误区是“每帧重画”。转盘动画里真正旋转的是 canvas 元素本身也就是 CSS transform盘面本身在抽奖开始前画好一次就行之后每一帧都只有 GPU 合成没有复杂路径绘制。我习惯把绘制函数只调用一次除非奖品数据变化。配套要做的是高清屏适配否则同一套代码在 2 倍屏和 3 倍屏上会发虚。Canvas 的像素尺寸和 CSS 尺寸是两回事需要用 devicePixelRatio下文简称 dpr把画布实际像素放大。function initCanvas(canvas, cssSize) { const dpr window.devicePixelRatio || 1; canvas.width Math.round(cssSize * dpr); canvas.height Math.round(cssSize * dpr); const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }这段代码里cssSize是转盘在页面上的显示尺寸比如 340。先按cssSize * dpr设置画布真实像素再用ctx.scale(dpr, dpr)把坐标系缩放回 CSS 像素。这样后面所有绘制代码都按 CSS 像素坐标写画出来的内容在 2 倍屏上依然锐利。参数dpr在 PC 上通常是 1在手机上多为 2 或 3直接取window.devicePixelRatio即可。2.2 旋转动画两条路CSS transition 与 requestAnimationFrame旋转动画有两条主流路线。一条是 CSS transition写法一行搞定靠缓动函数模拟“甩出去再慢慢停”canvas.style.transition transform 4s cubic-bezier(0.15, 0.85, 0.25, 1); canvas.style.transform rotate(${target}deg);4s是动画时长cubic-bezier(0.15, 0.85, 0.25, 1)是一条先快后慢的缓出曲线前段冲劲足后段逐渐停稳很适合转盘结束时“定住”的感觉。transition 方案的优点是代码量极小配合transitionend事件能拿到动画结束时机缺点是中途要打断动画时需要重设 transition逻辑会绕。另一条是 requestAnimationFrame 逐帧控制适合需要精确控制加速度、或抽奖结果要等后端接口返回再落定的场景const start performance.now(); const duration 4000; const from 0; const to target; function step(now) { const p Math.min((now - start) / duration, 1); const eased easeOutQuart(p); canvas.style.transform rotate(${from (to - from) * eased}deg); if (p 1) requestAnimationFrame(step); } requestAnimationFrame(step);p是归一化的时间进度eased是缓动后的进度easeOutQuart可以自己实现为1 - Math.pow(1 - p, 4)。rAF 的好处是只要p没到 1就能一直控制转盘甚至可以在动画过程中等待接口返回再修正目标。代价是代码多且要考虑浏览器切后台时requestAnimationFrame会被暂停。简单展示型活动用 CSS transition 足够涉及后端结果回填时我倾向 rAF。2.3 三态状态机不锁状态的抽奖都是给自己挖坑转盘组件内部最好维护一个三态状态机待抽奖、旋转中、结果展示。旋转中必须锁住所有触发路径否则连点、键盘回车、脚本调用都会重复触发抽奖。const State { IDLE: IDLE, // 可以启动一次抽奖 SPINNING: SPINNING, // 动画进行中禁止任何触发 REVEAL: REVEAL // 结果已出可进入下一轮 }; let state State.IDLE; function resetToIdle() { state State.IDLE; }状态机的价值在于把“能不能点”从按钮 disabled 中抽离出来。按钮 disabled 只能挡住鼠标点击而状态机能把脚本调用、事件冒泡、键盘触发全部挡在同一个判断后面。后面第 3 章的spinTo和第 5 章的防连点都会围绕这个状态机展开。3. 从零实现一套最小可用转盘绘制、角度计算与动画回调这一章把转盘拆成四步搭骨架、画盘面、算角度、转起来。每一步都给可直接抄的代码参数含义写在代码后面。3.1 HTML 骨架与画布初始化转盘的 DOM 结构只有三层外容器、Canvas 盘面、指针和按钮。指针不画在 Canvas 里而是用独立元素定位在 12 点方向这样旋转转盘时指针可以纹丝不动。div classwheel-box canvas idwheelCanvas/canvas div classwheel-pointer/div button idspinBtn classwheel-btn抽奖/button /div.wheel-box { position: relative; width: 340px; height: 340px; margin: 40px auto; } #wheelCanvas { width: 340px; height: 340px; border-radius: 50%; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); will-change: transform; } .wheel-pointer { position: absolute; left: 50%; top: -12px; width: 0; height: 0; margin-left: -14px; border-left: 14px solid transparent; border-right: 14px solid transparent; border-top: 28px solid #ff3b30; z-index: 2; } .wheel-btn { position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 3; padding: 8px 28px; border: none; border-radius: 20px; background: #ff3b30; color: #fff; font-size: 16px; cursor: pointer; }wheel-box是定位父级所有绝对定位子元素都相对于它。will-change: transform提前告诉浏览器这个元素会被 transform合成器会专门为它分配图层减少旋转时的重绘开销。指针用 CSS 三角形实现margin-left: -14px是为了水平居中top: -12px让三角尖端超出盘面正对 12 点方向。按钮放在底部中间z-index确保它不会被 Canvas 盖住。画布初始化直接调用上一章的initCanvasconst canvas document.getElementById(wheelCanvas); const ctx initCanvas(canvas, 340);参数340必须和 CSS 里wheel-box的宽高一致。如果两者不一致绘制坐标和显示坐标就会错位最常见的表现是盘面外圈被裁掉一圈。3.2 按权重绘制扇形与文字奖品用数组维护每一项包含名称、权重、颜色。权重决定这个扇区占整个圆的比例也决定中奖概率的展示形态。const prizes [ { name: 10元红包, weight: 10, color: #ff6b6b }, { name: 5元红包, weight: 20, color: #feca57 }, { name: 谢谢参与, weight: 40, color: #48dbfb }, { name: 优惠券, weight: 20, color: #1dd1a1 }, { name: 再来一次, weight: 10, color: #f368e0 } ];这段数据里weight总和是 100方便观察比例。实际项目中 weight 可以是任意整数比如 3、7、15代码只关心相对大小。function getTotalWeight() { return prizes.reduce((sum, p) sum p.weight, 0); } function getSliceAngleDeg(index) { return prizes[index].weight / getTotalWeight() * 360; } function drawWheel(ctx, size) { const center size / 2; const radius center - 6; let startAngle -Math.PI / 2; // 从 12 点方向开始画第一个扇区 prizes.forEach((prize, i) { const sliceAngle (prize.weight / getTotalWeight()) * Math.PI * 2; const midAngle startAngle sliceAngle / 2; // 画扇形 ctx.beginPath(); ctx.moveTo(center, center); ctx.arc(center, center, radius, startAngle, startAngle sliceAngle); ctx.closePath(); ctx.fillStyle prize.color; ctx.fill(); // 画奖名沿扇区中线方向写 ctx.save(); ctx.translate(center, center); ctx.rotate(midAngle); ctx.textAlign right; ctx.fillStyle #fff; ctx.font 14px sans-serif; ctx.fillText(prize.name, radius - 14, 4); ctx.restore(); startAngle sliceAngle; }); }startAngle从-Math.PI / 2开始对应 Canvas 坐标系里的 12 点方向。Canvas 的 0 度在 3 点方向角度按顺时针增大这个基准在后面算目标角度时非常关键错一点指针就会指歪。扇区文字先平移到圆心再旋转到扇区中线方向textAlign: right让文字从靠近外缘的位置向左书写radius - 14留出外圈边距。3.3 目标角度计算让指针准确落在指定扇区旋转的角度不是随便给的。要让指针12 点方向最终落在第index个扇区的中线上需要把该扇区中心转到 12 点位置。先用角度制算出扇区中心当前在哪function getSliceCenterDeg(index) { const total getTotalWeight(); let startDeg -90; // 与绘制起点一致12 点方向 for (let i 0; i index; i) { startDeg getSliceAngleDeg(i); } return startDeg getSliceAngleDeg(index) / 2; }-90度就是 12 点方向第一个扇区从这里开始排。循环累加前面所有扇区的角度最后加上当前扇区的一半得到第index个扇区中心线的极角。比如第 0 个奖品权重 10总共 100中心角就是-90 18 -72度。拿到中心角后旋转目标这样计算const POINTER_DEG -90; function getTargetRotation(currentRotation, index) { const centerDeg getSliceCenterDeg(index); let delta POINTER_DEG - centerDeg; delta ((delta % 360) 360) % 360; // 归一化到 [0, 360) return currentRotation 5 * 360 delta; }逻辑说明扇区中心在centerDeg指针在POINTER_DEG两者差delta就是转盘需要多转的角度。(delta % 360 360) % 360这行很关键JavaScript 的取模对负数返回负值先加 360 再取模能把角度约束到 0 到 360 之间保证转盘始终顺时针旋转不会出现“往回倒”的诡异动画。5 * 360是预先加的整圈数让转盘至少转满 5 圈视觉上更像真实抽奖。3.4 旋转动画与结果回调动画用 CSS transition 驱动配合transitionend事件收尾同时加一个setTimeout做兜底。这里把第 2 章的状态机真正用起来。let currentRotation 0; function spinTo(index) { if (state ! State.IDLE) return; state State.SPINNING; spinBtn.disabled true; const target getTargetRotation(currentRotation, index); canvas.style.transition transform 4s cubic-bezier(0.15, 0.85, 0.25, 1); canvas.style.transform rotate(${target}deg); currentRotation target; let settled false; function finish() { if (settled) return; settled true; state State.REVEAL; spinBtn.disabled false; spinBtn.textContent 再来一次; onResult(prizes[index]); } canvas.addEventListener(transitionend, finish, { once: true }); setTimeout(finish, 4600); // 4s 动画 600ms 余量兜底 } spinBtn.addEventListener(click, () { if (state State.IDLE) { const idx weightedRandom(prizes); // 真实场景此处换成接口返回值 spinTo(idx); } else if (state State.REVEAL) { resetToIdle(); spinBtn.textContent 抽奖; } });settled标志位避免transitionend和setTimeout双触发导致结果回调执行两次。setTimeout(finish, 4600)是兜底如果页面切后台导致 transition 被暂停、或某些浏览器不派发transitionend动画结束后 600 毫秒也会进入结果态。按钮在REVEAL状态下点击会把状态复位到IDLE并把文案改回“抽奖”。weightedRandom是本地的随机抽奖函数第 4 章会给出实现并说明为什么真实项目里不能只靠它。4. 概率与服务端校验转盘抽奖的“公平”不能只靠前端转盘抽奖的“公平”是一个容易被低估的工程问题。前端Math.random只能决定盘面转到哪里真正决定奖品归属的必须是服务端。这一章先给前端权重算法再解释为什么它只能当表现层最后给出服务端校验和幂等的关键设计。4.1 前端权重算法用累减实现 weightedRandom权重抽奖的常规做法是把所有奖品按权重映射到数轴上的区间然后取一个随机数看落在哪个区间。实现上不需要真的构建一个大数组累减写法更省内存function weightedRandom(list) { const total list.reduce((sum, p) sum p.weight, 0); let r Math.random() * total; for (let i 0; i list.length; i) { r - list[i].weight; if (r 0) return i; } return list.length - 1; // 浮点误差兜底 }r的初始值范围是[0, total)每减去一个权重就判断是否小于 0。比如权重是 10、20、40、20、10随机数落在哪个区间就返回对应下标。这个函数的输出可以直接传给第 3 章的spinTo作为演示闭环。注意weight为 0 的奖品永远不会被选中这在逻辑上是期望行为但会给后续对账带来困惑建议配置层就过滤掉权重为 0 的项。4.2 前端随机只是“表现层”接口校验才是底线如果只在前端决定中奖结果活动很快会被刷穿。某开发者只需要打开浏览器控制台改掉prizes数组把所有别人的权重改成 0或者直接反复调用spinTo的触发函数就能无限抽奖。更隐蔽的问题是库存你前端随机到了“iPhone”但库存只有 3 台第 4 个用户抽中时根本无货可发。正确流程是前端点击按钮后先转动画、同时并行请求后端接口接口完成校验后把中奖下标返回前端再把这个下标作为目标角度传进spinTo。服务端接口至少要做三件事登录态校验、每日次数校验、奖品库存扣减。// 伪代码服务端抽奖接口 app.post(/api/lottery, async (req, res) { const user req.user; if (!user) return res.status(401).json({ code: 401, msg: 未登录 }); const todayKey lottery:count:${user.id}:${dateStr()}; const used await redis.get(todayKey); if (used Number(used) DAILY_LIMIT) { return res.status(403).json({ code: 403, msg: 今日抽奖次数已用完 }); } // 用行锁或 redis 原子扣减库存避免并发超卖 const prize await drawFromDB(user.id); if (!prize) return res.status(429).json({ code: 429, msg: 奖品已抽完 }); await redis.incr(todayKey); return res.json({ code: 0, index: findPrizeIndex(prize.id) }); });这段伪代码体现的是职责划分/api/lottery负责最终裁决req.body.requestId用于幂等同一个请求 ID 只处理一次防止前端重试导致重复发奖。次数用 Redis 计数器库存扣减必须和发奖记录放在同一个事务里。前端拿到的index只用来让转盘停在正确答案上改前端任何代码都改变不了结果。4.3 防连点与限流状态机只是第一道闸第 3 章的state State.IDLE判断能挡住普通用户连点但挡不住脚本。真正的限流必须做在服务端按用户维度限制每日次数按 IP 维度做接口限流必要时加上图形验证码。活动页里我一般还会让前端在 SPINNING 状态把按钮文字改成“抽奖中”从交互层面降低误触概率。还有一个细节接口请求和动画是并行的如果接口 1 秒就返回了而动画要转 4 秒前端需要缓存接口返回的index等动画结束再spinTo。反过来如果接口 5 秒才返回动画已经停住这时候有两种处理延长动画时长或先给一个“加载中”的等待态接口返回后再补一段旋转。常见做法是动态增加duration把等待时间消化在旋转里避免用户盯着静止的转盘干等。5. 转盘抽奖最常见的 5 个翻车点现象、排查与修复转盘这类纯前端组件功能逻辑本身不难难的是在真机和各种边界条件下不翻车。下面 5 个问题是实际项目里出现频率最高的每一条都按“现象 → 原因 → 解决”来写可以直接对照排查。5.1 指针总是指向相邻扇区现象抽中了“优惠券”转盘停下来指针却指着旁边的“谢谢参与”而且每次偏差的角度很固定比如总是偏了一个扇区。原因绘制起点和目标角度计算用的零度基准不一致。绘制时startAngle用了-Math.PI / 212 点方向但计算delta时如果忘了POINTER_DEG也是-90或者直接用 0 代替就会整体偏一个固定角度。解决检查三点是否一致。第一drawWheel里第一个扇区的startAngle第二getSliceCenterDeg里的startDeg初始值第三spinTo里POINTER_DEG的取值。三者必须统一我习惯全部以“12 点方向等于 -90 度”为基准并且在代码注释里写明。排查时先在纸上画一个四扇区转盘手算一遍第 0 个扇区的目标角度再和程序输出对比。注意Canvas 的 0 度在 3 点方向顺时针增大。这是所有角度问题的根源。5.2 手机上盘面发虚、文字有锯齿现象PC 上清晰换到 1 倍屏或 2 倍屏手机上看文字边缘发毛扇区边界有锯齿感。原因canvas.width只设置了 CSS 尺寸没有乘 dpr。CSS 里转盘显示 340px但画布实际像素也是 340在 2 倍屏上每个像素要放大到 2 个物理像素自然糊。解决用第 2 章的initCanvas初始化画布把真实像素设为cssSize * dpr再用ctx.scale(dpr, dpr)恢复坐标系。注意重新设置canvas.width会清空画布所以必须设置在drawWheel之前。如果项目是后期修复改完 dpr 后要重新调用一次drawWheel否则盘面会被清掉。5.3 动画结束不回调结果弹不出来现象转盘停稳了但onResult一直不执行按钮还停留在“抽奖中”或者结果弹窗迟迟不出现。原因transitionend事件没有派发。常见触发条件有三个动画中途页面切到后台浏览器暂停合成转盘元素被display: none隐藏后动画被取消或者代码里再次修改了transform导致 transition 被覆盖。解决保留第 3 章的setTimeout(finish, 4600)兜底并让finish用settled标志防重复。另外在visibilitychange事件里做一次校正如果页面从后台回来时state还是SPINNING直接补一条短过渡把剩余角度转完。这个兜底不是可有可无在低端安卓机上它是保证活动可用的关键。5.4 连点或双击触发两次抽奖现象快速点击按钮转盘还没停下来就启动了第二次旋转或者第一次结果未出第二次已经扣了次数。原因只给按钮加了disabled没有做全局状态锁。某些点击方式键盘回车、移动端 touch 事件、脚本调用会绕过按钮的禁用状态直接触发事件回调。解决状态机判断在事件回调里第一时间执行state ! State.IDLE直接return这比disabled更可靠。服务端再做每日次数兜底双保险。前端锁是保体验服务端锁才是真防线顺序不能反。5.5 iOS Safari 下转盘边缘闪烁、指针轻微抖动现象iOS 真机上转盘转到后半段盘面外圈出现一条闪线或者指针轻微上下抖动PC 和安卓上没有。原因transform旋转的 canvas 元素同时带有box-shadow和border-radius这两个属性会让浏览器在合成时反复重绘边缘指针作为独立元素和 canvas 在同一个图层里旋转时发生了页面级重排。解决把box-shadow从 canvas 移到外层wheel-box上canvas 只保留圆角给 canvas 加will-change: transform老版本 Safari 还要补transform: translateZ(0)强制创建合成层指针独立定位并提高z-index不要让它和 canvas 共享变换。改完如果还闪把转盘的边框用outline代替border能进一步减少边缘重绘。6. 进阶玩法必中区间、动态奖品与带“记忆”的假随机转盘做到能转、能停、能防刷之后真正和业务价值挂钩的是三个进阶点必中区间、动态配置、个体概率调节。必中区间是运营最常提的需求要求某个奖品在指定轮次必须被抽中。实现上并不需要真“作弊”而是把随机范围收窄到第index个扇区内部function getRandomInSlice(index) { const slice getSliceAngleDeg(index); const center getSliceCenterDeg(index); const offset (Math.random() - 0.5) * slice * 0.7; return center offset; }0.7是安全系数让落点只出现在扇区中央 70% 的范围内避免随机到边界导致视觉上像“没指中”。调用时把getSliceCenterDeg(index)换成getRandomInSlice(index)其余逻辑不变。动态奖品配置比较简单。把prizes改成从接口拉取拉到之后再初始化画布async function loadPrizes() { const data await fetch(/api/lottery/config).then(res res.json()); prizes.splice(0, prizes.length, ...data.prizes); drawWheel(ctx, 340); }这里用splice替换数组内容而不是重新赋值是为了保持prizes的引用不变避免其他模块持有旧引用导致数据不一致。接口返回的奖品结构保持和本地数组一致name、weight、color。带“记忆”的假随机是我做过一次活动后总结出的一课。纯随机概率下一个用户可能连续 10 次“谢谢参与”体验很差。常见处理是服务端记录该用户连续未中次数按比例修正权重const actualWeight Math.round(baseWeight * (1 user.noPrizeCount * 0.15));这个公式让连续未中次数越多中奖权重越高整体概率仍受控制但个体体验更平滑。注意这类调整必须放服务端因为“记忆”本身是状态前端不可信。最后说一个我自己的教训第一次做转盘抽奖时我把概率全放在了前端上线两天就被脚本刷走了大量库存后来被迫连夜加接口校验和幂等。那之后我再写抽奖永远是先定服务端协议、再写转盘动画。动画只是面子数据校验才是里子。这套代码从绘制到角度计算都围绕“接口返回下标、前端负责转到位”来设计你照着改奖品、改颜色、接接口都不会遇到结构性的坑。希望帮到你。本文还有配套的精品资源点击获取