简介一份面向企业年会场景的抽奖小程序资源包含九宫格抽取幸运者和转盘抽奖两个页面无需数据库所有数据以静态方式配置可直接用于年会“抽人发红包”环节。规则设计完整先用九宫格抽出一名免红包幸运者再通过转盘随机发放100、200、300、500、1000元五档红包并自动限制每人累计红包不超过2000元。资源共74个文件主要由22张png图片、8个js脚本、5个css样式、4个Java源文件、8个jar依赖及相关jsp页面组成压缩包仅2.25MB代码结构清晰便于二次修改。目前已有940人学习下载适合需要快速搭建年会互动环节的活动策划者、前端学习者或Java Web入门者参考借鉴可在此基础上调整红包额度、抽奖规则或页面样式。1. 年会抽奖九宫格转盘小程序为什么它最容易被低估、也最容易当众翻车年会抽奖九宫格转盘小程序听起来只是个助兴的小功能真正接需求后你会发现它其实是整场年会里最不能出错的页面动画卡一下、结果跳一格、重复抽到同一个人都会在几百双眼睛的注视下被立刻放大。它同时要处理两种机制完全不同的交互——九宫格靠高亮格子沿固定路径快速轮巡来伪装成旋转转盘则是真实地把整个盘子转到指定角度停在指针位置。两者共用同一套名单和中奖记录却各自有不同的停止判定方式这就导致真正写起来时错误往往不是出在“不会画”而是出在“停不准”和“结果落错”。这篇笔记适合被临时叫去做年会抽奖的前端和小程序开发者也适合想在模拟项目里完整练一次动画控制链的人。我会直接给能抄的小程序原生代码、关键参数和现场避坑点不泛泛讲概念。你先有个结论这个小程序能不能用取决于停止逻辑是否和服务端结果严格一一对应而不是动画多炫。2. 九宫格与转盘的动画机制先搞清楚两种“旋转”到底差在哪2.1 九宫格不是真旋转是高亮索引沿固定路径轮巡九宫格抽奖的常见布局是外圈八个格子放奖品中间一个按钮放“开始”。看起来是格子们被依次点亮、像一圈灯带在转但底层其实没有任何格子发生位移或旋转。程序做的只有一件事改“当前高亮格子的下标”。对应到小程序里就是一个 data 字段 activeIndex 在变模板里那个格子因此带上高亮样式类。这里第一个设计点是格子轮巡的顺序。九宫格外圈在 3x3 矩阵里的下标是 0~8但你不能用 0,1,2,3,4,5,6,7 这样横扫过去。因为外圈真正的顺时针顺序是 0→1→2→5→8→7→6→3→0只有沿着这个环形路径走视觉上才是连贯转动。我用一个数组把这条路径固化下来之后所有动画都基于这个数组的位置序号而不是格子本身的下标。// 外圈8格的顺时针路径忽略正中间的下标4 const ringPath [0, 1, 2, 5, 8, 7, 6, 3];接下来是动画节奏。一次性匀速轮巡会显得很机械年会现场需要的是“先快中段保持高频最后几格明显减速”这样才有抽奖悬念。常见做法是每走一步用一个定时器延迟时间按当前步数查表或计算。我一般把整个过程分成三段前 1/3 每步 45ms中段 60ms最后 1/3 从 60ms 逐步放大到 240ms 左右。数字不用太精确真机上试一下手感关键是最后两三格要慢到观众能看清落点。至于九宫格结果怎么“落准”有个常见翻车点动画走到最后停在了目标格但视觉上看起来像多走了半格。原因是有些实现把停止逻辑写成了“随机步数”等步数耗尽后高亮在哪个格就停哪个格这本质上是随机到了格子。正确做法是先确定目标格子再反推当前路径位置到目标需要多少步把这个步数设计进总步数里。也就是说总步数 固定整数圈 到目标格的偏移量。// 从当前位置 startPos 走到目标位置 targetPos走两整圈后再停下 const totalSteps 16 ((targetPos - startPos ringPath.length) % ringPath.length);这里的 16 是两整圈因为路径只有 8 格。这样无论当前在哪一格最终必然踩中 targetPos且至少会让观众看到两圈完整轮巡。能把这个公式写对九宫格的停止精度问题就解决了一半。2.2 转盘是真实角度旋转停止位置由“目标角度”决定转盘和九宫格完全是两套思路。转盘里指针是固定不动的转动的是圆盘本体。样式上就是给圆盘视图绑定一个 transform 的 rotate 值每次抽奖把 rotate 变得足够大配合 transition 的缓动函数圆盘就会转起来并在指定角度停下。真正决定“停在哪个奖品”的不是动画过程而是旋转结束时的目标角度。这个目标角度由三部分组成const targetRotate currentRotate 360 * 3 targetSectorCenter;currentRotate 是圆盘当前已经转过的角度360 * 3 是固定转三整圈制造“转了很多圈”的视觉效果targetSectorCenter 是目标扇区的中心角。举个例子8 个奖品扇区每个占 45 度指针在正上方第一个扇区的中心角是 0 度第二个是 45 度第 n 个就是 n * 45 度。这里有个容易踩的细节目标角不能直接等于扇区边界。很多新手写的是 targetIndex * sectorAngle以为这样指针正好落在扇区开头实际指针恰好压在两个扇区的交界线上。停在那儿谁也无法分辨到底算哪个奖品。正确做法是取扇区中心角附近的一个随机偏移让指针停稳后明确落在扇区内部。那一圈转完了之后第二次抽奖怎么办有两种习惯一种是把圆盘先转回 0 度再开始下一轮另一种是继续在现有角度上累加。我建议用累加方式因为回零动作本身就是一段动画处理不好会显得很突兀。只要保证 rotateDeg 一直在增长transition 每次从当前值过渡到新值就不会出现“往回跳”的现象。还要注意 transition 的缓动函数。转盘要的是“启动时加速、接近目标时减速”的物理感我用 cubic-bezier(0.15, 0.95, 0.25, 1)相当于一个比较强的 ease-out但保留了一点前期的加速过程。时间设置在 3.5 到 4 秒之间太短会显得草率太长观众会不耐烦。这两个参数是手感上限的来源现场彩排时要专门调不要用默认的 ease。2.3 两种实现方式的对比与选型维度九宫格转盘视觉核心高亮格子轮巡圆盘整体旋转结果位置高亮停在某个格子指针指向某个扇区停止精度由步数与下标偏移决定由角度与浮点精度决定典型失控高亮跳格或停偏停到扇区边界实现成本低中调试重点路径顺序与减速段角度累加与缓动曲线九宫格适合预算低、奖品数量少、现场只要“一个幸运儿”的场景转盘适合奖品多、扇区多、希望观众看到物理旋转感的场景。如果你的年会要求两个都要那就把它们当成两个独立组件共用一套抽奖结果服务但动画逻辑完全隔离。我在实际项目里从不让它们互相调用彼此的方法只通过抽奖结果数据层通信这样改一个组件不会带崩另一个。3. 用原生小程序语法跑通最小可用版本代码、参数与运行顺序3.1 九宫格页面数据驱动高亮格子的完整实现先看模板结构。九宫格本质上是一个三乘三网格我用 grid 布局把 8 个奖品格和一个中间按钮排进去。关键是把 ringPath 里的路径位置映射到 CSS grid-area保证数据顺序和屏幕布局一一对应。!-- 九宫格抽奖面板 -- view classgrid-panel view wx:for{{ringPath}} wx:key*this classgrid-cell {{activeIndex index ? grid-active : }} stylegrid-area: {{gridAreaMap[index]}} text{{prizeNames[item]}}/text /view view classgrid-center bindtapstartDraw开始抽奖/view /view代码里的 activeIndex 是 ringPath 的位置序号不是格子下标。gridAreaMap 把位置序号映射成 CSS 网格区域比如第 0 个路径位置对应左上角第 3 个对应左中格。prizeNames 按格子下标存放奖品名。这样写的好处是动画只关心“路径走了几步”完全不关心格子在屏幕上位于哪里视觉排版和抽奖逻辑彻底解耦。Page({ data: { ringPath: [0, 1, 2, 5, 8, 7, 6, 3], gridAreaMap: { 0: 1 / 1, 1: 1 / 2, 2: 1 / 3, 3: 2 / 1, 5: 2 / 3, 6: 3 / 1, 7: 3 / 2, 8: 3 / 3 }, prizeNames: [特等奖, 一等奖, 二等奖, 三等奖, , 四等奖, 五等奖, 六等奖, 阳光普照], activeIndex: 0, drawing: false }, startDraw() { if (this.data.drawing) return; this.setData({ drawing: true }); // 先由奖池决定目标格子再反推动画步数 const targetGrid this.pickTargetGrid(); const targetPos this.data.ringPath.indexOf(targetGrid); const startPos this.data.activeIndex; const totalSteps 16 ((targetPos - startPos 8) % 8); this.runStep(startPos, 0, targetPos, totalSteps); }, runStep(currentPos, step, targetPos, totalSteps) { if (step totalSteps) { this.setData({ drawing: false }); this.saveWinner(this.data.prizeNames[this.data.ringPath[targetPos]]); return; } const nextPos (currentPos 1) % 8; this.setData({ activeIndex: nextPos }); const delay this.delayForStep(step, totalSteps); setTimeout(() { this.runStep(nextPos, step 1, targetPos, totalSteps); }, delay); }, delayForStep(step, totalSteps) { const third Math.floor(totalSteps / 3); if (step third) return 45; if (step third * 2) return 60; return 60 (step - third * 2) * 18; } });这段逻辑里有两个参数值得说。第一个是 delayForStep 的 45 和 60分别代表加速段和中段的单步间隔最后一个 18 是减速段每步递增的毫秒数。十八毫秒乘上后面十几步最后一步约三百毫秒视觉上会有明显的“卡顿感”正好制造悬念。第二个是总步数里的固定圈数 16也就是两整圈。如果你想开场更有仪式感改成 24 就是三圈时间整体长一点。这段代码还有一个隐藏细节startDraw 用 drawing 锁住了重复点击runStep 结束前不会解锁。如果用户连点开始按钮第二次点击会被 direct return。这是九宫格最重要的防线没有它一次抽奖可能同时跑两条动画计时器高亮就会乱跳。3.2 转盘页面旋转角度、缓动函数与结束回调转盘的模板更简单就三层外容器、指针、可旋转的圆盘。view classwheel-box view classwheel-pointer/view view classwheel-panel styletransform: rotate({{rotateDeg}}deg); transition: transform 4s cubic-bezier(0.15, 0.95, 0.25, 1); bindtransitionendonRotateEnd /view /viewwheel-panel 的背景建议用切好的扇形图或者 canvas 绘制不要用样式拼扇区。小程序里纯 CSS 渐变做扇区在某些机型上边界会有 1~2 像素锯齿现场投屏放大后非常明显。我是用一张 8 等分的圆形图片作为背景固定宽度高度然后只转这个图片容器。JS 侧的关键点是把目标角度计算和结果保存分离。动画开始前先通过奖池确定这次要命中哪个奖品再算出对应的目标角度。动画结束后用 pendingTarget 里存好的奖品去落结果而不是在动画结束时重新随机。Page({ data: { rotateDeg: 0, drawing: false, pendingTarget: null, sectorCount: 8 }, startWheel() { if (this.data.drawing) return; const targetIndex this.pickTargetIndex(); // 由奖池得出 0-7 const sectorAngle 360 / this.data.sectorCount; const currentDeg this.data.rotateDeg; const targetDeg currentDeg 360 * 3 targetIndex * sectorAngle; this.setData({ drawing: true, pendingTarget: targetIndex, rotateDeg: targetDeg }); }, onRotateEnd() { if (!this.data.drawing) return; const target this.data.pendingTarget; // 这里落库而不是再取随机数 this.saveWinner(this.data.prizeList[target]); this.setData({ drawing: false, pendingTarget: null }); } });这里有个小程序特有的大坑如果动画进行中用户切到后台或者页面被系统回收transitionend 可能永远不触发drawing 就一直锁住整个转盘“死”掉。我一般会在 startWheel 里加一个兜底计时器时间比 transition 长一点比如 4.5 秒到点后如果 drawing 还是 true也执行 onRotateEnd 的逻辑。兜底不会让结果出错因为获奖奖品在动画开始前就已经确定了回调只是把结果“取用”而已。角度累加这事也要专门说。第一次 rotateDeg 是 1245 度第二次再加 1170 度几轮下来会到几千甚至上万度。CSS transform 支持大角度没问题但我见过有的实现每次把角度取模回 360 度内再继续结果转盘每转一次就猛地回跳一圈完全没法看。不要取模就让数值一直涨。3.3 中奖记录落库本地缓存优先服务端同步兜底抽奖结果落库是“能不能在年会后交差”的关键。单机版时我用本地缓存存一条抽奖历史有服务端时本地缓存和服务端并行写入本地优先展示。// 抽奖结束后的统一保存入口 saveWinner(winnerInfo) { const history storage.get(lottery_history) || []; history.push({ memberId: winnerInfo.memberId, name: winnerInfo.name, prize: winnerInfo.prize, time: Date.now() }); storage.set(lottery_history, history); // 服务端写入失败不阻塞界面重试放到后台 post(/api/lottery/record, { list: history.slice(-1) }).catch(() { // 下次启动时补传即可 this.pendingSync history.slice(-1); }); }为什么本地优先因为年会现场大概率不会有人盯着服务端的日志但主持人会随时问“刚才中奖的人叫什么”。如果服务端挂了或者网络延迟中奖结果已经显示在大屏上不能因为接口失败回滚。本地缓存保证大屏数据和记录至少有一份服务端只是用来做后续名单汇总和防重复抽奖的。恢复逻辑也简单小程序重新打开从 storage 里把 lottery_history 读出来已中奖的 memberId 从候选池里排除就行。4. 随机与概率如何做到“看起来随机”又“不会把大奖发超”4.1 先定中奖对象和奖品再决定视觉停在哪很多开发第一次做抽奖会直接在动画结束回调里写 Math.floor(Math.random() * 8)随机一个格子或扇区作为结果。这是最容易翻车的写法因为奖品数量是有限的一等奖只有一台手机但随机数可能连续五次都落在那一格。正确的顺序是反过来的。抽奖动作发生在视觉动画之前。也就是说点击“开始”的瞬间程序已经通过奖池和人员名单算出了“谁中奖、中什么奖”动画只是把已经确定的结果演出来。转盘和九宫格本质上都是在“演”这个结果。// 奖池预排布在年会开始前就把每次可抽的奖品按随机顺序排好 function buildPrizeQueue(prizeConfig) { const queue []; prizeConfig.forEach(item { for (let i 0; i item.count; i) { queue.push(item.name); } }); // Fisher-Yates 洗牌避免直接用 sort(() Math.random()) 这种不均匀写法 for (let i queue.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); const tmp queue[i]; queue[i] queue[j]; queue[j] tmp; } return queue; }洗牌之后每次抽奖只需要从队尾 pop 一个奖品就能保证奖品数量不发超也不会出现在某次意外抽到已发完奖品的情况。人员名单同理抽中的人从中奖名单里剔除下一轮不再参与。这两件事做完视觉上停在哪个格子纯粹是“为结果找位置”。4.2 转盘扇区的“假概率”与真实配额的关系有人会问如果转盘上“谢谢参与”占了一半面积是不是中奖率就是 50%答案是视觉面积和真实概率可以不一致而且现场通常就不应该一致。年会不像游戏概率抽卡奖品数量是行政提前定死的。转盘上有 8 个扇区可能 6 个扇区都写满了奖品但奖项本身只有 10 份。真实概率由“当时奖池还剩多少份、候选池还剩多少人”决定跟扇区大小无关。扇区大小只是让画面好看。这个认知直接影响 pickTargetIndex 的实现。正确的 pickTargetIndex 应该先问奖池再返回一个“能放这个奖品的扇区下标”。如果你发现奖池里剩下的奖品 A 只有一个而转盘上有两个扇区都叫 A就随机选其中一个扇区而不是独立随机两次。function pickTargetIndex(prizeQueue, sectorPrizeMap) { // 从奖池拿到真实奖品 const prize prizeQueue.pop(); if (!prize) return null; // 找出转盘上所有代表该奖品的扇区随机选一个作为落点 const sectors []; sectorPrizeMap.forEach((name, index) { if (name prize) sectors.push(index); }); return sectors[Math.floor(Math.random() * sectors.length)]; }这样视觉随机性保留了但每个扇区的出现概率是均匀的真实配额由奖池控制。九宫格也是同一套逻辑只是把 sectorPrizeMap 换成格子下标和奖品名的映射。还有一个隐藏需求年会抽奖通常不是“保证每个人都有奖”而是“抽中的人从名单里移除”。所以候选池每次缩小的代价很高必须在抽奖前拿到最权威的未中奖名单。单机版用本地缓存维护联机版必须在接口层面做剔除前端拿到的候选池永远是服务端算好后的剩余名单。4.3 抽奖状态机与请求幂等双击、重试和异常恢复一个完整的抽奖组件至少要有三个状态空闲、抽奖中、已落定。空闲时能点开始抽奖中所有按钮不可点落定后展示结果并等待下一次开始。这个状态机要放到数据层而不是用局部变量因为页面刷新后状态要能恢复。const STATE { IDLE: idle, SPINNING: spinning, SETTLED: settled }; // 抽奖开始前申请一个请求 ID接口层用这个 ID 去重 const requestId ${Date.now()}_${Math.floor(Math.random() * 1e6)}; this.setData({ state: STATE.SPINNING, requestId }); post(/api/lottery/draw, { requestId, memberId: currentMember.memberId }).then(res { // 接口先返回动画还在走先把结果暂存 this.pendingWinner res.winner; this.pendingPrize res.prize; });requestId 是整个防重复机制的核心。前端即使被连点两次发出两个一模一样的请求服务端遇到相同 requestId 只处理一次。这个字段还有一个作用如果动画中途页面崩溃重新打开小程序后服务端能根据最后一条 requestId 判断刚才那次抽奖是否已经落库避免补发奖品。我在实际实现时会把“动画结束回调”和“结果保存”拆成两件事。动画结束只负责把 pendingWinner 展示到页面和落库绝对不在回调里再发请求。因为动画时长是固定的接口返回时间是不定的如果等动画结束再去请求现场可能因为网络延迟卡在“转完了但不出结果”的尴尬状态。5. 年会现场的四个典型翻车现场自查与避坑5.1 现象九宫格高亮跳到一半停住或者直接跳过目标格年会大屏上九宫格高亮在快速轮巡时突然停顿了一两秒然后猛地跳到最后一格。另一个变体是动画结束时高亮停在目标格的下一格主持人已经喊出结果了屏幕却又跳了一次。这种现象最常见的原因是 setTimeout 的可靠性被高估了。小程序里如果同时有日志上报、图片加载、接口回包在跑这些任务会排在 JS 执行队列里定时器回调会被延后。几个回调一起延迟高亮就表现出“卡住”和“跳格”。另一个原因是 setData 频率太高时小程序会做批量合并旧值覆盖新值。我的解法是把“基于步数”改成“基于时间”。每次动画开始时记录一个 startTime在定时器回调里用 (Date.now() - startTime) 除以当前步长算出此刻应该走到第几步。哪怕回调被延迟了 200ms计算出的也是当前正确位置不会越走越错。动画结束前再强制把高亮设置为 targetPos兜住所有累积误差。步进间隔不要低于 35ms人眼在这个速度下已经分辨不出卡顿低于这个值只会白白增加 setData 压力。5.2 现象转盘停在两个扇区交界处引发奖品归属争议转盘转动 4 秒后停在两个扇区中间颜色一边一半。这种结果观众不认主持人也尴尬。原因有两个层面初始目标角用的是扇区起始边界而不是中心浮点数累积误差让最终角度和理论值差了零点几度。解决方法是分两层。第一目标角改成扇区中心角附近的小范围随机偏移。比如扇区中心是 90 度就取 80 到 100 度之间的随机值保证直观落在该扇区内。第二setData 的 rotateDeg 要四舍五入到整数再存避免每次转完留下 89.9996 这种小数几轮叠加后偏差变大。如果你在真机上发现停的位置还是偏多半是背景图本身的扇区切割不对称和代码无关。这时候拿切图设计师的源文件检查每个扇区的起始角比调代码更有效。5.3 现象屏幕显示中奖者 A后台记录却是 B抽奖结束后大屏上清清楚楚写着 A 中了三等奖但导出的中奖名单里是 B。对比时间线发现A 和 B 在同一个时间戳被写入了记录。这种问题几乎都是因为“结果在动画结束后才去确定”。动画结束回调里再向接口要结果接口返回期间如果有两条记录并发写入最后落库的可能是另一个请求的返回值。正确做法是第 4 章说的三件事分离开始抽奖时先锁定目标动画期间接口结果存 pending动画结束只消费 pending 值。我还会在保存记录时把 requestId 也存进去作为唯一线索。之后核对名单时只看 requestId 对应的那条记录不认其它字段。5.4 现象双屏同显不同步解说对着旧名单报名字控制端用平板操作大屏投动画。控制端已经显示“张三中奖”大屏却还在转盘旋转中或者大屏先转完控制端还没出结果。两个设备上看到的名单不同现场直接乱套。原因是双屏各跑了一套状态机中间没有主从关系。我踩过一次坑之后不再让控制端和大屏各自独立抽奖。控制端只负责发“开始”指令并把抽奖结果一并带上大屏收到指令后只播放动画和展示结果自己不再做任何随机逻辑。这样可以保证两边的结果来自同一个数据源就算动画时长有细微偏差最终展示的名单是同一份。离线单机模式下做不到主从同步至少要在抽奖前固定一份“本次结果快照”控制端和大屏都从快照读取。6. 上线前五分钟验证清单与一个救过场的回滚习惯年会开场前把下面几项过一遍比再调十遍动画曲线有价值。第一连续快速点击开始按钮确认第三下开始前 drawing 锁还在生效。第二抽完一奖后刷新页面确认已中奖名单从候选池里消失了。第三转盘连续抽五轮检查每次停的位置都不在扇区边界。第四断开网络抽一次确认本地缓存记录仍然生成。第五用投屏模式走一遍完整流程注意大屏低温状态下是否比真机卡如果是把动画时长从 4 秒降到 3 秒。再分享一个救过我很多次的习惯每次点击“开始”前把当前候选池和奖池快照存一份到本地命名带时间戳。现场万一出现重复中奖或名单错乱我可以直接从快照恢复到一个干净状态而不是在现场一行行改数据。这事成本极低代码就几行但它是年会后半夜唯一的后悔药。说句实在话这类小程序的技术难度不在写动画而在把随机性、状态、异常恢复三者串起来。我吃过一次亏当年一个转盘组件在联调时一切正常现场连续抽到第三轮transitionend 因为屏幕切换没触发整个转盘锁死主持人只能干笑。从那以后凡是带动画的抽奖我都会加兜底计时器并且让结果在动画开始前就定死。这个习惯一直留到现在。希望这篇笔记能帮你把年会抽奖九宫格转盘小程序这个需求做得更稳真正上台时不掉链子。本文还有配套的精品资源点击获取