ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手机号批量滚动抽奖:原生JS动画实现与避坑指南

手机号批量滚动抽奖:原生JS动画实现与避坑指南

简介:该资源是一份面向网页前端开发者的JavaScript手机号批量滚动抽奖实现代码,适用于年会、活动互动等场景,帮助读者快速搭建随机滚动、不重复中奖的抽奖页面。资料为单个PDF文档,压缩包大小仅56KB,内含完整示例代码与实现思路说明,重点讲解了Math.random()随机索引生成、setInterval定时滚动、已选号码去重、中奖人数控制等关键技术点。已有368人浏览学习。通过阅读这份PDF,读者可以掌握从页面结构、样式布局到抽奖逻辑的完整写法,还能在此基础上结合后台接口扩展为真实可用的活动抽奖系统,兼顾公平性与安全性。

1. 手机号批量滚动抽奖:先分清结果层和表现层再动手

手机号批量滚动抽奖这几年一直是营销活动页的常客:报名用户留下手机号,活动结束时用大屏滚动的方式抽出中奖号码。很多团队的第一次实现,都是把手机号一条条塞进列表,再让整块列表匀速往上滚,点击"停止"后取当前屏幕上的第一个号当作中奖号——听着简单,上线就发现几个问题:滚着滚着数字错位、停止位置总是对不上想抽的人、名单超过两千条就卡到掉帧。实际上,一场能安全上线的滚动抽奖至少由两条线组成:一是在后端或前端把"谁中奖"这个结果定下来,二是用动画把"滚到这个人"的视觉过程稳定做出来。这两条线一旦耦合,就是大部分事故的来源。下面会从数据加载、渲染优化、减速停机、结果回显到避坑验证,给出一套可以直接抄的原生 JS 实现思路。

2. 滚动抽奖的模型选型与数据准备:结果从哪来,名单怎么进

先做手机号批量滚动抽奖,第一步不是去写 CSS 动画,而是先把"中奖号码由谁决定"这件事拍死。我拆过好几个翻车的抽奖活动,绝大多数事故都是因为结果层和表现层搅在一起:负责视觉的前端把中奖号码在前端用 Math.random 产出来,负责核销的后端却按自己的名单抽了一遍,两边各抽各的,一兑奖就露馅。所以这一部分先讲清楚三种常见的抽奖模型,再讲名单怎么批量加载和清洗,这两件事定了,后面的动画代码才有意义。

2.1 三种滚动抽奖模型的取舍:前端随机、后端预抽、停止定格

我把手机上常见的滚动抽奖实现拆成三种模型,它们决定了结果的产生位置和可信程度。

模型中奖结果产生方式公平性/防作弊实现成本适用场景
前端全量随机名单载入前端后,用 Math.random 抽一个 index中,结果完全在客户端,懂开发者工具就能改最低内部茶歇抽奖、非实物奖品
后端预抽号,前端只滚不停点击开始后请求后端,返回中奖手机号和名单 index高,结果可审计、可重放中正式活动、涉及兑奖和退款
停止时定格用户点停止,把当前屏幕第一行定格为中奖号中下,和点击时机强相关,连点可以精确定位低直播间瞬时互动、快闪小游戏

前端全量随机最简单,逻辑上就是一个Math.floor(Math.random() * list.length)拿到 index,再让动画滚到那里。只要不涉及真实奖品,这方案完全够用;但一旦奖品值钱,或者要对账,就要慎重——懂控制台的人可以改内存里数组的顺序,随机结果也不具备可验证性。

我一般会推荐第二种:后端预抽号,前端只负责把动画恰好停在那个号上。原因是"滚动抽奖"四个字里,滚动只是视觉包装,结果必须独立生成。后端返回的不只是手机号,还应该带上该号在名单里的索引,前端按索引来停。这样前后端共用一份排序后的名单,结果可核对、可重放,出问题时运营能查到当时那一轮到底是谁。

停止定格这种玩法,本质上是把随机性交给了"用户点击停止的时机"。它的公平性取决于两个前提:一是点击事件和动画帧之间没有被人为干预,二是用户无法预测下一秒滚到哪一行。实际做起来,连点、预判、脚本自动点击都能破坏随机性,所以只适合不追求严肃对账的场景。选型建议很简单:要上正式活动,直接选第二种,多一次接口开销换来的是事后不用背锅。

2.2 手机号批量加载、去重与脱敏:先把名单洗干净

名单是滚动抽奖的数据源,通常不是一次接口就能拿全的。人数少的活动可能只有几十条,但营销活动动辄几千上万,后端不可能一个接口把几千个手机号一次性吐给前端。常见做法是分页拉取,每页 500 或 1000 条,拉完后拼接成一个数组。

/** * 分页拉取参与者名单 * @param {string} apiUrl 名单接口地址 * @param {number} pageSize 每页条数,建议 500~1000 * @returns {Promise<Array>} 手机号记录数组 */ async function fetchParticipants(apiUrl, pageSize = 500) { const list = []; let page = 1; let totalPages = 1; do { const res = await fetch(`${apiUrl}?page=${page}&pageSize=${pageSize}`); if (!res.ok) throw new Error(`名单加载失败:HTTP ${res.status}`); const data = await res.json(); totalPages = data.totalPages || 1; // 注意语义:totalPages,不是 total list.push(...(data.list || [])); page += 1; } while (page <= totalPages); return list; }

这里有两个参数值得注意:pageSize不是越大越好,后端单次查询超过 1000 条往往会触发超时限制,建议从 500 起步,压测后觉得接口快再往上调;totalPages的语义要和后端对齐,有的接口返回totalCount,前端要自己算Math.ceil(totalCount / pageSize),两种写法都要兼容,避免少拉最后一页。接口返回结构统一为{ list, totalPages }是最省事的约定,回头还要给参与人数做个 loading 提示,全量拉完再点"开始抽奖"。

拉回来的原始数据不能直接用,至少有四件事要处理:去重、格式清洗、脱敏、排序。

/** * 名单清洗:去重 + 格式化 + 生成脱敏展示文本 * @param {Array} rawList 接口原始数据 * @returns {Array} 清洗后的名单,保持原顺序 */ function normalizeParticipants(rawList) { const seen = new Set(); const result = []; for (const item of rawList) { const phone = String(item.phone || '').trim(); if (!phone) continue; // 空号码过滤 if (seen.has(phone)) continue; // 同一手机号只保留一次 seen.add(phone); result.push({ id: item.id ?? phone, // 后端没给 id 时用手机号兜底 phone, masked: phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2') }); } return result; }

这段代码里,String(item.phone).trim()是为了防后端返回数字类型或带空格脏数据;seen是 Set,用它去重比includes的数组二分查找快得多,几千上万条时差异明显;脱敏正则/^(\d{3})\d{4}(\d{4})$/把中间四位替换成****,滚动阶段展示打码号,中奖后再用phone字段回显完整号码,这是隐私合规的底线。

排序这步容易被忽略,但它直接影响抽奖结果的对应关系。名单需要从前端接口拿回来的那一刻就固定顺序,比如按createdAt升序,之后所有轮次都基于这份顺序计算索引。千万不要在前端做sort(() => Math.random() - 0.5)来"洗牌",一来打乱后索引不可复现,二来和后端索引对不上,避坑章里会展开讲。名单唯一性还有一层隐含要求:一个手机号在一场活动里只能参与一次,去重必须在页面初始化时就做掉,而不是等抽奖时临时过滤。

数据准备好的标志是:normalizeParticipants(rawList)返回的数组顺序固定、无重复、有空值剔除,并且列表长度能和后端接口统计的参与人数对上。我在本地调试时会先打印console.log(list.length),再和活动后台的报名人数比对,不一致就先排查接口,不要带着脏数据往下做动画。

3. 用原生 JS 实现滚动抽奖:列表渲染、动画循环与三个必调参数

3.1 批量渲染手机号滚动带:DocumentFragment 与重复拼接

先定义容器和样式。这里有一个容易被忽略的规划点:窗口高度必须等于可见行数乘以单行高度,后续所有位移计算都依赖这个等号。

<div class="lottery-window" id="lotteryWindow"> <div class="phone-track" id="phoneTrack"></div> </div>
.lottery-window { width: 300px; height: 120px; /* 视觉上固定显示 3 行,高度 = 行高 × 3 */ margin: 0 auto; overflow: hidden; /* 超出部分裁掉,形成窗口 */ border-radius: 10px; background: #f7f8fa; } .phone-track { will-change: transform; /* 动画只动 transform,不触发布局 */ } .phone-item { height: 40px; line-height: 40px; text-align: center; font-size: 20px; font-family: "Courier New", Courier, monospace; /* 等宽:数字宽度一致 */ }

滚动带.phone-track是个内部的长条,通过transform: translateY(负值)往上平移,看起来就是列表在滚动。.lottery-window高度固定 120px,除以.phone-item的 40px,正好露出 3 行。如果你的设计稿是 4 行可见,就把窗口高度改成 160px,同时改 JS 里的itemHeight,保持一致。

let phoneList = []; // 清洗后的完整名单 let track = null; // 滚动带 DOM let itemHeight = 40; // 初始化时用 offsetHeight 实测 /** * 把手机号列表渲染成滚动带 * @param {Array} list 清洗后的名单 * @param {number} repeatCount 复制份数,建议 5,保证有足够的滚动余量 */ function renderTrack(list, repeatCount = 5) { const fragment = document.createDocumentFragment(); for (let i = 0; i < list.length * repeatCount; i++) { const item = list[i % list.length]; // 用取模循环,避免再拼一个大数组 const div = document.createElement('div'); div.className = 'phone-item'; div.textContent = item.masked; // 滚动期间只显示打码号 div.dataset.phone = item.phone; // 中奖后还原完整号码要用 div.dataset.index = String(i % list.length); fragment.appendChild(div); } track.innerHTML = ''; track.appendChild(fragment); }

为什么用DocumentFragment:如果 list.length 是 2000,repeatCount 是 5,这里要创建 10000 个 div,逐个appendChild到 track 上,浏览器每插入一个都要重新计算布局,主线程会被拖垮,页面直接白屏几十秒。先把节点挂到 fragment 上,最后一次性appendChild,插入操作从 1 万次降到 1 次,这是滚动抽奖渲染阶段最关键的一步优化。

参数说明:repeatCount = 5是个经验值。它的意义是保证"从初始位置滚到中奖号并绕两整圈"的过程中,滚动带始终有内容可显示,不会滚到一半露出空白。如果名单只有 10 人,5 倍也只有 50 个 div,无压力;如果名单有 8000 人,5 倍就是 4 万个节点,这个方案就不合适了。

提示:名单在 5000 条以内用重复渲染方案,超过 5000 条转虚拟滚动,第 4 章会给出虚拟滚动的思路。

>async function init(apiUrl) { const rawList = await fetchParticipants(apiUrl); phoneList = normalizeParticipants(rawList); track = document.getElementById('phoneTrack'); renderTrack(phoneList); itemHeight = document.querySelector('.phone-item').offsetHeight; // 实测行高 }

3.2 变速滚动动画:requestAnimationFrame 与 easeOutCubic 减速曲线

滚动抽奖的视觉核心是"滚得快、快停下来时明显减速"。实现上绕不开两点:用requestAnimationFrame驱动,以及给位移套一条减速曲线。

let rafId = null; // 当前动画帧 id,用于取消上一次动画 let isRolling = false; // 防重复点击的锁 let currentOffset = 0; // 当前已经滚过的像素 /** * 启动一次滚动抽奖 * @param {number} winnerIndex 中奖手机号在名单中的索引,由后端或前端随机产生 * @param {number} totalCount 名单总人数(phoneList.length) */ function draw(winnerIndex, totalCount) { if (isRolling) return; // 滚动中不允许再触发 isRolling = true; cancelAnimationFrame(rafId); // 保险起见,取消可能遗留的动画帧 currentOffset = 0; // 每轮从头部重滚,避免方向和距离出问题 track.style.transition = 'none'; track.style.transform = 'translateY(0)'; const targetIndex = winnerIndex + totalCount * 2; // 中奖号再绕两整圈 const targetOffset = targetIndex * itemHeight; const distance = targetOffset; // 因为起点是 0,distance 就是 targetOffset const duration = 3600; // 单轮总时长 ms,见 3.3 参数表 const startTime = performance.now(); function step(now) { const progress = Math.min((now - startTime) / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic:先快后慢 const offset = distance * eased; track.style.transform = `translateY(${-offset}px)`; currentOffset = offset; if (progress < 1) { rafId = requestAnimationFrame(step); } else { finishDraw(targetIndex); // 停稳后再做回显和高亮 } } rafId = requestAnimationFrame(step); }

几个设计点解释一下。第一,performance.now()的时间戳直接来自 rAF 回调参数now,同一个高精度时钟,不涉及Date.now()的系统时间跳变。第二,起点强制translateY(0)再滚,是为了让distance = targetOffset永远为正,避免出现"往回滚"的视觉错误;如果保留上一轮的偏移,可能出现上一轮滚太远、这一轮目标索引反而在当前位置上方的情况,处理起来要加很多位运算,不值。第三,totalCount * 2表示至少绕两整圈,再加上随机到 winnerIndex 的那一段,实际视觉效果是从名单开头一路滚到中奖号附近,节奏刚好。

曲线选型上,easeOutCubic是轮盘和老虎机里最常用的一条曲线:前 60% 的时间滚掉 80% 以上距离,让人感觉速度很快;最后 30% 时间只滚一小段,是那种"想停又没停住"的停顿感。也可以用easeOutQuart让减速更急,但那种曲线在快到终点时容易肉眼可见地"卡一下",活动页上反而不自然。

3.3 三个必调参数与中奖回显

参数建议初始值影响调整方向
duration单轮滚动总时长3600ms太短看不清号码,太长用户以为卡死名单超 2000 人时可加到 4500~5000ms
itemHeight单行高度40px停止位置偏移的直接原因必须用渲染后行高的实测值,别硬编码
repeatCount列表复制份数5滚动时露白底用户在滚动途中随时可能点停,份数越多越安全

itemHeight这个参数值得多说一句。.phone-item的 height 是 40px,但如果在样式里加了margin-top,或者用了box-sizing: content-box加 padding,实际行高就不是 40px。停止位置按 40px 算,实际列表却按 42px 滚动,滚得越远偏差越大,最后停在中奖号上面好几行。排查时先打印document.querySelector('.phone-item').offsetHeight,拿实测量替换硬编码值,比在 JS 里猜快得多。

停稳之后的回显和防连点也要一起写掉:

function finishDraw(targetIndex) { const items = track.children; const winEl = items[targetIndex]; const record = phoneList[targetIndex % phoneList.length]; // 把打码号替换成完整手机号,并加高亮样式 winEl.textContent = record.phone; winEl.classList.add('winning'); // 停留 2000ms 让用户看清中奖号,再弹活动弹窗 setTimeout(() => { showResultDialog(record.phone); // 你自己的弹窗逻辑 isRolling = false; // 解锁,允许下一次开奖 }, 2000); }

注意isRolling = false不要放在draw()的 return 里,必须等回显完成后再解锁。如果提前解锁,用户连点两次"开始",会出现两个动画互相叠加,避坑章第 4 条专门讲这个。finishDraw里用textContent直接替换,而不是先清空再插入节点,是为了减少一次重排。

如果你的活动页把"中间一行"当作中奖位,而不是第一行,那停在窗口第一行会让高亮错位。调整方式很简单:构造targetIndex时多加 1,const targetIndex = winnerIndex + totalCount * 2 + 1;,这样最终滚到 winnerIndex 的下一行,窗口中间行正好是目标号码。这个 +1 属于业务视觉细节,不同活动的判定行不一样,代码里留一个常量即可。

4. 手机号滚动抽奖避坑指南:5 个翻车场景与排查方法

4.1 数字跳动错位、停止位置偏移,先检查字体与行高

现象:滚动过程中手机号左右晃动,看起来像每行宽度都在变;动画停稳后,高亮的行和中奖号差一行甚至好几行。

原因:第一,默认字体在不同数字下宽度不一致,滚动时逐行替换数字,行的宽度一直在变;第二,itemHeight用了 CSS 定义值,但实际渲染行高因为 padding、margin、border 或 line-height 撑出了更多高度,位移计算逐步累积误差。

解决:写样式时给.phone-item设等宽字体加固定width: 100%,行内不用 flex 而是用text-align: center,保证每行数字视觉居中;行高不要靠算,在初始化时执行const rowHeight = document.querySelector('.phone-item').offsetHeight,把实测量交给动画代码。如果offsetHeight和 CSS 值对不上,优先检查是否有全局样式把box-sizing覆盖成了content-box。

4.2 名单超过 2000 条直接卡白屏,换虚拟滚动或分批渲染

现象:页面加载名单时白屏几十秒,点"开始抽奖"后按钮无响应,浏览器开发者工具的性能面板里长条都耗在脚本和渲染上。

原因:一次性创建几万甚至十几万个 DOM 节点,即使有 DocumentFragment 也只是把插入从 1 万次变 1 次,节点本身占用的内存和样式计算仍然存在。几万节点的列表,光布局计算就是几十毫秒,滚动起来每帧都可能超过 16.6ms。

解决:5000 条以内的活动继续用重复渲染;再往上改用虚拟滚动,维护一个startIndex和endIndex,只渲染窗口内可见的行数加少量缓冲:

function getVisibleIndexes(scrollOffset, rowHeight, viewportHeight, buffer = 2) { const startIndex = Math.max(0, Math.floor(scrollOffset / rowHeight) - buffer); const endIndex = Math.min(phoneList.length - 1, Math.floor((scrollOffset + viewportHeight) / rowHeight) + buffer); return { startIndex, endIndex }; }

虚拟滚动的要点是:滚动带高度不变,内容用绝对定位top精确放在对应偏移量上,滚动过程中只更新startIndex/endIndex附近的节点内容。这样哪怕名单有 5 万人,DOM 里始终只有几十个节点。抽奖场景里滚动很快,虚拟滚动的性能反而比重复渲染更容易压到 60fps。

4.3 切后台回来停错位置,给 rAF 补一个可见性兜底

现象:用户点"开始抽奖"后立刻切到微信聊天,过几秒切回来,看到的是滚动已经结束,但停止的那一行不是本该出现的号码,或者干脆停在半空中。

原因:浏览器为了省电,页面切到后台时requestAnimationFrame会完全休眠,回调不再被触发。时间一长,动画停在某个中间态;等切回前台,回调恢复执行,进度可能直接跳到 1,停位和预期对不上。

解决:监听visibilitychange,切回前台时重新计算当前进度并强制收敛。抽奖开始时把目标索引存到一个变量里:

let currentTargetIndex = null; // draw 开始时赋值 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && isRolling) { cancelAnimationFrame(rafId); finishDraw(currentTargetIndex); } });

更稳妥的做法是:抽奖开始时记录currentTargetIndex,在visibilitychange回调里直接调finishDraw,把结果高亮出来。用户切后台本来就是非正常路径,直接结算比等他回来看到一半动画更合理。

4.4 防连点失效,两次动画交叠

现象:按钮没有 loading 或禁用态,用户手速快点了两下,第一次动画还在跑,第二次又从头开始,最终停下来时中奖号被覆盖,控制台还可能报cancelAnimationFrame is not a function或动画跳变。

原因:isRolling锁只放在了draw()开头,但有的写法在触发函数里自己cancelAnimationFrame(rafId)后没有把isRolling复位,第二次点击从非isRolling状态进来了。

解决:锁的复位放在finishDraw的回显完成后,前端在draw()开头加if (isRolling) return;。同时按钮要绑定disabled状态,视觉上让用户知道不可点。每次点击开始抽奖时,入口处都先cancelAnimationFrame(rafId),确保上一个 rafId 不存在。排查手段:在draw()和finishDraw()里各打印一次isRolling的值,看点击两下时draw是否被调用两次。

4.5 后端结果和前端停的位置永远对不上,问题出在排序清洗

现象:后端接口返回中奖手机号和名单索引,前端按索引停,可是演示时中奖号经常和实际停的那一行不一致,每次都不一样。

原因:前端在normalizeParticipants里去重、过滤空号,但后端返回的索引是按原始全量名单算的,去重后数组顺序变了,两边对齐不上。另一种常见原因是前端某处隐式用了Set或者sort,改变了顺序。

解决:把"名单清洗结果"和"后端索引"绑定成同一个数据源。前端清洗后,把清洗后的手机号数组提交给后端,或者和后端约定同一套去重规则,后端基于清洗后名单返回索引。前端在初始化完成后,打印一次phoneList.map(item => item.phone).join(',')和后端名单快照比对,顺序一致再开始抽奖。这个约定我会在接口联调阶段就写进文档,不让前端自行决定清洗规则。

5. 进阶:把滚动抽奖做成可校验组件,并跑 100 次回归脚本

把手机号滚动抽奖模块从业务页剥出来之后,我会补两块:可审计的结果日志,和自动化的回归验证。

先讲审计日志。正式活动里,后端返回winnerIndex和ticketId时,前端不要只把它当临时变量用完就丢,要写进sessionStorage,活动结束后导出来可以对账:

function appendAuditLog(record) { const logs = JSON.parse(sessionStorage.getItem('lottery_logs') || '[]'); logs.push({ time: new Date().toISOString(), winnerIndex: record.winnerIndex, phone: record.phone, ticketId: record.ticketId }); sessionStorage.setItem('lottery_logs', JSON.stringify(logs)); }

这样每一轮抽奖都有时间戳、索引、手机号和后端票据,兑奖时用户说你漏记了,直接把整段日志拉出来比对,比口说无凭来得快。

再讲回归脚本。动画代码只要改过duration、曲线函数、itemHeight或targetIndex的构造方式,就必须重新验证"停的位置是否等于期望的中奖号"。手动点十次看不出问题,但换一条曲线后可能每次停的都不是目标行。我习惯写完就挂一个冒烟测试脚本:

// 根据当前滚动偏移反推停稳后的第一行属于名单里的哪一个索引 function currentOffsetToIndex() { const row = Math.round(currentOffset / itemHeight); return row % phoneList.length; } async function runSmokeTest(total = 100) { let passed = 0; for (let i = 0; i < total; i++) { const winnerIndex = Math.floor(Math.random() * phoneList.length); draw(winnerIndex, phoneList.length); await new Promise(resolve => setTimeout(resolve, 4100)); // duration=3600,多等 500ms const stoppedIndex = currentOffsetToIndex(); if (stoppedIndex !== winnerIndex) { console.error(`第 ${i} 轮回归失败:期望 ${winnerIndex},实际 ${stoppedIndex}`); return false; } passed++; } console.log(`回归通过:${passed}/${total}`); return true; }

这里有两个细节:setTimeout的等待时间要比duration长,否则动画没停稳就去断言,误报;currentOffsetToIndex用% phoneList.length取模,因为滚动带的偏移量是带着整圈倍数的,最终取模后才对应真实名单索引。如果你发现某次回归失败且失败轮次毫无规律,八成是曲线或itemHeight出了问题,先打印currentOffset和itemHeight的实时值,不要靠肉眼在高亮的行里找答案。

我现在的习惯是,动画代码每次改动都会先跑一轮回归脚本再交给活动运营,参数再玄学,有脚本兜底也不怕。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进