简介:面向需要在年会或活动现场实现手机号批量滚动抽奖效果的前端开发者,这份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 class="prize_con"> <p class="prize_grade"><span>开始抽奖</span> <i>0</i>元</p> <div class="prize_list vetically"> <ul> <li>000000</li> <li>000000</li> ... <li>000000</li> </ul> </div> <p class="start"><img src="images/prize_start.png" alt=""></p> <ul class="prize_set"> <li class="set_grade">奖等 <select id="set_grade"> <option>选择奖等</option> <option>特等奖</option> <option>一等奖</option> <option>二等奖</option> <option>三等奖</option> </select> </li> <li class="set_people">人数 <input type="tel" placeholder="输入中奖人数" id="prizeCount"> </li> <li class="set_money">金额 <input type="tel" placeholder="输入中奖金额" id="prizeMoney"> </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 type="hidden" value="0" id="prize_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,用来在停止时clearInterval;num是中奖人数,从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;charset=utf-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 能不能正常打开,最后再按两下回车确认状态锁没被绕过。这套抽奖页面我前后改过三个版本,踩过重复中奖的坑、也见过现场数字抖动的尴尬,后来每次写抽奖页面都默认带上状态锁和名单导出。希望帮到你,让年会的大屏别再出岔子。
本文还有配套的精品资源,点击获取