简介一款面向企业年会场景的滚动照片抽奖小程序专为活动组织者打造用于在年会现场以动态滚动参与者照片的方式抽取幸运者增强互动与悬念感。程序无需数据库支撑参与者信息与照片均以静态文件存储降低了部署与运行门槛。整个资源为zip压缩包共74个文件大小约2.3MB主要包含HTML页面、JavaScript脚本、CSS样式表以及java/jsp/class等后端逻辑文件png/jpg/gif图片用于照片与界面展示jar包提供运行依赖目录结构清晰便于二次修改。抽奖规则设计灵活首位幸运者无需发红包后续中奖者可获得100元至1000元不等的红包且单个参与者发出红包总额设2000元上限兼顾趣味与公平。包内提供index.html、script.js、style.css、data.json等核心文件理解并替换data.json中的参与者数据即可快速投入使用。已有2279人学习下载适合具备基础前端知识、需要为年会或类似活动快速落地抽奖方案的组织者与开发者。1. 年会滚动照片抽奖这就是现场气氛组的技术核心又到年会季行政同事抱着一沓打印名单来找我说去年用「按一下停一下」的抽奖太干今年想要那种大屏上照片来回滚动、越滚越慢、最后停在一张脸上的效果。这个需求听起来像是个小 Demo真做起来却有不少讲究照片怎么滚才不卡、停靠位置是不是真随机、抽中的人怎么从池子里拿走、现场断网了怎么办。这篇文章要拆的就是这份「年会滚动照片抽奖小程序」资源包它解决的是名单展示、随机抽取、去重管理和现场音效四件事的合体问题。适合两类人一类是年会组织者想直接拿去用一类是刚接触前端动画的开发者想看看滚动抽奖这类场景是怎么把动画帧、随机算法和 DOM 操作揉在一起的。2. 选型与目录为什么是 Canvas 而不是 WebGL 或 DOM 动画2.1 为什么是 Canvas 而非 DOM 动画第一次接到这个需求时我脑子里闪过两个方案一是给头像列表套一个 CSS 的 transform 滚动再配 opacity 渐入渐出二是直接上 WebGL 做粒子效果。后来都否了。CSS 方案在照片数量超过三十张、滚动速度又很快的时候会明显掉帧因为每一帧都在触发大量 DOM 节点的重排和重绘WebGL 方案对行政同事的旧笔记本和现场老投影仪又太不友好驱动兼容性就是个无底洞。中选的方案是 Canvas 2D。理由很直接Canvas 是一整块画布滚动时只需要在每一帧重绘当前可见的照片网格不需要维护几十个 DOM 节点的状态照片数量从 20 张加到 200 张代码逻辑不变只是数据源多了一页而且 Canvas 的帧率可控配合 requestAnimationFrame 能精确控制「先快后慢」的减速曲线这是 CSS 动画很难做到的手感。简单说这个场景里 Canvas 是性价比最高的那个选择。2.2 资源包的文件结构与启动方式拿到资源包之后第一步是看目录。这套小程序的结构不复杂核心就五个部分文件 / 目录作用index.html抽奖主页面包含大屏展示区和抽奖按钮assets/photos/存放员工照片的目录支持 jpg、png、webpjs/common.js照片预加载与降采样工具js/raffle-core.js滚动动画、停靠计算、去重逻辑js/names.js名单配置文件维护参与人信息和状态我一般建议直接用本地静态服务器启动双击 index.html 用 file:// 协议打开在部分浏览器会有本地图片读取限制虽然现在大多数浏览器放开了但现场演示时没必要赌这个。在资源包根目录起一个最简单的 Python 静态服务就行cd raffle-photo python3 -m http.server 8080然后在浏览器访问 localhost:8080看到滚动照片墙就说明资源包基本是好的。这一步的常见问题是现场电脑没装 Python那就用 Node 起一行命令npx serve .效果一样。不用纠结哪种方式只要能通过 http 协议访问到 index.html 就行file 协议打开时如果照片全部空白先怀疑这个环节别急着改代码。3. 滚动与抽取速度曲线、停靠位置与名单去重怎么配合3.1 滚动速度曲线先快后慢的现场感滚动抽奖的「现场感」九成来自速度曲线。如果全程匀速观众会觉得像倒带如果一直很快、最后瞬停又像程序 bug。我在这套资源里看到的做法是分段减速启动后前 1 秒保持高速滚动然后进入一个 2.5 秒左右的缓降段速度从每帧 12 格逐渐降到每帧 0.2 格最后停在预先算好的位置上。核心动画循环在 raffle-core.js 里简化后是这样的function animateScroll(timestamp) { const elapsed (timestamp - startTime) / 1000; // speedCurve 返回当前帧的滚动格数 const speed speedCurve(elapsed); offset speed; if (offset gridSize) { offset - gridSize; // 循环滚动保持 offset 在网格范围内 } drawPhotoGrid(ctx, offset); if (!stopRequested) { requestAnimationFrame(animateScroll); } } function speedCurve(t) { // 0-1s匀速高速段1-3.5s指数缓降段 if (t 1) return 12; const progress Math.min((t - 1) / 2.5, 1); return 12 * Math.pow(1 - progress, 2) 0.1; }逻辑上要注意的是「循环滚动」和「减速停靠」是两套机制。动画帧负责让网格无限循环滚动offset 到边界就折回停靠位置则是预先算好的减速段的每一步都在朝那个位置逼近逼近到差值小于 0.5 格时就宣布抽中。这里有几个参数可以调高速段的每帧 12 格对应一个 3 列 x 4 行的网格如果照片尺寸改了单位就要从「格」重新换算成「像素」减速段的 0.1 是末速度太大会停不准太小会显得拖沓。3.2 停靠位置与去重逻辑随机与不随机的平衡很多人误以为抽奖是「滚动过程中随机停」其实正好相反抽奖逻辑绝不放在动画帧里而是在点击按钮的那一瞬间就决定了「谁会中奖」之后的滚动动画只是把镜头摇到那张脸上。这个顺序很重要——先定人、再定位置才能保证「排除已中奖者」的逻辑是确定性的而不是每次滚动停下来才去查一次名单。核心抽取逻辑长这样function pickWinner() { // eligibleList 是尚未中奖的人员数组 const candidates eligibleList.filter(p !p.won); if (candidates.length 0) { fallbackToAllStaff(); // 全员中过一轮后重置 return null; } const idx Math.floor(Math.random() * candidates.length); const winner candidates[idx]; winner.won true; // 根据 winner 所在网格位置反算停靠偏移量 targetOffset calculateStopOffset(winner.gridIndex); return winner; }一个小细节Math.random() 在浏览器端足够用它不需要密码学强度的随机性现场抽奖不存在安全攻击的威胁模型。停靠位置的换算则是把 winner 在名单数组里的序号映射成网格里的行列索引再换算成像素偏移这样动画停下来的那一格必然是目标人脸。去重则简单粗暴但有效——已中奖者只是被过滤掉不参与后续轮次全场抽完后重置名单这样第二轮从零开始但记录还在方便做「最佳幸运奖」之类的回溯统计。3.3 参数表一次把界面参数调明白参数默认值建议范围说明COLS32-5滚动网格列数列越多屏幕利用率越高ROWS43-6行数行太多时人脸太小SPEED_HIGH128-16高速段每帧滚动格数按屏幕尺寸调DECEL_TIME2.5s2-4s减速段时长越长现场越紧张PHOTO_PRELOAD全量全量照片必须全部预加载否则滚动时白屏人手不够时最容易忽略的是 PHOTO_PRELOAD 这个参数。如果名单有 80 人、每人一张 2MB 照片预加载就是 160MB 内存占用看起来吓人但大屏场景下这个开销是值得的。不想全部预加载的话至少要把首屏 12 格和路径上可能出现的照片都加载掉否则滚动过程中出现一张加载中的灰色占位现场气氛会很尴尬。4. 避坑手记照片卡顿、重复中奖、误触刷新这类现场事故4.1 照片大图内存暴涨导致页面白屏现象名单只有 60 人每人一张手机拍的 3MB 照片滚动到一半浏览器标签页直接崩溃屏幕一黑。原因Canvas 在绘制大图时需要把每张原始尺寸的位图都放进内存3MB 的 jpg 解码后可能是 4000x3000 像素、约 48MB 位图60 张就是近 3GB任何浏览器都顶不住。解决资源包里的 js/common.js 提供降采样函数所有照片在导入时先画到一个小尺寸 canvas 上再存为缩略图。我在现场实践里会把长边强制缩到 640px显示效果在 1080p 的投影大屏上完全够用。操作方式是调 photoPreload(targetSize640)改完后再测一次内存占用目标控制在 100MB 以内。4.2 已中奖者再次被抽中现象抽到第三轮时屏幕上出现了第一轮已经中奖的人脸。原因滚动用的照片网格是「显示层」它只负责把照片循环铺开而 eligibleList 是「数据层」。如果在抽取时只从 eligibleList 里取人但滚动网格没有同步把这张照片从网格中移除就会造成「数据显示在墙上、人却已经不在池子里」的错位。解决正确做法是网格里始终显示所有人的照片中奖者的照片不删除只是打上一个「已抽中」的半透明遮罩或灰色勾选。这样画面始终完整数据层过滤又快视觉上也避免了「名单越来越少」的冷场。我一般会在 gridIndex 上记录每个人的显示位置中奖后先查数据层再决定是否重新铺一次网格。4.3 快速连点抽奖按钮造成动画重新初始化现象主持人手抖连按两次「开始」动画刚启动又重启滚动方向乱跳。原因按钮的 click 事件没有做防抖或状态锁。第一次点击启动了动画循环第二次点击又触发了一次 reset两个 requestAnimationFrame 循环同时在跑offset 被多个来源覆盖。解决在 raffle-core.js 里加一个 running 状态锁动画期间锁死开始按钮只有在 stop 完成、结果公布后才重启。这个问题的另一个隐蔽变种是「抽奖进行中误按了空格键」如果页面监听了键盘事件空格默认会触发按钮点击。我把键盘事件的 e.preventDefault() 直接去掉了大屏场景下禁用键盘事件反而更安全。4.4 中奖瞬间截图留存模糊现象照片停在最终位置但用手机拍屏幕时总是糊的因为动画还在做最后一帧的缓动微调。解决停靠判定是「逼近到小于 0.5 格」但照片实际呈现的位移可能还有亚像素级的移动。我在实现里会让最后一个中间帧直接硬切到目标位置不保留缓动余量。同时在中奖结果弹窗出现前加 150ms 的停顿让人眼和手机快门都能跟上。资源包里有个参数 STOP_HOLD_MS150想留更充裕的拍照时间可以改成 300但别超过 500不然现场会冷场。5. 从下载到现场三项配置让这场抽奖稳稳落地有了这份资源最快的一条路径是把员工名单整理成 names.js 支持的结构照片按「员工编号.jpg」命名放进 assets/photos/然后按上一章说的把照片长边缩到 640px最后起个静态服务浏览器打开就能测。但走到这一步的人会碰到三个只有现场才会暴露的问题这里给一套我常用的配置顺序。第一内网访问要用 IP 而不是 localhost。如果抽奖现场用的是一台投影电脑观众的互动屏想看到同一个画面或者你想用手机当遥控器控制开始暂停那前端页面就要通过局域网 IP 访问比如 http://192.168.1.20:8080而不是 localhost:8080。起服务时可以指定监听地址python3 -m http.server 8080 --bind 0.0.0.0第二大屏建议输出到扩展屏抽奖页面放在投影仪对应的那个屏上全屏控制用的笔记本屏幕保持操作界面。资源包里没有现成的遥控端但我通常会在现场加一个简单的列表页把开始、暂停、重置三个按钮做成手机能访问的 HTML 控制板本质上就是几个带 id 的 button配合前端的 postMessage 或 WebSocket 转发指令。如果不想引入 Node 依赖用浏览器自带的 BroadcastChannel 也能跨标签页通信代价是手机控制端必须和主屏在同一个浏览器实例里打开。第三兜底模式。万一现场照片加载失败或者名单读取异常我习惯把 names.js 里的名单内置成一份静态 JSON 兜底数据并让 raffle-core.js 在 fetch 名单失败时自动降级。这个降级模式不显示照片只用文字名字滚动视觉上弱一些但至少现场不会因为技术故障停下来。从那以后我每次部署抽奖类项目都会强制走一遍「断网 名单清空 照片目录改名」三连测试确保兜底逻辑真的能被触发。希望这些梳理能帮你把这个小程序顺利用完整个年会季。本文还有配套的精品资源点击获取