ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

前端抽奖转盘简易版实现:CSS动画与JavaScript状态机实战

前端抽奖转盘简易版实现:CSS动画与JavaScript状态机实战 前端圈子里抽奖转盘算是一个特别经典的“小而美”项目——逻辑不难但体感很强。所谓简易版我的定义是不依赖框架、不接后端接口把代码粘到一个HTML文件里就能跑起来用CSS控制转盘旋转动画用JavaScript控制角度和状态。它解决的是“线下活动临时要个抽奖交互”这类需求适合新手把DOM操作、CSS动画、事件处理、随机数应用这几块基础串成一条完整链路也适合老手快速产出一个可演示的demo。这篇文章我会沿着“设计思路→核心细节→完整实现→问题排查”的顺序走直接把可复现的代码和参数计算过程铺开。开始之前提醒一句这个项目是纯前端的所以它的概率控制天然可以被用户篡改真要用于正式商业活动后端开奖和签名校验是必须补上的。简易版的定位是把交互和视觉做扎实。1. 先把思路捋清楚简易版抽奖转盘的设计架构1.1 这个项目到底解决什么问题抽奖转盘的核心交互非常明确用户点一下按钮转盘旋转若干圈后停在一个扇区上弹窗提示结果。从工程角度拆开它其实由四个独立的问题组成。第一个是扇区布局。6个、8个奖品怎么均匀地铺满一圈每个奖品占多少度这是纯粹的几何问题。第二个是旋转动画。点击后转盘不能瞬间跳变要有“转起来、逐渐减速、停止”的运动曲线这是CSS过渡或动画的职责。第三个是开奖判定。用户看到的“转盘停在第几个扇区”必须和代码里预先决定的奖励一致二者之间靠数学换算对齐。第四个是交互约束。旋转过程中不能重复触发抽奖否则连点几下就会导致状态错乱。把这四个问题分别解决掉整个项目就完成了。我不建议一开始就去看别人封装好的抽奖组件因为组件把计算过程全部隐藏了出了问题你都不知道该从哪调。自己写一遍哪怕写得粗糙你对“转盘指向”这件事的理解会完全不同。1.2 用Canvas还是CSS我为什么选CSS画转盘的方案主要有两条路Canvas绘制和CSS样式。Canvas的优点是绘制灵活想画多复杂的渐变、纹理、边框都可以性能在扇区数量很大时也更好。CSS方案主要依赖conic-gradient渐变函数和transform: rotate()旋转代码更贴近普通前端习惯不需要操作画布上下文。简易版我选择纯CSS原因有三。第一扇区的背景色、边框、文字都可以直接用HTML元素和CSS声明调试成本低。想吃点视觉细节比如每个扇区加高光、圆角边缘也比Canvas里逐个fillPath方便。第二旋转动画用CSS的transition配合transform就能做浏览器会走合成器线程处理部分动效动画要比手动requestAnimationFrame逐帧改Canvas更省心代码量少一大截。第三项目的目标读者大概率是前端新手CSS方案里每一步都能用浏览器开发者工具直观检查教育和排障体验更好。当然这不是说Canvas不好。如果转盘的奖品数量超过20个或者需要实时响应用户拖拽旋转、摩擦阻力这种复杂物理效果Canvas会更有优势。简易版没必要为了“技术含量”引入它选简单可靠的方案本身就是一种合理工程决策。1.3 状态机设计从点击到结果的一次完整旅行我在动手前习惯先画一个状态模型这能避免“写着写着忘了按钮禁用”之类的问题。简易版抽奖转盘有三个状态idle等待抽奖按钮可点转盘静止。spinning转盘旋转中按钮禁用防止连点。done旋转结束正在展示结果弹窗按钮恢复。整个流程是用户在idle状态下点击按钮程序立刻根据概率决定中奖下标然后按下标计算需要旋转到的目标角度接着设置CSS过渡并更新transform当transitionend事件触发后进入done状态弹出结果用户关闭弹窗回到idle。这样一次抽奖的完整生命周期就闭环了。我特别强调“先决定中奖结果再让转盘转过去”。很多新手写抽奖转盘是反过来的——先随机转一个角度然后用角度反推中奖项。这两种方式在数学上可以等价但“先定结果”有一个优势概率控制放在一个纯函数里随机数抽得很清楚后续要接入后端或者加权也是在这一步改。“先转角度再引导弹”会让你在调试时很难受因为你无法直接指定“这次就想中手机”。2. 三个核心细节决定转盘靠不靠谱2.1 扇区几何奖品布局的角度计算转盘和切蛋糕本质上是一回事。一圈360度有n个奖品就分成n个扇区每个扇区的角度是sectorAngle 360 / n。比如6个奖品每个扇区60度8个奖品每个扇区45度。在CSS里我用conic-gradient来铺扇区。它的默认起点是12点方向颜色按顺时针排列。假设有prizes数组动态生成的background可以是const prizes [ { name: 谢谢参与, color: #FF6B6B }, { name: 5元红包, color: #FFD166 }, { name: 小米手环, color: #06D6A0 }, { name: 优惠券, color: #118AB2 }, { name: 再来一次, color: #F78C6B }, { name: 谢谢参与, color: #FF9F1C } ]; const sector 360 / prizes.length; let gradientStops []; prizes.forEach((item, i) { const start i * sector; const end (i 1) * sector; gradientStops.push(${item.color} ${start}deg ${end}deg); }); wheel.style.background conic-gradient(${gradientStops.join(, )});这里有一个容易忽略的细节扇区起始角度是“转盘自身坐标系”里的角度0度固定指向初始位置。无论之后rotate多少度扇区之间的相对几何关系不会变变的只是整个转盘在屏幕上的展示角度。把转盘想成一个贴了彩色标签的托盘旋转的是托盘标签本身的空间关系不变。给奖品名称加文字标签时要特别注意标签位置。最简单的方法是把每个标签绝对定位于盘面中心再用transform: rotate(起点角度) translate(半径) rotate(-起点角度)把它“扛”到对应扇区的中间。我知道不少初学者直接堆rotete数值结果文字全部歪倒。核心公式是每个扇区中心角度centerAngle i * sector sector / 2先转这个角度把标签放到12点方向再沿纵轴向外平移。2.2 指针与旋转的数学关系我觉得这个项目里90%的bug都出在这一步所以把推导过程写细一点。假设指针固定在容器的顶部也就是屏幕12点方向。转盘元素初始transform: rotate(0deg)此时指针指向的是第一个扇区的起点也就是转盘自身坐标的0度位置。当转盘顺时针旋转R度由于指针不动它在转盘自身坐标里就相当于逆时针移动了R度。所以指针此刻在转盘上指向的角度是pointerAngle (360 - R % 360) % 360这个式子是理解一切后续计算的基础。比如R0时pointerAngle0指在第一个扇区起点R90时pointerAngle270指针落在转盘自身坐标270度的位置。我们的目标反过来提前选中扇区i希望它的中心对准指针。扇区i的中心在自身坐标中是centerAngle i * sector sector / 2把二者对齐让pointerAngle centerAngle也就是(360 - R % 360) % 360 centerAngle解这个取模方程得到最终停止时R对360取余应该是R % 360 (360 - centerAngle) % 360我拿6个扇区验证过这个公式。第一个扇区中心是30度最终停止角度对360取余应等于330度。想象转盘顺时针转330度原12点方向向左偏了30度——等一下按这个模型扇区0的中心把自身30度的位置转到了屏幕30度位置而指针在顶部那应该指到扇区5啊。这里我需要立刻澄清防止文章把读者带沟里。这里问题的根源在于我把“转盘自身坐标”和“屏幕坐标”混淆了。重新严谨走一遍转盘自身坐标的0度就是初始时12点方向。每个扇区i占用自身坐标[i*sector, (i1)*sector]其中0度在顶部顺时针增长。当转盘顺时针旋转R度屏幕上展示的效果是自身0度位置跑到了屏幕R度方向。指针在屏幕12点也就是屏幕0度方向此时指针下方对应的内容是转盘自身坐标的(360 - R%360) %360的位置。当R330度指针下方是转盘自身坐标30度的位置。扇区0的自身坐标范围是[0, 60]中心恰好是30度。所以R330时指针确实对准了扇区0的中心。转盘顺时针转动330度以后原来的扇区0中心原本在12点方向它跟着转盘顺时针跑了330度理论上应该停在屏幕330度方向怎么又在12点呢我刚才的推演里又引入了错位。这里需要严格用“一个点跟随刚体旋转”的角度变化来想扇区0的中心的自身角度坐标是30度。转盘旋转R度后它在屏幕上的角度是30 R度因为整个转盘顺时针旋转所有点同步增加角度。指针在屏幕0度。要让扇区0中心到屏幕12点需要30 R ≡ 0 (mod 360)推出R ≡ 330 (mod 360)。这和计算结果一致。我刚才中途的直观描述“自身0度在屏幕R度方向指针下方是自身坐标(360-R)度的位置”这个描述没有问题当R330时指针下方是自身坐标30度。而扇区0的中心自身坐标就是30度。两个思路殊途同归说明公式正确。只是“指针指向扇区0中心”和“扇区0中心在屏幕0度”等价因此不存在矛盾。是我刚才在快速推演时自己绕进去了一下。所以最终停止角度可以直接代入上式。为了防止每次旋转角度都停留在接近360度的数值我们会在此基础上加上若干整圈比如4圈让转盘“多转几周”再停下观感更真实。这部分如果读者觉得绕建议直接用我在实操部分给出的累加公式不要自己临时推很容易踩到符号方向坑。2.3 概率控制与防连点设计先决定中奖项概率的写法就变得非常直接。常见做法是用权重数组const prizes [ { name: 谢谢参与, color: #FF6B6B, weight: 100 }, { name: 5元红包, color: #FFD166, weight: 30 }, { name: 小米手环, color: #06D6A0, weight: 1 }, { name: 优惠券, color: #118AB2, weight: 50 }, { name: 再来一次, color: #F78C6B, weight: 20 }, { name: 谢谢参与, color: #FF9F1C, weight: 100 } ];抽中下标的方法把所有权重累加成totalWeight然后生成[0, totalWeight)的随机数依次减去每个奖品权重当剩余值小于0时当前奖品就是中奖项。这种写法比Math.random() * 100判断百比分更通用权重可以不归一化成百分比维护成本低。防连点的核心是一个布尔锁。点击按钮后立刻进入spinning状态按钮置灰直到transitionend事件触发后再恢复。这里要注意两点一是监听transitionend时必须确认事件的propertyName是transform因为盘里可能还有其他元素的过渡二是旋转时间建议3-4秒过快会让用户觉得机械过慢又影响体验4秒配cubic-bezier(0.16, 1, 0.3, 1)的缓动比较合适。如果要稍微防一下刷新刷奖可以在localStorage里记录当天的抽奖次数超过上限就不允许抽。这只能拦住老实人拦不住懂技术的用户但简易版做到这个程度就够了。3. 完整实现纯手写一个可运行的抽奖转盘3.1 HTML骨架与CSS视觉我习惯把结构做得很简单一个容器包住转盘、指针、按钮转盘用div承载渐变背景文字标签单独放置最后用一个弹窗区域承载结果。div classlottery-container div classpointer/div div classwheel-wrap div classwheel idwheel/div div classwheel-center idwheelCenter/div /div button classstart-btn idstartBtn开始抽奖/button /div div classresult-modal idresultModal p classresult-text idresultText/p button classclose-result idcloseResult知道了/button /div转盘本身做成正方形用border-radius: 50%变成圆形。指针是一个绝对定位在顶部的小三角我用CSSborder实现.pointer { position: absolute; top: -12px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 14px solid transparent; border-right: 14px solid transparent; border-top: 36px solid #d33; z-index: 10; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3)); }盘面中心放一个圆形按钮盖住CSS gradient的中心区域视觉上更像“转盘”。这个圆形在JS里可以点击触发抽奖也可以单独用旁边的按钮。两头都能点容易混乱我这个demo统一走外部按钮。扇区的分隔效果可以直接在每个颜色块两端加白边做法是调整conic-gradient的stop让每个色块边界预留2-3度的间隙const gap 2; gradientStops.push(${item.color} ${start gap}deg ${end - gap}deg);这样每个扇区之间会出现自然的白色细缝比深色边框更清爽。3.2 标签定位文字不歪的小技巧扇区文字我强调用“独立span旋转反向旋转”的方式。先不翻译位移只把每个标签放到圆心位置再依次做三步变换按中心角转到指定方向、沿纵轴向上平移出半径、再反向旋转回水平。CSS变换是按从右往左的顺序执行的所以最终代码看起来是“先反转会正、再位移、再旋转”的顺序容易搞混我现场给一个可运行的简化写法。prizes.forEach((item, i) { const label document.createElement(span); label.className prize-label; label.textContent item.name; const centerAngle i * sector sector / 2; label.style.transform rotate(${centerAngle}deg) translate(0, -${radius}px) rotate(-${centerAngle}deg); wheel.appendChild(label); });其中radius是转盘半径的70%左右太靠近圆心文字会挤太靠近外沿会被裁掉。实际调的时候建议开着DevTools把radius当做变量多试几个值找到最顺眼的。3.3 JavaScript核心逻辑角度累加与中奖计算这是整个项目的心脏。我把代码写成一个可复用的对象变量名尽量直白。const lottery { currentRotation: 0, isRolling: false, init(wheel, prizes) { this.wheel wheel; this.prizes prizes; this.sector 360 / prizes.length; this.drawWheel(); this.bindEvents(); }, drawWheel() { /* 生成conic-gradient和标签 */ }, roll() { if (this.isRolling) return; const index this.getWinnerIndex(); const totalRotation this.calcTargetRotation(index); this.isRolling true; this.wheel.style.transition transform 4s cubic-bezier(0.16, 1, 0.3, 1); this.wheel.style.transform rotate(${totalRotation}deg); // 监听transitionend }, calcTargetRotation(index) { const centerAngle index * this.sector this.sector / 2; const targetMod (360 - centerAngle) % 360; const currentMod this.currentRotation % 360; const delta (targetMod - currentMod 360) % 360; const extraRounds 5; // 额外转5圈 this.currentRotation delta extraRounds * 360; return this.currentRotation; }, getWinnerIndex() { const totalWeight this.prizes.reduce((sum, p) sum p.weight, 0); let random Math.random() * totalWeight; for (let i 0; i this.prizes.length; i) { random - this.prizes[i].weight; if (random 0) return i; } return 0; } };calcTargetRotation里关键的逻辑就两行算目标角度对360取余应与targetMod一致再在当前角度的基础上补一个最小增量外加5整圈。这样不管上一次停在哪下一次都会自然地从当前位置继续转不会突然往后退。关于transitionend回调我是这样挂的this.wheel.addEventListener(transitionend, (e) { if (e.propertyName ! transform) return; this.isRolling false; const index this.lastIndex; this.showResult(this.prizes[index].name); });注意在roll()里必须先把lastIndex存好因为在回调触发时getWinnerIndex()已经重新随机过好几回了直接再去算会拿到另一个结果。3.4 让代码更健壮的边界处理简易版也要处理几个边界条件我在实际开发里踩过不少按钮点击发生在动画中途必须靠isRolling兜底不能在HTML里只加一个disabled属性就完事因为某些浏览器对disabled按钮的样式处理不一致。用户在动画中途切换到后台标签页transitionend可能会延迟触发导致按钮长时间不可用。解决方法是加一个setTimeout兜底比如4.5秒后强制释放锁和transition时长做个冗余。转盘累计旋转角度非常大时CSS值会有精度问题但数值达到上万度也没什么明显影响不用刻意清理。只是测试时别用无限累加否则浏览器性能会有损耗。如果奖品数量是奇数或者某个扇区的文字特别长标签可能溢出。可以在标签上设置最大宽度配合text-overflow: ellipsis和white-space: nowrap不过奖品名字建议控制在4个字以内这是产品层面要处理的。我习惯在init里把盘面绘制和事件绑定分离drawWheel负责视觉roll负责业务。这样后期想换成Canvas绘制的盘面只需要重写一个drawWheel其他逻辑不动。4. 实战踩坑与排查实录4.1 转盘总是指向不准怎么定位最典型的症状是“转盘明明停在了某个扇区弹窗却提示另一个奖品”。首先要确认两个坐标系是否一致conic-gradient的0度起点和计算中心角时用的0度起点是不是同一个位置。都从12点开始、顺时针增长公式才成立。排查方法很简单在calcTargetRotation里临时把extraRounds设为0点击一次看转盘是否停在目标扇区的正中间。如果停歪了但从头算都对那通常是标签定位用的半径和扇区绘制不匹配如果停的位置和公式预测有明显偏差那就是取模方向反了检查(360 - centerAngle) % 360是不是写成了centerAngle % 360。后一个错误会让指针指到目标的对称位置也就是隔着半个扇区或者一个扇区开外。4.2 连续点击导致动画疯狂乱转这个问题我见过很多次现象是狂点按钮以后转盘一直旋转不停或者停止位置随机。原因只有一个没有在旋转过程中锁住入口。防连点不能只改按钮样式必须用业务逻辑拦截。我在项目里把isRolling的判断放在roll()第一行这样就算外部代码不小心连续触发也会被直接return掉。还有一个连环坑是监听transitionend时没检查propertyName。转盘中心的圆形按钮如果设置了hover过渡或者弹窗淡入淡出它们的transition事件也会冒泡到wheel上导致isRolling被提前释放。判断e.propertyName transform这一行是必要的防御。4.3 概率测试和预期不一致很多人用Math.random()生成「0到99」整数然后判断落到哪个区间。这里有个隐蔽问题如果权重不是100而是像weight: 30这种相对权重就必须先算totalWeight再做区间判断直接用random % 100就会把权重比例破坏。还有一种情况是奖品顺序写反了测试时发现“中大奖”的概率特别高先检查getWinnerIndex里随机数累减的方向确保权重大的奖品在前面或后面都不影响命中逻辑因为累减的过程是无序的。想快速验证概率不需要真的点1000次按钮。我建议写一段临时测试代码在控制台直接循环调用getWinnerIndex100万次输出每个奖品的中奖次数分布。理论上越接近权重比越合理要是偏差超过几个百分点就要回头检查算法。4.4 针对“简易版”的取舍说明我做这个项目时特意砍掉了几个东西没有后端抽奖记录、没有轮次限制、没有音效、没有“再来一次”的自动跳转。砍掉的目的一是保持代码量可控二是把核心链路打磨清楚。如果你要把这个简易版投入到真实活动至少要补三件事服务端开奖前端只负责展示服务端下发的奖品不能自己算中奖结果。幂等校验用户抽奖时带一个活动订单ID服务端保证同一ID只能抽一次防止刷奖。结果归档每次抽奖记录用户ID、奖品、时间方便后续运营对账。比如一个运营活动要求“120人参与设置20个红包”那“先定结果”的思路正好可以扩展成后端预先生成奖品序列前端按顺序揭晓。这也是我推荐先胜后转的另一个原因。4.5 常见问题速查表现象根因解决方案指向偏了一个扇区扇区中心角起始误差检查conic-gradient的0度起点和标签定位是否一致指针指向对称位置取模方向写反确认使用(360 - centerAngle) % 360动画还没结束就能再抽transitionend监听未过滤或按钮锁失效roll()首行判断isRolling且过滤propertyName切换标签页后按钮卡死transitionend延迟或丢失加setTimeout兜底释放锁大奖“中奖率”异常高权重算法错误或随机数边界处理不当用累减法并做100万次循环验证分布文字标签方向不对忘记反向旋转修正按“反旋转-位移-旋转”顺序组合transform扇区边缘出现毛刺gradient stop精确度在颜色边界留2度gap结尾前的一点个人经验抽奖转盘这种项目真正的难点不在代码而在“让用户觉得结果公平且有趣”。概率计算只是基础转盘旋转的圈数、停下来的位置是否落在扇区正中间、结果弹窗出现的时机这些细节共同决定体验。我做完这个简易版以后又顺手把它扩展成了带后端签名校验和服务端发奖的完整版本前端这部分框架几乎没有改动只是把getWinnerIndex替换成了请求服务端接口。这也印证了“先决定中奖再计算角度”这套设计是带得走的。如果你也想做升级版建议从这个状态机入手而不是从头重写交互。
返回列表