
简介这套无后台抽奖网页源码面向年会、比赛、促销等各类现场活动解决临时搭建抽奖系统的繁琐配置问题适合活动策划、行政人员及前端入门开发者。压缩包内含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 建议设在 25004000ms 之间太短观众没气氛太长主持人会尴尬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因为动画还没结束就不可能 revealpool 也没扣减重新抽一次即可。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 ${i1}: ${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 srcconfigs.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;charsetutf-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()之后立即执行让浏览器完成下载再释放临时对象。无后台方案的终点不是网页而是把现场数据安全带回业务侧——这也是把年会抽奖网页源码无后台做到位和做完整的区别。本文还有配套的精品资源点击获取