☰
HTML5大转盘抽奖:Canvas绘制与requestAnimationFrame动画实现
2026/10/13 10:51:36 网站建设 项目流程

简介:这是一份基于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避免固定落点太大可能落到另一扇区
duration3000~5000ms控制动画时长太短显得廉价,太长流失耐心

这 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,既能保证数值一直在 0~360 之间,也能让后续计算更简洁。我之前因为没做这个保留,连续抽了二十次后再抽,指针直接反向跳转,当时排查了半天才意识到角度累加可能溢出。现在每写一个转盘,都会把状态管理放在函数开头,养成习惯后这种问题基本不会再遇到,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询