简介:这是一份面向企业年会、节日庆典等场景的年会抽奖网页源码,纯前端实现、无需后台服务器,适合行政人员或前端开发者快速部署使用。包体共24个文件,约4.77MB,以HTML入口页、JavaScript逻辑脚本、图片及字体资源构成,其中抽奖名单集中存放于js/member.js中,修改该文件即可替换参与者数据,另附README.md说明文档便于迅速上手。抽奖逻辑由浏览器端随机算法实现,数据处理均在本地完成,部署简单;17个png图片涵盖奖品展示、按钮、背景等界面元素,视觉效果较好。目前已有986人学习浏览,适合需要在短时间内搭建界面友好、操作直观的现场抽奖工具,并根据实际需求定制名单与奖品配置的使用者。
1. 年会抽奖网页:无后台是什么意思,能扛住多大的场子
年会抽奖网页源码+无后台,等于把你原以为必须塞给服务器的活儿,全部搬进浏览器里干。名单、随机、记录、导出,四件事由一组静态文件完成,现场只需要一台能开网页的电脑加一块投影幕。无后台不是没能力,只是不去依赖哪台“中央服务器”;恰恰因为少了这个中心,反而砍掉了一整类“活动现场服务器挂了”的灾难。我去年帮某公司做年会时就用的这套方案,两百人三轮奖,中途换场地拔电源,重新打开页面数据照常接着用。它适合单场 100 到 300 人活动的组织者、接年会活儿的开发者,也适合不想碰服务器和数据库的非技术主办方。它不擅长的是多人同时写数据、跨天跑长线运营的线上活动,那种场景我建议老老实实去上后端。
2. 为什么无后台可行:浏览器就是你的“中控台”
2.1 纯静态方案的文件组成与工作边界
一段年会抽奖流程里,后台真正要做的事其实只有三件:存一份参与者名单、产生不重复的随机结果、把中奖记录留档。这三件事不需要高并发,不需要鉴权,也不需要跨设备同步,因为它们都发生在同一个物理空间里——年会现场。既然所有操作都集中在主持人手里的那台电脑上,把这三件事压进浏览器就是顺理成章的优化。
无后台方案的“后台”由两部分顶替:文件系统负责名单的静态存储,浏览器的 localStorage 负责运行期状态记录。文件组织通常就四件套:一个 HTML 放页面骨架,一个 CSS 放布局和动效,一个 JS 放全部业务逻辑,一个 txt 放每行一个名字的参与名单。这个结构对新手友好,任何一个文件坏了都能单独替换,不用重新部署,也不存在“某个接口超时”这种查起来让人血压升高的玄学问题。
边界条件必须说清楚:这种方案的数据所有权属于打开页面的那台浏览器。手机和现场其他设备如果同时打开同一个页面,只能当观众,不能写入数据,因为每个浏览器各自持有自己的 localStorage 副本。现场唯一合法的“写数据者”是负责抽奖的主投屏电脑。所以操作习惯上,我一律只在这些主设备上点击抽奖,其他连进来的设备只做投屏或展示。
我一般按这个标准判断能不能用无后台:参会人数在 500 人以内,奖项轮次不超过五轮,活动时长不超过一整天。超过这个规模,名单导入方式要从粘贴文本升级成表格文件读取,存储也要考虑多设备协同,那就不如直接上后端。另外,这种一次性活动页面完全不需要打包框架,一个原生 JS 文件就能讲清楚全部逻辑,维护成本远低于引一个脚手架。
2.2 名单与结果存哪:localStorage 的读写模型
无后台方案里最容易被低估的是存储。很多人以为不开服务器就没法保存数据,其实浏览器的 localStorage 就是一个小型持久化数据库,它按同源策略隔离,以键值对形式存储字符串。数组、对象这类结构必须先序列化成 JSON 再写入,读取时再反序列化。年会名单几百行文本、中奖记录几十条,撑死也占不了几百 KB,而 localStorage 的常规容量在 5MB 级别,空间完全够用。
为什么不选 cookie?cookie 设计出来是给 HTTP 请求携带状态的,容量只有几 KB,而且每次请求都会带上它,在一个无后台的静态页面里根本没有请求可带,用它属于绕远路。为什么不选 sessionStorage?它的问题是关掉标签页就清空,年会现场一旦有人误关页面,所有记录归零,这在现场是不可接受的。localStorage 的持久性正好匹配“临时工具但现场不能出错”的定位。
还要提一个容易翻车的点:localStorage 绑定在“协议 + 域名 + 端口”这个源上。如果你用 file:// 协议直接双击 HTML 打开页面,有些浏览器对 file 源的存储行为并不稳定,换个浏览器就找不到你存过的数据;所以我在无后台方案里也坚持用本地静态服务来访问页面,这既让手机能连进来,也让 localStorage 的行为可预期。顺便补充一句:如果现场用的是隐私模式,某些浏览器会限制 localStorage 写入,极端情况下会直接抛异常,我一般会在存储操作外面包一层 try/catch,失败时弹提示让用户换普通模式,避免抽奖中途页面静默失忆。
读写封装通常就是 setItem 加 getItem,配合 JSON 序列化就能覆盖大多数需求。重要的是把“内存里的实时状态”和“localStorage 里的备份状态”这两套数据保持一致,抽奖每走一步就立刻落盘。这部分代码在第 3 章直接给出,这里先讲明白模型:抽奖本质上是对“剩余池”和“中奖名单”这两个数组的持续变更,变更一次、保存一次。
2.3 抽奖公平性:从 Math.random 到洗牌取样的差异
“用 Math.random 抽个人出来而已,能有多难?”这是我常听到的一句话,直到看见现场抽完一轮发现有人中两次。直接写 Math.floor(Math.random() * arr.length) 来抽取,单次单人的概率看起来没问题,但多轮多人时就暴露两类缺陷:第一,随机到的下标可能重复,需要额外的去重循环;第二,如果不去重就让同一个人进入下一轮,上一轮中奖者仍然留在池子里,后续轮次的概率就乱了。
公平的做法是洗牌后取样。先把参与名单整体做一次 Fisher-Yates 洗牌,把顺序彻底打乱,之后按顺序从前到后取人即可。Fisher-Yates 的流程是从数组末尾开始向前遍历,每到一个位置 i,就随机挑一个 0 到 i 的下标 j,交换 i 和 j 的位置,一轮下来每个元素出现在任意位置的概率相同。时间复杂度 O(n),空间 O(1),几百人的数组瞬间完成,不存在性能压力。
无后台方案里洗牌还有个额外的好处:洗牌是在页面加载时一次性完成的,洗完之后每轮抽取只是“顺序取数”,不用反复生成随机数,现场逻辑复杂度大幅降低。需要注意 Math.random() 不是加密安全随机源,但年会场景没有敌手去反推种子,现场的信任靠“投影实时展示候选人名单变动”来建立,而不是靠随机源强度。如果你确实需要更硬的随机性,可以从 window.crypto.getRandomValues 拿随机数来驱动洗牌,下面第 3 章的代码里我会保留这个替换入口。
3. 把源码跑起来:文件结构、核心代码与最小可用配置
3.1 文件清单与目录结构
先把目录组织好。下面是我常用的最小文件集,四个文件就够了,不需要构建工具,不需要任何依赖:
| 文件 | 职责 | 现场通常要改吗 |
|---|---|---|
| index.html | 页面骨架,放标题、奖项区、抽奖按钮、名单展示区 | 基本不改 |
| style.css | 布局、走马灯动效、大屏适配 | 按年会主题色改 |
| app.js | 名单解析、洗牌、抽奖、持久化、导出 | 核心逻辑所在 |
| names.txt | 每行一个参与者姓名或工号 | 必改,换成当届名单 |
目录结构就是一个普通文件夹,我习惯命名成 lottery-static:
lottery-static/ ├── index.html ├── style.css ├── app.js └── names.txt文件夹直接放桌面就可以,不要放进系统盘权限高的目录,免得现场读文件弹权限提示。所有 js/css 用相对路径引用,这样整个文件夹拷到任何一台电脑都能原样运行,不用改一行配置。
3.2 名单导入与奖项配置:一个 HTML 页面怎么管理入场数据
名单导入的无后台方案常见有两种:第一种直接把名单写进 JS 数组变量,适合几十人的小团队;第二种维护一个 names.txt,页面加载时读取。我本人倾向于第三种——页面文本框粘贴,因为年会前名单经常改到最后一刻,改 txt 文件还得刷新页面,而粘贴直接在页面上完成,所见即所得,改完立即生效。
奖项配置放在 JS 文件顶部,结构就是一个对象数组:
// 奖项配置:现场只需要改这里 const PRIZES = [ { name: '三等奖', count: 10 }, { name: '二等奖', count: 3 }, { name: '一等奖', count: 1 }, ]; // 名单解析:支持每行一个名字,忽略空行和首尾空格 function parseNames(text) { return text .split(/\r?\n/) // 兼容 Windows 与 macOS/Linux 换行 .map((line) => line.trim()) .filter((line) => line.length > 0); }逻辑说明:PRIZES 数组的顺序就是现场抽奖的顺序,按三等奖、二等奖、一等奖排列符合大多数年会的节奏;如果你需要先抽大奖再抽小奖,直接调整数组顺序即可。count 表示这一轮抽几个人,会被用来控制洗牌后取出的人数。parseNames 函数把多行文本拆成数组,关键在于用\r?\n同时兼容两种换行符,并过滤掉空行,避免复制 Excel 名单时带进来的空行被当成一个“人”。
参数说明:count 必须小于等于剩余人数,否则在抽奖函数里会弹窗提醒。如果名单里有重名,parseNames 不过滤重复,重复会被当成两个人,这通常符合“重名但工号不同”的现实;如果业务上不允许重名,你可以加一句return [...new Set(names)]去重。另外建议在活动前用一组测试名单把 PRIZES 的 count 总和比对一遍,如果总人数小于总奖项数,趁早调整,别等上台才发现人不够。
3.3 抽奖核心逻辑:抽取、去重、状态恢复的 JS 实现
这是整个方案最核心的代码块。先说状态模型:我用一个 state 对象保存剩余池、中奖记录和当前轮次三个字段,全部塞进 localStorage,确保现场任何一次刷新都能恢复。
const STORAGE_KEY = 'lottery_static_v1'; const state = { remainingPool: [], // 还没被抽中的人,洗牌后有序 winners: [], // 已中奖记录,含轮次、奖项、姓名 currentRound: 0, // 当前抽到第几轮(从 0 开始) }; // 原地洗牌:Fisher-Yates 变体 function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } // 从剩余池里顺序取 count 个,洗过牌后天然不重复 function drawFromPool(count) { if (state.remainingPool.length < count) { alert(`剩余可抽人数不足,仅剩 ${state.remainingPool.length} 人`); return []; } // remainingPool 在加载时已经洗过牌,这里按顺序取即可 return state.remainingPool.slice(0, count); } // 立即把状态写入 localStorage function saveState() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } // 页面启动时恢复状态 function loadState() { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { Object.assign(state, JSON.parse(raw)); } } // 执行一键抽奖:从当前奖池取人,记录并落盘 function onDraw() { const prize = PRIZES[state.currentRound]; if (!prize) { alert('所有奖项已抽完'); return; } const result = drawFromPool(prize.count); if (result.length === 0) return; result.forEach((name) => { state.winners.push({ round: state.currentRound, prize: prize.name, name: name, }); }); const winnerSet = new Set(result); state.remainingPool = state.remainingPool.filter((name) => !winnerSet.has(name)); state.currentRound += 1; saveState(); render(); // 把获奖名单渲染到页面 DOM,具体实现随页面结构而定 }逻辑说明:洗牌只需要在页面加载时执行一次,所有轮次共享洗牌后的 remainingPool,每轮只取前 count 个,抽完立刻把这些人从池子里移出。这样从概率上保证了一个人最多中一次,而且每一轮的抽取概率对剩余所有人均等。特别要注意 drawFromPool 用的是 slice(0, count),不是 splice,因为 splice 会修改原数组顺序,而我们希望 remainingPool 一直保持“剩余池顺序”,这样下一轮还能接着取;移除中奖者的动作统一放在 onDraw 里用 filter 做,两步分开,逻辑不绕。
参数说明:STORAGE_KEY 建议带版本号和场次标识,比如改成lottery_2024_annual_v1,避免同一台电脑上往年年会的数据残留干扰本届。Object.assign 用来把 localStorage 读出的 JSON 对象合并回 state,这样以后加字段,旧数据读取时不会因为缺字段抛异常。alert 在演示版里够用,正式场合我一般换成页面内嵌提示条,否则主持人误点回车会弹窗打断节奏。
3.4 让手机也能投屏:局域网静态服务的启动命令
无后台方案最容易被忽略的一步是:如果你想在现场让手机或其他电脑访问同一个抽奖页面,不能双击 index.html 用 file:// 打开,因为那样手机根本访问不到文件。常见做法是在文件夹里起一个极轻量的本地静态服务,以 Python 为例:
# 在 lottery-static 目录下执行 python3 -m http.server 8080 --bind 0.0.0.0启动后,电脑和手机连同一个 Wi-Fi,在手机浏览器里输入“电脑局域网 IP:8080”就能打开。查看电脑局域网 IP,Windows 用 ipconfig,macOS 和 Linux 用 ifconfig 或 ip addr。8080 端口如果被占用就换 8899,换端口时手机访问地址的端口也要同步换。这里只在局域网内提供静态文件,不涉及任何对外暴露,适合年会这种临时场景。
参数说明:--bind 0.0.0.0 表示监听所有网卡,如果漏掉这个参数,服务可能只对 127.0.0.1 生效,手机自然打不开。地址栏的 IP 一定要填电脑的局域网 IP,不是 127.0.0.1。
提示:如果手机打开一直转圈,优先检查电脑防火墙是否拦截该端口,以及路由器是否开启了 AP 隔离,这两个坑我在第 4 章展开。
4. 年会现场常用避坑:刷新丢名单、重复中奖、手机连不上
4.1 页面一刷新,中奖名单全没了
现象:抽奖抽到第二轮,手滑按了 F5,页面重载后名单恢复成初始状态,中奖记录全丢。
原因:很多简单实现只把“参与名单”放进了 HTML 或 JS 变量,从未写入 localStorage。页面刷新后变量被销毁,一切归零。另一种隐藏原因是只保存了名单,没保存 currentRound,刷新后名单还在,但轮次回到第一轮,相当于让已经抽过的人再抽一次。
解决:把 state 整体序列化后写入 localStorage,启动时按 3.3 里的 loadState 恢复。特别注意 currentRound、剩余池、中奖记录三者必须同时落盘,缺一个都会造成“数据还在但流程错乱”。我还会在 onDraw 里每轮末尾强制 saveState,抽奖这种低频动作不会带来性能问题,但换来的是足够强的现场安全感。
4.2 同一轮次有人抽了两次
现象:第二轮抽奖结果里,出现了第一轮已经中奖的人,主持人和观众当场愣住。
原因:抽奖逻辑写成了每轮都从“全员数组”里重新随机,而不是从“剩余池”里抽。即便用了洗牌,如果你每轮都洗一遍原数组,上一轮中奖的人没有被移出,重复无法避免。
解决:明确维护一个 remainingPool 字段,每轮抽完后用 filter 把中奖者移除,下一轮只操作这个池,也就是 3.3 里的这一行:
state.remainingPool = state.remainingPool.filter((name) => !winnerSet.has(name));这一行一定要放在 saveState() 之前,先改内存再落盘。如果你在调试时看到中奖名单正常但剩余池没变,优先检查是不是把这行注释掉了。现场遇到这种情况不用慌:剩余池里把重复中奖的人移除,重新抽下一轮即可,页面上的已中奖记录不会受影响。
4.3 手机死活打不开电脑上的页面
现象:手机扫了现场投屏上的二维码,浏览器转圈半分钟,最后提示无法访问。
原因:这类问题 90% 出在三个地方:一是电脑防火墙拦截了 8080 端口的入站连接;二是手机连的 Wi-Fi 跟电脑不在同一网段,年会场地常见“员工 Wi-Fi”和“访客 Wi-Fi”物理隔离;三是服务启动时没带 --bind 0.0.0.0,只监听了本机回环地址。
解决:按顺序排查。先在本机浏览器访问 http://127.0.0.1:8080 确认服务本身正常;再用手机浏览器访问电脑的局域网 IP,如果本机能开手机不能开,检查防火墙放行对应端口;如果两台设备显示连同一个 Wi-Fi 还是不通,大概率是 AP 隔离,换到同一接入点或直接让手机开电脑的移动热点。顺便说一句,我一般会在抽奖前半小时用手机实际点一遍页面,而不是到开场前才测。
4.4 大屏动画卡顿,抽奖点下去没反应
现象:本地笔记本测试流畅,接到投影后点抽奖按钮要等一两秒才出人,中奖名单显示一顿一顿。
原因:投影仪分辨率高但刷新率低,加上老笔记本的核显性能有限;而页面如果用了大量 DOM 节点动画、大尺寸背景图或一次性渲染所有参与者名单,CPU 会瞬间跑满。
解决:把动效从“所有参与者一起滚动”改成“只滚动当前中奖者或当前候选窗口”;背景图在上场前压缩成 WebP 或 JPG,宽度控制在 1920 以内;动画属性尽量使用 transform 和 opacity,避免动画 left/top 这类会触发重排的属性。如果真的还会卡,就在代码入口加一个 lowPerf 开关,把所有粒子特效直接关掉,保证抽奖核心动作不卡顿。现场演示的数据永远比特效重要。
4.5 活动结束想要 Excel 版中奖名单
现象:活动结束后领导要一份中奖名单,现场嘉宾用手机拍了屏幕,整理时还发现漏了一个名字。
原因:无后台方案没有任何服务端日志,中奖记录只存在于浏览器 localStorage 里,不主动导出就等于没有记录。
解决:在页面里内置一个“导出 CSV”按钮,从 state.winners 生成 CSV 文件并下载。下面是我常用的导出函数:
function exportWinnersCSV() { const rows = [ ['轮次', '奖项', '姓名'], ...state.winners.map((w) => [`${w.round + 1}`, w.prize, w.name]), ]; const csv = rows .map((row) => row.map((cell) => `"${cell}"`).join(',')) .join('\n'); // 加 BOM 前缀,避免 Excel 打开中文乱码 const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = '年会中奖名单.csv'; a.click(); URL.revokeObjectURL(a.href); }逻辑说明:CSV 的每一行用逗号分隔,我给每个字段都加了双引号,这样即使名单里有逗号或特殊字符也不会破坏列结构。文件头加 \ufeff 是专门用来告诉 Excel 这个文件是 UTF-8 编码,不加的话中文大概率显示成乱码。导出按钮建议放在页面右上角,活动一结束立刻点一下,数据就有了官方留档。
5. 进阶玩法:键盘控制、名单一键导入与数据复用
5.1 键盘与遥控器控制
现场抽奖最怕主持人握着鼠标乱点,误把弹窗点掉。我给页面加一个全局键盘监听,空格键触发抽奖,回车键跳到下一轮:
document.addEventListener('keydown', (e) => { if (e.code === 'Space' && !e.repeat) onDraw(); if (e.code === 'Enter') nextRound(); });加!e.repeat是为了防止长按空格触发多次抽奖,这个细节在现场特别重要。如果主持人用翻页笔,把翻页笔的“下一个”键映射成回车,整个抽奖过程就能彻底离开鼠标,节奏会顺很多。
5.2 名单从本地文件一键导入
年会名单经常到开会前还在改,反复粘贴文本容易出错。我习惯在页面上放一个文件选择框,用 FileReader 读取 txt 文件,直接喂给 parseNames:
关键在于 FileReader 的 onload 回调里拿到文件文本,随后调用 parseNames 并重新洗牌初始化。txt 文件格式保持每行一个名字,编码保存成 UTF-8,避免从 Windows 记事本出来的 ANSI 编码导致中文乱码。这个功能让名单更新成本降到零,也是无后台方案里最实用的补强。
5.3 跨场次数据隔离与复用
同一台电脑今年用完明年再用,localStorage 里还留着去年数据,容易串场。解决办法是把 STORAGE_KEY 带上场次标识,比如lottery_2024_annual_v1,每年年会换一个 key,旧数据自然隔离。配合导出 CSV,去年的记录还能继续留档,新场次从零开始。我现在做这类小工具,第一步就是先想清楚“这台电脑明年换个场次,数据会不会互相污染”,这个习惯是吃了亏才养成的。希望帮到你。
本文还有配套的精品资源,点击获取