在公司年会上被临时拉去救场或者自己搞个粉丝回馈活动却找不到合适的抽奖工具——这种场景我碰到过太多次了。第三方抽奖平台要么要注册、要么有广告、要么样式丑得没法看数据还得经过别人服务器总感觉不那么踏实。后来我干脆自己用 HTML 写了个随机抽奖页面需求变了就改两行代码样式想怎么调就怎么调几秒钟打开浏览器就能用比什么在线工具都顺手。如果你也想搭一个类似的在线抽奖、随机选取、自动挑选的页面不依赖任何框架和服务器这篇文章应该能帮你省不少事。我会从最基础的设计思路讲起把完整代码拆开揉碎再把我在实际使用中踩过的坑和总结的技巧一并分享给你保证你照着做就能出一个能用的版本。做完之后你会发现这种“小而美”的前端小工具恰恰是提升效率最实在的东西。1. 内容整体设计与思路拆解1.1 为什么用原生 HTMLCSSJS 而不是现成框架很多朋友一听到写网页第一反应就是上 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 langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title在线随机抽奖/title link relstylesheet hrefstyle.css /head body div classcontainer h1在线随机抽奖/h1 section classconfig-panel textarea idnameInput rows5 placeholder每行一个名字例如#10;张三#10;李四#10;王五/textarea div classconfig-actions button idupdateBtn更新名单/button span idcountTip当前名单0 人/span /div /section section classlottery-panel div idresultBox classresult-box等待抽奖/div div classaction-buttons button idstartBtn classprimary-btn开始/button button idstopBtn classdanger-btn disabled停止/button button idresetBtn classghost-btn重置/button /div /section section classhistory-panel h2中奖记录/h2 ul idhistoryList/ul /section /div script srcscript.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 langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.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 classcontainer h1在线随机抽奖/h1 p classsubtitle随机选取 · 自动挑选 · 公平公正/p div classconfig-panel textarea idnameInput rows5 placeholder每行一个名字例如#10;张三#10;李四#10;王五/textarea div classconfig-actions button idupdateBtn更新名单/button span idcountTip当前名单0 人/span /div /div div classlottery-panel div idresultBox classresult-box等待抽奖/div div classaction-buttons button idstartBtn classprimary-btn开始/button button idstopBtn classdanger-btn disabled停止/button button idresetBtn classghost-btn重置/button /div /div div classhistory-panel h2中奖记录/h2 ul idhistoryList/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 styleopacity: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\nsplit(\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;charsetutf-8 }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download 中奖名单.csv; a.click(); URL.revokeObjectURL(url); }这里有个细节Blob内容前加了\ufeffBOM 头。如果不加Windows 上的 Excel 打开 CSV 时会直接乱码加上 BOM 后 Excel 就能正确识别 UTF-8 编码。这个坑我在导出报表时踩过不止一次现在顺手就写上。6. 从个人小工具到公开使用一些容易被忽视的细节如果你的抽奖页面不只是自己用还要发给别人、或者部署到公网有几个地方需要额外留神。第一页面标题和说明文案要足够清晰。HTML 的title标签会显示在浏览器标签页上也是别人收藏时看到的名称别随便写个 “index”。我一般会写成“公司年会在线抽奖系统”这样直白的名称别人一眼就知道这个页面是干嘛的。页面内部也可以加一小段使用说明比如“请先将名单填入上方文本框每行一个名字点击更新名单后即可抽奖”降低使用门槛。第二要考虑到多人同时操作的并发情况。虽然页面是前端本地逻辑但如果部署到服务器上理论上多个人同时打开、操作的是各自独立的localStorage不会有数据冲突。不过如果你做了“导出中奖名单”功能要提醒用户导出的是本机的状态如果想集中汇总得在主办方那台电脑上操作。这个沟通成本不高但不说清楚到时就可能出岔子。第三注意 XSS 风险。虽然我们的页面只是把用户输入的名字显示出来如果不加处理直接把 HTML 字符串插入页面用户粘贴名单时万一夹带了脚本内容就可能被执行。例如用户粘贴img srcx onerroralert(1)这种玩意儿。虽然只是本地运行影响有限但养成好习惯所有动态插入的用户文本都用textContent赋值而不是innerHTML。我在代码中全部使用了textContent目的就在于此。7. 写在最后我把这个页面用了三年做这个小页面的初衷只是不想在部门团建时为了抽奖到处找免费网站。三年时间我在年会、产品发布、粉丝群活动、甚至家里小孩的生日派对上反复用同一个页面每次只改名单其他都不用动。它没有多高大上的技术但是稳定、透明、随时可改这就是它能一直用下去的核心理由。如果你也是刚接触 HTML 和 JavaScript我建议你亲手把这份代码敲一遍不要直接复制粘贴。因为抽奖页面虽然小却把数组操作、定时器、事件绑定、DOM 操作、数据校验这几个前端最基础也最重要的知识点都过了一遍。等你把它跑起来、改出自己想要的样子你会发现自己对前端从“看了教程”到“真的能上手”有了质的转变。最后分享一个我常用的判断标准当一个页面可以用“自己动手”来解决的时候就别让第三方工具把你的数据带走。不管是抽奖、签到、还是随机分组自己写永远是最省心、最可控的方案。