前端抽奖页面实现:JavaScript数组洗牌与定时器随机不重复方案
发布时间:2026/10/17 17:10:16 锦皓数字建站

简介面向需要在年会或活动现场实现手机号批量滚动抽奖效果的前端开发者这份PDF文档提供了一套基于JavaScript的完整实现思路。内容围绕手机号滚动抽奖最核心的随机性与不重复性展开覆盖随机索引生成、已选号码去重、setInterval定时器滚动展示、中奖人数与奖项等级控制以及如何通过后台最终确认中奖号码以保障公平性同时配有示例代码、HTML页面布局和基础样式可直接将手机号数组替换成邀请码或其他号码使用并扩展设置奖项、金额和人数。资源压缩包为1个PDF文件大小仅56KB体积虽小但要点完整适合快速阅读后投入实际活动开发。该资源已有368人学习对希望提升年会、赛事或直播活动互动氛围的开发者和组织者具有不错的参考价值。1. 手机号批量滚动抽奖一场年会抽奖背后的定时器与索引随机做前端这些年接到的年会需求里最热闹的就是 js 手机号批量滚动抽奖。主持人口令一下大屏上几百个手机号快速滚动停的时候圈出几个人效果拉满。这份代码解决的正是这个场景把一批手机号或邀请码放进数组用定时器持续渲染不重复的号码点击开始和停止切换状态再配上奖等、人数、金额的输入面板。它适合两类人一类是年会前临时被安排写抽奖页面的前端另一类是看着网上各种抽奖插件觉得太黑匣子、想自己掌控逻辑的人。整个实现只有一份 HTML 加一个 jQuery不依赖后端也能跑通前端效果。2. 随机不重复的底层原理为什么打乱索引比随机取值更稳2.1 Math.random() 直接取下标的问题重复与分布不均先看最直觉的写法Math.floor(Math.random() * code.length)每次从数组里随机取一个下标。这个写法在抽奖场景有两个硬伤。第一是重复。第二次随机很可能抽到同一个手机号要避免重复就得维护一个已选集合每次生成后去查一次。抽的人少还好抽到后面集合越来越大随机数命中已选集合的概率越来越高经常连续生成十几个随机数才拿到一个没选过的性能上很浪费。第二是分布。Math.random()本身在 [0, 1) 区间近似均匀但取整之后每个下标的概率确实接近相等这里没问题。真正的问题在于如果你用「已选集合 do-while 重新生成」这种方案每次生成的新随机数分布是均匀的但「重新生成」的次数是几何分布整体逻辑绕而且写着写着容易在循环退出条件上写错。所以这个项目里没有走「随机取值 查重」路线而是换了个思路先把所有下标放进去打乱顺序再按顺序取前 N 个。一次打乱后面取到的天然不重复。var arr []; for (var i 0; i code.length; i) { arr[i] i; // arr 里存的是 code 的下标 } arr.sort(function () { return 0.5 - Math.random(); });这段代码做了两件事初始化一个连续索引数组然后调用sort传入一个随机返回正负数的比较器让排序结果尽量打乱。0.5 - Math.random()的值域是 [-0.5, 0.5)正负出现的概率在浮点精度下基本对半所以每次比较都会随机决定两个元素的相对顺序。对于 29 个元素这种量级视觉上的随机性足够。打乱后只需要按顺序取arr[0]到arr[num - 1]的值再用它们去code里取手机号天然不重复。2.2 数组洗牌法的代价sort 随机比较器的玄学上面这个写法有个不太严谨的地方Array.prototype.sort的比较器按规范应当保持一致也就是同一对元素如果被比较两次应该返回相同结果。这里每次比较都随机返回排序结果就和引擎的排序算法强相关。主流引擎用的是 TimSort对小数组会走插入排序的优化路径随机比较器产生的排列并不是等概率的。但要说清楚这不是当前实现的缺陷——29 个号码、视觉滚动、主持人喊停这个场景里概率偏好根本看不出来。真正需要在意的是另外一个点这种写法会执行多次比较比较次数大约是n * log(n)级别每个比较都调用一次Math.random()。如果数组是几千上万人的手机号打乱一次的性能和随机性都会变得不可控。那什么时候要换更好的洗牌算法当你把数据源换成全公司几千号人、并且每次滚动都要重新打乱时就该用下面的 Fisher-Yates 洗牌。function shuffle(arr) { for (var i arr.length - 1; i 0; i--) { var j Math.floor(Math.random() * (i 1)); var tmp arr[i]; arr[i] arr[j]; arr[j] tmp; } return arr; }i从末尾向前走每次把当前位置和 0 到 i 之间的随机位置交换。关键在于Math.random() * (i 1)保证每个位置被换到的概率相同一轮下来所有排列等概率时间复杂度 O(n)一次遍历完成。替换原代码时只需要把arr.sort(...)换成arr shuffle(arr)其余逻辑不变。2.3 一个隐藏前提打乱的是下标不是手机号本身回看原始代码arr里放的不是手机号而是手机号在code数组里的下标。打乱下标、取出前 N 个再去映射手机号这个中间层算是这个小项目里最值得借鉴的细节。为什么绕一层因为手机号可能是长字符串也可能是业务里的邀请码直接打乱手机号数组当然也可以但打乱之后你拿到的中奖结果是一串手机号想要知道「第几个手机号中奖了」还得反向查找多一次遍历。而打乱下标之后arr[i]本身就是原数组的位置后续接后台、做去重、写中奖名单导出都只需要维护这份下标列表就够了。function showRandomNum(num) { var li ; for (var i 0; i code.length; i) { arr[i] i; } arr.sort(function () { return 0.5 - Math.random(); }); for (var i 0; i num; i) { var index arr[i]; li li code[index] /li; } $(.prize_list ul).html(li); }showRandomNum(num)接收一个参数num表示这一轮要显示多少个号码。先重置arr打乱后取前num个下标拼成li字符串一次性写入prize_list里的ul。注意它每次调用都会重新初始化arr所以定时器每次触发都是全新一轮打乱视觉上是号码在不断跳变。3. 抽奖页面的完整实现HTML 布局、状态控制与 30ms 滚动节奏3.1 从 10 个占位 li 到奖品面板HTML 结构怎么看页面结构不算复杂核心是prize_con这个全屏容器。背景图铺满中间prize_grade显示奖等和金额下面prize_list是滚动号码区再往下是开始按钮右下角是设置面板。div classprize_con p classprize_gradespan开始抽奖/span i0/i元/p div classprize_list vetically ul li000000/li li000000/li ... li000000/li /ul /div p classstartimg srcimages/prize_start.png alt/p ul classprize_set li classset_grade奖等 select idset_grade option选择奖等/option option特等奖/option option一等奖/option option二等奖/option option三等奖/option /select /li li classset_people人数 input typetel placeholder输入中奖人数 idprizeCount /li li classset_money金额 input typetel placeholder输入中奖金额 idprizeMoney /li /ul /div样式上有一个容易忽略的细节prize_set用了position: absolute固定在右下角prize_con本身也是绝对定位铺满全屏。也就是说整个抽奖页面是脱离文档流的它要求这个页面就是独立的抽奖展示页不是嵌在某个业务系统里的一段内容。prize_list里的li宽度是 20%一行放 5 个10 个占位刚好两行。.vetically这个类做了 flex 居中prize_list自身还带overflow: hidden防止号码滚动时溢出。CSS 里font-size: 0设置在外层的ul上消除了li之间因为空白字符产生的 4 像素间隙否则 5 个 20% 的 li 会折行。这些细节都是可以直接抄作业的。3.2 开始与停止的状态机prize_btn 锁住重复启动抽奖按钮的难点不在「点击开始」而在「防止疯狂连点」。如果没有状态锁用户快速点两次开始就会创建两个setInterval号码滚动速度翻倍再点停止时只清掉一个定时器另一个还在跑。原代码用了一个隐藏输入框作为状态标志input typehidden value0 idprize_btn$(.start).click(function () { if ($(#prize_btn).val() 0) { // 校验参数通过后置为 1启动定时器 $(#prize_btn).val(1); myNumber setInterval(function () { showRandomNum(num); }, 30); } else { $(#prize_btn).val(0); clearInterval(myNumber); } });点开始时先读prize_btn的值0 就进入「未开始」分支执行参数校验、启动定时器、把值改成 1再点一次进入 else 分支清定时器、把值改回 0。这样无论用户怎么连点同一时刻最多只有一个定时器在运行。为什么用隐藏input而不是一个普通全局变量两种方式在这个页面里效果一样但隐藏input有一个好处它的值可以在浏览器控制台里直接改调试时想跳过某个状态很方便。实际项目中全局变量ran其实没被用到真正起作用的是这个prize_btn。我一般写这类状态锁会用普通变量但隐藏input对非技术同事来说更容易理解——他们在浏览器里看到的就是「这个框变成了 1」。3.3 定时器 30ms滚动速度、DOM 重建与性能取舍myNumber setInterval(...)的间隔是 30 毫秒约等于每秒 33 次刷新。对抽奖这种持续 3 到 10 秒的滚动来说这个频率已经接近流畅的视觉下限。每次触发都调用showRandomNum(num)重新打乱、重新拼li、重新设置innerHTML。这里有个性能上的取舍需要说清楚innerHTML整体替换意味着每 30ms 销毁重建 10 个li节点。10 个节点的量级对现代浏览器毫无压力但如果把num调大、或者手机号列表增长频繁整体替换就会出现肉眼可见的闪烁和掉帧。myNumber setInterval(function () { showRandomNum(num); // num 来自输入框最大 10 }, 30);参数说明myNumber是定时器 ID用来在停止时clearIntervalnum是中奖人数从prizeCount输入框读取。如果现场屏幕分辨率和电脑配置一般我会把 30 改成 50滚动速度稍微慢一点但稳定性好很多。视觉上 50ms 和 30ms 的差别在高速滚动的号码上几乎看不出。4. 交互细节与控制参数奖等联动、人数上限和回车键操作4.1 奖品面板的数据联动change 与 keyup 绑定的细节设置面板改动时要同步到大屏展示区这个联动由两个事件完成。$(#set_grade).change()监听奖等下拉框选中「特等奖」时页面顶部的prize_grade span文本同步变成「特等奖」。$(#prizeMoney).keyup()监听金额输入每次松键都会把prize_grade i里的数字更新成输入值实时反馈到屏幕上。选择这个事件组合是有讲究的下拉框用change是因为它的取值只有选项变化时才需要响应金额输入用keyup是因为主持人可能边输入边看大屏输入过程中实时变化比失焦后再更新更符合现场调参的手感。如果你用change监听金额输入会出现输完数字还得点一下其他地方才更新现场看着就很呆。奖等和金额都展示在同一个prize_grade里「奖等」放span「金额」放i。项目里i标签被重置了font-style: normal因为默认斜体在舞台上不好看。4.2 单次 10 人上限从哪里来一行 5 个两行布局的约束prizeCount输入框有三行校验空值直接提示大于 10 直接提示其他情况通过。这个「10」不是拍脑袋定的它正好对应prize_list里 10 个li的位置。li宽度 20%一行 5 个10 个就是两行。如果你把li的宽度改成 25%一行 4 个那 10 个位置会变成两行多两个布局变得不整齐。这个页面里中奖人数上限其实是由布局决定的改人数之前先想清楚一行放几个、放几行。想要支持 12 人就不能只改校验得同步改li宽度或者增加li数量。else if ($(#prizeCount).val() 10) { alert(单次抽奖人数不能超过10人); return; }这段校验代码放在点击开始的分支里配合其他三个if形成一个完整的参数检查链是不是选了奖等、是不是填了人数、人数是不是超标、是不是填了金额。任何一步不通过都会return不会进入后面的setInterval。4.3 回车键触发与事件兼容演示时的掌机体验$(document).keydown()把空格之外的回车键绑定到开始按钮上这个设计在年会场景里非常实用。主持人拿着无线演示笔或者键盘不用跑到屏幕前点按钮按一下回车就开始再按一下停止。对于不用键盘的触摸屏场景这个功能是多余的但它不干扰正常点击保留无妨。$(document).keydown(function (event) { var e event || window.event || arguments.callee.caller.arguments[0]; if (e e.keyCode 13) { $(.start).click(); } });event || window.event是兼容旧版 IE 的写法arguments.callee.caller.arguments[0]这种回溯写法现在已经不推荐了现代浏览器里直接event.key Enter更干净。替换时可以简化成$(document).keydown(function (event) { if (event.key Enter) { $(.start).click(); } });还有一个现场会踩的坑如果页面上有其他表单元素回车键触发document级监听的同时可能触发表单默认提交。抽奖页里没有真正的form所以影响不大但如果你往这个页面里加了搜索框之类的表单要在监听里加event.preventDefault()阻断默认行为。5. 避坑与常见问题排查前端抽奖最容易翻车的四个场景5.1 现象上一轮中奖的号码下一轮再次出现在大屏上抽完第一轮三等奖紧接着抽二等奖屏幕上又滚动出一个刚才已经中奖的号码。观众席立刻有人喊「这不是刚才中过的吗」现场气氛直接尴尬。原因showRandomNum每次打乱的是完整的code数组所有号码都在池子里「已中奖」这个概念在前端没有任何记录。原代码的注释里也写得很直白去除中奖手机号需要后台控制。解决在前端做一层临时的中奖过滤。每次停止时把当前显示的号码收集到一个winnerList数组里后续调showRandomNum前先从池子里过滤掉这些号码。这一步只能保证当前页面存活期间不重复刷新页面就失效最终还是得靠后台记录杜绝重复。5.2 现象号码滚动时左右抖动屏幕看起来发虚数字是li里的文本每个数字宽度不一样比如数字「1」和「8」的字符宽度差很多。滚动时li的宽度被固定为 20%但数字本身是左对齐还是居中取决于有没有text-align: center。项目里li设置了居中可字符宽度差异依然会让数字在视觉上有轻微位移。原因网页默认字体里数字宽度不一致每次替换成新数字时字符的实际渲染宽度不同。这是排版问题不是逻辑问题。解决给li里的数字指定等宽显示。最简单的方式是在 CSS 里加font-variant-numeric: tabular-nums让浏览器把数字按等宽排布或者直接把字体换成带等宽数字的字体Arial本身对数字的处理在不同系统上表现不一致换Consolas或者加font-family: monospace更稳。5.3 现象快速点击开始号码越滚越快停止也停不干净主持人手速快连续点了几次开始按钮结果大屏上的号码滚动速度快到看不清再点停止号码还在滚。原因多个setInterval同时运行。每个「开始」点击都创建一个新的定时器多个定时器叠加触发滚动频率翻倍而停止时只clearInterval了一个定时器 ID其余定时器还在跑。解决原代码里的prize_btn状态锁已经能挡住这个问题——第一次点击进入抽奖状态第二次点击是停止根本不给你第三次启动的机会。如果你改了代码、去掉了这个状态锁一定要补回来。另一个建议是停止时主动clearInterval(myNumber)外再把myNumber置空形成双重保险。5.4 现象浏览器刷新后中奖名单全部丢失抽奖结束记录下中奖手机号结果误触刷新大屏回到初始状态刚才抽出的号码全没了只能重新抽一遍。原因前端内存数组winnerList是运行时状态刷新即销毁。项目原代码里根本没有收集获奖结果的逻辑显示出来的号码如果不手动记录刷新后没有任何痕迹。解决抽奖页做成不能随意刷新的独立页面且停止时立即把当前结果写入localStorage页面加载时检测到有存档就提示「是否恢复上次中奖名单」。更稳妥的做法是停止时把结果POST到后台接口由后台持久化记录前端只负责展示。6. 进阶用法中奖名单导出与后台配合的完整闭环前面几章都在讲怎么让抽奖转起来这一章讲停下之后的事怎么把结果留住。6.1 停止时的中奖名单导出原代码在停止分支里只做了两件事改按钮状态、清定时器。我在同样的位置加一个「收集结果」步骤。因为showRandomNum里拿到的是code[index]停止时把当前显示的号码直接读出来存进数组再生成 CSV 文件。var winnerList []; function collectWinners() { $(.prize_list li).each(function () { winnerList.push($(this).text()); }); exportCSV(winnerList); } function exportCSV(rows) { var csv \ufeff rows.map(function (item) { return item; }).join(\n); var blob new Blob([csv], { type: text/csv;charsetutf-8; }); var a document.createElement(a); a.href URL.createObjectURL(blob); a.download winners.csv; a.click(); URL.revokeObjectURL(a.href); }collectWinners从当前 10 个li节点里把文本取出来exportCSV把数组拼成一行一个号码的文本。\ufeff是 UTF-8 BOM加上它之后导出的 CSV 用 Excel 打开才不乱码。URL.createObjectURL生成临时下载链接下载完用revokeObjectURL释放掉。注意这个 CSV 只有手机号一列想要包含奖等和金额需要在exportCSV前把rows改成二维数组。6.2 把数据源换成接口异步加载手机号再启动现在数据是硬编码在code数组里的真实的年会场景数据一般在后台。改造思路是加载完数据再允许点击开始。var code []; $.get(/api/audience, function (res) { code res.data; $(#prizeCount, #prizeMoney).prop(disabled, false); });接口返回后填充code同时把设置面板解禁。没拿到数据前按钮置灰避免主持人提前点击导致空数组打乱。这一步把前端抽奖和后台数据打通中奖名单也可以顺势提交到/api/prize做持久化。年会之前我每次都强制走一遍流程清空winnerList确认code长度大于 10跑一轮完整的开始和停止检查 CSV 能不能正常打开最后再按两下回车确认状态锁没被绕过。这套抽奖页面我前后改过三个版本踩过重复中奖的坑、也见过现场数字抖动的尴尬后来每次写抽奖页面都默认带上状态锁和名单导出。希望帮到你让年会的大屏别再出岔子。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。