原生Canvas实现HTML5转盘抽奖:绘制、动画与概率控制实战

发布时间:2026/9/9 9:19:20
原生Canvas实现HTML5转盘抽奖:绘制、动画与概率控制实战 简介基于HTML5与jQuery打造的移动端转盘抽奖源码面向需要快速上线抽奖活动的前端开发者、运营人员及实战学习者可高效解决手机端互动营销页面从零实现的效率问题。压缩包内共37个文件整体仅452KB涵盖样式表、页面结构、核心交互脚本及17张PNG切图另含3个JSP后台文件与工程配置文件前后端分层明确便于直接部署调试。目前已有2299人学习源码通过canvas绘制转盘区域配合jQuery动画控制旋转、停靠与结果弹窗并针对触屏事件和响应式布局做了适配在各类手机屏幕上均可流畅使用。借助这份资源既能理解H5抽奖机制、奖品概率控制与数据提交思路也能学习素材组织与移动端性能优化技巧适合按需修改奖品列表、背景图片后快速二次开发。 最近在帮人做活动页面的时候被问得最多的一句话就是“你手里有没有现成的 HTML5 转盘抽奖源码”说实话转盘抽奖算是前端项目里一个非常典型的“小而美”案例——它既能当 html5 网页设计作业交也能直接改改用于线下扫码活动。更妙的是它的技术覆盖面很广Canvas 基础绘图、弧度计算、动画缓动、概率控制全都能在一个小项目里串起来。如果你正打算做活动页、课程设计或者单纯想练手写一个可以炫耀的小游戏那这篇内容应该能直接帮上忙。我没有用任何第三方库全部基于原生 HTML、CSS 和 JavaScript 实现。这样做的好处是你可以完全理解每一行代码在干什么而不是拿了一套 Vue 组件一头雾水。更重要的是这个实现方案在 PC 端和移动端都能跑哪怕是用微信内置浏览器打开也不会出什么大问题。1. 整体设计与思路拆解1.1 为什么选择 Canvas 而不是图片或纯 CSS 实现我第一次做转盘抽奖时第一反应是“转盘不就是一张图然后转起来吗”后来发现这条路能走但很笨。如果奖品文案、颜色、扇形数量变了我得重新找设计改图开发效率极低。而 Canvas 方案是把整个转盘当作一个画布每一项奖品都通过 JavaScript 动态绘制想改奖品直接改数组就行。我也对比过纯 CSS 实现方案用一张画好的转盘底图配合transform: rotate()做旋转。这种方式做静态展示挺快但有两个致命问题一是每次修改奖品文案都要重新做图二是 CSS 旋转只能控制整体很难在动画结束时精确定位到某个扇形——你要自己算一大串角度偏移量调试起来很痛苦。选 Canvas 的核心原因我总结成一个对比表格方案动态更新奖品定位精度实现复杂度兼容性图片 CSS rotate差需重新做图一般角度计算繁琐低好SVG较好较好中较好Canvas 绘制 JS 动画优秀直接改数组高可通过算法精确停到指定扇形中好兼容 H5 标准环境最终我选择 Canvas还有一个理由Canvas 绘制出的扇形是矢量式的路径填充视觉上比位图更清晰不受分辨率影响在 Retina 屏上只要处理好设备像素比效果可以非常锐利。1.2 转盘的核心物理模型与数学概念转盘说白了就是一个圆被分成 N 个扇形。每一块扇形占据的角度是360° / N。整个转动过程表面上是在“旋转图片”本质上是在做“角度的累加与取余”。这里最需要搞懂的是角度与弧度的换算。Canvas 的arc()方法只认弧度不认角度所以我们先要记住一个公式// 角度转弧度 function toRadians(deg) { return (deg * Math.PI) / 180; }打个比方转盘就像切披萨一刀从圆心切到边缘就得到两条半径和一段弧线夹出来的一块就是扇形。你在 Canvas 里画转盘其实就是不停地切披萨只是每块的大小由奖品数量决定。为了让转盘“正上方”是第一个奖品我会把第一块扇形的起始角度设为-90°也就是从 12 点钟方向开始画。这个细节很重要因为它直接影响指针指向的判定。1.3 抽奖概率与结果控制这里必须区分“演示用”和“商用”两种场景。如果是课程作业或个人演示直接在纯前端用Math.random()随机一个奖品索引再让转盘转到那个扇形完全够用。但如果你把这个方案拿到真实活动中前端只能负责“转得好看”真正的中奖概率必须由后端决定。我在实际项目里常用两种做法后端直接返回中奖索引前端照着索引指过去后端返回权重值前端根据权重算出中奖项。无论哪一种核心原则都是概率逻辑绝不放前端。原因很简单前端代码可以被修改、被调试纯前端的概率就是一张废纸。我会在后面的实操环节给出一个纯前端演示版同时提醒哪些行需要替换成后端接口。2. 核心细节解析与实操要点2.1 绘制扇形最容易踩的三个坑画扇形看起来简单但细节特别多。第一个坑是路径没有闭合。很多人画完arc()就直接fill()结果图形边缘出现一条不该有的斜线或者扇形中间多了一个缺口。正确做法是先用moveTo(x, y)把起点移到圆心再画圆弧最后再closePath()闭合路径。第二个坑是文字画到了画布外面。如果你把所有标签直接绘制在半径相同的位置长一点的文案会超出转盘边界看起来非常业余。我通常把文字基准半径控制在radius * 0.68再把textAlign设置为centertextBaseline设置为middle保证文案既在扇区内部又能自然居中。第三个坑是文字方向在某个区域是反的。默认情况下文字沿水平方向绘制当扇形位于转盘左侧时文字就像“躺倒”了一样。解决方案是在绘制文字前先判断文字中心点所在的象限角度如果角度大于90°且小于270°就把文字旋转180°再画这样所有文字都能清晰可读。2.2 高清屏适配处理设备像素比很多人在手机上打开 Canvas 转盘发现边缘发虚、线条模糊。原因不是代码逻辑问题而是画布的实际像素尺寸远小于 CSS 展示尺寸。手机屏幕的物理像素通常是 CSS 像素的 2 倍甚至 3 倍而 Canvas 默认一个 CSS 像素对应一个物理像素自然会出现拉伸模糊。解决办法是把 Canvas 的实际尺寸设置为 CSS 尺寸乘以devicePixelRatio。我在初始化时通常这样写function setupCanvas(canvas, width, height) { const dpr window.devicePixelRatio || 1; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); return ctx; }这里有一个容易被忽略的点执行ctx.scale(dpr, dpr)之后你后续所有绘制逻辑都可以继续按 CSS 像素来思考不需要在每个坐标上手动乘以dpr。2.3 动画引擎的选择requestAnimationFrame 优于 setInterval旋转动画我用requestAnimationFrame而不是setInterval。原因很现实setInterval的实际执行频率会受到浏览器性能、标签页切换等条件影响容易掉帧或者被积压执行多次而requestAnimationFrame与浏览器屏幕刷新率天然同步页面切换走后会自动暂停节省性能。具体抽奖流程我设计成这样点击按钮时确定一个目标角度目标角度等于“当前角度 多圈数比如 5 圈 中奖扇区对应的角度”每帧将当前角度向目标角度靠近使用缓动函数让速度从快到慢模拟真实的转盘物理减速感。核心的减速过程不需要复杂物理引擎一个easeOut缓动函数就够了我习惯用cubicOutfunction easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }配合每帧的进度百分比不断更新当前角度直到完成动画。3. 实操过程与核心实现3.1 准备数据结构和基础配置我会把转盘所有可配项集中到一个配置对象里方便复用和交接。第一版我会定义奖品数组和颜色数组const prizes [ { name: 一等奖, color: #e74c3c }, { name: 二等奖, color: #3498db }, { name: 三等奖, color: #f1c40f }, { name: 四等奖, color: #2ecc71 }, { name: 五等奖, color: #9b59b6 }, { name: 谢谢参与, color: #e67e22 } ]; const config { canvasId: wheelCanvas, pointerAngle: -90, // 指针在正上方 baseRadius: 150, // 转盘基础半径 duration: 4000, // 动画时长毫秒 extraRounds: 5 // 额外旋转圈数 };奖品和颜色分开的原因是有时候颜色阵列需要循环使用而奖品名称必须和扇区一一对应。扇区数量多时我会准备一组高饱和度、明暗交替的颜色保证相邻扇区边界清晰视觉上不刺眼。3.2 绘制扇形与文字的核心代码绘制转盘的核心在drawWheel()方法里。我先把每个扇区分割然后绘制扇形路径再绘制文字。贴上核心片段这部分代码是完整可运行的function drawWheel() { const canvas document.getElementById(config.canvasId); const ctx setupCanvas(canvas, config.baseRadius * 2, config.baseRadius * 2); const centerX config.baseRadius; const centerY config.baseRadius; const sliceAngle (2 * Math.PI) / prizes.length; const startOffset toRadians(config.pointerAngle); prizes.forEach((prize, i) { const startAngle startOffset i * sliceAngle; const endAngle startAngle sliceAngle; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, config.baseRadius - 10, startAngle, endAngle); ctx.closePath(); ctx.fillStyle prize.color; ctx.fill(); ctx.strokeStyle #fff; ctx.lineWidth 2; ctx.stroke(); // 绘制文字 ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(startAngle sliceAngle / 2); ctx.textAlign center; ctx.textBaseline middle; ctx.fillStyle #fff; ctx.font bold 14px sans-serif; ctx.fillText(prize.name, config.baseRadius * 0.68, 0); ctx.restore(); }); }文字绘制这里我加了一次ctx.save()和ctx.restore()目的是让旋转设置不影响后续扇区的绘制。实测下来这一步一旦漏掉后面所有扇区的文字、颜色、边框都会错乱。3.3 转动动画与命中判定转动动画我用“目标角度逐步逼近”的方式随机生成最终停在哪一个扇区算出该扇区中心对应的目标角度在每帧更新中用缓动函数把当前角度“拉”向目标角度。命中判定的关键公式是先把当前角度换算成 0 到 360 的区间然后逆推得到当前指针指向的扇区索引。这个逻辑我单独抽成getHitIndex(currentAngle)方法function getHitIndex(angle) { const normalized ((angle % 360) 360) % 360; const sliceDeg 360 / prizes.length; const offset (normalized (360 - ((config.pointerAngle % 360) 360) % 360)) % 360; return Math.floor(offset / sliceDeg); }注意这里我做了两次取模运算目的是保证角度是正数。经过-90 360取余这种处理之后指针停在扇区内就不容易出现边界偏差。然后我把动画函数封装成 Promise方便在动画结束后显示结果弹窗也方便接入后端数据function animateTo(targetAngle) { return new Promise(resolve { const startAngle currentAngle; let totalAngle targetAngle - currentAngle; const startTime performance.now(); function frame(now) { const elapsed now - startTime; const progress Math.min(elapsed / config.duration, 1); const eased easeOutCubic(progress); currentAngle startAngle totalAngle * eased; const canvas document.getElementById(config.canvasId); const ctx canvas.getContext(2d); ctx.clearRect(0, 0, config.baseRadius * 2, config.baseRadius * 2); ctx.save(); ctx.translate(config.baseRadius, config.baseRadius); ctx.rotate(toRadians(currentAngle)); ctx.translate(-config.baseRadius, -config.baseRadius); drawWheelContent(ctx); ctx.restore(); if (progress 1) { requestAnimationFrame(frame); } else { resolve(getHitIndex(currentAngle)); } } requestAnimationFrame(frame); }); }这里说明一下为什么在动画里先translate到圆心再旋转再平移回去转盘绕中心旋转时需要把旋转原点移到画布中心否则会绕着左上角(0, 0)旋转转盘直接飞出视野。动画过程中的drawWheelContent(ctx)和静态drawWheel()的区别只是前者不再重新创建画布尺寸而是复用已有的ctx。3.4 防止连点与抽奖次数控制连点问题几乎每个抽奖组件都会遇到。用户在动画还没结束时又点了按钮会导致多次动画互相干扰角度计算混乱。我的做法是设置一个isRolling锁let isRolling false; async function handleSpin() { if (isRolling) return; isRolling true; const target getTargetAngle(); // 随机或来自后端 const hitIndex await animateTo(target); showResult(prizes[hitIndex].name); isRolling false; }关于剩余次数我常用localStorage记录每次抽完后自减。这个方案在单浏览器环境下够用但真要防刷还是得靠后端接口计数。4. 常见问题与排查技巧实录4.1 转盘文字“躺倒”了怎么办这是我被问得最多的一个问题尤其是奖品数量是 6 个或 8 个时难免有文字方向不对。如果你按我上文的方式用ctx.rotate()把文字沿扇形中轴线方向绘制那左右两侧的文字就容易倒挂。解决方案是在绘制文字前判断角度范围决定是否需要额外旋转 180 度const midAngle startAngle sliceAngle / 2; if (midAngle Math.PI / 2 midAngle (3 * Math.PI) / 2) { ctx.rotate(Math.PI); }这行代码的意思很直接如果扇区中心在左半圆就把文字旋转半圈让文字始终以“头部朝外、底部朝圆心”的方向显示。4.2 指针停在边界线上怎么算指针恰好停在两个相邻扇区的边界时判定结果可能忽左忽右。这种问题通常不是代码 bug而是角度归一化时少算了一个偏移量。我的排查步骤是先打印出每次动画结束时的currentAngle和getHitIndex的计算过程确认pointerAngle是否和视觉上“指针所指的正上方”一致。如果你改变了指针在页面上的位置就一定同步修改pointerAngle配置。4.3 手机上模糊或卡顿如果确认已经按 2.2 节做了高清屏适配但整体还是卡重点排查以下三处有没有在动画循环里频繁创建大对象或字符串拼接有没有对全屏元素做多层box-shadow或filter效果有没有在drawWheelContent里反复读取超大的图片资源。实测中只要转盘尺寸控制在 360px 左右扇形数量在 12 个以内使用纯色填充和简单描边手机上是完全流畅的。如果你需要更花哨的高光和阴影效果建议先离屏绘制一个静态转盘再在动画里只旋转整张离屏画布能有效降低掉帧率。4.4 快速连点导致结果异常这类问题大多由异步时序引起核心防御手段就是我在 3.4 节写的isRolling锁。但还有一种隐蔽情况模拟器或者高刷新率屏会导致requestAnimationFrame回调的时间戳不稳。我通常会在动画结束后再等一小段时间比如setTimeout(resolve, 100)给浏览器一帧的刷新时间避免弹窗和绘制竞争渲染资源。4.5 浏览器兼容性排查最后给一份常见问题速查表方便你在现场快速定位现象可能原因排查方向解决建议白屏某个浏览器不支持 Canvas控制台输出canvas.getContext是否存在尽量用标准写法避免webkit前缀 API动画卡顿每帧重新绘制导致性能开销大Chrome 性能面板看scripting耗时离屏缓存静态转盘只旋转画布文字重叠文字基准半径太大检查fillText的 x 坐标改用radius * 0.62 ~ 0.72角度偏差pointerAngle 配置不正确打印normalized中间值锁定正上方为 -90 度并统一换算概率不对随机索引与扇区索引不一致核对getHitIndex计算先固定索引写死动画验证我把这些坑踩过一遍之后最大的体会是转盘抽奖这个项目真正难点从来不在“画一个圆”上而在于角度换算、动画控制、交互状态管理这些前端基本功。如果你正拿它当 html5 网页设计作业建议在此基础上加入“抽奖记录”“重置按钮”和“中奖动效”三个功能得分点会明显不一样。做活动页的则一定记住把概率判断交给后端前端只负责给用户一个足够过瘾的旋转体验。本文还有配套的精品资源点击获取