ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

纯前端年会抽奖程序全解析:零依赖、离线可用、大屏高稳

纯前端年会抽奖程序全解析:零依赖、离线可用、大屏高稳

2026年年会季又近了。坦白说,每年这个时候,最让人头大的不是订场地,也不是排节目,而是“抽奖”这个环节。我这些年经手过几版年会抽奖网页程序,踩过的坑比年会现场的奖杯还多。今天就把这套能在大屏上稳定跑、不会被现场网络拖后腿、改名单不用求开发帮忙的纯前端抽奖程序,完整拆给大家。不管你是负责年会的HR、行政,还是被临时抓壮丁的IT人员,照着我这份方案来,基本能保证现场不出大岔子。

这个网页程序解决的就一件事:把“抽奖”做成一个稳定、公平、有仪式感的大屏流程。名字滚动起来,屏幕闪烁,音乐卡点,最终定格的那一秒,全场欢呼。它不需要联网,不需要服务器,一台普通电脑加一个浏览器就能跑。适合小团队年会、部门聚餐抽奖,也适合一两百人的中型活动。下面我会从设计思路、核心算法、实操代码到现场应急,一次性讲透。

1. 年会抽奖程序的整体设计思路

1.1 需求拆解:不只是“随机”这么简单

年会抽奖表面看就是一个随机选人的功能,但真正到了现场,你会发现它要满足的要求比想象中多得多。

第一是公平性。几百双眼睛盯着大屏,如果抽中的人跟主持人“很熟”,或者连续两次都是同一个部门,台下立刻会有嘘声。所以随机算法本身必须是数学上公平的。

第二是氛围感。抽奖的看点不只是结果,更是过程。名字滚动的几秒钟,全场会跟着尖叫。如果程序只是草草弹出一个名字,现场会冷得像开会。我见过很多现成抽奖工具,功能齐全,但界面土得掉渣,滚动效果生硬,根本撑不起年会大屏。

第三是操作的可控性。现场抽奖不是录播,主持人说“停”的时候,你要能稳定定格。万一抽到的人已经离职,你得能快速把他剔除,再补抽一次。这些“意外”都必须提前想好。

第四是环境的恶劣。年会场地网络经常不稳定,很多公司年会还在酒店宴会厅或户外草坪。如果程序依赖在线资源,比如字体、CDN、外部接口,现场加载不出来就全完了。所以我的核心原则是:整个程序必须能离线运行,双击HTML文件就能用。

1.2 技术选型:为什么坚持用纯前端

现在市面上的抽奖平台一抓一大把,但用起来总有这样那样的问题。一是界面广告多,大屏放出来特别不专业;二是规则被平台锁死,我想临时加个“阳光普照奖”都不知道该怎么操作;三是数据都存别人服务器上,名单这种公司内部信息,直接传上去总归有点不放心。

用后端方案也不行。很多朋友一上来就说“我用Python写个随机接口,前端调”。这就是典型的过度设计。年会抽奖本质上是单机活动,数据量就百来个人,随机性用前端本地代码完全能保证,没必要引入服务器、数据库、API这些重家伙。多一层技术栈就多一个故障点,真到了现场,问题往往不是算法不行,而是服务没起来、端口被占、网络超时。

所以我的最终方案是:HTML + CSS + JavaScript,一个页面搞定所有功能。不需要任何框架,不需要打包,不需要安装依赖。我甚至建议把代码合并成单个HTML文件,通过U盘拷到现场电脑上,双击就能打开。这看起来有点“原始”,但恰恰是它最可靠的地方。

1.3 功能模块划分

整个程序我拆成了六个模块,每个模块各管一摊事:

模块职责关键要求
名单管理支持导入名单、手动添加、剔除人员操作要简单,现场能秒改
奖项配置设置奖项名称、数量、每轮抽取人数要能灵活调节
抽奖引擎执行随机抽取、保证概率公平算法必须严谨
动画展示大屏滚动、定格、视觉效果必须流畅、有仪式感
中奖记录记录每轮中奖名单,支持查看用于后续领奖核对
控制面板主持人/操作员控制抽奖启停与大屏展示分开,防误触

如果现场只有一台电脑,控制面板和大屏可以放在同屏不同区域,用快捷键控制。如果条件允许,我强烈建议用两台设备:一台接大屏专门展示,一台主持人拿在手里点击控制,两台之间通过局域网通信。这个后面第3章我会讲具体做法。

2. 核心技术点解析

2.1 随机抽取算法:为什么“洗牌”比“瞎蒙”更可靠

很多初学编程的人写抽奖,第一反应是:

const winner = list[Math.floor(Math.random() * list.length)];

这样写不是不行,但要考虑一个实际场景:如果连续抽多轮奖项,每次都从原名单里随机,就可能出现后面几轮抽到的人和前面重复。如果规则是一人只能中一次,你就得不停判断“这个人是不是已经中过了”,代码越写越乱,而且当名单池逐渐变空时,抽到已中奖者的概率越来越高,白白浪费很多次无效抽取。

我采用的方案是Fisher-Yates洗牌算法,也叫Knuth洗牌。思路特别直观:把所有人放进数组,从最后一个位置开始,随机挑一个前面的人跟他交换,然后逐步往前,直到整个数组被打乱。打乱后的数组天然就是一个随机顺序,只要按顺序取人,就绝对不重复。

function shuffle(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }

这个算法最大的好处是:每个元素出现在每个位置的概率都是均匀的,而且只会被交换一次,时间复杂度是O(n)。哪怕名单里有500个人,洗牌也是一瞬间的事。抽完之后,我直接维护一个“剩余名单池”,每次抽人从池子头部取,抽完就从池子里移除,彻底避免重复中奖的问题。

如果要让连中多奖的规则(比如最高奖获得者还能参与阳光普照),也可以用这个思路,维护两份名单:一份是“总池”,一份是“本奖池”。本奖池抽完不删人,只记录中奖者;总池记录所有中奖者,用于最终统计。

2.2 大屏滚动的视觉实现:别小看这几秒

大屏抽奖最迷人的就是名字滚动的那几秒。很多人以为就是用setInterval每秒换个名字,其实做出来效果非常生硬。真正的抽奖动画需要有一种“从快到慢、戛然而止”的节奏感。

我的做法是使用requestAnimationFrame配合时间差控制显示频率。简单说就是:刚开始每40毫秒刷新一个名字,然后逐步拉长刷新间隔,最后在三秒左右稳稳停住。这种变化率更贴近人眼的注意力曲线,观众会本能地跟随节奏屏住呼吸。

function startRolling(duration = 3000) { const startTime = performance.now(); function update(timestamp) { const elapsed = timestamp - startTime; // 将时间进度映射为间隔:从40ms逐步增加到300ms const progress = Math.min(elapsed / duration, 1); const interval = 40 + (260 * progress); if (elapsed < lastElapsed + interval) { requestAnimationFrame(update); return; } lastElapsed = elapsed; displayName(pool[Math.floor(Math.random() * pool.length)]); if (progress < 1) { requestAnimationFrame(update); } else { // 最终定格,显示真正的中奖者 showWinner(); } } requestAnimationFrame(update); }

这里有个细节:滚动过程中显示的名字是“随机乱闪”,但真正定格的名字必须是洗牌后顺序里的那一个。也就是说,滚动只是表演,结果早已注定。为了避免“表演结果”和“实际结果”不一致,我会在动画开始时先确定本轮的winner,然后用一个假的随机数组填充滚动过程,直到最后把winner呈现出来。这样既保证了视觉节奏,又保证了结果公平。

2.3 音效与视觉反馈:让全场情绪跟得上

好的抽奖程序,音效和视觉反馈绝对不能少。但现场不要依赖外部音频文件,万一文件丢失或者格式不支持就麻烦了。我用的是Web Audio API,直接在浏览器里合成音效。

function playTension(progress) { const ctx = new AudioContext(); const oscillator = ctx.createOscillator(); const gainNode = ctx.createGain(); oscillator.connect(gainNode); gainNode.connect(ctx.destination); // 频率随进度升高,营造紧张感 const freq = 300 + progress * 400; oscillator.frequency.setValueAtTime(freq, ctx.currentTime); gainNode.gain.setValueAtTime(0.15, ctx.currentTime); oscillator.start(); oscillator.stop(ctx.currentTime + 0.1); }

这个代码不算完整,但思路很明确:用频率逐渐升高的音调配合滚动动画,让观众产生“要出结果了”的紧张感。定格瞬间再放一个“叮”的提示音,全场自然就沸腾了。视觉方面,我用CSS的高亮边框和背景色渐变配合定格动作,确保中奖名字在画面上一眼就能被看到。

2.4 控制端与展示端分离的原理

如果主持人和操作员是两个人,最忌讳的是两个人挤在一台电脑前抢鼠标。我的方案是做一个简单的控制端页面,通过BroadcastChannel或WebSocket把控制指令发给大屏端。如果不想引入后端,可以直接用BroadcastChannel,这是浏览器原生的API,同源页面之间可以实时通信。

// 大屏端 const bc = new BroadcastChannel('lucky-draw'); bc.onmessage = (event) => { if (event.data.type === 'start') startRolling(); if (event.data.type === 'stop') stopRolling(); }; // 控制端 const bc = new BroadcastChannel('lucky-draw'); bc.postMessage({ type: 'start' });

广播频道要求两个页面必须在同一个浏览器中打开,如果控制端用手机就做不到。如果现场要手机控制,就需要在电脑上跑一个简单的WebSocket服务,这个复杂度会上涨不少。大部分年会场景下,操作员坐在大屏边上的一台笔记本上,用浏览器开两个标签页就够用了。

3. 实操过程与核心环节实现

3.1 文件结构与基本页面的搭建

为了省事,我直接推荐单文件架构。新建一个index.html,把所有CSS和JavaScript全部写在里面。这样做的好处是:拷贝方便、不容易漏文件、双击直接能跑。缺点就是一个文件会比较大(大概几十KB),但年会程序这点体积完全不是问题。

页面结构我分成两个区域:大屏展示区(在后台跑,接投影或大屏)和控制面板区(给操作员看)。大屏区包含标题、滚动名字显示框、中奖名单列表。控制面板区包含名单导入、奖项设置、开始/停止按钮、中奖记录。

核心HTML骨架大概是:

<div id="screen"> <h1 id="stageTitle">年会抽奖</h1> <div id="nameDisplay"> <span id="currentName">等待抽奖</span> </div> <div id="winnerList"></div> </div> <div id="controlPanel"> <textarea id="nameInput" placeholder="输入名单,每行一个名字"></textarea> <button onclick="loadNames()">载入名单</button> <button id="drawBtn">开始/停止</button> <div id="configArea"> <label>奖项名称</label> <input id="awardName" /> <label>抽取人数</label> <input id="drawCount" type="number" /> </div> </div>

这只是一个骨架,实际用的时候还需要加上一些样式和JS逻辑。但重点是你要理解每个区域的作用,而不是死记代码。

3.2 名单导入:三种方式,总有一种用得上

很多抽奖程序只支持上传Excel文件,我觉得这是个大坑。现场操作员不一定有U盘,就算有,Excel格式可能不对、第一行可能是标题、可能包含合并单元格。我设计了三种导入方式,按优先级排列:

第一种是直接粘贴文本。这是最不容易出错的方式。把名单从Excel里复制出来,粘贴到textarea里,每行一个名字,程序自动把回车符变成数组元素。这招我推荐现场优先用,因为不需要处理任何文件格式问题。

function loadNames() { const raw = document.getElementById('nameInput').value; const names = raw.split('\n').map(name => name.trim()).filter(name => name.length > 0); pool = shuffle(names); // 更新展示 }

第二种是上传CSV文件。用FileReader解析文本内容,但要注意编码问题。UTF-8编码的CSV大概率没问题,如果是Excel另存的CSV,通常也是UTF-8,但有些老版本的Excel会输出ANSI编码,就会乱码。后面第4章我会详细讲乱码处理。

第三种是手动增减。在控制面板里显示当前名单列表,每一项后面带一个“剔除”按钮。现场发现有离职人员或者临时加人,直接点按钮即可。

3.3 奖项配置:规则要先想清楚

年会奖项的规则五花八门。有时候是一个奖项抽一轮,有时候一个奖项分多轮抽完,有时候前一个三等奖中奖的人还能参加二等奖抽奖。建议在写代码前,先画一张简单的表:

奖项名称中奖人数是否可重复中奖
三等奖10人否
二等奖5人否
一等奖3人否
特等奖1人否
阳光普照30人是(可重复)

多数年会的默认规则是一人最多中一个奖。代码里维护一个remainingPool数组,一个奖项抽完,就把中奖者从池子里移出去。对于阳光普照这类的“保底奖”,我的做法是在正式奖项抽完之后,再从剩余未中奖名单里随机补齐,确保人人有奖。

3.4 核心抽奖逻辑的完整代码思路

抽奖主流程分三步:

第一步,点击“开始”时,先从当前剩余名单池里洗牌,取出前N个人作为本轮候选。这一步叫做“锁定候选人”。

第二步,大屏进入滚动动画,视觉上随机闪烁名字,持续2到3秒。

第三步,点击“停止”时,动画减速并最终定格在第一个候选人名字上。将该候选人从剩余名单池中移除,并写入中奖记录。

这里有一个容易翻车的细节:如果把“锁定候选人”放到点击“停止”时才做,有一定概率操作者会连续点击两次“开始/停止”,导致同一轮抽了两次,名单错乱。必须锁定为:点击开始就确定本轮的潜在中奖者,点击停止只是结束表演。

3.5 局域网部署与手机遥控

如果要把控制端放到手机上,最轻量的办法是用Python起一个HTTP静态服务。在公司电脑上执行:

python -m http.server 8080

然后在同一局域网的手机浏览器里访问http://电脑IP:8080就能打开抽奖页面。手机控制端如果要发送指令给大屏端,需要加一个WebSocket服务,这个会复杂一些,但也不是没有方案。

一个更简单的代替方案是:把大屏端和控制端放在同一台电脑上,用两个浏览器窗口。操作员在控制窗口点击按钮,通过BroadcastChannel通知大屏窗口。这个方案零依赖,非常适合现场。

4. 常见问题与排查技巧实录

4.1 名单乱码

这可能是年会现场频率最高的问题。Excel默认保存的CSV是ANSI编码,浏览器读取时按UTF-8解码,于是中文全变成乱码。解决方案有三种:

一是导入时判断编码,遇到乱码提示用户用“粘贴”方式输入。二是使用Excel另存为CSV UTF-8格式。三是代码层面做兼容,读取文件后用TextDecoder尝试两种解码:

async function readCsvFile(file) { const buffer = await file.arrayBuffer(); const utf8Text = new TextDecoder('utf-8').decode(buffer); // 如果包含常见中文乱码字符,则尝试GBK解码 if (utf8Text.includes('锟斤拷') || utf8Text.includes('�')) { const gbkText = new TextDecoder('gbk').decode(buffer); return gbkText; } return utf8Text; }

老实说,现场真遇到编码问题,让操作员直接复制粘贴,5秒钟解决,比搞什么编码兼容都靠谱。代码做兼容只是为了减少“现场改数据”的频率。

4.2 重复中奖或漏人

如果抽完第一轮,第二轮还能抽到已经中奖的人,一定是名单池没有更新。排查思路很简单:打开开发者工具的Console,检查remainingPool的长度变化。每次抽完奖,长度应该严格减少本次抽取数量。

如果发现没减少,说明代码里没有执行移除操作。还有一个隐蔽的坑:如果你在每次点击开始的时候都调用shuffle(remainingPool),然后在动画中又用pool这个变量去滚动,两边用的是同一个数组引用,可能因为操作顺序导致结果混乱。所以我建议把“滚动显示”和“实际抽奖”用两个变量完全分开:滚动数组只负责表演,实际抽取用洗牌后的顺序数组。

4.3 大屏误触

大屏上的触摸屏有时候非常灵敏,手肘一碰就可能触发抽奖。轻则尴尬,重则把只准备抽一轮的奖提前抽完。我的解决方案是控制面板按钮必须双指点击或者键盘快捷键控制。

最简单的方式是用键盘空格键操作。操作员站在大屏后面,手里拿个无线键盘,轻轻一按空格就是一轮抽奖。代码监听全局键盘事件:

document.addEventListener('keydown', (e) => { if (e.code === 'Space' && !e.repeat) { toggleDraw(); } });

如果一定要用鼠标,我也建议在控制按钮上增加一个确认弹窗,防止误触。

4.4 抽奖卡顿或者浏览器崩溃

名单几百人,滚动动画如果每一帧都操作DOM,页面可能卡顿。一个有效的优化方向是:在滚动过程中,不要用“真实名字”,而是把名字渲染在Canvas上。如果坚持用DOM,可以把名字显示区域缩小、避免频繁改变元素的class,通过修改textContent来实现。

另一个容易忽视的问题是浏览器缓存。现场打开页面,看到的还是昨天测试时的老名单,这就是浏览器缓存惹的祸。解决办法:第一,测试完务必关闭浏览器标签;第二,正式上场前用无痕模式打开页面,确保加载全新数据。打印一份纸质名单作为后备,这也是我每次都要叮嘱的。

4.5 临场设备故障应急处置

程序再稳,也顶不住电脑突然蓝屏或者投屏线松动。所以现场必须有Plan B。我的习惯是准备一个打印好的纸质名单+扑克牌。如果真的抽奖程序彻底坏了,就把所有人编号,现场抽扑克牌,主持人念编号。虽然少了点科技感,但至少不会冷场。

如果只是投屏问题,可以在笔记本屏幕上的演示模式里操作,大屏显示另外一套画面的方案也不难,我通常会准备一个备用HDMI转接头和一台备用笔记本,反正也是U盘启动,换台电脑从头跑不超过1分钟。

实际操作中的体会

这版程序我在内部测试了很多次,也在模拟年会上跑过完整流程。我个人最深的体会是,技术上的东西反而不是越复杂越好,真正决定现场体验的,是提前把流程捋顺了再捋顺。抽奖前三天,我建议至少完整走一次:导入真实名单、设置好奖项顺序和数量、模拟抽掉一轮、看看中奖记录是否有错。有条件的话,用两台设备模拟一下控制端和大屏端的配合。

最后分享一个小技巧:正式抽奖前,把浏览器开成无痕模式,清理掉所有旧名单缓存。打开页面后,先用一个测试名单抽几轮,确认控制按钮、动画、记录都正常,再导入真实名单。这样现场基本就万无一失了。毕竟年会一年就一次,那几分钟的高光时刻,值得多花点心思把它稳稳托住。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进