在公司年会上被临时拉去救场,或者自己搞个粉丝回馈活动却找不到合适的抽奖工具——这种场景我碰到过太多次了。第三方抽奖平台要么要注册、要么有广告、要么样式丑得没法看,数据还得经过别人服务器,总感觉不那么踏实。后来我干脆自己用 HTML 写了个随机抽奖页面,需求变了就改两行代码,样式想怎么调就怎么调,几秒钟打开浏览器就能用,比什么在线工具都顺手。
如果你也想搭一个类似的在线抽奖、随机选取、自动挑选的页面,不依赖任何框架和服务器,这篇文章应该能帮你省不少事。我会从最基础的设计思路讲起,把完整代码拆开揉碎,再把我在实际使用中踩过的坑和总结的技巧一并分享给你,保证你照着做就能出一个能用的版本。做完之后你会发现,这种“小而美”的前端小工具,恰恰是提升效率最实在的东西。
1. 内容整体设计与思路拆解
1.1 为什么用原生 HTML+CSS+JS 而不是现成框架
很多朋友一听到写网页,第一反应就是上 Vue、React,或者至少用个 Bootstrap。但对于“随机抽奖页面”这种单文件小工具,我强烈建议直接用原生三件套,原因特别朴素:
- 零依赖、零构建:不需要安装 Node、不需要 npm install、不需要 webpack 打包。双击 HTML 文件就能跑,或者扔到任意静态服务器上就能访问,真正做到了“即开即用”。
- 部署成本几乎为零:不管是传到 GitHub Pages、公司内网服务器,还是用微信发文件给同事本地打开,都毫无压力。
- 逻辑足够简单:抽奖的核心逻辑就是一个随机数,根本用不着引入一个几十 KB 的框架。原生 JS 几十行就能搞定,加载更快,出问题也更好排查。
我之前见过一个同事用 Vue CLI 搭了全套工程来做一个年会抽奖页面,代码仓库拉下来几百个文件,就为了屏幕上转个名字。有必要吗?完全没有。杀鸡用牛刀,维护起来反而麻烦。
当然,如果你本身就在开发一个大型应用,抽奖只是其中一个模块,那引入框架是合理的。但作为一个“简单随机抽奖页面”,原生实现永远是最优解。
1.2 需求拆解:一次抽奖究竟需要哪些能力
在动手写代码之前,我习惯先把需求一条条列清楚。这次抽奖页面的核心诉求其实可以拆成三块:
- 随机选取:从预置名单中随机挑出一个对象,这是抽奖的本质功能。
- 自动挑选:用户不想手动控制节奏时,页面自动滚动、自动停止、自动公布结果,营造“开奖”的仪式感。
- 在线运行:通过浏览器直接访问,不需要安装客户端,最好同时支持电脑和手机打开。
基于这三个核心诉求,我把功能点进一步细化:
- 一个可编辑的参与名单,既能写死在代码里,也能支持从文本框粘贴导入。
- 一个醒目的抽奖结果展示区,要有“滚动翻牌”的视觉效果。
- 按钮交互至少包含“开始”和“停止”两个动作,分别对应滚动和出结果。
- 支持设置抽取人数,比如一次抽 1 个还是 5 个。
- 要能排除已中奖的人,避免重复获奖,这点在真实活动里太重要了。
把需求拆成这个粒度之后,再回过头去看代码结构,思路就非常清晰了。整个页面只需要一个 HTML 文件,内部由三部分构成:HTML 负责结构、CSS 负责视觉、JS 负责抽奖逻辑。
2. 核心细节解析与实操要点
2.1 HTML 结构:一个页面如何安放抽奖交互
HTML 是骨架,我用最直白的标签把页面分区。整体布局主要分四个区域:
- 标题区:告诉用户这个页面是干什么的。
- 名单配置区:让用户粘贴或编辑参与抽奖的名单。
- 抽奖展示区:中央醒目的位置,用来显示当前滚动中的名字或最终结果。
- 操作按钮区:开始、停止、重置这三个按钮。
下面是我常用的基础结构写法,考虑到展示效果,我把 CSS 类名留好,后面填充样式:
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>在线随机抽奖</title> <link rel="stylesheet" href="style.css"> </head> <body> <div class="container"> <h1>在线随机抽奖</h1> <section class="config-panel"> <textarea id="nameInput" rows="5" placeholder="每行一个名字,例如: 张三 李四 王五"></textarea> <div class="config-actions"> <button id="updateBtn">更新名单</button> <span id="countTip">当前名单:0 人</span> </div> </section> <section class="lottery-panel"> <div id="resultBox" class="result-box">等待抽奖</div> <div class="action-buttons"> <button id="startBtn" class="primary-btn">开始</button> <button id="stopBtn" class="danger-btn" disabled>停止</button> <button id="resetBtn" class="ghost-btn">重置</button> </div> </section> <section class="history-panel"> <h2>中奖记录</h2> <ul id="historyList"></ul> </section> </div> <script src="script.js"></script> </body> </html>注意我用了<textarea>而不是<input>,因为参与名单通常是一长串名字,文本框多行显示更友好。每个名字占一行,这样 JS 解析的时候用split('\n')就能轻松处理。
2.2 抽奖逻辑到底该怎么写:从随机数到不重复抽取
JS 是整个页面的核心。很多新手在这里容易翻车,所以我详细讲一下几个关键函数的写法。
先看最基本的随机选取函数。我用Math.random()生成一个[0, 1)之间的浮点数,然后乘以数组长度,再Math.floor()向下取整,得到一个有效的数组下标。这样就能保证每次都从名单里等概率地选出一个名字,不会越界,也不会漏人。
function getRandomName(names) { if (!names.length) return ''; const randomIndex = Math.floor(Math.random() * names.length); return names[randomIndex]; }这个方案的底层逻辑是“均匀分布”。Math.random()生成的随机数在[0, 1)区间内分布相对均匀,乘以长度后映射到不同下标,每个名字被选中的概率理论上都是1/n。对抽奖场景来说,这种均匀性完全够用,不需要用更复杂的加密随机算法。
但要支持“一次抽多人且不重复”时,就不能每次都用同一个名单了。我采用“索引池”方案:先把名单的所有索引放进数组,每次抽完一个就把它从池子里移除,下一个循环抽的人就不会和之前重复。
let pool = []; function initPool(names) { pool = names.map((_, index) => index); } function drawFromPool(names) { const randomPos = Math.floor(Math.random() * pool.length); const winnerIndex = pool[randomPos]; pool.splice(randomPos, 1); // 从池中移除,避免重复 return { name: names[winnerIndex], index: winnerIndex, }; }有人可能会问:为什么不直接在原名单上splice?因为那样会破坏原始名单,后续如果用户要重置抽奖、恢复全部参与者,还得先保存一份原始副本。用索引池的方式,原始名单始终保持不变,每次抽奖只是从“可抽池”里拿走对应索引,重置时重新initPool(names)就完事了,干净利落。
2.3 自动滚动的实现:定时器与防连点
抽奖页面要有“在线抽奖”的临场感,名字就得在屏幕上快速滚动。这个效果并不需要动画库,用setInterval就够。我设定的默认速度是每 50 毫秒更新一次名字,人眼看起来既紧张又有悬念。
let timer = null; function startRolling() { if (timer) return; // 防止重复启动 timer = setInterval(() => { const currentName = getRandomName(names); resultBox.textContent = currentName; }, 50); } function stopRolling() { if (!timer) return; clearInterval(timer); timer = null; }这算是我反复提到的一个实用小技巧:开始滚动前先检查timer是否为 null。如果用户手速太快连续点了两次“开始”,就会生成两个定时器,页面上名字会闪得乱糟糟,逻辑也会错乱。加一个判断就能从根本上杜绝“连点”问题。
停止时同样要判断,避免clearInterval(null)抛错。更稳妥的做法是停止后直接从可抽池里取出最终结果,而不是把最后一次随机显示的名字当作结果——因为定时器更新和按钮点击的时机存在误差,展示的那一瞬间未必是你想要的那次随机。
function stopAndGetWinner() { stopRolling(); const winner = drawFromPool(names); resultBox.textContent = winner.name; updateHistory(winner.name); updateCountDisplay(); }2.4 名单管理与状态刷新:每个细节都关系到体验
要让页面真正“好用”,还得把名单管理和状态刷新做细致。我在页面加载时就默认放入一组示例名单——别小看这个细节,用户打开页面如果看到一个空盒子,往往会愣一下“要怎么用”。有一组示例数据,用户马上就能理解页面的工作方式,直接点“开始”就能看到效果。
名单校验也是不能省的。如果用户把名单清空了,或者只粘贴了一堆空白字符,点击“开始”时页面应该有明确的提示,而不是静默无反应。我用trim()和filter(Boolean)把非法数据过滤掉,然后检查有效人数。
function parseNames(rawText) { const lines = rawText.split('\n'); return lines .map(line => line.trim()) .filter(name => name.length > 0); } function updateNameList() { names = parseNames(nameInput.value); if (!names.length) { alert('名单为空,请先填写参与人名单'); return; } initPool(names); updateCountDisplay(); clearHistory(); resultBox.textContent = '名单已更新'; }这里又涉及一个设计取舍:updateNameList里我顺手把历史记录清空了,因为名单一旦改变,之前的抽奖结果和当前名单就没有对应关系了,保留反而容易让人困惑。在真实活动中,“名单变动后旧记录作废”这个逻辑必须处理利索。
3. 实操过程与核心环节实现
3.1 完整代码:一个 HTML 文件搞定全部功能
为了好复制好部署,我把 CSS 和 JS 都内联到一个文件里。这样不管是本地双击打开,还是后来传给同事,都只需要传递一个文件,不会丢样式不会缺脚本。完整代码如下:
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>在线随机抽奖</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(135deg, #1e1e2f, #2b2b3c); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #e8e8f0; } .container { width: 520px; max-width: 94vw; background: rgba(255, 255, 255, 0.06); border-radius: 20px; padding: 30px 28px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); } h1 { text-align: center; font-size: 28px; margin-bottom: 6px; letter-spacing: 2px; } .subtitle { text-align: center; font-size: 14px; color: #9a9ab0; margin-bottom: 24px; } .config-panel { margin-bottom: 24px; } textarea { width: 100%; background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 10px; color: #e8e8f0; padding: 12px; font-size: 14px; line-height: 1.6; resize: vertical; outline: none; } textarea:focus { border-color: #5b8cff; } .config-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; } #countTip { font-size: 13px; color: #9a9ab0; } .lottery-panel { text-align: center; } .result-box { min-height: 120px; background: rgba(0, 0, 0, 0.4); border-radius: 16px; display: flex; align-items: center; justify-content: center; font-size: 44px; font-weight: 700; color: #ffd866; letter-spacing: 4px; margin-bottom: 20px; border: 2px solid rgba(255, 216, 102, 0.3); word-break: break-all; padding: 12px; } .action-buttons { display: flex; gap: 14px; justify-content: center; } button { padding: 12px 28px; font-size: 16px; border: none; border-radius: 10px; cursor: pointer; transition: transform 0.15s, opacity 0.15s; font-weight: 600; } button:active { transform: scale(0.95); } button:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } .primary-btn { background: #5b8cff; color: #fff; } .danger-btn { background: #f0564a; color: #fff; } .ghost-btn { background: rgba(255, 255, 255, 0.1); color: #e8e8f0; } .history-panel { margin-top: 24px; } .history-panel h2 { font-size: 16px; margin-bottom: 10px; color: #c0c0d8; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 6px; } #historyList { list-style: none; max-height: 180px; overflow-y: auto; } #historyList li { background: rgba(0, 0, 0, 0.2); margin-bottom: 6px; padding: 8px 12px; border-radius: 8px; font-size: 14px; display: flex; justify-content: space-between; } .history-badge { background: #ffd866; color: #1e1e2f; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 700; } </style> </head> <body> <div class="container"> <h1>在线随机抽奖</h1> <p class="subtitle">随机选取 · 自动挑选 · 公平公正</p> <div class="config-panel"> <textarea id="nameInput" rows="5" placeholder="每行一个名字,例如: 张三 李四 王五"></textarea> <div class="config-actions"> <button id="updateBtn">更新名单</button> <span id="countTip">当前名单:0 人</span> </div> </div> <div class="lottery-panel"> <div id="resultBox" class="result-box">等待抽奖</div> <div class="action-buttons"> <button id="startBtn" class="primary-btn">开始</button> <button id="stopBtn" class="danger-btn" disabled>停止</button> <button id="resetBtn" class="ghost-btn">重置</button> </div> </div> <div class="history-panel"> <h2>中奖记录</h2> <ul id="historyList"></ul> </div> </div> <script> (function () { const nameInput = document.getElementById('nameInput'); const countTip = document.getElementById('countTip'); const updateBtn = document.getElementById('updateBtn'); const startBtn = document.getElementById('startBtn'); const stopBtn = document.getElementById('stopBtn'); const resetBtn = document.getElementById('resetBtn'); const resultBox = document.getElementById('resultBox'); const historyList = document.getElementById('historyList'); let names = []; let pool = []; let timer = null; let winCount = 0; // 示例名单 const defaultNames = ['张三', '李四', '王五', '赵六', '孙七', '周八', '吴九', '郑十']; function parseNames(rawText) { return rawText .split('\n') .map(line => line.trim()) .filter(name => name.length > 0); } function initPool() { pool = names.map((_, index) => index); } function updateCountDisplay() { countTip.textContent = `当前名单:${names.length} 人,可抽:${pool.length} 人`; } function refreshHistory() { if (winCount === 0) { historyList.innerHTML = '<li style="opacity:0.6">暂无记录</li>'; } } function updateNameList() { names = parseNames(nameInput.value || ''); if (!names.length) { alert('名单为空,请先填写参与人名单'); return; } initPool(); winCount = 0; updateCountDisplay(); historyList.innerHTML = ''; resultBox.textContent = '名单已更新,点击开始'; } function getRandomName() { const randomIndex = Math.floor(Math.random() * names.length); return names[randomIndex]; } function startRolling() { if (timer) return; if (!names.length) { alert('名单为空,请先填写参与人名单'); return; } if (!pool.length) { alert('所有人都已中奖,请更新名单或重置'); return; } startBtn.disabled = true; stopBtn.disabled = false; timer = setInterval(() => { resultBox.textContent = getRandomName(); }, 50); } function stopRolling() { if (!timer) return; clearInterval(timer); timer = null; } function drawFromPool() { const randomPos = Math.floor(Math.random() * pool.length); const winnerIndex = pool[randomPos]; pool.splice(randomPos, 1); return { name: names[winnerIndex], index: winnerIndex, }; } function stopAndGetWinner() { stopRolling(); if (!pool.length) return; const winner = drawFromPool(); winCount += 1; resultBox.textContent = winner.name; const li = document.createElement('li'); const nameSpan = document.createElement('span'); nameSpan.textContent = winner.name; const badge = document.createElement('span'); badge.className = 'history-badge'; badge.textContent = `第 ${winCount} 位`; li.appendChild(nameSpan); li.appendChild(badge); historyList.prepend(li); startBtn.disabled = false; stopBtn.disabled = true; updateCountDisplay(); } function resetAll() { stopRolling(); initPool(); winCount = 0; startBtn.disabled = false; stopBtn.disabled = true; updateCountDisplay(); historyList.innerHTML = ''; resultBox.textContent = '点击开始'; } // 初始化 function init() { nameInput.value = defaultNames.join('\n'); names = parseNames(nameInput.value); initPool(); updateCountDisplay(); refreshHistory(); resultBox.textContent = '点击开始'; updateBtn.addEventListener('click', updateNameList); startBtn.addEventListener('click', startRolling); stopBtn.addEventListener('click', stopAndGetWinner); resetBtn.addEventListener('click', resetAll); } init(); })(); </script> </body> </html>这份代码保存为index.html,直接双击就能在浏览器里运行。核心逻辑全在script标签里,我用了一个立即执行函数包住,避免变量污染全局环境。如果把names、pool这些变量直接暴露到window上,万一页面上还有别的小脚本,就可能出现命名冲突。包装一层是低成本高收益的好习惯。
3.2 模板字符串与数组操作:几个容易写错的地方
这段代码里新手最容易出错的,是数组方法和模板字符串的混用。比如用split('\n')解析名单时,Windows 系统下用记事本编辑的多行文本,换行符可能是\r\n,split('\n')后每行末尾会残留一个\r。所以我的parseNames里用了.trim(),这能同时处理行首行尾空白和\r残留,非常实用。
另一个常见的坑是historyList.prepend(li)。prepend会把新元素插到子节点最前面,这样最新的中奖记录显示在最上方,符合日常阅读习惯。如果误用了appendChild,最新记录反而排在列表末尾,用户得往下翻才能看到,体验很差。这个细节在写代码时很容易被忽略,但实际用起来感受非常明显。
数组的splice方法也要说清楚。它有两个参数时,第一个是起始位置,第二个是删除个数。pool.splice(randomPos, 1)的意思是“从 randomPos 开始删掉 1 个元素”,这个操作会直接修改原数组。很多初学者会误写成pool.slice(randomPos, 1)——slice是返回新数组、不修改原数组,而且第二参数是结束位置而不是个数,两个方法混用是经典 Bug。用错之后,代码不会报错,也不会直接翻车,但抽奖结果就会出现重复,特别隐蔽。
3.3 页面美化技巧:如何让抽奖结果更抓眼球
要让一个抽奖页面看起来“高级”,其实不需要用复杂的设计。我用的是深色背景搭配亮黄色文字,中奖结果以 44 像素的大号粗体显示,视觉冲击力很强,在投影仪上投出去也看得很清楚。
有两点美化经验值得单独提:
- 深色背景:抽奖场景经常处于活动现场,光线偏暗。深色背景比白色更不刺眼,也能更好地衬托高亮文字,让中奖名字成为全场焦点。
- 最小高度和边框:
.result-box设置了min-height: 120px和边框,这样名单更新前后,页面布局不会因为文字长短而跳来跳去。活动开始前展示“当前名单:0 人”和“可抽:0 人”的状态很重要,但展示结果的盒子不能空得让人心慌,必须有个稳定的视觉锚点。
如果你想把页面改得更有“庆典感”,还可以把背景调成浅橙色、结果加个霓虹灯闪烁的 CSS 动画。但要记住,动画频率不要太高,否则长时间转动时会对眼睛产生刺激,活动进行到后半场大家都累了,过于花哨的效果反而让人烦躁。
4. 常见问题与排查技巧实录
4.1 名单更新无效:都是引用的锅
有一次我在本地测试,点击“更新名单”后,页面上显示的人数始终没变。排查了好一会儿,发现是初始化顺序的问题——我把initPool()调用放在了names赋值之前,导致pool始终基于旧名单初始化。后来我把初始化顺序固定下来:先解析names,再initPool(),这个坑就再也没踩过。
这类问题很典型,它的本质是“执行顺序错位”。建议写代码时给初始化流程固定一个模板:
- 读取输入并解析得到
names。 - 校验
names非空。 - 根据
names初始化pool。 - 更新界面显示。
严格按照这个顺序写,就不会出现名单已经变了、但奖池还是旧数据的情况。
另一个容易犯的错误是在updateNameList里初始化pool后又调用了initPool,但initPool的内部引用的names可能还没赋值。所以我的建议是:让initPool不接收任何参数,直接读取当前的names变量,这样调用时保证names已经更新即可。代码内聚性更高,也不容易错位。
4.2 中奖者出现在历史记录里两次:检查 splice 和显示时机
在一次帮同事调试的过程中,他遇到了“同一个人连续中两次奖”的问题。我检查了一下代码,发现他在点击停止后,没有从池子里移除索引,而只把显示名字改了。也就是说,他每次“停止”都只是在滚动中的当前名字上停下来,并没有真正决定中奖者是谁,所以连续停两次当然可能就是同一个人。
正确的做法就是我在 2.3 里讲的:停止时先清除定时器,然后从池子里抽一个索引,用这个索引对应的名字作为中奖者,并且马上把该索引从池子里移除。这里的顺序不能反,必须先clearInterval再drawFromPool,如果反过来,定时器还在跑,界面上可能刚好被下一次滚动覆盖,结果和按钮点击不一致。
我整理了一个简单的排查顺序,碰到类似问题可以按这个来:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 中奖者重复 | 没有从池子移除索引 | 检查splice是否执行 |
| 名单更新不生效 | names赋值顺序错位 | 检查initPool的调用时机 |
| 点击开始没反应 | 定时器没被清理 | 检查timer是否停留在非空状态 |
| 历史记录错乱 | prepend与appendChild混用 | 确认添加元素的方法统一 |
| 手机上布局错乱 | 没加 viewport 或固定宽度 | 改用百分比宽度和max-width |
把这张表打印出来贴在工位上,出问题先对着自己代码过一遍,十有八九能定位。
4.3 移动端适配与安全边距的坑
这个抽奖页面经常是在活动现场通过手机打开的,所以响应式适配一定不能漏。我在 CSS 里加了viewport元标签,内容区域用max-width: 94vw,防止在窄屏手机上出现横向滚动条。但这里有一个容易被忽略的点:iPhone 的“刘海屏”底部有一条 Home Indicator 区域,如果页面内容比较长,按钮和记录可能会被系统手势条挡住。
解决方法是给 body 或容器加一个安全边距:
.container { padding-bottom: calc(30px + env(safe-area-inset-bottom)); }env(safe-area-inset-bottom)是系统提供的安全区域距离值,现代浏览器都支持。加上这一句之后,不管你是 iPhone 还是带全面屏的安卓机器,页面底部都不会被系统 UI 遮挡。
另外,在手机上字体也要适当缩小,否则 44px 的抽奖姓名在 6 英寸屏幕上显示会溢出。可以给.result-box加一个媒体查询:
@media (max-width: 480px) { .result-box { font-size: 32px; min-height: 90px; } button { padding: 10px 16px; font-size: 14px; } }这样小屏幕下布局依然清爽,按钮也能单手操作。
4.4 抽奖公平性的代码保障与群众质疑
现场抽奖最怕的不是代码 Bug,而是参与者质疑“这抽奖是不是有猫腻”。与其事后解释,不如提前在代码里做几件事:
- 用索引池而不是临时随机:每次抽取都真正从“剩余名单”中选,结果不会提前被人为控制。
- 展示中奖名单池的变化:在页面上显示“当前名单:X 人,可抽:Y 人”,让所有人看到每次抽完人数都会减少,透明可验证。
- 保留历史记录:每次中奖都追加到列表里,现场投屏可以看到完整轨迹,谁中了、第几个中的,一目了然。
现实中的抽奖活动,中奖者的名字可能会被多次念到——比如抽中了但人不在现场,需要重抽。这种情况下,可以把“索引回填”做成一个可选操作:当需要重新抽时,把人已到场的获奖者索引从记录池中恢复。我通常会在重抽前先人工确认,然后决定是否执行pool.push(winnerIndex)。虽然这个功能在标题里没体现,但在真实活动中属于高频发生的需求,写代码时顺手留个扩展口,省得到时候临时改页面。
5. 扩展玩法:让这个抽奖页面的上限再高一点
5.1 一次抽取多人:支持奖项等级
如果你要抽的不是一个名字,而是一等奖 1 人、二等奖 2 人、三等奖 3 人,可以给抽奖函数加一个“数量”参数。每次从池子中连续抽取 n 个索引,然后一次性展示。做法很简单,循环调用drawFromPool即可:
function drawMultiple(count) { const winners = []; for (let i = 0; i < count; i++) { if (!pool.length) break; const winner = drawFromPool(); winners.push(winner.name); } return winners; }展示的时候,可以把中奖名单用逗号或顿号拼在结果框里。注意一次抽取多人时,滚动效果建议只停留在一个大奖名字上,其他获奖者通过弹窗或者下方列表展示,避免结果框文字过多影响现场观感。
5.2 权重抽奖:让特定人员更容易中奖
有些场景并不是完全随机,而是需要让一部分人概率更高,比如“新用户中奖率提升 20%”。要改成权重抽奖,只需要调整随机算法:给每个参与者一个权重值,累加总权重,然后生成一个[0, totalWeight)的随机数,找到它落在哪个区间即可。
function weightedDraw(weights) { const totalWeight = weights.reduce((sum, w) => sum + w, 0); let random = Math.random() * totalWeight; for (let i = 0; i < weights.length; i++) { random -= weights[i]; if (random <= 0) return i; } return weights.length - 1; }这种“按区间命中”的方式,在地推、拉新活动中很实用。不过需要注意:如果权重差距太大,低权重的人可能永远轮不到,这属于业务规则问题,不是代码问题。写之前先跟需求方确认清楚概率模型,别现场才被发现不合理。
5.3 持久化存储:刷新后还能不能继续抽
活动进行到一半,如果现场有人不小心刷新了页面,已经抽中的记录就全没了——这是我在真实活动中遇到过的最高频“事故”。解决方法很简单:使用浏览器的localStorage把“中奖记录”和“剩余可抽池”存下来。
function saveState() { localStorage.setItem('lotteryPool', JSON.stringify(pool)); localStorage.setItem('lotteryWinners', localStorage.getItem('lotteryWinners') || '[]'); } function loadState() { try { const savedPool = localStorage.getItem('lotteryPool'); if (savedPool) pool = JSON.parse(savedPool); } catch (e) { pool = []; } }这里有一个关键判断:pool里存储的是剩余索引,但如果用户刷新前刚更新过名单,索引就可能错位。所以存储时我建议把“完整名单”也一起存下来,加载时先比对名单是否一致,不一致就丢弃旧状态。这就像给数据加了一层校验,能避开很多现场翻车的尴尬。
5.4 导出中奖名单:收尾工作不能少
活动结束后,主办方通常需要一份中奖名单存档。在页面上加一个“导出结果”按钮,把历史记录拼成 CSV 格式下载,一行一个名字,中奖序号也带上。用Blob对象就能实现:
function exportCSV() { const rows = [['序号', '姓名']]; const lis = document.querySelectorAll('#historyList li span:first-child'); lis.forEach((el, i) => rows.push([i + 1, el.textContent])); const csvContent = rows.map(row => row.join(',')).join('\n'); const blob = new Blob(['\ufeff' + csvContent], { 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); }这里有个细节:Blob内容前加了\ufeff(BOM 头)。如果不加,Windows 上的 Excel 打开 CSV 时会直接乱码,加上 BOM 后 Excel 就能正确识别 UTF-8 编码。这个坑我在导出报表时踩过不止一次,现在顺手就写上。
6. 从个人小工具到公开使用:一些容易被忽视的细节
如果你的抽奖页面不只是自己用,还要发给别人、或者部署到公网,有几个地方需要额外留神。
第一,页面标题和说明文案要足够清晰。HTML 的<title>标签会显示在浏览器标签页上,也是别人收藏时看到的名称,别随便写个 “index”。我一般会写成“公司年会在线抽奖系统”这样直白的名称,别人一眼就知道这个页面是干嘛的。页面内部也可以加一小段使用说明,比如“请先将名单填入上方文本框,每行一个名字,点击更新名单后即可抽奖”,降低使用门槛。
第二,要考虑到多人同时操作的并发情况。虽然页面是前端本地逻辑,但如果部署到服务器上,理论上多个人同时打开、操作的是各自独立的localStorage,不会有数据冲突。不过如果你做了“导出中奖名单”功能,要提醒用户:导出的是本机的状态,如果想集中汇总,得在主办方那台电脑上操作。这个沟通成本不高,但不说清楚到时就可能出岔子。
第三,注意 XSS 风险。虽然我们的页面只是把用户输入的名字显示出来,如果不加处理直接把 HTML 字符串插入页面,用户粘贴名单时万一夹带了脚本内容,就可能被执行。例如用户粘贴<img src=x onerror=alert(1)>这种玩意儿。虽然只是本地运行,影响有限,但养成好习惯:所有动态插入的用户文本,都用textContent赋值,而不是innerHTML。我在代码中全部使用了textContent,目的就在于此。
7. 写在最后:我把这个页面用了三年
做这个小页面的初衷,只是不想在部门团建时为了抽奖到处找免费网站。三年时间,我在年会、产品发布、粉丝群活动、甚至家里小孩的生日派对上反复用同一个页面,每次只改名单,其他都不用动。它没有多高大上的技术,但是稳定、透明、随时可改,这就是它能一直用下去的核心理由。
如果你也是刚接触 HTML 和 JavaScript,我建议你亲手把这份代码敲一遍,不要直接复制粘贴。因为抽奖页面虽然小,却把数组操作、定时器、事件绑定、DOM 操作、数据校验这几个前端最基础也最重要的知识点都过了一遍。等你把它跑起来、改出自己想要的样子,你会发现自己对前端从“看了教程”到“真的能上手”有了质的转变。
最后分享一个我常用的判断标准:当一个页面可以用“自己动手”来解决的时候,就别让第三方工具把你的数据带走。不管是抽奖、签到、还是随机分组,自己写永远是最省心、最可控的方案。