纯HTML单文件实现年会抽奖:零依赖、稳定不翻车的完整方案
发布时间:2026/10/1 9:11:20来源:尧图网络
简介HTML实现名字滚动年会抽奖页面源码适合前端开发者、企业策划人员快速搭建年会或活动抽奖环节。页面按奖项分别展示奖品未抽奖时显示抽奖按钮点击后员工姓名滚动闪烁停止时自动弹出中奖信息已抽奖的奖项则直接显示对应中奖用户交互逻辑完整。压缩包共18个文件包含2个HTML页面、CSS样式、jQuery脚本、11张界面与背景图片以及一个MP4操作演示视频包体约96.45MBreadme.txt提供使用说明结构清晰便于直接部署或二次修改。目前已有3407人学习使用对想要快速实现年会抽奖并兼顾展示效果与代码可读性的读者很有参考价值既能直接用于现场大屏也能作为前端交互练习的完整案例。1. 为什么年会抽奖我首选纯 HTML 单文件现场永远不会翻车的方案年底各大公司都在筹备年会而抽奖环节几乎每次都是全场焦点。说实话我刚入职那几年见过用 PPT 做抽奖的见过用专门抽奖软件的也见过直接往群里发红包让拼手气的。但真到了领导说抽个奖名单给我这种场景纯 HTML 单文件实现的名字滚动年会抽奖永远是我第一个想到的方案。不需要数据库不需要 Node.js 环境不需要连内网服务器一个文件拷到 U 盘任何一台能打开浏览器的电脑都能跑。如果把文件发给同事微信里直接点开也能用。这篇会把这套源码怎么写的、参数怎么调、现场会踩什么坑一次说清楚。2. 抽奖页面的核心逻辑先想清楚滚动 抽中 移除三段式2.1 名字滚动不是动画是快速换名字的视觉欺骗做名字滚动抽奖前得先纠正一个常见想法很多人以为要用 CSS animation 或 canvas 做粒子效果但其实年会抽奖页面的滚动本质上是高频替换 DOM 文本。视觉原理很简单人的眼睛对 50ms 以内的内容变化几乎感知不到具体内容只觉得在闪。利用这个特性用一个定时器每隔几十毫秒把展示区里的名字换掉看起来就像名字在高速滚动。用户喊停的时候定时器一清停下来的那个名字就是中奖者。这套逻辑在实现上就是三个状态滚动中setInterval每秒触发 20~30 次每次从名单里随机取一个名字填到展示区。停止点击按钮或按空格clearInterval没收定时器当前名字定格。移除把中奖者从候选人数组里splice掉保证第二次开奖不会抽到同一个人。2.2 文件结构一个 HTML 文件扛下所有这里我选择把所有东西写进一个index.html不拆 CSS 和 JS 文件。原因很实际年会现场拷贝文件时多一个文件就多一个少拷了的风险。单个 HTML 文件意味着不管用 U 盘、微信文件传输还是邮箱附件只要这一个文件能打开整套抽奖就能跑。文件内部结构如下style标签内的 CSS负责全屏背景、舞台感、大字展示。body内的静态骨架一个用于显示滚动名字的容器、一个已中奖名单容器、两个按钮。script标签内的 JS名单数组、滚动定时器、抽奖状态机、键盘事件。代码语言上就是标准 HTML5 CSS3 原生 JavaScript不依赖任何框架。有人会问能不能用 Vue 或 React 写能但没必要——现场打开一个 HTML 文件Vue 的 CDN 加载如果碰上网络抽风整场年会就尬住了。2.3 热身先手写一个最小可运行的抽奖页面这个部分直接给可以抄的代码。先把最核心的跑通打开页面点开始名字滚动点停止出中奖者。!DOCTYPE html html langzh-cn head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title年会抽奖 - 名字滚动版/title style body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #8B0000 0%, #DAA520 100%); font-family: Microsoft YaHei, sans-serif; color: #fff; } #display { font-size: 80px; font-weight: bold; letter-spacing: 8px; width: 80%; height: 200px; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: 20px; border: 3px solid #DAA520; word-break: break-all; } .btn { margin: 20px; padding: 12px 40px; font-size: 22px; border: none; border-radius: 30px; background: #fff; color: #8B0000; cursor: pointer; } .btn:disabled { opacity: 0.5; cursor: not-allowed; } #winners { margin-top: 30px; font-size: 24px; min-height: 40px; } /style /head body div iddisplay点击开始/div div button classbtn idstartBtn开始/button button classbtn idstopBtn disabled停止/button /div div idwinners/div script // 候选人名单直接写在这里按需增删 const candidates [张三, 李四, 王五, 赵六, 孙七, 周八, 吴九, 郑十]; let rollingTimer null; const display document.getElementById(display); const startBtn document.getElementById(startBtn); const stopBtn document.getElementById(stopBtn); const winnersBox document.getElementById(winners); const winners []; // 滚动中每50毫秒更新一次显示内容 function startRolling() { if (candidates.length 0) { alert(没有可参与抽奖的名单了); return; } rollingTimer setInterval(() { const randomIndex Math.floor(Math.random() * candidates.length); display.textContent candidates[randomIndex]; }, 50); startBtn.disabled true; stopBtn.disabled false; } // 停止清掉定时器当前显示的就是中奖者 function stopRolling() { clearInterval(rollingTimer); rollingTimer null; const winner display.textContent; winners.push(winner); // 从候选中移除避免重复中奖 const idx candidates.indexOf(winner); if (idx -1) { candidates.splice(idx, 1); } winnersBox.textContent 中奖名单 winners.join(、); startBtn.disabled false; stopBtn.disabled true; } startBtn.addEventListener(click, startRolling); stopBtn.addEventListener(click, stopRolling); /script /body /html这个版本是整套方案的骨架。有几个关键点需要说明一下setInterval(..., 50)这里 50ms 是滚动速度基准数值越小滚动越快视觉上停不下来的感觉越强但太小会显得闪得眼花个人建议 30~60ms 之间。candidates.indexOf(winner)用来找到中奖者下标再用splice移除。这个操作保证了同一人不会二次中奖但也带来一个问题如果名单有重名两个人会互相影响后面避坑章节我会专门说。display.textContent取值作为中奖结果而不是重新从数组里取就是为了保证你看到的和你得到的一致。按钮的disabled状态是硬性切换的防止连点停止导致同一轮重复抽两次。3. 从能用到好用加音效、加控制、加名单管理3.1 名单来源改成 JS 数组动态维护上一版代码把名单写死在candidates数组里看起来也是最直白的做法。但实际年会现场名单往往是 Excel 或企业通讯录里拷出来的动辄几十上百人。总不能每加一个人都改一遍源码。更常见也更稳妥的做法是把名单集中到script标签里一块独立区域和逻辑代码分开。改名单的人不需要懂 JS只需要找到类似下面的数组注释区域增删名字const candidates [ 张伟, 王芳, 李娜, 刘洋, 陈静, 杨帆, 赵磊, 黄敏, 周涛, 吴迪, 徐强, 孙丽, 马超, 朱婷, 胡军, 郭明, 何平, 高翔, 林峰, 罗鑫, 郑爽, 梁浩, 谢飞, 唐宋, 许晴, 韩雪, 冯坤, 曹阳, 彭飞, 董洁 ];这样一行的名字数组业务部门拿到手也能自己改。另外要注意JS 字符串里不能出现英文单引号如果名单里有OBrien这种名字会报错中文名没有这个问题。3.2 支持键盘操作空格开抽、空格停主持人不用抢鼠标年会现场抽奖节奏通常是主持人控制。站在舞台侧边去够鼠标很不优雅但如果用键盘空格控制就很自然。给按钮事件加一个键盘监听同时屏蔽掉空格触发的默认页面滚动行为。document.addEventListener(keydown, function(event) { if (event.code Space) { event.preventDefault(); if (stopBtn.disabled) { startRolling(); } else { stopRolling(); } } });逻辑说明event.code Space比event.key 更可靠因为它不受输入法全角/半角影响。stopBtn.disabled的状态就是当前是否在滚动——如果停止按钮可点说明正在滚动那么空格应该触发停止反之触发开始。这里有个小坑有些笔记本把 Fn 键和空格键组合用作其他功能但那是极少数。还有就是要确保页面在焦点状态如果主持人点过浏览器外部区域键盘事件不会触发页面里的 JS。所以稳妥的做法是把开始/停止做成按钮和键盘双通道哪个好用用哪个。3.3 加音效用 Web Audio API 生成嘟嘟声不引外部音频文件年会现场往往有音响和调音台但如果投影仪自带的电脑没有音效文件临时找不到合适的开奖音乐怎么办用 Web Audio API 现炒一个短音效不依赖任何外部资源代码还非常少。// 用 Web Audio API 生成开奖提示音 let audioCtx null; function playTone() { if (!audioCtx) { audioCtx new (window.AudioContext || window.webkitAudioContext)(); } const oscillator audioCtx.createOscillator(); const gainNode audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.type sine; oscillator.frequency.setValueAtTime(880, audioCtx.currentTime); gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime 0.3); oscillator.start(audioCtx.currentTime); oscillator.stop(audioCtx.currentTime 0.3); }逻辑说明oscillator是一个振荡器可以理解成电子音发生器。sine是正弦波听着是干净的嘟声不刺耳。frequency.setValueAtTime(880, ...)把频率设为 880Hz这是 A5 音比较清脆。gainNode.gain.exponentialRampToValueAtTime做了音量衰减模拟出一个短促的叮效果避免单调的方波音。在stopRolling()里调用playTone()开奖瞬间就会响。如果想要连续滚动时也有节奏感可以在startRolling()里每隔一段时间调用一次。3.4 状态机优化防止未开始就停止“抽完还继续”的边界错误上一版代码用按钮 disabled 控制了基本交互但还有两个边界情况没处理第一抽完最后一个人后继续点开始candidates.length 0时弹了一个 alert但用户体验差第二如果滚动过程中浏览器标签页切出去再切回来定时器会不会乱针对第一个问题更优雅的做法是把抽奖是否还能进行做成状态机而不是靠按钮状态被动判断。const LotteryState { IDLE: IDLE, // 空闲可开始 ROLLING: ROLLING, // 滚动中可停止 FINISHED: FINISHED // 所有名单都已抽完不可再开始 }; let currentState LotteryState.IDLE; function startRolling() { if (currentState LotteryState.FINISHED) { display.textContent 所有奖品已抽完; return; } if (currentState LotteryState.ROLLING) { return; } if (candidates.length 0) { currentState LotteryState.FINISHED; display.textContent 所有奖品已抽完; return; } currentState LotteryState.ROLLING; rollingTimer setInterval(updateDisplay, 50); startBtn.disabled true; stopBtn.disabled false; } function stopRolling() { if (currentState ! LotteryState.ROLLING) { return; } clearInterval(rollingTimer); const winner display.textContent; winners.push(winner); candidates.splice(candidates.indexOf(winner), 1); display.textContent winner 恭喜中奖; currentState LotteryState.IDLE; startBtn.disabled false; stopBtn.disabled true; playTone(); }这里把状态收敛成三个值空闲、滚动、结束。FINISHED状态不是靠数组长度临时判断的而是一个真正的终态除非重新刷新页面否则不会再进入抽奖流程。这也避免了提醒弹窗关掉之后还可以继续抽的尴尬。4. 从能抽到像年会装饰大屏、调整参数、接入真实业务数据4.1 调整滚动速度与字体大小参数档位参考很多人在这个环节容易忽略的是投影仪的分辨率和现场观众距离直接影响参数选择。投影到 300 寸幕布上字太小后排根本看不清滚动太快则名字还没被看清就闪过了。一个常用的参数组合可以直接套用屏幅展示区字号滚动间隔备注普通会议室≤100 人60~80px50~60ms观众距离近字号适中宴会厅200~500 人120~180px70~100ms必须加大字号滚动稍慢超大主会场500 人200px 以上80~120ms建议分屏或精简展示信息为什么大屏要把滚动间隔调大因为人眼在远处对快速变化内容的捕捉能力下降。如果还按 50ms 一换观众只会看到一片残影根本没有等待开奖的悬念感。80ms 左右反而更合适既感觉在滚动又隐约能看到名字在跳。4.2 接入真实名单CSV 粘贴板方案现场拿到名单通常有两种形式一是一张 Excel 表二是企业微信/钉钉通讯录导出的 CSV。不可能让业务同事手动把名字敲进 HTML 源码更合理的做法是提供一个粘贴名单的入口。textarea idnameInput styledisplay:none; placeholder每行一个名字直接粘贴/textarea隐藏的textarea配合一个编辑名单的管理按钮点击后弹出来粘贴完点确定就重新解析数组。这样源码本身不用动名单维护成了页面内的一个操作。function importNames() { const raw document.getElementById(nameInput).value; const lines raw.split(\n).map(s s.trim()).filter(s s.length 0); if (lines.length 0) { alert(名单为空请检查粘贴内容); return; } candidates.length 0; candidates.push(...lines); currentState LotteryState.IDLE; winners.length 0; winnersBox.textContent ; display.textContent 名单已更新 candidates.length 人; }这里有个细节值得说明candidates.length 0是清空数组最快的方法比candidates []好因为它不会让外部引用失效。虽然在这个场景里差别不大但在函数里操作全局数组时这是好习惯。push(...lines)用扩展运算符一次性推入所有行注意如果名单上千人也没问题数组 push 是常数级操作。4.3 做点仪式感中奖名单滚动条和大屏适配年会抽奖的仪式感往往在公布中奖者的瞬间。前面中奖者只是在一小块区域里排队展示体现不出分量。可以把中奖名单改为竖排滚动的独立区域用 CSS 动画让新中奖者高亮闪烁三下。.winner-item-highlight { animation: flash 0.5s ease-in-out 3; background: rgba(255, 255, 255, 0.15); border-radius: 10px; padding: 6px 20px; margin-bottom: 8px; } keyframes flash { 0% { opacity: 0.3; } 50% { opacity: 1; } 100% { opacity: 0.3; } }配合 JS在中奖者插入列表时动态加上这个 class几百毫秒后移除。这个效果比纯文字强很多现场观众对新中奖者的注意力是聚焦的。生成新 DOM 节点的代码比较简单但要注意移除 class 的时机用setTimeout三遍动画结束后把 class 去掉这样下一个人中奖时再添加不会有叠加残留。5. 年会抽奖源码的 5 个避坑记录乱码、死循环、中奖者重名5.1 乱码问题中文名显示成锟斤拷或???这是一个几乎每个初写 HTML 抽奖页面都会遇到的祖传问题。现象是打开页面后中文名字全部变成乱码。原因几乎都是文件编码不对。HTML 文件被保存成了 GBK 或 ANSI但meta charsetUTF-8却要求浏览器按 UTF-8 解码。或者反过来浏览器按 GBK 解读 UTF-8 内容。解决用 VS Code、Sublime、Notepad 打开 HTML 文件右下角或保存对话框里把编码明确设置成 UTF-8重新保存。这里强调一点如果用 Windows 自带的记事本编辑过再保存时选择UTF-8而非ANSI。编码正确后这个坑基本不会再出现。5.2 死循环卡死页面名单里有空字符串从 Excel 复制名单时末尾常带一个空行或看不见的换行符。如果直接把单元格内容 join 进 JS 数组可能出现[张三, , 李四]这种情况。在某些逻辑下空字符串也会被当成一个候选人滚动时显示一片空白但抽奖逻辑还在跑台下看到名单少一个。更严重的场景是如果代码没有过滤而某个名字恰好是null或undefinedtextContent会被赋成字符串 undefined这显然不是想要的效果。解决名单导入处做一次过滤map(s s.trim()).filter(s s.length 0)顺手把缩进、全角空格也清掉。如果从 Excel 粘贴时中文之间有 Tab 字符也应该一并替换成普通空格。5.3 重名问题两个张伟先后中奖indexOf删错人如果名单里有两个人同名比如两个王芳。第一次抽中王芳splice(candidates.indexOf(王芳), 1)会删除第一个王芳。第一次没问题但如果第二个王芳又被抽中indexOf又找到第一个已经被删返回值是 -1splice(-1, 1)会把数组最后一个元素删掉而这个元素可能是另一个人。这就导致王芳没被移除反而把另一位同事从名单里误删了。解决用户档案不只是名字而是一个唯一 ID。但年会名单通常只有名字怎么办两个思路在重名者名字后追加部门或工号尾号如王芳市场部。抽中后不做indexOf删除而是把candidates重新建一遍过滤掉所有名字等于中奖者的人const winnerName display.textContent; // 过滤所有同名项避免 indexOfsplice 的副作用 const newCandidates candidates.filter(name name ! winnerName); candidates.length 0; candidates.push(...newCandidates);filter会把所有王芳全部移除而不是只删一个。这在逻辑上是自洽的——都叫王芳无论哪一个被抽中整个名字池就不该再有王芳否则台下会觉得王芳怎么中两次。5.4 大屏投影的颜色和对比度红色背景 金色字反而看不清年会现场最忌讳的就是视觉上什么都看不清。默认代码里我用的是深红到金色的渐变配白字和金色边框。但很多投影仪的色准很差红色会被投成暗褐色金色边框几乎融入背景。解决做两套配色方案一套放在body的 class 里切换。一套默认是喜庆的红金配色另一套是高对比的黑底白字。高对比模式在大屏特别管用黑色背景幕布感强白色字清晰锐利金色只在关键元素出现。不要依赖投影仪的鲜艳模式那是现场调试不了的。5.5 浏览器自动翻译导致的名字错乱这个坑很隐蔽有些浏览器Chrome如果开了自动翻译会把页面里的开始按钮翻译成英文但更坑的是如果页面里有用户名单某些浏览器插件或翻译引擎可能把中文名做分词处理或替换成拼音导致textContent返回的值根本不是原名中奖者名字显示成拼音或错字。解决在html标签上加langzh-cn并且页面不要出现翻译此页面的触发条件。另外抽中后把名字存到数组时建议从candidates原始数组里根据当前展示的文本反查。但如果反查不到被翻译改了不要用翻译后的字符串做删除而是弹窗提醒请关闭浏览器自动翻译。这个场景虽然少见但真的会在现场出现尤其是公司统一安装的浏览器默认开启了翻译。6. 让抽奖页面直接上生产把文件部署到任何一台电脑的 3 个方案到了这一步页面本身逻辑已经完整了但还有一个非常现实的操作问题现场电脑不一定是你自己的可能是行政临时借来的笔记本上面什么环境都没有。怎么把这一个 HTML 文件部署成能用的状态我有三套方案按推荐顺序排列。方案一直接双击打开。HTML 文件在浏览器里以file://协议加载所有script内联在文件里没有跨域限制AudioContext在file://下也能用。这是最快路径也是我平时年会现场首选的方案唯一要确认的是这台电脑装了 Chrome 或 Edge而不是拿 IE 去开。方案二局域网 HTTP 服务。如果现场需要多块屏幕同时展示主屏 侧屏或者需要在手机上同步控制那file://协议不够用需要用 HTTP 服务。Windows 上最常见的做法是装一个 Python然后python -m http.server 8080这个命令会在当前目录起一个 8080 端口的静态服务打开浏览器访问http://localhost:8080/抽奖.html就能看到页面。局域网内其他设备用这台电脑的 IP 加端口访问适合手机上一键控制抽奖的场景。但调用前提是电脑装了 Python如果没装可以绕过 Node 环境直接部署到 Nginx 上。或者更省事的办法发一个批处理文件自动检测并启动服务但没必要展开。现场大多数情况双击 HTML 就够这套方案只是备选。方案三打包成可执行文件加分项。有些公司年会电脑是锁定的不允许用浏览器打开本地文件或者不想暴露源码内容。可以将 HTML 文件用 Electron 打包成独立可执行程序大屏一打开就是全屏抽奖界面没有地址栏、没有按钮关都关不掉极其适合主会场。这个方案工程量会大一些需要electron/main.js来加载本地 HTMLpackage.json里配build配置然后用 electron-builder 打包。代码上核心就是一个const { app, BrowserWindow } require(electron); app.whenReady().then(() { const win new BrowserWindow({ fullscreen: true, frame: false, webPreferences: { nodeIntegration: false } }); win.loadFile(index.html); });逻辑说明fullscreen: true全屏展示frame: false去掉窗口边框和关闭按钮nodeIntegration: false是安全项防止页面脚本拿到系统权限。这样打包出来的 exe 在 Windows 大屏机上跑观众看到的就是纯净的抽奖界面不会误触关闭。但说实话这个方案一般是第二年、第三年稳重了才会去做。第一年做年会抽奖直接把 HTML 源码拷到桌面双击就开。最后我想强调一个习惯任何改动保存后至少做过一轮清空名单、重新导入、抽三次的完整流程再拿到现场。年会抽奖是不允许出 bug 的一旦抽奖页卡死整场气氛直接垮掉。这行的血泪经验就是提前模拟现场大屏跑一遍把所有名单、所有按钮、所有环节都点过才能安心合上笔记本。希望这篇能帮你顺利搞定今年的年会抽奖抽得开心翻车为零。本文还有配套的精品资源点击获取
网站建设高端定制企业官网