☰
Web前端抽奖功能实战:从需求拆解到原生JS实现
2026/10/6 3:38:22 网站建设 项目流程

咱们这次聊的题目是“web第三次作业(抽奖)”,乍一看挺普通的,但只要做过前后端的人都知道,这其实是web前端开发课程里一个特别典型的分水岭:前两次作业可能还在做静态页面、表单校验这种偏“展示”的东西,第三次直接要求你做一个带交互逻辑的抽奖功能。抽奖这个需求很有意思,它把一个非常日常的活动场景,拆成了随机算法、DOM操作、事件处理、状态管理、动画反馈一整条链路,几乎把前端入门阶段的核心知识点全串起来了。

这篇内容我就按自己做这个项目时的完整思路来写:先拆需求,再选方案,接着写页面、写逻辑,最后把踩过的坑和排查经验一并放进来。适合同样在做web课程作业的同学,也适合刚学完JS基础、想拿一个小项目练手的人。哪怕你完全没写过抽奖功能,跟着这篇文章一步步走,也能做出一个观感不错、逻辑完整、能直接演示交差的抽奖页面。

1. 作业拆解:抽奖页到底要做什么

1.1 先弄明白什么叫“抽奖作业”

我拿到这个标题第一反应是:这不就是做一个“随机选一个人”的页面吗?但真动起手来才发现,事情没那么简单。抽奖的本质确实只是随机抽取,但作业要求里藏着的隐性考点还挺多的:

  • 如何维护一份参与抽奖的人名单;
  • 抽过的名单怎么处理,是否能重复中奖;
  • 抽奖按钮怎么防止连续点击;
  • 中奖结果用什么形式展示,是否需要历史记录;
  • 数据全部放在内存里,还是刷新后还能保留。

这些看着都是小问题,但每一个都在考你同一个能力:能不能把一个模糊需求拆成清晰的数据结构和状态流程。我个人当时是这么拆的:抽奖页面 = 名单容器 + 抽奖动作 + 结果反馈 + 中奖记录。四个部分各管各的,互不干扰。

用现实里的场景打个比方,抽奖作业其实就是做一个“电子抽奖箱”。现实里开年会,HR拿个箱子,里面放一堆写满名字的纸条,先摇一摇,再伸手抽一张,念出名字,后面有个助理在PPT上记录中奖人。你把这套流程翻译成web页面,箱子变成数组,摇一摇变成随机动画,伸手抽变成随机索引,念名字变成页面弹窗,记录中奖人变成一条历史列表。这样一翻译,需求就非常清楚了。

1.2 方案选型:为什么作业用纯前端就够了

当时面对一个问题:用原生三件套(HTML/CSS/JS)写,还是直接上Vue?我的判断是,作业阶段优先用原生JS,能不上框架就不上框架。原因有三点。

第一,课程考核的通常是基础能力。老师想看到的是你对DOM操作、事件循环、数组方法这些基本功的掌握程度,你用Vue写出来虽然代码可能更简洁,但很容易被误认为“套模板”。第二,原生写法对你自己的帮助更大。框架帮你处理了响应式更新、状态同步,你反而失去了理解底层逻辑的机会。第三,根本没有服务器成本。抽奖名单写死在前端数组里,双击HTML文件就能运行,演示起来毫无压力。

不过我也得说明白:纯前端方案只适合作业和Demo。将来如果你要做一个真正能被很多人同时访问的抽奖活动,名单和中奖结果必须放服务端,这个我们放到后面第5部分专门展开。作业阶段,我把整体架构设计成这样,一层管一件事:

  • 数据层:存名单数组、奖项数组、中奖记录数组;
  • 控制层:监听抽奖按钮的点击事件,执行随机算法;
  • 视图层:把数组渲染成列表,把中奖结果展示到页面上。

这样拆的好处是后面改需求很轻松。比如老师临时说“再给我加一个二等奖”,你只需要在奖项数组里加一项,改一下渲染逻辑就行,不用满页面去找代码。

2. 页面搭建与视觉反馈

2.1 布局方案:让抽奖过程有“仪式感”

页面布局是我最先动手的部分,因为视觉观感直接决定这个作业的“第一印象”。我选了最保险也最容易出效果的结构:一个主卡片居中,标题在上方,中间是名单滚动区,下方是抽奖按钮,侧边或下方放中奖记录。名单滚动区我用的是一个列表容器,让参与者的名字以卡片排列,抽奖时这些卡片会快速高亮跳动。

这里有个经验值得单独说:抽奖页面的核心不是“名单展示”,而是“抽奖瞬间的反馈”。用户点按钮之后,如果只是瞬间弹出“中奖人XXX”,体验会很干。我加了一个滚动高亮动画,名单在短时间内快速扫过,然后慢慢减速停在一个人身上,这个过程才有“抽奖感”。

布局代码我精简过一版,核心结构大概是这样的:

<div class="container"> <h1>幸运抽奖</h1> <div class="member-list" id="memberList"> <!-- 动态渲染的名单卡片 --> </div> <button class="draw-btn" id="drawBtn">开始抽奖</button> <div class="records" id="records"> <h2>中奖记录</h2> <ul id="recordList"></ul> </div> </div>

CSS方面我用了flex布局配合一条主轴线,按钮用了比较大的圆角,hover时有轻微上浮。这里有个细节:抽奖按钮的“禁用状态”一定要做好视觉区分,比如normal状态是橙色底,禁用后变成灰色底,文字从“开始抽奖”变成“抽奖中”。如果禁用态和正常态长得一样,用户点了没反应,第一反应是“页面坏了”,而不是“正在抽奖中”,体验差很多。

名单卡片的高亮效果我用了CSS的transition加class切换,这样避免频繁操作style属性导致的重排性能问题。高亮卡片时加一个背景色和盒阴影,没选中的卡片恢复默认样式,视觉层次一下就有了。

2.2 动画与反馈:不要用边距实现位移

这里有一个新手特别容易踩的坑:做上下滚动的名单动画时,很多人直接用修改margin-top或top值的方式实现位移,结果发现频繁切换类名时页面一卡一卡的。原因是top、margin这类属性变化会触发Layout重排,性能开销大。正确做法是用transform: translateY(),它走的是合成线程,动画会顺滑很多。

我做的抽奖动画流程是这样的:点击按钮后,名单列表在短时间内快速随机切换高亮目标,模拟“滚动中”的效果,持续约1.5秒后速度逐渐放缓,最终停在一个随机索引上,中奖的那个卡片会有一个放大+背景变金黄的class。这个动画流程通过一个简单的setInterval来实现,每次间隔里随机换一个高亮索引。

这里面要注意一个状态问题:动画期间按钮必须禁用,否则用户连续点击会启动多个定时器,导致高亮乱跳。我的处理方式是设置一个isDrawing布尔变量,动画开始时置为true,动画结束并展示结果后再置回false。按钮的disabled属性和isDrawing变量同步控制,双保险。

音效这块,如果不想引外部音频文件,可以用Web Audio API生成一个简单的提示音。我实测下来,几行代码就能在结果弹出时播放一个清脆的“叮”声,效果提升非常明显。代码如下,这属于锦上添花的功能,不加也不影响交作业。

function playTone() { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.connect(gain); gain.connect(ctx.destination); oscillator.frequency.value = 880; oscillator.type = 'sine'; gain.gain.setValueAtTime(0.2, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5); oscillator.start(); oscillator.stop(ctx.currentTime + 0.5); }

要注意的是AudioContext在部分浏览器里需要用户主动交互后才能启动,所以这个函数只在点击事件里调用,不要在页面加载时调用,不然会被浏览器拦掉。

3. 核心抽奖逻辑:代码写得顺,问题少一半

3.1 名单与奖项的数据结构

抽奖功能写得好不好,九成看数据结构设计。我当时的第一版代码把所有名字直接写死在HTML的<li>标签里,点击按钮时再从DOM里去读,后来发现这样逻辑写起来非常别扭。后来我重构了,把数据完全从DOM中抽离出来,页面渲染全部由数组驱动。

我定义了三个数组:

let pool = ['张三', '李四', '王五', '赵六', '孙七', '周八', '吴九', '郑十']; let prizes = ['一等奖:手机', '二等奖:耳机', '三等奖:杯子']; let winners = [];

pool是奖池,prizes是待发放的奖项,winners是中奖记录。抽奖时从pool里随机取一个人,然后用splice把这个人的索引从pool里移除,之后渲染中奖记录时再把这个人的名字和你给他的奖项拼在一起。为什么要用splice而不是打标记?因为从数组里移除后,下一次随机就天然不可能抽到同一个人,逻辑上省掉很多判断。

更重要的一个原因是,这个做法和真实世界里的抽奖完全一致:纸条抽出来就不会再放回箱子里。如果你希望某些人可以重复中奖,那就不移除,让他留在pool里即可。这两种规则用同一个数据结构就能表达,区别只是要不要调用splice。

奖项分配这里有个小细节。很多抽奖页是“人抽出来后再随机决定一个奖项”,但现实活动通常反过来:先设定一等奖1名、二等奖2名、三等奖3名,抽中第一个人就消耗掉一等奖名额。所以你的代码里,奖项数组应该和抽奖轮数一一对应,而不是先抽人再随机给奖。我当时这么实现:维护一个prizeIndex,每抽一轮就用prizes[prizeIndex]作为本轮奖项,然后prizeIndex++,直到所有奖都发完。

3.2 随机过程的实现与状态控制

核心随机逻辑其实非常简单。判断数组中是否还有剩余参与者,如果有,就生成一个0到pool.length - 1之间的随机整数,用这个整数作为索引取出一个人名。JavaScript里用的Math.random()生成的是[0,1)区间内的浮点数,乘以长度后向下取整就可以了。

function pickRandom() { if (pool.length === 0) { alert('奖品已抽完'); return null; } const index = Math.floor(Math.random() * pool.length); const user = pool.splice(index, 1)[0]; return user; }

这里我要强调一个“什么时候不能用Math.random”的问题。如果你做的抽奖只是一个视觉Demo,用Math.random()完全够用;但如果这是一个真实活动,最终中奖结果由前端随机数决定,那问题就大了。原因在于前端代码是可以被用户直接查看和篡改的,用户打开开发者工具,把Math.random改成固定返回0,就能精准抽到自己。所以任何有真实奖品、真实参与者的抽奖活动,随机数生成和结果裁决必须放在服务端。前端的随机只能作为动画展示——也就是说,前端摇啊摇的那几秒钟,高亮的那个名字是随机在跳,但最终停在哪个人身上,应该由后端接口返回的结果来定。

权重抽奖我也简单说一下。如果想让某些人中奖概率高一些,比如老用户权重2,普通用户权重1,可以生成一个权重累积数组,然后随机出一个0到总权重之间的数,再线性扫描确定命中哪个区间。代码如下:

function pickWeighted(users) { const totalWeight = users.reduce((sum, u) => sum + u.weight, 0); let rand = Math.random() * totalWeight; for (const user of users) { rand -= user.weight; if (rand <= 0) return user; } return users[users.length - 1]; }

这个方案在作业里属于加分项,用上会让老师觉得你考虑问题很全面。

按钮防连点我这里再提一次,因为这是抽奖功能最最常见的漏洞。很多同学写的抽奖逻辑是每次点击都直接执行随机,然后弹窗显示结果。如果你手速快,连点十下,就能一次性抽出十个人。正确的做法是引入“状态”这个概念,用一个drawing变量标识当前是否正处于抽奖动画中。动画运行中,点击事件直接忽略或按钮置灰。这个变量本质上是前端交互的状态锁,特别基础但特别重要。

4. 踩坑记录与问题速查

4.1 典型问题:按钮连点、中奖重复、动画卡顿

我写完第一版后测试时遇到了几个非常典型的问题,如果你也在做这个作业,大概率会碰到一模一样的坑。

第一个是按钮连点导致的多开定时器。我的动画是靠setInterval控制的,点一次按钮启动一组定时器,点两次就两组定时器并行,高亮和目标人选完全错乱。排查时我打印了日志才发现,定时器根本没有被清除。解决办法是在每次点击事件里先clearInterval之前可能残留的定时器,再启动新定时器;同时加上drawing变量做状态判断,动画期间直接返回。

第二个是池子越来越小直到空。抽奖进行到最后,pool只剩几个人时,如果动画的高亮索引还是按原数组长度来算,就可能出现undefined。这个问题用splice天然规避了一部分,但我仍然在抽取前增加了空池判断。另外奖项抽完后池子还有剩余,也要处理,不然抽奖按钮还在,但实际已经没有奖品可发了。

第三个是动画卡顿。前面提到了,卡顿多半是因为直接改margin-top这类布局属性。如果把滚动动画改成transform,卡顿立刻缓解。另一个隐藏问题是,我在动画里每一帧都重新查询了一堆DOM元素,比如document.querySelectorAll('.card'),高频查询也会带来额外开销。正确做法是在动画开始前把需要操作的DOM节点缓存到一个变量里,动画期间只操作缓存,不要反复查询。

4.2 想“隐藏”概率控制与刷新恢复

概率控制这件事,很多同学喜欢搞歪门邪道,比如先抽奖后补概率,或者中奖名单写死。我的建议是,如果你在作业里展示了权重算法,老师会觉得你有思考;如果你的概率控制和需求无关,那就不加,加了反而让代码复杂度上升。真实项目的概率控制应该交给后端配置中心,前端不碰。

刷新恢复数据这块,我用localStorage做了个简单持久化。中奖记录存到localStorage里,页面刷新后自动加载先前的记录,名单池却重置为初始状态。这里要小心一个问题:如果用splice抽人,刷新后名单全部满血复活,但中奖记录还在,前后就对不上了。我当时处理得比较粗暴:刷新后恢复到初始状态,不算大问题,毕竟作业演示时不会有人中途刷新页面。如果想做得更严谨,就需要把剩余名单也序列化存起来。这里有个关键结论:当你要用localStorage持久化数据时,存的内容必须是一整套快照,而不是只存半套。

下面这张表是我整理的常见问题速查,如果你卡住了可以直接对照着排查。

现象可能原因解决办法
点击按钮后页面卡死定时器未清除或递归无限循环点击时先clearInterval,再检查是否重复启动
快速连点后抽出多个人没有状态锁引入drawing变量,动画期间直接return
名单空时仍能点击抽奖缺少空池判断抽取前检查pool.length,为空则提示并禁用按钮
中奖出现undefined索引越界或数组操作错误随机索引生成后先验证,再使用splice
动画很卡、掉帧使用了top/margin,频繁重排改成transform + opacity做动画
刷新后中奖记录消失没有持久化localStorage存储记录,启动时读取
抽奖过程感受不到“抽”直接弹框出结果增加滚动高亮或转盘旋转动画

4.3 一个值得特别注意的边界:只剩一个人

这里补充一个非常容易被忽略的边界情况:当奖池里只剩下一个人时,抽奖就不是“抽”了,而是“钦定”。但看起来还是要走完整动画,不然你只剩一个人,点按钮瞬间直接弹出结果,观感很突兀。我的做法是:当pool.length === 1时,先正常播放下动画,但随机索引固定返回0。这样用户看到的效果还是“摇了一会儿才摇中”,但逻辑上是唯一解。

其实这个边界背后反映出一个更通用的原则:交互流程的一致性比逻辑的正确性更影响体验。逻辑上直接返回唯一结果没错,但用户感知不到逻辑正确,他们感知到的只有“体验顺滑还是突兀”,所以哪怕只有一个人参与,也要演完整个抽奖过程。

5. 从作业到项目:如果还要继续做

5.1 加后端会变成什么样

如果你做完这个作业还想继续深入,我建议把它从纯前端升成前后端分离的小项目。核心改动就是把抽奖逻辑挪到服务端,前端只负责界面展示和调用接口。

接口设计非常简单。后端提供一个POST /api/lottery接口,请求带上用户ID或抽奖次数,响应返回中奖与否、中奖奖项、当前剩余名额。参与者名单、奖项配置、中奖记录全部存到数据库里。前端点击抽奖按钮后,先向后端请求结果,拿到结果后再播放动画——注意,动画播到什么哪个人停,要由后端返回的真实结果来决定,而不是前端随机跳完后给后端上报。

服务端还需要做的几件事是:校验用户是否还有抽奖资格、限制抽奖接口的调用频率(防止刷接口)、把每次抽奖请求写入日志(方便事后审计)。这些加起来就是完整的web抽奖后端。你会发现,很多真实的营销活动抽奖页面,后端的代码量比前端大得多,因为你要防作弊、防并发、防超发奖。

5.2 前端安全与公平性提醒

最后讲两句web安全层面的东西,虽然作业用不上,但想清楚对面试很有帮助。纯前端抽奖在“安全”视角下的问题很明显:所有数据都在用户手里,所有逻辑都在用户手里,用户可以随意篡改。真正要防的不只是捣乱,还有重放攻击、并发超领。一个合格的抽奖页面对外展示时,“前端负责好看,后端负责公正”这句话一定要记住。

我见过一个比较有趣的方案:后端生成中奖结果时,不是简单返回一个人名,而是返回一串带签名的票据,前端拿票据渲染动画,活动方事后可以凭签名验证这个中奖结果确实是由服务端签发且未被篡改。这相当于给中奖结果盖了一个数字公章。这个思路在作业里做个简化版,哪怕只是把中奖结果用字符串拼个hash传给前端展示,都是很有深度的加分成型。整体而言,作业版抽奖页做到前面第3部分的程度已经完全够用,能做得更精更严谨全看个人兴趣和时间。

我个人做完这个项目之后的体会是:抽奖这种看似不起眼的小功能,反而是练习“从需求到代码”能力最好的训练场。它难度不高,但链条很长,能把思路理清,后面写复杂系统也会顺手很多。如果让我给正在做这个作业的人一个最实际的建议——先别急着写界面,把名单怎么存、奖品怎么发、能不能重复中这三件事想清楚,再动手,你会发现写起来快得不止一倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询