HTML5大转盘抽奖:Canvas绘制与requestAnimationFrame动画实现

发布时间:2026/10/13 10:51:39
HTML5大转盘抽奖:Canvas绘制与requestAnimationFrame动画实现 简介这是一份基于HTML5 Canvas与jQuery技术实现的大转盘抽奖特效代码主要面向Web前端初学者和需要快速上线互动活动的运营开发者适用于微信营销、年会抽奖等场景。资源共8个文件包含一个HTML入口页面、一份CSS样式表、两个JS脚本jQuery库和jQueryRotate旋转插件以及4张转盘背景、指针等PNG素材整体压缩包仅89KB体积小巧便于部署测试。已有2211人学习下载适合作为H5动效与插件集成的入门参考。代码结构完整转盘绘制、指针旋转、停位判定等逻辑均有体现可直接运行查看效果也可据实际奖品位置修改配置后嵌入现有活动页面有助于快速理解Canvas绘图与jQuery插件的配合方式。1. 大转盘特效不只是一张图HTML5 转盘抽奖的完整落地思路在做活动弹窗或者首页营销位的时候“HTML5大转盘抽奖特效代码”是被检索得最多的前端组件之一。很多人以为转盘就是一张背景图叠个旋转动画等真正写出来才发现指针停在缝隙里、连点按钮多转一圈、移动端糊成一片。转盘抽奖的核心其实不在样式而在两件事用 Canvas 把奖品扇区画准再用 requestAnimationFrame 让旋转物理上足够顺滑。这篇文章会从渲染路线选型讲到概率权重、角度对齐、旋转防抖的完整代码并把最容易翻车的四个问题单独拉出来给你排雷。无论你正准备给活动页做转盘还是想把手里的轮子替换成可控方案都能直接照做。2. Canvas 绘制还是 CSS 布局HTML5 转盘的渲染路线与选型标准2.1 从扇形拼图到 Canvas 绘制两条路线能省多少工作量做过抽奖活动的前端都知道转盘最常见的两种做法一种是设计切好扇形图片前端用 CSS transform 旋转整张图另一种是全部用 Canvas 绘制扇区、文字和装饰。图片切片的优势是视觉完全可控品牌色的渐变、投影、质感不会被代码折损但代价是奖品数量一调整就要重新出图新增一个扇区还得让人检查背景衔接缝两天改一轮是常有的事。我一般会选 Canvas。营销活动的奖品名称、权重、排序经常在上线后临时调整用 Canvas 绘制只需要改一个配置对象前端把扇区、文字半径、旋转逻辑全部统一处理省掉的不仅是切图工时还有设计和前端的沟通链路。现代浏览器对 Canvas 的支持已经足够稳定弱网下最多把 canvas 替换成静态背景图抽奖逻辑完全不动这也让 Canvas 成了大多数活动页的默认实现。两种路线的对比可以看下面这张表方便你在动手前做决定。对比项图片切图 CSS 旋转Canvas 动态绘制奖品数量变更需要重新出图、检查衔接直接改配置数组重绘动画性能依赖 GPU 合成可能闪烁每帧可控配合 RAF 稳定文字与动态内容只能做静态图改动麻烦运行时绘制支持变量兼容旧浏览器需要额外处理 transform提供静态图降级即可维护成本中高低逻辑集中在一份代码里2.2 弧度、角度与扇形动手前必须算清的坐标基础Canvas 的圆弧坐标系和很多人的直觉不一样。它默认以正右方为 0 度顺时针方向为正但转盘筛子的起点通常放在正上方也就是 12 点钟方向所以绘制扇区时必须把起始角度减去Math.PI / 2否则画出来的盘面会整体偏向右方指针无论怎么转都落在错误刻度上。这是转盘场景里第一个“玄学”坑也是新手一上来最容易犯的坐标错误。在 Canvas 里扇形绘制用ctx.arc(x, y, radius, startAngle, endAngle)。这里的角度单位是弧度不是我们平时说的“度”。换算关系是radian degree * Math.PI / 180。比如 6 个等分扇区每个扇区占 60 度等于Math.PI / 3弧度。假如你没有做任何坐标偏移直接以 0 度作为第一个扇区的起点那么第一个扇区会从正右方开始整个转盘在视觉上就歪了 90 度。正确的做法是让起始角度从-Math.PI / 2开始然后按扇区数量均分2 * Math.PI。如果你把第一个扇区画在了正上方偏左第二个扇区依次顺时针排过去那么指针固定在上方时命中第一个扇区的旋转角度就和 Canvas 的起始角度严格对齐。记住这条约定线后面算目标角度时就不用再反复试错。绘制代码里所有startAngle和endAngle都基于这条约定线。3. 手写 HTML5 大转盘从绘制到旋转的完整代码与拆解3.1 定义奖品与样式一份可维护的配置对象转盘最容易被改的不是动画而是奖品数据。我习惯先把奖品配置抽成一个数组每个字段包含名称、概率权重和扇区颜色。这里的权重不是扇区面积而是抽奖时命中的概率权重扇区只在视觉上均分这样权重怎么调都不会让盘面变形。const prizes [ { name: 谢谢参与, weight: 20, color: #f2e894 }, { name: 20积分, weight: 30, color: #e8b36a }, { name: 优惠券, weight: 25, color: #f2e894 }, { name: 实物奖品, weight: 10, color: #e8b36a }, { name: 谢谢参与, weight: 20, color: #f2e894 }, { name: 再来一次, weight: 15, color: #e8b36a } ];name是要展示在扇区上的文案weight只参与抽奖概率计算color是扇区轮换的底色。因为扇区是等分的颜色按数组顺序交替即可不需要为每个奖品单独设置角度。在正式写绘制函数之前还有一个隐藏前提无论奖品列表怎么改Canvas 重绘时都需要知道中心点、半径和视觉尺寸。你可以在初始化时把这三个值算好绑定到一个常量对象上后续所有函数共享避免在绘制时到处写魔法数字。const canvas document.getElementById(wheelCanvas); const SIZE 320; canvas.width SIZE; canvas.height SIZE; const ctx canvas.getContext(2d); const centerX SIZE / 2; const centerY SIZE / 2; const radius SIZE / 2 - 10;这里SIZE直接控制了整个转盘的尺寸radius留了 10px 的边距防止扇区边缘被裁切。如果你的页面里有高清屏后面还要乘一个devicePixelRatio但这个放到避坑章再讲。3.2 Canvas 绘制六个扇区fillStyle 与 arc 的配合有了配置对象和画布常量绘制扇区就变成了一次循环。注意arc最后一步要closePath()否则扇区边缘会出现奇怪的连接线。每次绘制前用beginPath()开启新路径不然上一个扇区的颜色会影响下一个。function drawWheel(prizes, centerX, centerY, radius) { const angleStep (2 * Math.PI) / prizes.length; let startAngle -Math.PI / 2; prizes.forEach((item) { const endAngle startAngle angleStep; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle item.color; ctx.fill(); ctx.strokeStyle #ffffff; ctx.lineWidth 2; ctx.stroke(); ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(startAngle angleStep / 2); ctx.textAlign center; ctx.fillStyle #333333; ctx.font 14px sans-serif; ctx.fillText(item.name, radius / 2 10, 0); ctx.restore(); startAngle endAngle; }); }代码里的核心逻辑是ctx.rotate(startAngle angleStep / 2)这一行保存当前画布状态把原点移到圆心再旋转到扇区中心角度这样fillText写在 x 轴半径的中点自然就落在扇区正中且保持朝向圆心。如果你不旋转文字直接填坐标文字会横七竖八地躺在那或者全堆在圆心附近这属于第二类视觉翻车。这里的10是给文字留出的内边距避免文字紧贴中心或超出外圈。你可以根据实际奖品名称的长度调整奖品名越短这个值可以越靠里名越长越要往外推。如果名称长到溢出扇区建议在绘制前对字符串截断或者把字号调小。3.3 旋转动画requestAnimationFrame 取代 setTimeout旋转动画最常见的错误是使用setInterval或setTimeout控制角度步进这两者都没法和浏览器的帧率同步会出现掉帧、抖动、闪屏。正确做法是用requestAnimationFrame让浏览器在每一帧绘制前回调一次配合缓动函数实现先快后慢的自然停靠。let currentRotate 0; let isRunning false; function rotateTo(targetRotate, duration 4000, callback) { if (isRunning) return; isRunning true; const startTime performance.now(); const startRotate currentRotate; const deltaRotate ((targetRotate - startRotate) % 360 360) % 360; function frame(time) { const progress Math.min((time - startTime) / duration, 1); const eased 1 - Math.pow(1 - progress, 3); const rotate startRotate deltaRotate * eased; canvas.style.transform rotate(${rotate}deg); if (progress 1) { requestAnimationFrame(frame); } else { currentRotate targetRotate; isRunning false; callback callback(); } } requestAnimationFrame(frame); }注意我这里是直接对canvas元素做 CSStransform旋转而不是重绘 Canvas。原因是旋转会让整块图形坐标变化重绘时需要不断清空并绘制容易闪烁用 CSS transform 交给浏览器合成性能更稳定。deltaRotate的计算保证了无论你给的目标角度是 720 还是 1080动画都只走最短正方向路径不会反向转一圈。duration默认 4000ms这个值直接决定用户体验。太快会显得廉价太慢用户会不耐烦。我通常做成配置项在运营要“转得久一点”时直接改一个参数。eased 1 - Math.pow(1 - progress, 3)是三次缓出最后的 10% 速度明显放缓这样指针停下时不会突然从高速变静止视觉上不会“撞墙”。4. 抽奖概率与刻度对齐让次数和结果都可控的 4 个参数4.1 概率权重如何映射到扇区弧度很多半路出家的前端写转盘时习惯把权重直接乘到角度上扇区面积跟着权重变化结果权重大的扇区特别宽转盘视觉上就失去公平感。正确做法是扇区保持等分权重只用于抽奖算法生成一个 0 到 总权重 之间的随机数然后依次减去每个奖品的权重减到负数就说明命中了这个奖品。这种方法不管权重怎么调总权重都可以自动计算。function getPrizeIndex(prizes) { const totalWeight prizes.reduce((sum, item) sum item.weight, 0); let target Math.random() * totalWeight; for (let i 0; i prizes.length; i) { target - prizes[i].weight; if (target 0) return i; } return prizes.length - 1; }Math.random()产生的是均匀分布的随机数落在哪个奖品区间完全由weight决定。比如上面配置里总权重是 100第一个“谢谢参与”是 20那么target 20就是它命中。这里要强调的是概率是长期统计的收敛结果不是“抽十次一定出几次”。如果运营要求单个用户不能连续抽中实物那就要把逻辑放进抽奖接口而不是在前端瞎改权重。抽奖算法拿到了奖品索引后接下来要做的是把这个索引转成旋转角度。这一步最怕的是“前端自己画了一个随机数然后又传给后端后端发现对不上”。我通常的做法是前端先调后端接口拿到奖品索引再根据索引计算旋转角度纯展示层不参与决策。这样概率这个“黑匣子”就留给了服务端前端只负责视觉还原。4.2 命中角度与当前刻度旋转圈数加随机偏移的坑如果转盘绘制时第一个扇区从正上方开始那么索引 0 的扇区中心点落在-90 angleStep / 2度也就是正上方偏右一点。要让它停在顶部指针下CSS transform 需要的旋转角度不是扇区中心角度本身而是“把扇区中心转到指针位置”的逆向角度。考虑到转盘要转好几圈才停下所以最终目标角度要加上360 * N。function getTargetAngle(index, currentRotate) { const sectorAngle 360 / prizes.length; const baseAngle sectorAngle * index sectorAngle / 2; const randomOffset (Math.random() - 0.5) * sectorAngle * 0.8; const finalAngle 6 * 360 baseAngle randomOffset; return finalAngle - (currentRotate % 360); }这里的6 * 360是旋转圈数你可以调整成 5 或 8圈数越多等待时间越长。核心是randomOffset它给最终角度一个 ±40% 扇区宽度的随机偏移保证每次停在扇区内不同位置而不是每次都精准停在正中央否则用户会马上发现“转盘是固定落点”。注意finalAngle要减去当前角度因为 CSS transform 的rotate是相对当前角度累加的如果你直接设置一个绝对角度下一次连续抽奖时指针会跳回前面位置那也一样翻车。当currentRotate逐渐变大比如转到 3600 度后再抽取余是关键。如果不取余finalAngle会越来越大甚至超过浮点数精度范围动画数据很糟糕。这段代码里的(currentRotate % 360)保证了每一次都是相对于当前角度的新目标数值始终保持在可控范围。4.3 参数一览从权重到视觉呈现需要盯住的地方参数取值范围作用调整误区weight任意正数决定中奖概率不能直接改扇区大小sectorAngle360 / 奖品数保证扇区等分不要与实际角度错位randomOffset±0.4 * sectorAngle避免固定落点太大可能落到另一扇区duration30005000ms控制动画时长太短显得廉价太长流失耐心这 4 个参数是转盘抽奖落地时必调的。weight服务于抽奖结果sectorAngle服务于视觉结构randomOffset服务于真实感duration服务于手感。它们各自独立又互相依赖只要有一个没对上就会出现“抽中了 A 却停到 B 上”的尴尬局面。另一个容易被忽略的是偏转方向问题。Canvas 顺时针绘制扇区CSS 的rotate也是顺时针但它们都以正右方为 0 度。如果绘制起点是正上方你必须以负的Math.PI / 2作为第一个扇区起点而目标角度公式里没有这个偏移就需要确保baseAngle是按“第一个扇区中心在正上方偏右半扇区”来算的。换句话说绘制时的startAngle必须和计算baseAngle时参考的起点完全一致不然就会出现整体错位 90 度。5. 转盘抽奖常见问题排查角度错位、闪屏与概率玄学5.1 现象一指针停在了两个奖品之间的缝隙上这是转盘上线后最典型的翻车。表现为明明抽中了“优惠券”但指针停下来正好压在两块扇区的交界线上用户会说“你们这个抽奖有问题”。原因基本有两个一是目标角度只计算了扇区中心点没有加randomOffset结果每次都精准落在中心附近二是randomOffset范围太大超过了半扇区导致偏移量越界最终落在相邻扇区里。解决方法是把randomOffset控制在±0.4 * sectorAngle同时让基准角度等于扇区中心点。写代码时不要直接用index * sectorAngle作为中心因为扇区不是从正右方开始的。检查顺序先确认绘制起点是-Math.PI / 2再确认目标角度公式里的baseAngle index * sectorAngle sectorAngle / 2最后看finalAngle是否取余。5.2 现象二连点按钮导致转盘多转过一圈用户手速快双击按钮后转盘像发了疯一样连续转两圈奖品结果早就不对了。根本原因是旋转函数没有锁状态。我第一版也踩过这个坑按钮点击事件里直接调用rotateTo没有做拦截。解决方法是加isRunning标志位在动画开始时置true结束回调里置false。点击时先判断if (isRunning) return;。另外如果抽奖接口延迟比较高完整的流程是点击按钮先禁用请求拿到奖品索引后再开始旋转旋转结束再恢复按钮。这样连点问题从根本上被切断了。5.3 现象三移动端 canvas 模糊或出现毛边同一个 HTML 在 PC 上清晰在手机上看扇区边缘有锯齿、文字发虚这是 canvas 位图和样式尺寸不一致导致的。Canvas 里width是绘制分辨率style.width是展示尺寸如果你只把width设成了 320实际显示时被拉伸成 375 或 414就会糊。解决方法是引入devicePixelRatio让绘制分辨率翻倍再通过 CSS 缩放回去。const dpr window.devicePixelRatio || 1; canvas.width SIZE * dpr; canvas.height SIZE * dpr; canvas.style.width SIZE px; canvas.style.height SIZE px; ctx.scale(dpr, dpr);ctx.scale(dpr, dpr)保证你在逻辑坐标里用 320 画实际绘制用 320 * dpr 的像素文字和扇区就都清晰了。注意绘制函数里的centerX和centerY还是按逻辑尺寸取不用改。这个参数是移动端适配里最容易被忽略的一环。5.4 现象四改了权重但抽中次数还是不对劲有时候测试同学反馈明明把实物奖品权重调到了 1用户还是连续中。分析后发现问题可能不在前端而是后端抽奖接口用了“采取不放回”逻辑或者前端拿到结果后没有同步最新权重导致重试机制把概率带偏。我的建议是前端只负责展示与动画概率决策一定放在服务端。如果只能本地演示也要清楚Math.random()是伪随机无法保证特定用户的短期分布。做测试时用一次模拟跑 10000 次看统计是否接近权重占比而不是拿二十次手动抽奖论成败。这个习惯能在项目初期就避开“概率黑匣子”带来的扯皮。6. 进阶技巧给转盘加上防抖、连续抽奖与可视验证转盘组件做到这一步其实已经能跑了但真正放到生产环境还需要做两件事一是把抽奖流程接成请求二是做概率验证。先说请求点击“开始抽奖”先调后端接口接口返回奖品索引后前端再rotateTo。如果接口超时必须让按钮恢复可点击避免用户卡在一个不可交互状态。async function startDraw() { if (isRunning) return; btn.disabled true; try { const prizeIndex await requestDraw(); // 返回 0 ~ prizes.length - 1 const targetAngle getTargetAngle(prizeIndex, currentRotate); rotateTo(targetAngle, 4000, () { showToast(prizes[prizeIndex].name); btn.disabled false; }); } catch (e) { btn.disabled false; showToast(网络繁忙请稍后重试); } }这段代码把按钮禁用、请求、旋转、恢复的状态串成了完整闭环。requestDraw是一个自己封装的接口函数里面只负责拿索引不做任何计算。如果项目里没有后端也可以临时用getPrizeIndex替代但一定要加一个setTimeout模拟网络延迟不然用户会感觉“无限连抽”体验很奇怪。另外做一个概率验证函数方便你调权重时不被临时运气带偏。它可以跑 10000 次抽奖把分布打出来function simulate(prizes, times 10000) { const stats new Array(prizes.length).fill(0); for (let i 0; i times; i) { const idx getPrizeIndex(prizes); stats[idx]; } prizes.forEach((item, i) { console.log(${item.name}: ${((stats[i] / times) * 100).toFixed(2)}%); }); }调用一次simulate(prizes)你就能马上看到权重 30 的奖品大约占 30% 的分布。如果某个奖品长期抽不到大概率是初始化权重时有零值或者是抽奖算法里因为随机数分布不均导致的统计偏差。这个验证技巧我也会在每次上线前跑一遍避免运营偷偷改了配置而你没发现。最后一个进阶点是在动画结束后保留currentRotate状态并在下一次抽奖前取余。如果你用的是 CSS transform 旋转html 里的角度值会持续累积久了会变成几千甚至几万度虽然不影响逻辑但调试时看数值很不舒服。在rotateTo的回调里加上一句currentRotate targetRotate % 360既能保证数值一直在 0360 之间也能让后续计算更简洁。我之前因为没做这个保留连续抽了二十次后再抽指针直接反向跳转当时排查了半天才意识到角度累加可能溢出。现在每写一个转盘都会把状态管理放在函数开头养成习惯后这种问题基本不会再遇到希望帮到你。本文还有配套的精品资源点击获取