ARTICLE DETAIL

资讯详情

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

JavaScript抽奖转盘结果固定问题:随机数、角度与动画修复

JavaScript抽奖转盘结果固定问题:随机数、角度与动画修复 简介这是基于 JavaScript 与 jQuery 实现的圆形大转盘抽奖网页源码适合前端初学者、Web 互动开发人员以及需要为运营活动快速搭建抽奖页面的开发者参考用于实现转盘旋转、结果判定、界面适配等常见交互。压缩包共包含 35 个文件整体约 271KB以页面、脚本、样式、图片等前端文件为主另附多份文本笔记、网址链接和工程配置文件目录简洁、便于按需查阅。目前已有 299 人学习下载。资源中不仅提供可直接运行的转盘活动页面还整理了移动端 meta 标签、script 异步加载、jQuery 多种就绪函数写法等关键知识点帮助读者理解页面加载顺序与兼容处理尤其保留了“每次转盘结果总是一样的”这一真实问题记录能引导读者从随机数生成、事件绑定等角度排查 bug加深对前端交互逻辑、异步加载和性能优化细节的理解。1. 一份写着“每次转盘结果都一样”的 JavaScript 抽奖源码拿到一套完整的 JavaScript 圆形大转盘抽奖源代码最扎眼的不是 index.html 里那张奖品图而是压缩包里的排错记录“lj每次转盘的结果总是一样的”。这套代码用 jQuery 2 和 CSS 搭了一个 H5 抽奖转盘奖品数组、扇区样式、启动按钮全都有属于把奖品文案一换就能直接用的活动页模板。但随机性一旦出错用户每次抽中的都是同一个位置活动效果就会变成灾难。下面把源码里的绘制方式、旋转角度、回调顺序和移动端 meta 组合逐个拆开重点说明转盘结果为什么会被写成死值以及怎样用可复现的方法验证抽奖公平性。需要写 H5 活动页或内部抽奖工具的人可以直接照这套思路改。2. 圆形转盘的 CSS 绘制扇区生成与指针定位2.1 先把 HTML 骨架和奖品数组定下来转盘从结构上可以拆成三部分可旋转的圆盘、固定在顶部的指针、触发抽奖的按钮。源码里的 index.html 大致长这样div classwheel idwheel div classwheel-body idwheelBody/div div classwheel-pointer/div /div button classwheel-btn idstartBtn抽奖/buttonwheel-body是唯一会被旋转的容器指针和按钮不参与转动这样计算目标角度时只需要处理一个元素的 transform。圆盘里的扇区和文字不会写死在 HTML 里而是由 JavaScript 按奖品数量动态生成因为奖品列表经常要改写死 HTML 每次都要同步改 CSS 角度。奖品数组建议放在独立变量中const prizes [定制杯, 优惠券, 谢谢参与, 会员月卡, 蓝牙耳机, 谢谢参与];这里奖品顺序就是扇区从上方开始顺时针排列的顺序后面所有角度计算都以这个数组下标为准。注意“谢谢参与”这类空奖也要占一个数组位置否则扇区数量和奖品数量对不上。2.2 用 conic-gradient 代替扇区图片很多老模板使用背景图来画转盘改文字要重新切图。这个源码包里保留了 bootstrap.css 和一堆 png实际扇区可以完全用 CSS 渐变代替。一个 6 等分的转盘是这么画的.wheel-body { width: 320px; height: 320px; border-radius: 50%; position: relative; background: conic-gradient( #ff6b6b 0deg 60deg, #ffd93d 60deg 120deg, #6bcb77 120deg 180deg, #4d96ff 180deg 240deg, #b983ff 240deg 300deg, #f4a261 300deg 360deg ); transition: transform 4s cubic-bezier(0.12, 0.76, 0.24, 1); }conic-gradient默认从 12 点方向开始顺时针铺色每 60 度一个色块正好对应 6 个扇区。这里的关键参数是每个色块的角度它必须和 JS 里的const sectorAngle 360 / prizes.length保持一致颜色段数和数组长度不一致时指针定位会直接错位。transition的 4 秒和 cubic-bezier 控制旋转手感前面加速、末端减速最后停住时不会来回弹。如果想让转盘多转几圈不需要改 transition改 JS 里的 targetAngle 就够了。这个方案不是没代价。conic-gradient在 IE 里不支持如果项目必须兼容 IE11就得退回背景图或者改用clip-path切扇形。下面是两种方案的取舍维度conic-gradient背景图切图修改奖品文字改 HTML 和 JS不用重做图片每次都要重新出图扇区角度精度精确到 0.1 度取决于切图精度IE11 支持不支持支持动态增加奖品数只改样式里的颜色段和 JS要重新设计实际做活动页时我一般优先用 conic-gradient。一根渐变代码能搞定 6 到 12 等分的转盘开发效率高真遇到 IE 场景再换图片。2.3 动态生成扇区文字并保持可读颜色块画好之后还要在每个扇区中间显示奖品名。直接为每个奖品创建一个绝对定位的文本块const sectorAngle 360 / prizes.length; const radius 120; // 文字离圆心的距离单位 px prizes.forEach((name, i) { const text document.createElement(div); text.className wheel-text; text.textContent name; const angle i * sectorAngle sectorAngle / 2; text.style.transform rotate(${angle}deg) translateY(-${radius}px) rotate(${-angle}deg); document.getElementById(wheelBody).appendChild(text); });angle是当前扇区中心相对 12 点方向的顺时针角度。transform 先把文本从顶部转到对应方向再沿 Y 方向平移 radius 像素来到圆盘中间最后反向旋转-angle让文字保持水平。如果省略最后的反向旋转文字会斜着排列在活动页上很难读。radius 建议控制在转盘半径的 60% 左右太靠近中心会挤在一起太靠近边缘则会被圆角裁掉。2.4 指针固定在顶部保证只有一个参照系指针用 border 画比图片少一个请求.wheel-pointer { position: absolute; top: -8px; left: 50%; width: 0; height: 0; border-left: 14px solid transparent; border-right: 14px solid transparent; border-top: 42px solid #e63946; transform: translateX(-50%); z-index: 10; }指针中心就在转盘顶部 12 点方向这就是所有角度计算的唯一基准。转盘旋转后指针指向的是扇区中心、扇区边缘还是空白取决于后面 JavaScript 里目标角度的计算方式。为了便于对齐这篇文章约定扇区 i 的中心角度是i * sectorAngle sectorAngle / 2并且把这个角度作为转盘停止时指针应该对准的位置。这个口径定了后续代码就不会改一处崩三处。3. 抽奖逻辑设计jQuery 事件绑定与旋转角度计算3.1 不同初始化写法的执行时机源码包中有一个链接专门讲$(function(){})、(function($){})(jQuery)、$(document).ready(function(){})的区别。实际项目里活动页脚本通常放在body底部用哪种都行但要搞清楚差异。常见写法// 写法 ADOM ready 后执行最稳 $(function () { $(#startBtn).on(click, startDraw); }); // 写法 B立即执行的 jQuery 插件写法不等 DOM ready (function ($) { $(#startBtn).on(click, startDraw); })(jQuery); // 写法 C原生版本 document.addEventListener(DOMContentLoaded, function () { document.getElementById(startBtn).addEventListener(click, startDraw); });写法 B 只是把 jQuery 对象传进函数函数会立刻运行。如果脚本还在head里此时按钮元素还没有解析出来$(#startBtn)拿到的是空集合点击事件自然绑定不上。写法 A 内部就是$(document).ready(...)的缩写会把回调推迟到 DOM 解析完。原生 DOMContentLoaded 也能达到相同效果但如果脚本是后续通过异步方式插入页面的DOMContentLoaded 可能已经触发监听器不会再执行相比之下$(function(){})内部会检查 readyState所以更保险。源码里的页面大量使用 jQuery初始化部分用 A 最省心。如果希望在 DOM 未就绪前先拉接口配置奖品可以在 A 外面再套一层(function($){...})(jQuery)来做依赖传参但事件绑定一定要放进 ready 回调。3.2 随机中奖下标与目标角度换算核心抽奖函数需要做三件事随机选一个下标、算出转盘要转到的绝对角度、让旋转结束后把结果弹出来。代码如下let currentAngle 0; let isRunning false; const sectorAngle 360 / prizes.length; function startDraw() { if (isRunning) return; isRunning true; const index Math.floor(Math.random() * prizes.length); const centerAngle index * sectorAngle sectorAngle / 2; const rounds 5 * 360; // 至少转 5 圈视觉效果才自然 const targetAngle rounds (360 - centerAngle) % 360; const angleIncrease currentAngle - (currentAngle % 360) targetAngle; currentAngle angleIncrease; const wheel document.getElementById(wheelBody); wheel.style.transform rotate(${currentAngle}deg); const onEnd () { wheel.removeEventListener(transitionend, onEnd); alert(抽中 prizes[index]); isRunning false; }; wheel.addEventListener(transitionend, onEnd); }这段代码的换算逻辑是扇区 i 的中心在转盘本地坐标中位于centerAngle处指针固定在顶部。当转盘整体旋转角度 R 后指针看到该扇区的实际位置是centerAngle R要让它回到指针处就要让centerAngle R ≡ 0 (mod 360)所以单圈角度是360 - centerAngle。targetAngle用五圈加上这个角度再通过currentAngle - (currentAngle % 360)去掉当前已转整数圈确保转盘永远往前转不会往回倒。transitionend事件在 4 秒动画结束后触发此时读取闭包里的 index 就是最终结果。参数调整时注意三点rounds越大动画越久超过 8 圈用户会烦躁centerAngle必须由 prizes 数组长度现场计算不能写死成 45 度或 60 度isRunning锁必须放在函数最前面否则用户在动画进行中连续点击会叠加两次旋转。3.3 加权随机不要只用 Math.random 打天下很多抽奖活动不希望每件奖品概率相同常见做法是给每个奖品挂一个 weight 权重再按权重分布生成下标const weightedPrizes [ { name: 优惠券, weight: 60 }, { name: 会员月卡, weight: 25 }, { name: 蓝牙耳机, weight: 10 }, { name: 定制杯, weight: 4 }, { name: 谢谢参与, weight: 1 } ]; function rollIndex(pool) { const total pool.reduce((sum, item) sum item.weight, 0); let rand Math.random() * total; for (let i 0; i pool.length; i) { rand - pool[i].weight; if (rand 0) return i; } return pool.length - 1; }rollIndex把随机数缩放到总权重区间然后依次减去每个奖品的权重减到小于等于 0 时返回当前下标。这样权重越大在随机数区间里占的份额越大。注意概率不是页面上的视觉比例而是权重比例这和 2.2 的扇区颜色角度无关。这里有一个容易踩的坑如果把权重直接当扇区角度画视觉大的扇区中奖率高用户一眼就能看出来。所以活动页面的扇区通常等分但中奖概率按权重走这个配置只放在前端。下表列出了几种常见随机误用遇到“结果总是固定”时可以逐条排查误用方式现象修复Math.random().toFixed(0)只有 0 或 1 两个结果改用Math.floor(Math.random() * n)随机下标在循环中被闭包覆盖弹窗结果永远等于最后一个奖品用let index保存本次下标transitionend 回调绑定多次一次点击弹出两三次结果用removeEventListener或off随机数被接口固定值覆盖每次都是同一个奖品检查请求里是否传了 mock 参数4. 修复“结果总是一样”抽奖随机性故障排查4.1 先复现再判断是渲染还是逻辑问题源码包里写着结果总是相同这类 bug 第一步不是改代码而是把随机函数单独摘出来跑。直接在浏览器 Console 中执行下面的脚本const counts new Array(prizes.length).fill(0); for (let i 0; i 10000; i) { const idx Math.floor(Math.random() * prizes.length); counts[idx] counts[idx] 1; } console.table(counts.map((c, i) ({ index: i, prize: prizes[i], count: c })));如果 counts 数组中某一个值接近 10000、其他接近 0说明Math.floor(Math.random() * prizes.length)这段随机逻辑本身出了问题常见原因是把prizes.length写成了固定数字。如果 counts 分布均匀但转盘停下来总是同一个扇区问题就出在角度换算而不是随机函数重点检查centerAngle计算时用的是不是同一个sectorAngle。再确认一遍 2.2 的颜色段角度conic-gradient里每段的度数、CSS 中扇区颜色数量、prizes数组长度必须三者一致。有一个地方写成360 / 8另一个地方写成45看起来一样但数组换成 6 项时就会错位。下表按排查优先级列出了组合场景现象可能原因排查优先级一万次模拟都落在一个下标随机函数被固定值覆盖先查接口是否回传固定 result概率分布正常但视觉总停同一点centerAngle 用了旧数组长度打印 currentAngle 和 index动画没结束就弹结果transition 监听属性过多或回调未移除检查 event.propertyName连续点击后结果错乱缺少 isRunning 锁在函数入口加互斥判断4.2 修复随机分布异常如果 4.1 发现是随机函数问题通常改一段就能解决// 修复前错误用法 const index Math.random().toFixed(0); // 只有 0 和 1 // 修复后 const index Math.floor(Math.random() * prizes.length);Math.random()返回[0,1)区间的小数toFixed(0)会四舍五入0.5 以下归 0、以上归 1中等概率掉落会被压成两端六个奖品的转盘结果只会出现下标 0 和 1。Math.floor(Math.random() * n)把随机小数等比例放大到 0 到 n-1 的整数区间这是前端抽奖最常规的写法。如果奖品数量很大可以用crypto.getRandomValues获取更安全的随机数低价值活动用 Math.random 性能更好、代码也更简单。4.3 transitionend 重复触发导致结果被覆盖另一种常见写法是让匿名函数直接作为事件回调但每次点击都 addEventListener上一次回调没有被移除动画结束后多个回调一起触发结果看起来就是被覆盖成某一次的奖品。修复时把回调具名并在结束后移除function onTransitionEnd() { wheel.removeEventListener(transitionend, onTransitionEnd); showResult(prizes[index]); isRunning false; } wheel.addEventListener(transitionend, onTransitionEnd);注意 transitionend 有一个特点只要transition-property里只有一个属性事件会触发一次如果有多个属性同时变化会对每个属性触发一次。转盘只旋转 transform 时还好但有些模板会把 transform 和 box-shadow 一起写进 transition这时同一个 transitionend 会触发两次。稳妥做法是在回调里检查event.propertyName transform或者干脆用setTimeout卡在动画时长后取值。用 setTimeout 时时间要设置成 CSS transition 的 duration 再加上 50ms 缓冲否则转盘还没完全停住就开始弹窗。4.4 浮点累积误差与角度溢出转盘每次增加的角度会一直累加几十次抽奖后 currentAngle 可能已经到几万度CSS transform 能处理大角度但小数位的累积误差会让停靠位置逐渐偏离扇区中心。修复方式是只保留整数度const angleIncrease Math.round( currentAngle - (currentAngle % 360) targetAngle ); currentAngle angleIncrease;Math.round之后每次旋转都在整度上扇区中心不会因为 0.1 度的误差偏移。另一个做法是每次把 currentAngle 对 360 取模但因为 targetAngle 已经包含圈数取模后会导致圈数丢失所以先取整圈部分再加 targetAngle 是最干净的。到这里结果不再固定也已经处理掉多次回调和精度问题剩下的工作就是移动端适配。5. 移动端 meta 标签、脚本加载顺序和结果分布验证5.1 活动页常用的 meta 配置源码包里的 meta 笔记整理了几个实际有用的标签。做 H5 抽奖页时我会在head里放这组配置meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno meta nameformat-detection contenttelephoneno, emailno meta nameapple-mobile-web-app-capable contentyes meta nameapple-mobile-web-app-status-bar-style contentblack-translucentviewport 让转盘在 iPhone 和安卓上不缩放避免 320px 的 CSS 被浏览器放大到 375px 时出现模糊。format-detection用来关掉 iOS Safari 对连续数字的自动识别否则活动文案里出现电话号码会被自动转成可点击链接。apple-mobile-web-app-capable和 status-bar-style 是给添加到主屏幕的 Web App 用的前者移除顶部工具栏和菜单后者把状态栏改成透明能露出页面背景色。meta 标签作用viewport控制页面可视宽度和缩放format-detection禁止自动识别电话、邮箱apple-mobile-web-app-capable全屏显示隐藏 Safari 工具栏apple-mobile-web-app-status-bar-style设置状态栏文字颜色与透明度注意user-scalableno在 iOS 10 之后部分版本会被 Apple 忽略不能完全依赖它阻止双指缩放活动页如果必须禁缩放按钮和文字还要专门适配大屏。5.2 async 与 defer 对抽奖脚本的影响源码包里有一条关于script async的笔记这条属性确实会改变脚本执行顺序。转盘页面依赖 jQuery 和业务代码典型写法script srcjquery.min.js/script script srcapp.js/script普通同步脚本按顺序执行app.js 运行时 jQuery 一定已经存在。如果改成script srcjquery.min.js async/script script srcapp.js async/script两个脚本都会异步下载谁先下载完谁先执行。当 app.js 先执行时$还没定义页面直接报错转盘按钮绑定不上。defer会保证脚本按 HTML 顺序执行但要等 DOM 解析完所以对依赖 DOM 的抽奖脚本比 async 更安全script srcjquery.min.js defer/script script srcapp.js defer/script实际项目中我的习惯是业务脚本不写 asyncdefer 也不一定写直接放在body底部最稳。只有统计脚本和埋点脚本用 async因为它们不依赖页面 DOM。如果需要更高性能可以把 jQuery 换成原生 JS 实现转盘H5 首屏会快不少。5.3 上线前用模拟抽奖验证分布转盘代码写完不能只看一次结果要在 Console 里批量模拟。下面脚本调用 3.3 的rollIndex统计一万次抽取结果function simulate(times 10000) { const counter new Array(weightedPrizes.length).fill(0); for (let i 0; i times; i) { const idx rollIndex(weightedPrizes); counter[idx] counter[idx] 1; } console.table(counter.map((c, i) ({ prize: weightedPrizes[i].name, weight: weightedPrizes[i].weight, count: c, actualRate: (c / times * 100).toFixed(2) % }))); } simulate(10000);如果运营要求“优惠券”中奖率 60%跑一万次后 actualRate 应落在 59% 到 61% 之间。这个波动范围随着 times 增大而收窄想看更稳定的结果可以把 times 加到 50000。还要在手机上真机点十次确认每次动画结束后弹窗的奖品和 Console 中打印的 index 对应避免角度换算和数组顺序错位导致 UI 指向 A 奖品、弹窗却报 B 奖品。最后删掉 console 调试代码把alert换成项目内的弹层组件这套转盘就可以正式发布。本文还有配套的精品资源点击获取
返回列表