尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

用原生JS+Canvas打造年会抽奖系统:从洗牌算法到动效设计全解析

用原生JS+Canvas打造年会抽奖系统:从洗牌算法到动效设计全解析 简介前端开发中构建交互式活动页面往往需要兼顾视觉效果、逻辑可靠性与部署便捷性。原生JavaScript结合Canvas和CSS动画无需任何框架或构建工具即可实现媲美综艺晚会的舞台效果。核心技术包括使用Fisher-Yates洗牌算法保证抽奖公平且不重复通过状态机管理抽奖流程的各个阶段并利用Canvas粒子系统与CSS动效营造沉浸式大屏体验。这类技术方案不仅适用于年会抽奖也能扩展到团建活动、课堂点名、现场互动等瞬时高并发场景。文章从基础概念讲起逐步剖析原理、实现细节和现场踩坑记录帮助你快速搭建一套稳定、可复用、零依赖的互动抽奖系统。 公司行政在年会前三天找到我问能不能做一个“看起来特别酷的年会抽奖”。我的答案是纯 html css js 搞定不依赖任何框架一个源码文件双击就能跑。这篇文章把这套系统的思路、关键代码、动效设计和现场踩过的坑一次性讲透适合准备年会、活动抽奖、团建抽奖的研发或行政同学直接参考。先说下最终效果舞台背景是深色星空渐变金色粒子缓缓飘动抽奖时名单像老虎机一样快速滚动中奖瞬间粒子爆炸、卡片飞入、文字描金整个会场都有综艺晚会的味道。核心逻辑上支持多轮奖项抽奖、不重复中奖、键盘控制、一键重置名单和奖项配置都可以通过普通文本文件维护行政人员也能轻松改。1. 年会抽奖的需求梳理与技术选型1.1 现场真实需求比想象中多年会抽奖看起来只是“随机抽个人”但现场实际需求远不止这些。大屏投放需要适配1920x1080甚至4K分辨率字体不够大后排根本看不清名单可能是Excel整理的两三百人得能快速导入奖项分数个等级特等奖、一二三等奖每轮抽取人数不同更重要的是一个员工只能中一次奖如果被重复抽中台下会起哄。这些需求拆解下来功能清单基本是名单导入、多奖项配置、不重复抽取、动画滚动、键盘控制、结果展示、一键重置。还有一个容易被忽略的点彩排和正式使用通常会有多次抽奖流程如果抽完一轮不能重置彩排就没办法完整走一遍。我最初考虑过用现成的Vue或React搭一个但评估后发现完全没必要。年会现场没有安装Node的电脑也不一定连得上内网还要考虑U盘拷过去双击就能用。用原生三件套是最省心的零构建、零依赖、双击即用。所有逻辑压缩到一个html文件里体积不超过几十KB拷到任何一台Windows电脑上都能直接跑。1.2 为什么选原生JavaScript而不是框架很多人一听到“项目”就条件反射要上框架这其实是经验问题。年会抽奖这种工具型页面生命周期只有半天用框架反而引入麻烦需要Node环境构建、需要处理打包产物、需要维护多个文件。原生JavaScript在浏览器里的表现完全不差而且逻辑简单到不用任何响应式框架也能写得很清晰。我实际做的版本是三个文件分离的index.html、style.css、app.js方便自己改代码交付给行政的时候再合并成一个单文件html方便他们复制拷贝。如果你只是自己内部用直接写单文件也没问题。选型上还有一个关键点不依赖任何CDN资源。公司会议室网络时好时坏如果字体、动画库都是CDN加载现场网络一抖动页面就花了。原生实现的所有动效和粒子都写在本地不请求任何外部资源这才是现场能稳定跑的保障。2. 视觉方案用纯CSS和Canvas做出舞台感2.1 背景怎么设计才有“晚会感”年会大屏抽奖的视觉底色我强烈建议用深色系。深色在投影和LED屏上对比度高光效和文字才能跳出来而且不会像白色那样刺眼。我用的主背景是深蓝紫渐变从#0a0e27过渡到#1a1450再叠加一层流动的光晕渐变色块模拟舞台聚光灯扫过的感觉。光晕用纯CSS就能实现关键点在于用多个半透明圆形色块放在绝对定位层里加上不断旋转或位移的keyframes动画再配合filter: blur()模糊边缘。为了让光效更自然可以给光晕层加一个mix-blend-mode: screen这样颜色叠加时只亮不加暗非常像灯光打在烟雾上的效果。粒子系统我用的是Canvas。本质上是维护一个粒子数组每帧更新位置和透明度向上漂浮生成后逐渐消失再重新生成。粒子数量控制在60~100个太多会抢焦点太少又看不到颜色以金色、淡黄、白色为主。粒子的运动加一点正弦波横向扰动比直线上升自然很多。这些代码不复杂但要会控制requestAnimationFrame的性能避免粒子一多CPU占用飙升导致风扇狂转。2.2 抽奖名单滚动动效抽奖名单的滚动是全场视觉重心。我的实现方式是中央一个大的文字展示区开始抽奖后以50~80毫秒间隔随机切换显示人员姓名速度够快就有“老虎机滚动”的错觉。关键细节是切换时给文字加一个极短的透明度和位移变化人眼会产生滚动的连贯感。直接setInterval切换文本够用了但要注意页面卡顿时interval可能会累积现场一般不会出问题。更专业一点的做法是用requestAnimationFrame配合时间差控制每帧判断是否到了切换时间点这样滚动流畅且不受setInterval叠加影响。中奖瞬间的动效我做了三层第一层是名字滚动逐渐减速第二层是Canvas粒子爆炸第三层是中奖者卡片带缩放和弹性动画飞入屏幕中央。卡片飞入用了CSS transition加transform: scale(0→1)配合cubic-bezier的弹性曲线观感上会有一点“弹跳感”。背景再闪一圈金色光芒整个会场目光都会被吸过来。2.3 字体、字号与配色细节大屏抽奖最容易犯的错就是字号设置得太小。电脑上看着合适投影出来一团糊。我的经验标题字号至少8vw以上抽奖滚动区的人名至少12vw。所有尺寸尽量用vw/vh而不是固定px这样无论屏幕分辨率大小都能自适应。字体方面中文优先用微软雅黑或系统黑体保证Windows和Mac显示一致。如果有特殊设计诉求想用思源黑体可以把字体文件转成base64嵌入但体积会大幅增加一般年会没必要。文字发光用text-shadow模拟可以多层叠加先一层大范围的模糊光晕再一层紧贴文字的亮边效果比单纯一层好很多。配色上每个奖项有自己的主题色特等奖金色、一等奖红色、二等奖蓝色、三等奖绿色。切换奖项时通过CSS变量切换主题色整体配色保持一致但不会单调。CSS变量的好处是改一处全局生效比写死颜色好维护得多。3. 抽奖核心逻辑公平性、状态机与名单管理3.1 使用洗牌算法保证公平且不重复初学抽奖的人最容易写出的代码是每次从全员里Math.random()抽一个抽到已中奖的就重新抽。这种“随机重试”的方式在人数少、奖项轮次多时效率会下降而且如果处理不好可能会陷入死循环。更优雅的方式是Fisher-Yates洗牌算法。洗牌算法的思路很简单把“待抽人员池”想象成一叠扑克牌从最后一张开始随机挑一张和它交换位置然后往前缩小范围。洗完之后数组顺序就是全新的随机排列。之后抽奖只需从洗好的数组尾部依次取人天然不重复效率为O(n)十分稳定。我写的核心代码大概长这样// 待抽人员池抽走的人会从pool中移除 let pool [...allStaff]; // 已经中奖的名单 let winners []; // Fisher-Yates洗牌 function shuffle(arr) { const list [...arr]; for (let i list.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [list[i], list[j]] [list[j], list[i]]; } return list; } // 从洗好的池子里取count个人 function pickWinners(count) { const shuffled shuffle(pool); const selected shuffled.slice(0, count); selected.forEach((p) { pool pool.filter((item) item ! p); winners.push(p); }); return selected; }这个方案有一个额外的好处池子在每轮抽奖时会持续缩小后续轮次的抽奖不会抽到前面已中奖的人。人员全量大几百人的情况下洗牌一次也就几毫秒完全不卡。3.2 名单和奖项配置怎么维护名单放在JavaScript数组里是最直接的方式但让行政翻开你的代码改数组多少有点不友好。我做的方案是预留两种名单来源第一种是数组直接配置适合技术人员自己维护第二种是读取同目录下的staff.txt文本文件每行一个姓名用fetch读取并转成数组。由于本地file://协议下fetch会有跨域限制所以读TXT文件的方式需要起一个本地服务比如python -m http.server才能用。如果你是直接双击html文件方式运行就要把名单直接粘贴在页面的配置文本域里点击确认后写入数组。我更推荐的做法页面放一个文本域默认填充一个人员模板行政可以直接把Excel里的姓名列复制粘贴进去点击“更新名单”就算导入完成。这样不依赖任何服务器环境而且对小白极其友好。名单导入后要做一次清洗去掉空行、去掉首尾空格、用Set去重。Excel里经常有隐藏的空格或者全角空格这些字符特别容易导致看起来像重复的名单实际上不是同一个字符串。奖项配置我用一个数组统一管理const AWARD_CONFIGS [ { name: 特等奖, count: 1, color: #ffd700 }, { name: 一等奖, count: 2, color: #ff4757 }, { name: 二等奖, count: 5, color: #1e90ff }, { name: 三等奖, count: 10, color: #2ed573 } ];每一轮从数组里按顺序取一个配置动态更新标题、颜色和本轮抽取人数不需要为每个奖项写死逻辑。如果年会临时加了个“阳光普照奖”只需要在数组里加一项代码不用动。3.3 状态机抽奖流程不乱套抽奖页面有很多状态待开始、滚动中、已出结果、已到底、已重置。如果不做状态管理按钮点击会乱套。我用了最简单的字符串状态机来解决idle待开始点击后开始滚动状态变为rollingrolling滚动中点击后停止并中奖状态变为stoppedstopped已显示中奖结果点击进入下一轮状态变为idle如果所有轮次抽完按钮变为不可用let status idle; // idle | rolling | stopped let currentAwardIndex 0; function onButtonClick() { if (status idle) { startRolling(); } else if (status rolling) { stopRolling(); } else if (status stopped) { nextRound(); } }nextRound里会判断当前奖项剩余数量。一个容易踩的坑是“多轮抽取同一种奖项”比如三等奖要抽10个人一轮抽不完我按5个一屏来分两次抽。这种场景下currentAwardIndex不能机械递增要先判断当前奖项的剩余名额是否已经归零归零才切到下一个奖项。重置功能我设计成按住Alt键再点击按钮触发防止彩排时误点。重置会清空winners、把所有员工重新放回pool、currentAwardIndex归零页面恢复到初始状态。这个功能实际上是彩排和正式使用无缝衔接的关键否则每演一次都要刷新页面重新导入名单。4. 可以直接运行的源码与关键代码讲解4.1 文件结构与启动方式我交付的源码结构非常轻量核心就三个文件lottery/ ├── index.html ├── style.css └── app.js如果你只需要最简版本也可以把一个html文件里通过style和script标签把三部分合并这样拷贝走就一个文件双击即用。在文件里加入完整配置后用Chrome或Edge打开就能看到效果。需要全屏展示时按F11进入全屏模式页面会自动适配。下面的讲解会按“HTML结构 - CSS动效 - JS逻辑”三段来拆实践中最容易出问题的代码我都在注释里标记了。4.2 HTML骨架把舞台拆成几个清晰区域HTML结构不需要复杂要的就是层次分明。我将整个舞台分成五个区域背景粒子层、顶部标题区、中央抽奖区、按钮控制区、中奖名单展示区。!doctype html html langzh-CN head meta charsetutf-8 title公司年会抽奖/title style /* CSS 见下文 */ /style /head body canvas idparticleCanvas/canvas div classlight-aura/div div classstage h1 classmain-title年会幸运抽奖/h1 div classaward-tag idawardTag三等奖/div div classroll-box idrollBox div classroll-name idrollName等待抽奖/div /div div classbtn-area button classlottery-btn idlotteryBtn开始抽奖/button /div div classwinners-area idwinnersArea/div /div script // JS 见下文 /script /body /html一个关键点是Canvas要挂在body最顶层或绝对定位层里否则它会被其他DOM元素遮挡。Canvas的宽高用JavaScript动态设置为window.innerWidth和innerHeight并用devicePixelRatio修正清晰度不然高分屏上粒子会糊。这里的light-aura是背景光晕层CSS里让它铺满全屏z-index低于stage内容。4.3 CSS动效背景光晕、滚动文字与卡片入场CSS里我把最核心的几个动效列出来。第一个是背景光晕.light-aura { position: fixed; inset: 0; z-index: 0; pointer-events: none; background: radial-gradient(circle at 20% 30%, rgba(255, 215, 0, 0.25), transparent 40%), radial-gradient(circle at 80% 20%, rgba(255, 71, 87, 0.2), transparent 45%), linear-gradient(135deg, #0a0e27 0%, #1a1450 100%); animation: auraMove 12s ease-in-out infinite alternate; } keyframes auraMove { 0% { transform: scale(1) translate(0, 0); } 100% { transform: scale(1.2) translate(-3%, -2%); } }浅色径向渐变以不同位置撒在背景上模拟三个不同颜色的聚光灯位。transform的缩放和位移让光晕有缓慢呼吸的感觉不会刺眼。第二个是滚动区域文字发光.roll-name { font-size: 12vw; line-height: 1; color: #fff; text-shadow: 0 0 20px rgba(255, 215, 0, 0.8), 0 0 60px rgba(255, 215, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.6); transition: transform 0.1s ease; } .roll-name.scrolling { animation: flick 0.08s infinite alternate; } keyframes flick { from { opacity: 0.85; transform: translateY(0); } to { opacity: 1; transform: translateY(-2px); } }第三部分是中奖卡片的弹出。中奖结果展示用了一个winners-area容器每个中奖者渲染成卡片。卡片入场动画我用一个弹性的过渡.winner-card { padding: 16px 32px; background: rgba(255, 255, 255, 0.12); border: 2px solid var(--theme-color, gold); border-radius: 12px; font-size: 2rem; transform: scale(0); opacity: 0; transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease; } .winner-card.show { transform: scale(1); opacity: 1; }那个cubic-bezier曲线的参数决定了“弹一下再归位”的效果比线性动画生动很多。如果觉得麻烦也可以直接用CSS里现成的transition-timing-function: cubic-bezier(.34,1.56,.64,1)这是比较通用的回弹参数。4.4 JS核心逻辑名单导入、洗牌、滚动、中奖收尾JS部分我把完整逻辑串一遍从初始化到一次抽奖结束。先定义员工名单和奖项配置const FULL_STAFF [ 张三, 李四, 王五, 赵六, 孙七, 周八, 吴九, 郑十 // 实际使用时替换为从文本域导入的名单 ]; const AWARD_CONFIGS [ { name: 特等奖, count: 1, color: #ffd700 }, { name: 一等奖, count: 2, color: #ff4757 }, { name: 二等奖, count: 5, color: #1e90ff }, { name: 三等奖, count: 10, color: #2ed573 } ];然后维护全局变量let pool []; let winners []; let status idle; let currentAwardIndex 0; let currentAwardRemain AWARD_CONFIGS[0].count; let rollTimer null;页面加载时初始化名单function init() { pool [...FULL_STAFF]; renderAwardTag(); displayWinnerCount(); } function normalizeList(input) { const seen new Set(); return input .split(/[\n,;]/) .map((s) s.trim()) .filter((s) s !seen.has(s) seen.add(s)); }normalizeList函数处理了换行、逗号、分号等常见分隔符并利用Set去重这样即使行政从Excel复制过来的格式是“张三,李四、王五”也能正常解析。这个函数是踩过不少坑之后补上的最初只按换行切分结果复制粘贴时混入了逗号导致整个名单解析错乱。滚动和停止逻辑function startRolling() { // 如果当前轮次只剩0个名额自动跳到下一奖项 if (currentAwardRemain 0) { currentAwardIndex; if (currentAwardIndex AWARD_CONFIGS.length) { alert(所有奖项已抽取完毕); return; } currentAwardRemain AWARD_CONFIGS[currentAwardIndex].count; } status rolling; updateBtnText(暂停抽奖); const award AWARD_CONFIGS[currentAwardIndex]; document.documentElement.style.setProperty(--theme-color, award.color); document.getElementById(awardTag).textContent award.name; rollTimer setInterval(() { const idx Math.floor(Math.random() * pool.length); document.getElementById(rollName).textContent pool[idx]; }, 60); }停止抽奖并执行中奖流程function stopRolling() { clearInterval(rollTimer); rollTimer null; status stopped; updateBtnText(下一位 / 下一轮); const award AWARD_CONFIGS[currentAwardIndex]; const selected []; for (let i 0; i currentAwardRemain pool.length 0; i) { const idx Math.floor(Math.random() * pool.length); selected.push(pool[idx]); pool.splice(idx, 1); } currentAwardRemain - selected.length; winners.push(...selected); showWinners(selected, award); burstParticles(); }这里有个细节停止时如果用洗牌后取一个随机下标的方式其实还是每次随机抽但因为抽完就splice移除所以保证不重复。也可以先洗牌再一次性取多个两种方式都行我代码里倾向于现场抽几个就取几个逻辑更直观。完整代码里还有键盘监听document.addEventListener(keydown, (e) { if (e.code Space) { e.preventDefault(); // 防止空格触发页面滚动 document.getElementById(lotteryBtn).click(); } });这是会场最容易忽略的一个点主持人一按空格浏览器把页面往下滚了一截画面垮掉。一定要preventDefault。5. 现场使用踩坑记录与排查技巧5.1 中文乱码问题中文乱码是年会抽奖最经典的问题。解决方案很简单html文件保存时必须使用UTF-8编码并且html头部必须有meta charsetutf-8。我遇到过的情况是行政同事用Windows记事本改完文档后另存为ANSI编码导致打开页面出现乱码。保险做法是在代码里写一个名单导入文本域让行政粘贴名单而不是直接改源码这样能最大程度避免编码问题。还有一个隐藏坑在Windows记事本里保存的UTF-8文件会带BOM头某些版本下可能会在页面顶部出现一个空行或者特殊字符。用VS Code保存时选择“UTF-8 without BOM”最稳。5.2 名单里有空行、全角空格等隐藏字符Excel复制出来的人名经常带着各种不可见字符全角空格、零宽空格、制表符。人名是“张 三”和“张三”在程序里完全是两个字符串会导致去重失效甚至中奖时报出看起来很怪的名字。我的经验是名单解析时统一trim并且把全角空格替换为半角const clean (s) s.replace(/\u3000/g, ).trim();还可以在控制台输出一份解析后的名单数组让操作人员肉眼检查一遍是否有异常。这个步骤看似多余实际能在彩排前发现问题。5.3 空格键按下后页面滚动键盘控制的坑前面说过这里再强调一次。在keydown处理函数里加e.preventDefault()后空格键的页面滚动行为就会被拦截主持人按空格就不会把页面搞乱。如果你还用到了Tab键切换奖项同样要在keydown里preventDefault否则焦点会在按钮和页面之间跳来跳去。另外现场的无线鼠标和键盘可能反应有延迟滚动间隔不要设置得太短。名字每秒切换15~20次就足够有“滚起来了”的感觉了太快的滚动反而会让主持人看不清、不好控制暂停时机。5.4 大屏显示比例不对公司会议室的大屏常见分辨率有1920x1080、2560x1440还有4K屏。我用vw/vh做自适应后任何比例下都能撑满。但要注意Windows系统默认的缩放率可能不是100%投影出来的内容会显得比预期大或小。稳妥做法是正式使用前先投一次看看字体是否完整显示必要时调整浏览器缩放比例。页面上可以加一个“适配全屏”按钮调用document.documentElement.requestFullscreen()避免主持人以为要手动F11。5.5 常见问题速查表问题表现可能原因解决方法中文乱码html文件保存编码不是UTF-8用VS Code另存为UTF-8 without BOM名单解析错乱存在全角逗号、分号等隐藏字符解析时统一替换全角符号trim去空格按空格页面滚动未拦截默认行为keydown中调用e.preventDefault()Canvas粒子不显示canvas宽高为0或z-index过低动态设width/height确保z-index在底层且不是0抽奖按钮无反应状态没有正确切换检查控制台是否报错确认status变量状态流转中奖名称显示undefined名单数组中存在空字符串清洗名单时过滤掉所有空项5.6 现场备用的应急方案再分享一个实战经验彩排时一定要准备一套纸质的应急抽奖方案。现场一旦电脑出问题、投影识别不了、或者HDMI线松动纸质方案就是最后的保底。另外正式抽奖前把一切东西都先打开关掉系统自动更新弹窗、关掉钉钉/微信的弹窗通知免得抽到一半弹个消息出来。这些都是我见过翻车现场总结出来的血泪教训。6. 锦上添花的扩展功能6.1 背景音乐和音效抽奖动效配上音效现场感会提升几个台阶。直接用HTML的audio标签背景音乐循环播放中奖时播放一个提示音。音效文件可以放在同目录下也可以使用base64编码内嵌。要注意浏览器自动播放策略用户必须点一下页面才能播放音频所以我在页面加了一个“开启音乐”的悬浮按钮主持人一点就能启动。6.2 中奖名单导出年会结束后HR通常需要一份中奖名单存档。我在页面上加了一个“导出中奖名单”按钮把winners数组格式化后复制到剪贴板。如果剪贴板API在某些浏览器不兼容可以fallback到控制台输出或者生成一个text blob下载文件。实际使用中我还会把奖项名称一起导出来格式类似“特等奖张三、李四”方便HR后续统计。6.3 照片头像模式如果公司的员工照片是全的可以把名单从数组升级成对象数组增加photo字段。中奖卡片上显示头像照片而不是纯文字视觉效果更“综艺”。图片建议做圆角或圆形裁切直径用8vw左右在大屏上非常清晰。如果部分员工没有照片渲染时做一个占位处理用姓名首字生成色块头像保证画面统一。年会结束之后我复盘这套抽奖系统最大的收益不是代码本身而是“用最笨的办法解决最实际的现场问题”。原生三件套确实不如框架看着高级但它在会场这种苛刻环境下的稳定性是我最看重的。如果你也准备给公司做年会抽奖直接拿这份源码去改把名单和奖项配置替换成你们的数据再在彩排时完整过一遍流程大概率能收获满场欢呼。本文还有配套的精品资源点击获取
返回列表