微信小程序年会照片抽奖:定时器与本地存储打造公平随机的滚动照片墙

发布时间:2026/10/18 18:42:57
微信小程序年会照片抽奖:定时器与本地存储打造公平随机的滚动照片墙
简介这套年会滚动照片抽奖小程序是一份可直接运行的Web项目源码面向企业年会策划者、活动开发者和Java Web初学者用于快速搭建带照片滚动效果的现场抽奖工具。抽奖规则设计灵活第一位中奖者免发红包后续红包分100元至1000元五档并设置每位参与者红包总额不超过2000元的预算控制兼顾氛围与公平。项目采用JSP、Servlet等技术结合JavaScript实现照片动态滚动与随机抽奖逻辑重点展示了数组循环、随机数生成、AJAX页面通信等典型写法的综合运用。压缩包共74个文件、约2.3MB涵盖JSP页面、Java类、jar依赖库、CSS样式、JavaScript脚本及大量png/jpg/gif图片素材目录结构包含src、WebRoot、images、css等模块便于按前后端分层查看。参与者数据以静态文件方式存储无需数据库即可运行修改数据文件即可更新照片和名单。已有2279人浏览学习适合拿来直接导入开发工具后部署演练也可借鉴其抽奖规则与交互设计完成二次开发。1. 年会滚动照片抽奖小程序一个让全场安静下来等定格的项目去年帮某公司行政同学做年会抽奖翻遍现成模板要么是纯数字滚动的老虎机要么是点名器式的随机抽取照片一放上去就少了“滚动照片”的仪式感。这个项目就是从那次需求里长出来的大屏上员工照片像照片墙一样横向滚动主持人喊停后减速定格到中奖者支持多轮抽奖和中奖名单去重。适合年会、团建、迎新会这类需要气氛和公平性兼顾的场合对开发者来说它还是一个把定时器、本地存储、页面生命周期串起来的小程序实战样本。整个包不依赖后端服务导入微信开发者工具就能跑改名单、改速度、加奖品档位都很直接。2. 技术选型与工程结构原生小程序加定时器加本地存储的轻量组合2.1 为什么选原生微信小程序而不是 H5 页面这个项目首先要解决的是“现场可用性”。年会现场没有时间装 App主持人拿手机控制、大屏同步展示微信里扫码即开是最顺的路径。小程序不需要提前部署服务器也不用在会场电脑上折腾本地服务打开开发者工具导入源码就能跑真机上扫码预览也只是多一步传体验版。技术栈上我选了原生小程序而不是 uni-app 或 Taro。原因很实际单页面抽奖场景用框架反而多一层构建依赖还要维护 node_modules对临时接手改名单的同事不友好。原生小程序的照片组件自带缓存和懒加载所有头像可以全部打进包内现场不依赖外部网络也能稳定展示。抽奖核心用 setTimeout 递归而不是 setInterval因为每一帧的间隔需要动态变化setInterval 做不到逐帧调整速度。这里有一个我踩过的坑如果数据源用网络图片链接真机上会受 downloadFile 合法域名限制平时开发工具里能显示到了会场现场经常整屏黑块。所以这个工程里所有照片默认放本地资产目录没有照片的人统一使用占位头像。2.2 目录结构速览拿到源码后第一件事是看目录。这个项目的文件安排很紧凑核心页面只有 lottery 一个。annual-draw-lottery/ ├── app.js # 小程序入口globalData 存全局配置 ├── app.json # 页面注册与窗口配置自定义导航 ├── app.wxss # 全局样式 ├── pages/ │ └── lottery/ │ ├── lottery.wxml # 抽奖页结构滚动墙 主照片 按钮 │ ├── lottery.wxss # 页面样式滚动动画在这里定义 │ ├── lottery.js # 核心逻辑滚动、抽奖、存储 │ └── lottery.json # 页面配置 ├── data/ │ └── participants.js # 参与人员数据数组格式 ├── assets/ │ └── avatars/ # 本地头像目录含 default.png └── utils/ └── lottery-core.js # 纯抽奖函数独立于页面便于测试每个文件职责都很单一。data/participants.js 是唯一需要手动维护的名单文件结构是对象数组每个对象包含 id、name、dept、photo 四个字段。utils/lottery-core.js 放的是不依赖页面实例的抽奖函数这也是后期做概率验证的基础。assets/avatars 目录放员工照片文件名和 participants.js 里的 photo 字段对应没有照片的人统一走 default.png。2.3 页面数据流与关键状态整个抽奖过程就是围绕几个状态字段转的先看它们各自的作用。字段类型说明poolArray本轮可抽人员列表已经中奖的人不会再出现在 pool 中curIdxNumber当前大屏展示的照片索引curItemObject当前展示的人员对象包含姓名部门头像modeStringidle / fast / stopping决定按钮文案和帧间隔策略winnersArray已中奖名单持久化到 storage_delayNumber当前帧间隔毫秒数仅页面实例内使用_fastFramesNumber已快速滚动的帧数达到阈值后进入减速阶段数据流是这样走的onLoad 阶段读取 data/participants.js 初始化 pool同时从 wx.getStorageSync 恢复 winners 名单。点击开始后 mode 从 idle 变 fast帧函数按固定 60ms 间隔切换照片索引同时累计 _fastFrames。达到预设帧数后自动切到 stopping 模式每帧间隔逐步增大照片滚动速度肉眼可见地慢下来。当间隔超过最大阈值就调用 finish 完成定格把中奖者写入 storage。这个状态机的设计初衷是防止人为干预结果。主持人只负责按下开始后续的快速滚动、减速、定格全部由代码自动完成不需要在滚动过程中再点一次停止也就避免了“什么时机按下停止”带来的争议。手动停止接口我留了但默认流程走全自动。3. 核心模块拆解从滚动照片墙到中奖定格的完整链路3.1 滚动照片墙CSS 动画和抽奖照片切换的分工大屏背景是一条横向滚动照片墙这是仪式感的主要来源。它和抽奖主体的照片切换是两条独立链路背景墙用纯 CSS 动画循环播放只负责氛围主体照片用 JS 定时器切换负责定格结果。两者互不干扰。/* pages/lottery/lottery.wxss */ .scroll-wall { position: absolute; top: 0; left: 0; display: flex; width: max-content; animation: wallScroll 30s linear infinite; } .scroll-wall image { width: 320rpx; height: 200rpx; margin-right: 24rpx; border-radius: 12rpx; opacity: 0.5; } keyframes wallScroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }背景墙实现的关键是“内容复制两遍”。wxml 里把前 8 张照片重复渲染两次动画结束时 translateX 刚好移动了总宽度的一半视觉上首尾无缝衔接观众看不出循环点。opacity 设成 0.5 是为了弱化背景干扰让主体照片更突出。这里有个常见误用有人会把滚动背景也交给 JS 定时器驱动连续跑几个小时后 setData 频繁刷新小程序会有明显卡顿。CSS 动画跑在合成线程上不占用逻辑层这是大屏场景下更稳的做法。3.2 抽奖主流程setTimeout 递归驱动的变速滚动抽奖页的核心逻辑在 lottery.js 里帧函数用 setTimeout 递归实现每帧执行完再根据当前模式计算下一帧间隔。// pages/lottery/lottery.js const BASE_DELAY 60; // 起始帧间隔 60ms const STEP_DELAY 24; // 减速阶段每帧增加 24ms const MAX_DELAY 480; // 最大帧间隔超过则定格 const FAST_FRAMES 40; // 快速阶段至少滚 40 帧 Page({ data: { pool: [], curIdx: 0, curItem: {}, mode: idle, btnText: 开始抽奖, winners: [] }, onLoad() { const participants require(../../data/participants); const winners wx.getStorageSync(winners_v1) || []; const pool participants.filter(p !winners.some(w w.id p.id)); this.setData({ pool, winners, curItem: pool[0] || {} }); this._delay BASE_DELAY; this._fastFrames 0; }, onTapStart() { // 自动流程只负责启动不负责停止 if (this.data.mode ! idle) return; if (!this.data.pool.length) { wx.showToast({ title: 没有可抽人员了, icon: none }); return; } this._delay BASE_DELAY; this._fastFrames 0; this.setData({ mode: fast, btnText: 滚动中 }); this._timer setTimeout(() this.nextFrame(), BASE_DELAY); }, nextFrame() { const { pool, curIdx, mode } this.data; if (!pool || !pool.length) { this.setData({ mode: idle, btnText: 重新开始 }); return; } // 随机跳 1 到 5 格让过程更像滚屏而不是逐帧翻页 const step Math.floor(Math.random() * 5) 1; const nextIdx (curIdx step) % pool.length; this.setData({ curIdx: nextIdx, curItem: pool[nextIdx] }); let delay; if (mode fast) { this._fastFrames 1; delay this._delay; if (this._fastFrames FAST_FRAMES) { this.setData({ mode: stopping }); } } else if (mode stopping) { this._delay STEP_DELAY; delay this._delay; } if (delay MAX_DELAY) { this.finish(); return; } this._timer setTimeout(() this.nextFrame(), delay); }, onUnload() { if (this._timer) clearTimeout(this._timer); } });几个参数直接决定手感。BASE_DELAY 越大滚动起步越慢适合照片数量少的场次STEP_DELAY 控制减速的陡峭程度数值越大从滚动到定格越快全场屏息的时间越短FAST_FRAMES 是快速阶段的帧数底线保证观众至少能感受到一段“疯狂滚动”的过程。支持手动停止的版本可以在 stopping 分支前加一个按钮事件把 mode 直接置为 stopping效果是马上开始减速最终仍由代码定格。随机跳步的 step 范围也是可以调的。如果希望抽奖感更强step 可以放到 3 到 10如果希望观众能看清每一张脸step 保持 1 到 2 更合适但那样滚动速度感会弱一些。我最终选了 1 到 5平衡了识别度和速度感。3.3 公平性设计先随机定人再表演滚动画真正决定中奖者的逻辑不在帧函数里而在 finish 阶段。这样做最大的好处是概率可控、可验证。观众看到的是照片滚了很多圈后慢慢停住实际中奖人在定格前已经用 Math.random 选好了后面的滚动只是视觉表演。// finish 阶段 finish() { const { pool } this.data; const winner this.pickOneFromPool(); const winnerIdx pool.findIndex(item item.id winner.id); this.setData({ curIdx: winnerIdx, curItem: winner, mode: idle, btnText: 再抽一轮 }); this.saveWinner(winner); wx.showModal({ title: 中奖者, content: ${winner.dept} ${winner.name}, showCancel: false }); }, pickOneFromPool() { const { pool } this.data; const idx Math.floor(Math.random() * pool.length); return pool[idx]; }, saveWinner(winner) { const key winners_v1; const list wx.getStorageSync(key) || []; if (!list.some(item item.id winner.id)) { list.push({ ...winner, time: Date.now() }); wx.setStorageSync(key, list); } const remainPool this.data.pool.filter(item item.id ! winner.id); this.setData({ winners: list, pool: remainPool }); }这里要注意定格前的最后一帧是中奖者的概率并不是 100%。因为帧函数是随机跳步的减速到最后一帧时可能停在任意一张照片上如果直接把 curIdx 换成 winnerIdx观众会看到明显的“瞬移”。处理方式有两种一种是把定格前一帧强制设为中奖者的前一张让视觉上顺滑地滑到中奖者另一种是接受这个小跳跃因为减速阶段每帧间隔已经接近 400ms人眼对最后一两帧的细节记忆并不强。我最终选择了后者现场效果没有翻过车。pickOneFromPool 从当前 pool 中随机取人而 pool 在 onLoad 和 saveWinner 里都过滤了已中奖者所以不会出现同一人中两次的情况。storage 里的 winners 名单同步维护页面每次加载时也会用它初始化 pool形成闭环。3.4 中奖名单去重与重置中奖名单存储在本地 storage 里key 是 winners_v1。saveWinner 里用 some 判断是否已存在避免重复写入。重置功能单独放在一个按钮上绑定的是 clear 方法需要二次确认防止现场误触把整场记录清掉。resetAll() { wx.showModal({ title: 确认重置, content: 将清空全部中奖记录确定继续吗, success: (res) { if (res.confirm) { wx.removeStorageSync(winners_v1); const participants require(../../data/participants); this.setData({ pool: participants, winners: [], curItem: participants[0] }); } } }); }重置时机很关键。年会现场常见的翻车场景是彩排时抽了几轮正式开场忘了重置导致正式抽奖少了好几个人。我的建议是每次彩排结束强制执行一次重置并且在大屏上显示当前剩余人数让操作者一眼就能确认状态。4. 运行配置与常见问题排查五类高发坑位一次排清4.1 三分钟跑起来导入、数据源与编译模式拿到工程后最顺的路径是打开微信开发者工具选择导入项目目录指向 annual-draw-lottery 根目录AppID 填写 touristappid 测试号开发者工具会自动识别 project.config.json。之后直接编译就能看到抽奖页面。{ appid: touristappid, projectname: annual-draw-lottery, compileType: miniprogram, setting: { urlCheck: false, es6: true, minified: true } }projcet.config.json 里的 urlCheck 设为 false开发环境不会校验图片域名照片加载更自由。但真机上这个开关无效所以工程里所有照片都放在本地 assets 目录不依赖外链图片。数据源是 data/participants.js直接用文本编辑器打开改数组即可。字段结构是对象数组每项包含 id、name、dept、photo。photo 填相对路径如 /assets/avatars/p001.png也可以留空代码会自动使用默认头像。4.2 真机预览与大屏投屏分辨率与安全区开发者工具里模拟器表现正常只是第一步年会现场用的是真机加大屏投屏方式一般是手机扫码预览后将画面镜像到大屏。这里有两个需要提前处理的问题。第一是分辨率适配。大屏可能是 16:9 的投影也可能是 4:3 的电视抽奖页的顶层容器要用固定比例布局而非依赖屏幕尺寸。我的做法是外层 view 设置 width: 100vw; height: 100vh内部主卡片按 rpx 固定宽高aspectFill 模式裁剪照片这块在真机上不会出现照片拉伸变形。第二是安全区。带刘海的手机在横屏投屏时顶部和底部会被裁掉一部分所以操作按钮要放在屏幕中部偏下避免贴在底部。页面开启自定义导航后状态栏区域的遮挡问题也要在开发工具的真机调试里提前确认。预览时建议用真机调试而不是普通预览真机调试能直接看到大屏投屏的实际效果还可以远程查看 console 日志。年会现场条件复杂提前做一次全流程彩排比临时调样式要省心得多。4.3 五条高频踩坑记录坑位一照片区域显示黑块。现象是真机上部分员工头像加载不出来模拟器却一切正常。原因是图片路径是网络地址触发了小程序合法域名校验。解决方法是把照片全部改为本地相对路径或者将域名加入 downloadFile 合法域名白名单年会场景我强烈建议全部本地化现场网络不稳定时本地图片不会掉链子。坑位二连续抽奖几轮后页面越来越慢。现象是前几轮正常第五轮开始按钮点击后要等一秒才有反应。原因是帧函数里的 setTimeout 链没有在每次启动前清掉上一次的定时器还在跑新点击又叠加了一条。解决方法是 onTapStart 开头先 clearTimeout(this._timer)onUnload 也要清理一次确保页面销毁后没有遗留定时器。坑位三模拟器上每次定格都像瞬移。现象是减速阶段最后看到的还是上一个人定格瞬间突然跳成另一个人现场观众会质疑“黑幕”。原因是 finish 阶段直接把 curIdx 强制改成中奖者索引没有预留视觉过渡。解决方法是接受最后一个小跳跃或者把中奖者前一张照片设置成定格前一帧让观众看到“滑”过去的动作。坑位四名单里有重复 id。现象是明明 pool 显示还有 20 人实际抽两次就提示“没有可抽人员了”。原因是 participants.js 里 id 没有唯一性检查saveWinner 去重时把重复 id 的人一起过滤掉了。解决方法是生成名单时给每行生成唯一 id或者写入前做一次去重校验重复 id 直接报错。坑位五真机上减速过程明显卡顿。现象是快速滚动一到减速阶段画面就一帧一帧跳得厉害。原因是真机性能低于模拟器每帧 setData 一次照片索引页面频繁重绘。解决方法是快速阶段保持 60ms 间隔减速阶段间隔递增时不要叠加 setData 的负担curItem 的更新和 curIdx 合并到一次 setData 里减少渲染次数。5. 二次开发把固定名单升级成可导入、可配置、可测试的抽奖工具5.1 名单导入Excel 转 JS 数据模块年会名单通常是一张 Excel手写 participants.js 容易出错。常见做法是写一个 Node 脚本用 xlsx 把表格转成 JS 模块字段映射在脚本里一目了然。// scripts/gen-participants.js const XLSX require(xlsx); const rows XLSX.utils.sheet_to_json( XLSX.readFile(participants.xlsx).Sheets.Sheet1 ); const participants rows.map((row, i) ({ id: P${String(i 1).padStart(3, 0)}, name: String(row[姓名] || ).trim(), dept: String(row[部门] || 未知部门).trim(), photo: row[照片路径] ? String(row[照片路径]).trim() : })); require(fs).writeFileSync( data/participants.js, module.exports ${JSON.stringify(participants, null, 2)}; );注意没有照片路径时不要留空字符串以外的东西前端会在渲染时自动 fallback 到默认头像。脚本跑完以后重新编译页面上的剩余人数会自动变化。5.2 多轮奖品规则档位与每轮人数默认逻辑每轮抽一个人年会现场往往需要按奖品档位一次抽多人。我的做法是把轮次配置抽成一个数组抽奖函数按档位循环消耗剩余名单。const rounds [ { name: 三等奖, count: 10 }, { name: 二等奖, count: 5 }, { name: 一等奖, count: 1 } ]; function runRound(pool, excludedIds, round) { const winners pickWinners(pool, excludedIds, round.count); return { winners, excludedIds: excludedIds.concat(winners.map(w w.id)) }; }每轮抽完后把中奖者 id 累加到 excludedIds下一轮就不会再抽到这些人。如果某个档位剩余人数不足pickWinners 会返回剩余全部页面侧需要加一个 toast 提示“剩余人数不足”避免大屏上出现空名单。5.3 可测试性改造抽奖核心抽成纯函数整个项目里最值得抽出来的就是抽奖函数把它放到 utils/lottery-core.js并且保持纯函数不依赖 wx 和 Page这样就能用 Node 直接跑概率测试。function pickWinners(pool, excludedIds, count) { const remain pool.filter(item !excludedIds.includes(item.id)); const winners []; for (let i 0; i count remain.length; i) { const idx Math.floor(Math.random() * remain.length); winners.push(remain.splice(idx, 1)[0]); } return winners; }有了纯函数验证概率分布就很简单了构造一个 100 人的名单跑五万次抽奖统计每次第一个中奖者的 id 分布次数把最大值和最小值打出来。正常情况下五万次里每个 id 出现的次数应该在几百到一千之间差值不会太离谱。如果某一个 id 长期缺席那就要怀疑随机源的问题了。那次年会我改了奖品档位却忘了重置 storage第二轮直接提示没有可抽人员现场白屏了三秒。从那以后我每次改抽奖逻辑都强制走一遍 Node 概率验证再清空 storage 重跑一遍全流程确认剩余人数正确才上大屏。希望帮到你少走这种现场翻车的路。本文还有配套的精品资源点击获取