简介:这套无后台抽奖网页源码面向年会、比赛、促销等各类现场活动,解决临时搭建抽奖系统的繁琐配置问题,适合活动策划、行政人员及前端入门开发者。压缩包内含22个文件,以HTML页面、JavaScript脚本和PNG/JPG图片素材为主,JavaScript负责抽奖逻辑与动态展示,HTML提供页面骨架,图片与字体文件用于界面视觉,整体仅4.79MB,轻量易上手。抽奖名单与随机抽取逻辑均封装在前端,页面无需服务器即可直接运行并即时显示中奖结果。目前已有849人学习/下载。通过源码能快速部署一套完整的活动抽奖页面,也可修改名单、奖品样式与交互方式,同时掌握在前端场景中防止恶意操控、保障抽奖公平性的关键细节,适合作为活动工具或前端练习项目。
1. 无后台年会抽奖网页源码在解决什么问题
办一场年会,最尴尬的不是奖品不够,而是抽奖系统临时出问题。公司内网没外网、行政的电脑没有 PHP 环境、IT 被拉去调音箱,抽奖页面却要装数据库、改配置、等部署。所谓"2024年会抽奖网页源码+无后台",指的就是一套纯静态的前端抽奖方案:HTML、CSS、JavaScript 直接跑在浏览器里,不需要服务器、不需要数据库、不需要安装任何运行时,双击 index.html 就能用。适合中小企业年会、部门聚餐、线上直播抽奖这类一次性场景。
这套方案的真正价值不在"没后台",而在部署链路的简化。你可以把整个源码拷进 U 盘,插到任意一台 Windows 或 macOS 电脑上就能开始抽奖;也可以丢到公司内网共享文件夹里,让主持人直接用浏览器打开。它和大厂年会用的后台系统是两套思路:后者解决并发、安全和数据审计,而年会抽奖的核心诉求是"现场别卡、别抽重、结果能对上"。本文会从数据模型、随机算法、动效调度讲到落地排错,全程给出可直接运行的代码。
2. 选型与数据模型:无后台抽奖页面怎么组织奖品、人员与结果
2.1 无后台不等于零代码:静态方案的边界在哪
先说清楚一个常见误解:无后台指的是没有独立的服务器进程和数据库,不代表不需要写代码。抽奖页面依然需要 JavaScript 去完成三个核心动作:读取人员名单、执行随机算法、保存中奖记录。区别在于这些动作全部放在浏览器本地完成,数据要么硬编码在 JS 文件里,要么通过用户在页面里粘贴导入,中奖结果则写入浏览器 localStorage。这个边界决定了它能干什么、不能干什么。
适合的场景:员工名单几百到两千人、奖品批次不超过十个、现场有主持人和一台能上网的电脑。不适合的场景:万人同时在线抽奖、需要强一致性的奖品库存扣减、需要事后审计中奖记录的合规性要求。后者需要真正的后台服务和数据库事务,不是纯前端能解决的。年会抽奖网页源码的定位就是"快、轻、准",不要拿它去对抗需要多人并发写入的业务系统。
硬编码方案的取舍:把人员名单直接写死在participants.js里最省事,但每次现场更新都要改文件。我一般会在页面里加一个文本框,支持粘贴名单和奖品配置,然后缓存到 localStorage——这样既保留无后台的优势,又不用每次改代码。名单格式统一用一行一个姓名,奖品用 JSON 配置,解析逻辑在下面代码里给出。
// 从 textarea 粘贴的多行文本转成人员数组 function parseParticipants(text) { return text .split(/\r?\n/) // 按换行拆 .map(line => line.trim()) // 去掉首尾空格 .filter(name => name.length > 0); // 过滤空行 } // 奖品配置:每行 "二等奖, 5, 降噪耳机" function parsePrizes(text) { return text.split(/\r?\n/) .filter(line => line.trim()) .map(line => { const [name, count, desc] = line.split(/[,,]/).map(s => s.trim()); return { name, count: parseInt(count, 10), desc: desc || '' }; }); }这段代码把「粘贴文本」和「数据结构」解耦。实际使用时,textarea 里放的就是年会负责人从 Excel 里复制出来的名单,每行一个姓名,不用管几列。奖品配置用逗号分隔三列:奖级名称、数量、奖品描述——描述会显示在大屏上,方便现场念奖。parseInt 务必传第二个参数 10,否则以 0 开头的数量会被解析成八进制,这是一线很容易踩的坑。
2.2 人员池与奖池的数据结构设计
数据结构直接决定后续随机算法好不好写。常见做法是把人员分成「未中奖」「已中奖」两个数组,而不是在原始数组上标记状态。原因在于抽奖是「按批次取人」的,每轮奖品从剩余人员里取固定数量的人,取完就把这些人从 pool 里移除,这样下一轮抽奖的时候天然不会抽到重复的人。
const lottery = { pool: [], // 未中奖的人员名单 winners: [], // 中奖记录,每项 { name, prize, round } prizes: [], // 奖品配置,见 parsePrizes currentPrize: null, // 当前正在抽的奖品 };这里有一个关键参数需要现场敲定:每轮抽取的人数与奖池剩余人数的关系。比如一等奖只有 2 个名额,但现场到会人数 300,如果从全部 300 人里抽,中奖率约 0.7%;若此前已经抽过三等奖 80 人,评委把 pool 收缩到 220,再抽一等奖,中奖率就变成 0.9%。这个概率变化对现场观众的感知几乎没有影响,但对代码来说意味着「必须每轮从 pool 里抽取,而不是从最初导入的全量名单里抽」。
为什么不用「随机数取模」直接筛人:如果第一轮三等奖抽完后,有人中奖但后台上没扣除,第二轮再抽的时候就可能重复。无后台方案的数据库就是内存里的 pool 数组,每轮抽取后要立刻splice移除中奖者,同时把记录 push 进 winners。这一步做错了,后面所有概率控制都是空谈。
2.3 结果数据的持久化方案:localStorage 与导出
无后台抽奖页面最怕的不是抽错,而是抽完奖、关了浏览器,结果全没了。所以持久化是所有实现方案里不能省的一环。数据量并不大,几百条中奖记录用 localStorage 绰绰有余,key 值结构如下:
| key | 存储内容 | 写入时机 |
|---|---|---|
lottery_pool_2024 | 尚未中奖的人员数组(JSON) | 每次抽奖轮结束后 |
lottery_winners_2024 | 中奖记录数组(JSON) | 每次刷新出中奖人后 |
lottery_status | 当前奖品轮次、tab 状态 | 页面每次状态变化时 |
写入时机有两个要求:第一,中奖人显示到大屏上的瞬间就要写,而不是等主持人点「下一轮」才写——现场电源断电、浏览器闪退时,最后一批中奖者要能从 localStorage 恢复;第二,恢复时要能区分「已抽完未展示」和「已展示未点下一轮」,这靠 lottery_status 里的 round 字段判断,恢复时直接回到「展示中奖结果」的状态。
代码上,我建议封装一个saveState()函数,在每一次抽奖动画结束和中奖名单确定后调用,而不是在动画刚开始时调用。原因很简单:动画滚动期间数据还没最终确定,那个时间点写入的 pool 尚未删除中奖者,一旦崩溃恢复,会出现重复中奖。
function saveState() { localStorage.setItem('lottery_pool_2024', JSON.stringify(lottery.pool)); localStorage.setItem('lottery_winners_2024', JSON.stringify(lottery.winners)); localStorage.setItem('lottery_status', JSON.stringify({ round: currentRound, phase: currentPhase, // 'idle' | 'rolling' | 'revealed' prizeIndex: currentPrizeIndex })); }面试或维护这类源码时,评审最关心的就是这三行 localStorage 的逻辑。无后台不是不存数据,而是把存储下沉到了浏览器。localStorage 的容量上限约 5MB,年会抽奖几千人的名单和中奖记录远达不到这个上限,不用担心写爆。需要提醒的是,localStorage 是同步 API,写入大对象时会短暂阻塞主线程,所以别把整份名单每次轮询式写入,只在状态变迁点写。
3. 核心抽奖逻辑:随机算法、概率控制与防重
3.1 为什么不用「边滚动边随机选人」的方案
很多现成的年会抽奖页面,效果是名字在屏幕上飞速滚动,点一下停住、选中一个人。这个交互本身没有错,但实现上如果每帧都重新从池子里随机选一个人来显示,那么停在谁身上完全取决于动画停止的那一帧,而「停止」本身又依赖 JavaScript 的 setTimeout 精度和浏览器渲染时机,结果会偏——这不是概率问题,而是时间窗口偏差。
正确的抽奖时机应该是:动画开始前先决定中奖名单,动画只是把这个结果"演"出来。先随机从 pool 里抽一批人,再让他们的名字参与滚动展示,最后停在预先选中的几个人上。这样无论现场怎么打断、动画卡顿,最终的中奖者都是事先确定好的,不会因为浏览器性能波动而改变结果。
// 从 pool 中随机抽取 count 个不重复的人 function drawWinners(count) { // 先把 pool 拷贝一份,避免直接修改原数组 const tmp = [...lottery.pool]; // Fisher-Yates 洗牌 for (let i = tmp.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tmp[i], tmp[j]] = [tmp[j], tmp[i]]; } // 取前 count 个 return tmp.slice(0, count); }Fisher-Yates 洗牌是这套方案里最重要的算法。用Math.random()直接sort(() => Math.random() - 0.5)洗牌是常见误用,V8 引擎的 sort 算法不保证对每个排列等概率,结果是有偏的。Fisher-Yates 从后往前逐个交换,每个位置被选中的概率严格等于 1/n,洗完取前 count 个,就是无放回抽样,天然去重。
3.2 概率权重与库存扣减的代码实现
奖品数量就是抽奖批次的人数上限。假设一等奖配置 2 名,那么 drawWinners 传 2,返回两个人,立刻从 pool 里移除并写入 winners。这里需要注意一个容易被忽略的点:奖品配置里的数量应该大于等于现场实际抽取数,但代码要以配置的 count 为准,不能以 pool.length 为准——如果同事改 Excel 时把一等奖配了 5 个,现场屏幕上卡出 5 个人名,这就是纯 bug,概率控制得住,人控不住。
function runDraw(prizeIndex) { const prize = lottery.prizes[prizeIndex]; if (!lottery.pool.length) { alert('抽奖池已空,无法继续抽奖'); return; } // 实际抽取数量不超过奖池剩余人数 const count = Math.min(prize.count, lottery.pool.length); const selected = drawWinners(count); // 从 pool 中移除中奖者 const selectedSet = new Set(selected); lottery.pool = lottery.pool.filter(name => !selectedSet.has(name)); // 记录中奖信息 selected.forEach((name, idx) => { lottery.winners.push({ name, prize: prize.name, round: currentRound, seq: idx + 1 }); }); // 立即持久化,防止后续页面崩溃丢失 saveState(); // 返回给动画层展示 return selected; }这里有一个性能点:filter每次遍历整个 pool,几百人的数组完全没压力。但如果年会名单到了几千人,且奖品数量少,更高效的做法是用「索引池」——把 pool 换成索引数组,中奖后只把对应索引置空。不过这个优化对年会场景没有实际意义,premature optimization 在一次性活动里不值得投入。
概率控制的本质:每轮抽取都是「从剩余人数中无放回抽取 N 人」,所以每个参加者中奖概率 = 本轮抽取人数 / 当前剩余人数。如果想要「每个人全场只能中一次」,只需要坚持 pool 只减不增;如果有「已中奖者可继续参与追加奖」的需求,才需要额外引入一个 allMembers 数组来单独放特性人群。绝大多数年会都是前者,别做复杂了。
提示:如果还会有「老板临时加一个特别奖」的情况,不要把中奖者重新放回 pool——而是直接新开一个奖品并给它一个独立的参与名单(比如「全场所有人」或「未中奖者」)。这个名单在配置页面用一段 JSON 内联即可,代码不用改。
3.3 抽奖轮次的状态机设计,避免连点和重复抽
现场操作人员的习惯是拿到鼠标就狂点。如果「开始抽奖」按钮没有做防连点,一轮抽奖可能同时触发两次 draw,导致一个奖品抽出双倍人数。用状态机去约束交互是唯一可靠的做法。
const state = { idle: 0, // 空闲,可以开始 rolling: 1, // 动画滚动中,禁止操作 revealed: 2 // 已揭示中奖者,等待确认 }; // 按钮点击 function handleStartClick() { if (currentState !== state.idle) return; // 非空闲直接忽略 startRolling(); // 进入滚动,设置状态为 rolling } function handleRevealClick() { if (currentState !== state.rolling) return; stopRollingAndReveal(); // 展示结果,设置状态为 revealed } function handleNextRound() { if (currentState !== state.revealed) return; saveState(); goToNextPrize(); // 状态回到 idle }状态机写得越严格,现场越不容易出事。三个状态对应三个按钮操作:开始、揭晓、下一轮。每个按钮只在自己的状态区间内响应,其它情况下直接 return。再配合disabled属性双保险,前端层面就不可能出现「同一轮抽出两拨人」的问题。这种设计不仅适用于抽奖,无后台的翻牌、弹窗类页面都能复用这套思路。
4. 动效调度与性能:从滚屏名字到大屏展示的稳妥实现
4.1 名字滚动用什么技术方案不卡顿
年会现场的主持电脑大概率不是顶配,可能是行政借来的办公本,浏览器可能还是旧版 Chrome 或 Edge。动效技术选型要遵循"兼容优先、效果次之"的原则。我用过的方案里最稳的是requestAnimationFrame逐帧驱动 + CSS transform 位移,两层组合,既照顾了帧率又照顾了低端 GPU。
滚动名字的常见实现有两种:DOM 节点逐个插入并移动到容器顶部,或者用 canvas 画出当前名字。前者在名单几百人时会出现大量 DOM 操作;后者没有 DOM 但有 canvas 初始化成本,且对中奖姓名的字体渲染不友好,可能出现「中奖者名字模糊」的低级事故。我一般选一个折中:固定只用一条 DOM 元素做滚动文本,每一帧更新它的 textContent 和 transform: translateY,这样布局几乎不变,触发的 reflow 极小。
let rollId = null; function startRolling(names, duration = 3000) { const el = document.getElementById('rolling-name'); const startTime = performance.now(); const speed = 20 + Math.floor(Math.random() * 50); // 每帧跳转的索引数 let index = 0; function tick(now) { const elapsed = now - startTime; // 逐帧随机跳名字,模拟快速滚动 index = Math.floor(Math.random() * names.length); // transform 位移制造上下穿梭感 el.style.transform = `translateY(${-(index % 6) * 24}px)`; el.textContent = names[index]; if (elapsed < duration) { rollId = requestAnimationFrame(tick); } else { // 最终停在中奖者名字上 stopRolling(names[0]); } } rollId = requestAnimationFrame(tick); } function stopRolling(finalName) { cancelAnimationFrame(rollId); document.getElementById('rolling-name').textContent = finalName; // 触发中奖展示动画 revealWinner(finalName); }这里的两个参数需要现场调试:动画时长 duration 建议设在 2500~4000ms 之间,太短观众没气氛,太长主持人会尴尬,3000ms 是个不容易错的默认值;speed 是每帧从一个名字跳到另一个名字的跨度,它影响的是「跳动的粒径」,20~60 之间比较合适。不需要做成精确参数,后面统一按「轮数 = 每秒 15~20 次跳动」的直觉调就可以了。
滚动期间有一件事必须额外注意:不要让真正中奖者的名字在滚动列表里被高亮,否则滚动结束时观众可能提前看到「哪个名字会停」。中奖者名单在 drawWinners 阶段已经确定,但动效层展示的滚动序列要从整个 pool 里随机取,直到最后一下才切到中奖者。
4.2 大屏投影场景下的字体和布局参数
年会现场用的是投影仪或大屏电视,和桌面浏览器相比有几个实际差异:色彩偏淡、分辨率可能只有 1920x1080 或更低、宽高比不一定是 16:9。最常见的问题不是 JS 崩溃,而是 CSS 把名字挤爆了。中奖名单有 6 个人时,如果每个人的姓名 3 个字加奖品名超长,一行放不下就换行,换行就会把页面撑高,然后露出后面的配置界面。
推荐的做法:中奖展示区域用 flex + flex-wrap 布局,禁止横向滚动,每个中奖者的卡片固定宽度,字体大小用clamp(24px, 5vw, 64px)自适应。展示区高度设为大屏视口高度的 60%,多出来的空间主要留给顶部横幅。
#winner-list { display: flex; flex-wrap: wrap; justify-content: center; align-content: flex-start; gap: 24px; max-width: 80vw; max-height: 60vh; overflow: hidden; /* 杜绝滚出大屏 */ } .winner-card { flex: 0 0 240px; text-align: center; } .winner-name { font-size: clamp(28px, 4vw, 56px); font-weight: 700; letter-spacing: 0.08em; }flex 的flex: 0 0 240px保证了每个卡片固定宽度 240 像素,不随内容伸缩,这样就算名字是 4 个字或者 8 个字,都按同样的卡片宽度排版,不会出现长短不齐。overflow hidden 不是兜底,而是刻意为之——如果中奖人数太多,宁可因为卡片显示不下而溢出隐藏,也不要让大屏页面整体滚动。溢出,说明奖品数量配置超过安全展示个数,实际年会中最多一轮 10 人,这样设置足够了。
4.3 现场断网、浏览器崩溃后的恢复流程
无后台页面虽然不依赖服务器,但浏览器崩溃、误关标签页、笔记本断电还是会中断流程。没有后台的情况下,恢复流程完全靠 localStorage。恢复要解决两个问题:抽到一半怎么续抽,以及怎么确认已中奖名单和现场已公布的一致。
我的做法是给恢复按钮绑定一个restoreFromStorage(),启动页面时自动执行。从 localStorage 读的状态里取 three 个字段:pool 剩余人员、winners 已中奖记录、当前阶段。如果当前阶段是revealed,说明最后一轮已经抽完但还没点「下一轮」,页面恢复后直接展示最后一次中奖结果;如果是rolling,说明动画被打断,此时应该把状态回退到idle,因为动画还没结束就不可能 reveal,pool 也没扣减,重新抽一次即可。
function restoreFromStorage() { const poolStr = localStorage.getItem('lottery_pool_2024'); const winnersStr = localStorage.getItem('lottery_winners_2024'); const statusStr = localStorage.getItem('lottery_status'); if (!poolStr || !winnersStr || !statusStr) return; lottery.pool = JSON.parse(poolStr); lottery.winners = JSON.parse(winnersStr); const status = JSON.parse(statusStr); if (status.phase === 'revealed') { // 恢复到最后一次中奖展示界面 renderWinners(); currentState = state.revealed; } else { // 滚动中被中断,重新回到 idle 等待 currentState = state.idle; } }恢复后立刻执行renderWinners()把中奖人的名单渲染回大屏。这里有一个细节:刷新后的滚动名单顺序和原来可能不同,但真正中奖的人以 winners 数组为准,动效展示顺序不影响结果有效性。这算是一个免责条款——在页面上增加一行小字「中奖结果以系统记录为准」,能减少现场大量解释成本。
5. 验证与兜底:零后台抽奖页面的三个收尾技巧
5.1 用控制台模拟 10 轮抽奖验证概率波动
上线前我最少要跑三组冒烟测试:正常抽奖、猛点按钮、中途刷新。但手动点 10 轮太慢,直接在控制台调函数跑批量模拟才是工程习惯。打开页面后 F12,在 Console 里执行下面的代码,会连续抽 10 轮并输出每轮人数和中奖名单,用来验证概率和去重是否生效:
// 模拟 10 轮抽奖 for (let i = 0; i < 10; i++) { const prize = lottery.prizes[i % lottery.prizes.length]; const before = lottery.pool.length; const winners = runDraw(i % lottery.prizes.length); const after = lottery.pool.length; console.log(`Round ${i+1}: ${prize.name}, 抽 ${winners.length} 人, 池 ${before} -> ${after}`); // 断言:reveal 的人数与奖品设定一致 console.assert(winners.length <= prize.count, '超出奖品数'); } // 检查有没有重复中奖 const allWinners = lottery.winners.map(w => w.name); console.assert(new Set(allWinners).size === allWinners.length, '存在重复中奖');注意这里的runDraw因为没有走状态机,绕过handleStartClick的保护,实际是在测试算法本身,不是测试交互。交互的防连点要手动在页面里猛点验证。另外 Console 里跑脚本会修改页面状态,恢复按钮要把模拟数据清掉,所以测试前最好先备份一次 localStorage,测完用localStorage.clear()复位。
5.2 让同事在内网直接打开页面:共享文件夹的正确姿势
无后台页面拷到 U 盘能跑,但现场主持人如果双击的是index.html,不同电脑对 file:// 协议下模块加载的支持不一致。浏览器允许直接打开单文件 HTML,但只要代码里出现了fetch()去读本地 JSON,就会因为 CORS 被拦截。
解决方案很简单:不要用 fetch 加载数据,而是把名单和奖品配置直接作为 JS 变量或 JSONP 式地写在 script 标签里。比如configs.js文件里写const CONFIG = {...};,页面用<script src="configs.js"></script>引入,而不是fetch('data.json')。这样整个目录拷到内网共享盘,任何人的 Windows 双击都能正常加载。
<!-- index.html 中的引入方式 --> <script src="./js/configs.js"></script> <script src="./js/lottery.js"></script>如果用 Vite、Webpack 那类构建工具打包出的文件,几乎都是基于 module 的,双击 file:// 大概率白屏。想走「无后台 + 直接双击」路线,就别上构建工具,要么纯手写源码,要么构建完改成 inline 单文件。年会抽奖网页源码的客观约束决定了「源码可拷、双击即用」比工程化更重要。
5.3 抽奖结果汇总给行政:一键导出中奖名单
年会结束后,行政通常需要一个 Excel 或 CSV 来发奖品。无后台页面没有后端接口,可以用 Blob 生成 CSV 文件让主持人下载,这是最后一个落地闭环。
function exportWinners() { const header = '序号,姓名,奖品,轮次'; const rows = lottery.winners.map((w, i) => `${i + 1},${w.name},${w.prize},${w.round}` ); const csv = [header, ...rows].join('\n'); const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '年会中奖名单.csv'; a.click(); URL.revokeObjectURL(url); }两个细节值得说明:\ufeff是 BOM 头,没有它 Excel 打开 CSV 中文会乱码;URL.revokeObjectURL放在a.click()之后立即执行,让浏览器完成下载再释放临时对象。无后台方案的终点不是网页,而是把现场数据安全带回业务侧——这也是把"年会抽奖网页源码+无后台"做到位和做完整的区别。
本文还有配套的精品资源,点击获取