年会这种场合,抽奖环节是整个晚上最容易冷场、也最容易出事故的十分钟。我做过几次现场支持,见过最尴尬的一种情况是:主持人喊"开始",大屏幕上照片滚得飞快,喊"停"之后画面卡了两秒,然后弹出一个名字——底下立刻有人喊"是不是内定的"。这就是我今天要聊的东西:年会照片滚动抽奖小程序,一个用Canvas 动画做照片墙滚动、用随机抽取算法决定中奖人、跑在浏览器里、投到大屏上就能用的轻量工具。它解决的其实不是"抽奖"这件事本身,而是三件事:现场视觉要够炸、结果要经得起质疑、流程要扛得住断网和手抖。适合谁看?如果你正在被安排做年会技术支持、需要在一个晚上之内交付一个能用的大屏互动页面,或者单纯想搞清楚 Canvas 逐帧动画和随机抽样在真实场景里怎么配合,这篇内容基本能让你照着复现一遍。
1. 从需求到选型:年会大屏抽奖到底难在哪
1.1 现场的四个硬约束
做这类东西,最忌讳一上来就写代码。我一般的做法是先把现场约束列清楚,因为它们直接决定技术选型。第一个约束是时间:通常从"你负责搞个抽奖"到"年会开始"只有三到五天,其中还包括收集照片、核对名单的时间,真正写代码可能就两个晚上。第二个约束是运行环境不可控:可能是酒店宴会厅的投影,可能是临时租的 LED 屏,分辨率、色彩、刷新率全未知,唯一确定的是你会用一台笔记本连 HDMI 输出。第三个约束是没有后端:公司内网、酒店 Wi-Fi、手机热点,你不敢赌网络,所以程序必须能纯前端跑起来,双击 HTML 就能用。第四个约束是容错成本极高:抽错了、卡住了、抽到已经离职的人,台下几百双眼睛看着,没有第二次机会。
这四个约束叠加起来,结论其实很明确:单页应用、零依赖、纯前端、本地数据。不需要框架,不需要构建工具,一个 HTML 加几个 JS 文件,甚至可以直接用 U 盘拷到现场那台电脑上打开。我试过用打包工具做过一版,结果现场那台电脑的浏览器版本比较老,ES module 加载直接报错,从那以后我所有现场类项目都降级到最朴素的 script 标签引入,语法也尽量往保守写。这个决定看起来"不专业",但在现场它就是最专业的选择。
1.2 Canvas 与 DOM 方案的取舍对比
照片墙滚动有两种主流实现:DOM 元素 + CSS transform,或者 Canvas 逐帧绘制。很多人第一反应是 DOM,因为简单。但我实测下来,只要照片数量超过一百张、格子数超过二十个,DOM 方案就开始吃力了。
| 对比维度 | DOM + CSS Transform | Canvas 逐帧绘制 |
|---|---|---|
| 一百张以上照片的布局 | 需要一百个 img 节点,初始渲染慢 | 只用一个 canvas 节点,节点数恒定 |
| 滚动动画流畅度 | 依赖合成层,格子多时容易掉帧 | 完全受控,帧内绘制,稳定 |
| 单个格子的精细控制 | 需要大量 CSS 类和状态切换 | 直接改绘制参数,灵活 |
| 高亮、缩放、描边效果 | 靠 CSS,组合起来容易互相干扰 | 想怎么画怎么画 |
| 调试难度 | 打开开发者工具就能看 | 需要靠日志和截图 |
| 首屏代码量 | 少 | 多,需要自己写布局和命中逻辑 |
真正让我倒向 Canvas 的是**"停止对准"**这个需求。抽奖要在滚动结束时精确停在某个照片上,并且那一格要放大、加光晕、其他格变暗。DOM 方案里这需要计算 transform 的最终位移量、处理 transition 结束事件、再叠加一堆 class,稍微有点时序问题就会出现"停歪了"或者"高亮错格"。Canvas 里这就是一个数字:我知道目标格子的索引,我知道当前滚动偏移量,两者一减就是需要滚动的总距离,然后用缓动函数把速度收敛到零,最后一帧直接按索引画高亮就行,不会有任何歧义。
还有一个隐性好处:Canvas 天然不会有"图片节点被浏览器回收导致闪烁"的问题。DOM 方案在快速切换 img 的 src 时,某些浏览器会出现短暂白屏,滚动起来就是一片一片的闪。Canvas 里我提前把所有 Image 对象解码好放在数组里,绘制时只做 drawImage,画面永远是满的。
1.3 项目骨架与数据流
整个项目我拆成五个文件,职责清晰,改起来不用满世界找代码:
index.html:只有一个全屏 canvas 和几个隐藏的容器,负责 DOM 结构和样式重置data.js:员工名单和中奖记录的初始数据,格式是数组,现场要改名单直接改这个文件loader.js:图片预加载、失败兜底、缩略图生成stage.js:Canvas 布局、滚动动画、高亮绘制,所有绘制逻辑都在这draw.js:抽奖业务逻辑,负责"抽谁"和"什么时候告诉舞台停"
数据流是单向的:data.js提供名单 →loader.js把每个人的照片加载成可绘制的 Image 对象 →stage.js拿到"照片数组 + 目标索引"开始滚动 → 动画结束时回调draw.js→draw.js把中奖人写进记录、更新界面、落盘。这条链路里没有双向绑定,也没有状态管理库,出问题时我只要在各个节点的入口打一行日志,就能定位是哪一环断的。这种朴素结构在赶工场景下比任何架构都可靠。
2. 随机抽取的核心设计:先定结果,后演动画
2.1 为什么结果必须在动画开始前就确定
这是新手最容易踩的坑:理所当然地认为"等动画停了再随机抽一个"。这个顺序在现场是灾难性的。因为动画停止需要一个收敛过程,如果你在动画结束的那一帧才调用随机函数,那么从"速度归零"到"拿到结果并渲染"之间必然有几帧的空窗,表现出来就是画面先停住、定格一两秒、然后名字才弹出来。台下的人不傻,这种停顿会被解读成"后台在操作"。
我的做法是先定结果、后演动画。点击"开始"的瞬间,抽奖函数就已经把中奖人算出来了,存进一个变量。滚动动画的唯一任务是把画面滚到这个人对应的格子上。这样整条链路上没有"等待计算"的环节,动画结束就是结果揭晓,视觉上严丝合缝。这个思路在业内基本是共识,不是什么取巧——它的本质是把"随机"和"呈现"解耦,随机是纯计算,耗时微秒级,呈现是动画,耗时几秒,两者不该耦合在一起。
顺带说一个衍生好处:因为结果提前已知,我可以做"预高亮"。也就是在滚动最后半秒,让目标格子开始微微提亮,或者让附近的格子滚得慢一点,制造一种"快要停下来了"的悬念感。DOM 方案里这种细节很难做,Canvas 里就是改个透明度参数。
2.2 洗牌与不放回抽样
抽奖的第二类事故是重复中奖。有人已经中过三等奖,结果一等奖又抽到他,主持人只能尴尬地说"那重抽一个",现场气氛直接掉一半。解决办法是把"抽一个人"换成"抽一个还没中过奖的人",也就是不放回抽样。
最朴素的实现是每次随机取一个索引,如果这个人已经中过就再取一次。小规模名单下这样没问题,但当名单剩下最后几十个人的时候,重试次数会急剧上升,极端情况下可能连续几十次都撞到已中奖的人。正确做法是先用洗牌算法把整个名单打乱,然后从头依次取。这样每次取都是 O(1),而且天然不放回。
// Fisher-Yates 洗牌,原地打乱副本,保证每种排列概率均等 function shuffle(list, rng = Math.random) { const arr = list.slice(); for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; }这段代码看起来平平无奇,但有个细节必须强调:循环必须从后往前,交换对象必须是[0, i]区间内的元素。我见过不少网上流传的"随机排序"写法是arr.sort(() => Math.random() - 0.5),这个写法在数学上是错的,它产生不了均匀分布,而且不同浏览器引擎的排序实现不一样,结果会有系统性偏差。年会抽奖这种场合,虽然没人会去做统计检验,但万一被较真的同事问起来"你这个随机真的随机吗",你至少得能答得上来。
实际使用时我会在页面初始化时就把名单洗一遍,存成一个"抽奖队列",之后每次抽奖从队列头部取人。这样还有一个好处:整个晚上的抽取顺序在开场就固定了,中途刷新页面只要把队列和游标一起存进 localStorage,就能精确恢复,不会出现"刷新之后抽奖顺序变了"的情况。
2.3 权重化抽取:让奖项级别可控
有些年会的规则不是纯粹等概率。比如新人占比高的部门希望新人中奖概率高一点,或者特等奖只在入职满一年的员工里抽。这时候就需要权重抽取。我的做法是给每个人挂一个权重字段,抽奖时按权重累加区间取随机数。
// 按权重随机取一个元素,pool 形如 [{id, weight}, ...] function pickWeighted(pool, rng = Math.random) { const total = pool.reduce((sum, item) => sum + item.weight, 0); let point = rng() * total; for (const item of pool) { point -= item.weight; if (point < 0) return item; } return pool[pool.length - 1]; }这里的关键是权重必须是正数,且总和不等于零。我踩过一次坑:数据表里有人 weight 字段是空字符串,sum + ''直接把整个求和变成了字符串拼接,total变成一个巨大的字符串,rng() * total得到 NaN,最后取到的是数组最后一个元素。表现出来就是"每次都抽到最后一个人",非常诡异。所以现在我在数据入口统一做一次Number(x) || 1的强制转换,宁可默认值给 1,也不能让脏数据流进算法。
另外权重抽取和"不放回"要配合使用:抽中之后把这个人从池子里移除,重新计算 total。注意不要只在抽取时跳过,那样会导致概率失真——已经被移除的人不该继续占据权重空间。
2.4 可复现的随机源与争议兜底
Math.random()是不能设种子的,这意味着同样的名单、同样的操作,你没法复现出同样的结果。这在平时无所谓,但在出现争议时就很被动。比如有人坚持说"我看到屏幕上明明是我,怎么最后变成了别人",如果你能拿出一个种子值,重新跑一遍证明结果一致,这事就过去了。
我的方案是用一个可设种子的伪随机数生成器替换Math.random(),抽奖开始时生成一个随机种子并显示在角落(很小,不显眼),同时写进日志。这样正常流程没人注意它,一旦需要复核,输入种子就能完整重放。
// mulberry32:短小、分布均匀、支持 32 位种子的伪随机生成器 function createRng(seed) { let state = seed >>> 0; return function () { state = (state + 0x6d2b79f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }这个生成器不是密码学安全的,但对于年会抽奖完全够用,而且它有个实打实的优点:同一个种子必然产出同一串数字,跨浏览器、跨设备都一致。我用它替换了所有Math.random()的调用点,包括洗牌和权重抽取,整个流程就变成可复现的了。
注意:种子值不要提前公开在界面上让人围观,否则有人会提前算好结果。放在控制台的日志里,或者藏在页面的一个不可见元素里,需要时用开发者工具取。这是"可复核"和"防预知"之间的平衡。
3. Canvas 照片滚动动画的实现细节
3.1 图片预加载与内存控制
照片墙最容易翻车的地方不是动画,是加载。两百个人的照片,如果每人一张 3MB 的原图,那就是 600MB,浏览器不崩才怪。我一般要求提供方把照片统一裁成正方形缩略图,边长 256 到 320 像素,单张控制在 40KB 以内。这个尺寸在大屏上放到一格 200 像素左右的位置完全够看,而且两百张加起来不到 8MB,加载起来毫无压力。
如果拿到的是原图,就得在前端处理。可以用离屏 canvas 做一次缩放,但更省事的是让服务端或者提供方批量处理,前端只负责加载。我试过在前端用createImageBitmap配合resizeWidth参数做缩放,效果不错,兼容性也还行,但没必要给自己找事。
预加载的关键是控制并发。一次性发起两百个请求,浏览器会排队,某些服务器还会限流,表现就是加载进度卡在 60% 不动。我的做法是维护一个并发池,默认同时跑 6 个,一个完成再启一个。
function preloadImages(urls, concurrency = 6) { return new Promise((resolve) => { const results = new Array(urls.length).fill(null); let cursor = 0; let finished = 0; const launch = () => { if (cursor >= urls.length) return; const index = cursor++; const img = new Image(); img.decoding = 'async'; img.onload = async () => { try { await img.decode(); } catch (err) { /* 解码失败也不阻塞 */ } results[index] = img; if (++finished === urls.length) resolve(results); else launch(); }; img.onerror = () => { results[index] = null; // 加载失败留空位,绘制时画占位块 if (++finished === urls.length) resolve(results); else launch(); }; img.src = urls[index]; }; for (let i = 0; i < Math.min(concurrency, urls.length); i++) launch(); }); }img.decode()这一步值得单独说。图片的onload只代表数据下载完成,不代表浏览器已经把它解码成可以绘制的位图。如果直接 drawImage,第一次绘制时浏览器会同步解码,这一帧就可能卡几十毫秒,滚起来就是明显的顿挫。提前 decode 之后,后续绘制全是内存拷贝,非常快。这个细节在照片多的时候差异特别明显,我实测两百张图的情况下,加了 decode 之后首帧卡顿从接近 400 毫秒降到 20 毫秒以内。
内存方面还有一个隐藏问题:Image 对象在绘制前占据的是压缩数据,绘制后被解码成位图,内存会暴涨。一张 320×320 的图解码后是 320×320×4 字节,约 400KB,两百张就是 80MB 左右。这个量级现代机器扛得住,但如果有人给你五百张 512×512 的图,就是 500MB 级别,低配笔记本会开始疯狂换页。所以我一般在上线前会看一眼实际照片数量和尺寸,超过三百张就建议分组抽,比如按部门分三轮,每轮只加载对应部门的照片。
3.2 网格布局与滚动相位计算
照片墙的视觉结构我固定用网格:列数根据大屏宽高比算,16:9 的屏用 8 列 5 行,4:3 的屏用 7 列 5 行,总共 40 个格子。每格显示一张照片,格子之间留 12 像素空隙,整体居中。计算方式如下:
function computeLayout(width, height, photoCount) { const cols = width / height > 1.7 ? 8 : 7; const rows = 5; const gap = 12; const cellW = (width - gap * (cols + 1)) / cols; const cellH = (height - gap * (rows + 1)) / rows; return { cols, rows, gap, cellW, cellH, total: cols * rows }; }滚动我不用"整屏平移",而是用索引偏移 + 逐格绘制。维护一个浮点数scrollPos,表示"当前滚过了多少格"。绘制时第 i 个格子显示的是photos[(i + Math.floor(scrollPos)) % n]这张照片,同时整格按scrollPos的小数部分做垂直偏移。
这个模型有两个好处。第一,照片循环使用,不管名单里有 50 人还是 300 人,格子永远填满,不会出现空格。第二,滚动位置和"谁在哪个格子"是一一对应的,我可以精确算出"要让第 k 号照片停在正中间那一格,scrollPos应该是多少",这对后面的停止对准至关重要。
具体到绘制,每一帧我会遍历所有格子,算出这个格子当前应该显示的索引,然后 drawImage。40 个格子每帧 40 次绘制,加上高亮描边、遮罩、文字,总共一百多次绘制调用,现代浏览器在 60 帧下毫无压力。我实测过低配的办公笔记本,稳在 55 到 60 帧,足够顺滑。
3.3 用缓动函数做"急停"与对准
滚动分三个阶段:加速、匀速、减速对准。点"开始"后,scrollPos以每秒 20 到 30 格的速度增长,大约 0.3 秒内加速到满速,然后匀速滚动。点"停止"后,进入减速阶段,用缓动函数把速度平滑收敛到零。
减速的实现方式有两种。一种是每帧把速度乘一个衰减系数,比如speed *= 0.96,简单但停不准,最后还得做一次位置校正,容易出现"抖动一下才停稳"的观感。另一种是在停止瞬间就算好总距离,然后按时间做插值,我选的是这种。
// 停止对准:先算总距离,再用缓动函数插值 function beginStop(targetIndex) { const { total, cellH } = layout; const currentIndex = Math.floor(scrollPos); // 先滚满至少两整圈,保证有足够的减速时间 const baseLoops = total * 2; // 计算从当前位置到目标位置需要走的格数 let delta = (targetIndex - currentIndex) % total; if (delta < 0) delta += total; const cells = baseLoops + delta; stopStart = performance.now(); stopFrom = scrollPos; stopDistance = cells; stopDuration = 2200; // 减速时长,2.2 秒 } // 缓动函数:前段快、后段慢,最后完全停住 function easeOutQuint(t) { return 1 - Math.pow(1 - t, 5); }每一帧在减速阶段这样算:
const elapsed = performance.now() - stopStart; const progress = Math.min(elapsed / stopDuration, 1); scrollPos = stopFrom + stopDistance * easeOutQuint(progress);easeOutQuint的效果是开始几乎不减速,最后半秒快速收住,观感上很像老虎机的"啪"一下停住,比线性减速有张力得多。我对比过easeOutCubic和easeOutQuint,前者停得太温柔,后者更有"砸下来"的感觉,现场反应明显更好。
为什么要滚满两整圈?因为如果目标就在当前位置附近,减速距离可能只有半格,那动画会在一百毫秒内结束,视觉上像是"点一下就停了",很廉价。两整圈大约 80 格,配合 2.2 秒的时长,速度变化有充分的表现空间。这个圈数也不要太多,超过三圈观众会失去耐心,觉得"怎么还在滚"。
3.4 高亮、放大与视觉反馈
停止之后的高亮是整场最有仪式感的部分,值得花时间打磨。我的做法是分三层:
第一层是格子放大。目标格子从原本的尺寸放大到 1.12 倍,以格子中心为原点,用ctx.translate和ctx.scale完成。放大过程用 300 毫秒的弹性缓动,会有一个轻微的"弹一下"的效果。
第二层是描边与光晕。用ctx.shadowBlur画一圈暖色光晕,再叠一圈 3 像素的高亮描边。这里有个坑:shadowBlur在部分集成显卡上开销不小,如果对整个画面用会掉帧,但因为只对一个格子用,影响可以忽略。
第三层是背景压暗。在画完所有格子之后,铺一层半透明黑色遮罩,然后用globalCompositeOperation = 'destination-out'在目标位置挖一个圆角矩形出来。这样视觉焦点会强烈集中在目标上,台下的人一眼就能看到是谁。
function drawFocusMask(ctx, rect) { ctx.save(); ctx.fillStyle = 'rgba(0, 0, 0, 0.55)'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); roundRect(ctx, rect.x - 8, rect.y - 8, rect.w + 16, rect.h + 16, 12); ctx.fill(); ctx.globalCompositeOperation = 'source-over'; // 再画一圈渐变描边,避免挖洞边缘太生硬 ctx.strokeStyle = 'rgba(255, 210, 120, 0.9)'; ctx.lineWidth = 3; ctx.beginPath(); roundRect(ctx, rect.x - 8, rect.y - 8, rect.w + 16, rect.h + 16, 12); ctx.stroke(); ctx.restore(); }这套效果叠起来,视觉冲击力足够,但代码量很小,也没什么性能负担。我一般还会加一个"名字条",在目标格子下方用大字号显示姓名(字号根据画布宽度算,一般是width / 24),因为照片墙上的脸可能拍得很糊,光靠脸认人容易认错,名字是最稳妥的识别方式。
3.5 高清屏与帧率稳定
Canvas 在高分屏上默认是糊的,因为 CSS 尺寸和像素尺寸不一致。标准的处理方式是让 canvas 的width和height属性等于 CSS 尺寸乘以devicePixelRatio,然后用ctx.scale(dpr, dpr)把坐标系换算回来。
function resizeCanvas() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const cssW = window.innerWidth; const cssH = window.innerHeight; canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); layout = computeLayout(cssW, cssH, photos.length); render(); }dpr要封顶在 2,不要用真实的 3 或 4。因为 4K 屏在某些系统上devicePixelRatio可能是 3,那 canvas 就会变成 5760×3240 的像素缓冲区,每帧填充率暴涨,帧率直接掉一半。封顶到 2 之后,4K 屏看起来已经很清晰了,性能也稳得住。
帧率方面,我只用requestAnimationFrame,绝不用setInterval。所有动画进度都用performance.now()的时间差计算,而不是"每帧加固定值"。这样即使某一帧因为系统卡顿延误了 50 毫秒,下一帧会自动把这个时间差补回来,动画总时长不变,只是中间少了一帧。如果按帧累加,卡顿就会表现为"动画整体变慢",那才是真正难看的问题。
提示:画布尺寸变化(比如从笔记本屏幕切到投影仪)会触发布局重算,此时必须重置动画状态。我踩过坑:切换显示器后画面错位,原因是
layout更新了但scrollPos还按旧格高计算,结果全部偏移。现在我在 resize 里强制把动画状态归零。
4. 现场落地:交互、容错与数据安全
4.1 键盘与遥控器交互
大屏操作最好不要用鼠标。原因很现实:现场你站在台下、手里可能还拿着话筒,鼠标线不够长,或者压根就没有放鼠标的台子。键盘是最可靠的输入设备,插上就能用,几十块钱的无线小键盘更是标配。
我的键位设计很简单:空格键开始滚动,再按一次进入停止减速,减速结束后自动锁定结果;回车键确认并进入下一位;Esc回到待机状态。这套键位配合一个无线小键盘,站在台下也能操作,不会挡到投影。
实现上要注意两件事。第一,必须阻止空格键的默认滚动行为,否则按一下页面往下翻,canvas 直接滚出视野,现场就是一片白。第二,必须有状态锁。滚动中按空格进入停止,停止动画进行中再按空格要忽略,否则会重复触发停止逻辑,scrollPos被重置,画面会诡异地跳一下。
let phase = 'idle'; // idle | rolling | stopping | revealed window.addEventListener('keydown', (e) => { if (e.code === 'Space') { e.preventDefault(); if (phase === 'idle') startRoll(); else if (phase === 'rolling') startStop(); } else if (e.code === 'Enter' && phase === 'revealed') { confirmAndNext(); } else if (e.code === 'Escape') { resetToIdle(); } });有些年会用翻页笔,翻页笔本质上就是键盘,映射的是 PageUp / PageDown 或者方向键。如果现场确认用翻页笔,就多监听几个键码,别只绑空格。这个细节我是在一次现场临时换设备时学到的,当时只有翻页笔,我赶紧掏出笔记本改代码,那几分钟手心全是汗。
4.2 音效与自动播放限制
音效对现场气氛的加成很大,滚动的"哒哒哒"、停住的"叮"、中奖的欢呼声,能让整个环节的节奏感强很多。但浏览器有自动播放策略:在用户产生交互之前,AudioContext处于 suspended 状态,你调play()是没有声音的。
处理办法是在第一次按键时主动resume(),并且这个动作要放在用户手势的回调里,不能延迟到 setTimeout 里。
let audioCtx = null; function ensureAudio() { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtx.state === 'suspended') { audioCtx.resume(); } return audioCtx; }滚动音效我不用音频文件,而是用OscillatorNode现合成一个很短的方波脉冲,每滚过一格触发一次。这样做的好处是不用加载资源,也不会有音效文件加载失败的尴尬。但要注意别每帧都响,40 个格子同时响会变成噪音,正确做法是只在scrollPos的整数部分发生变化时响一次,也就是每滚过一格响一下。速度慢的时候间隔大,速度快的时候连成一片,听感上很像老虎机。
注意:音效音量一定要能现场调。我把音量做成了隐藏快捷键(比如按住 Shift 加方向键),彩排时按现场扩音情况定一个值,比在代码里改常量靠谱。
4.3 名单导入与脏数据处理
名单的来源通常是 HR 给的表格,导出成 CSV 或者直接复制粘贴。这里面的脏数据种类之多,超出你的想象:姓名前后带空格、姓名里有全角空格、有人用英文名有人用中文名、部门字段是合并单元格、照片文件名和姓名对不上、有人照片是横版导致裁切后脸被切掉一半。
我的处理策略是入口统一清洗、问题项显式暴露。清洗规则包括:姓名trim()后去掉所有空白字符、空姓名直接丢弃并计入错误列表、照片路径统一转成小写匹配、找不到照片的人用占位图替代并在控制台打印名单。最关键的是页面上要有一个隐藏的"数据自检"入口,按某个组合键能弹出一个列表,显示"共导入 186 人、照片缺失 3 人、姓名重复 1 人"。彩排时我一定先看一眼这个,把问题在开场前解决掉。
照片文件名和姓名对不上是很常见的问题。我的做法是不依赖文件名,而是在数据表里明确写一列photo字段存路径,加载时按这个字段来。如果这个字段为空,就回退到"按姓名匹配文件名",再不匹配就用占位图。这样无论提供方怎么命名,都不会出现张冠李戴——毕竟把 A 的照片配给 B,然后 B 中奖了,大屏幕上放着别人的脸,这种事故比抽错人还尴尬。
4.4 断点恢复与名单导出
现场最怕的不是程序崩,是程序崩了之后无法恢复。我的做法是每一次抽奖结果落盘都做双备份:写 localStorage,同时更新页面内存里的记录数组;并且在每次确认中奖后,自动在页面上生成一个可下载的 JSON 快照。
function persistResult(record) { const snapshot = { seed: currentSeed, winners: winnerList, updatedAt: new Date().toISOString(), }; localStorage.setItem('annual-draw-state', JSON.stringify(snapshot)); // 同时挂到一个全局变量上,方便人工复制 window.__DRAW_SNAPSHOT__ = snapshot; }localStorage 的容量限制大约是 5MB,存几百条记录完全够用。但要注意它绑定的是域名和浏览器,如果你现场换了一台电脑,或者用无痕模式打开,记录就没了。所以我坚持每次抽完一个奖项就手动导出一次 JSON 文件,U 盘拷走,这是最原始也最可靠的备份。
还有一点容易被忽略:刷新页面后的恢复逻辑必须实测。我见过太多"存了但没读"的情况。恢复时要做到三件事:把已中奖名单重新标记、把抽奖队列的游标推到正确位置、把界面上的中奖列表重新渲染出来。这三件事少一件,刷新之后的状态就是错的,可能重复抽到已经中奖的人。
5. 常见问题速查与排查实录
5.1 问题速查表
下面这些是我在不同场次里真实遇到过的问题,整理成表格,遇到时可以直接对号入座。
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 照片糊成一片 | canvas 像素尺寸未按 dpr 放大 | 检查 canvas.width 与 clientWidth 是否相等 | 按 dpr 设置 width/height 并 scale |
| 滚动掉帧、卡顿 | 图片未提前 decode,每帧同步解码 | Performance 面板看是否有长任务 | 预加载时调用 img.decode() |
| 加载进度卡在 60% | 并发请求过多被限流 | Network 面板看 pending 数量 | 并发池限制到 6 |
| 每次抽到的都是最后一个人 | 权重字段为字符串导致求和异常 | 打印 total 看类型 | 数据入口做 Number() 强制转换 |
| 抽到重复中奖的人 | 未做不放回处理 | 检查队列是否移除已中奖者 | 洗牌后按游标顺序取 |
| 停止位置总是差一格 | 位移计算用了向上取整 | 打印 currentIndex 和 target | 改用向下取整并处理负数取模 |
| 按空格页面往下滚 | 未阻止默认行为 | 观察页面是否位移 | keydown 里 preventDefault |
| 音效没声音 | AudioContext 处于 suspended | 控制台打印 audioCtx.state | 首次用户交互时 resume |
| 切换到投影仪后画面错位 | resize 未重置滚动状态 | 改窗口大小观察 | resize 时重置布局与动画 |
| 刷新后中奖记录丢失 | 只写内存未落盘 | 查看 localStorage | 每次确认后立即持久化 |
| 照片显示成别人的脸 | 按文件名匹配导致错配 | 对比数据表 photo 字段 | 显式指定路径,不依赖文件名 |
| 高亮光晕导致帧率骤降 | shadowBlur 作用于大面积区域 | 注释掉光晕看帧率 | 只对单个格子使用,或改为预渲染贴图 |
表格里最值得展开的是"停止位置差一格"。这个问题的根源在取模运算。JavaScript 的%对负数返回负数,比如-3 % 40得到-3而不是37。如果你的目标索引小于当前索引,targetIndex - currentIndex就是负数,直接取模会得到一个负的偏移量,scrollPos往回滚,视觉上就是"停在了目标的前一格"或者"往回跳了一下"。正确写法是((target - current) % total + total) % total,先取模再加一圈再取模,保证结果落在[0, total)区间内。这个 bug 我第一次写的时候踩了整整一晚上,因为它在某些索引组合下是正确的,只在特定情况下出错,非常隐蔽。
5.2 彩排清单与独家避坑
代码写完只是完成了一半,另一半是现场。我现在固定会做一次完整彩排,按下面这份清单走一遍,任何一项不通过就不算完。
- 用现场那台电脑跑一遍,不要用自己开发用的机器。现场电脑的浏览器版本、显卡驱动、显示器分辨率都可能和你的不一样。
- 提前打开,不要现场开。浏览器首次渲染大页面会有明显延迟,提前十分钟打开、加载完、停在待机页。
- 把浏览器缩放调回 100%。我遇到过有人为了看清文字把页面缩放到 125%,结果 canvas 尺寸计算全乱,格子溢出屏幕。
- 关掉所有通知。系统弹窗、聊天软件消息、下载完成提示,任何一个弹出来遮挡大屏都是事故。
- 插上电、关掉休眠。这个不用解释,但每年都有人栽在这上面。
- 准备一份离线备用方案。我的做法是把整个项目文件夹拷在 U 盘里,同时在笔记本桌面放一份,万一主文件损坏,直接切换。
- 彩排时完整走一遍全流程:从待机到抽完所有奖项,包括刷新页面后的恢复,包括一次性抽多个人的情况。
- 确认奖项与人数对应关系。三等奖 10 人是一次抽 10 个,还是一次抽 1 个抽 10 轮?这两种交互的实现完全不同,必须提前和主持人对齐。
关于"一次抽多人",我补充一点实操经验。如果一次要抽 10 个人,不要让 10 个格子同时高亮,视觉上会非常混乱。我的做法是依次揭晓:滚动停止后,先高亮第一个,停顿 400 毫秒,再高亮第二个,以此类推,同时配上递进的音效。整个过程大约 4 秒,节奏感很好,台下会跟着一个一个数,气氛比"唰一下全出来"强得多。实现上就是一个定时器队列,每 400 毫秒往highlightList里推一个索引,绘制时遍历这个列表逐个画高亮。
最后说一个我很在意的细节:待机页面不能是空的。开场前大屏上如果是一片黑,或者只有一行小字,那整个场地的气氛就散了。我会做一个待机态:所有照片缓慢地、无规律地微微浮动,中间显示"XX 年会抽奖"的大字,底部滚动显示已中奖名单。这个待机动画的开销很小(每帧只改每格几像素的偏移),但视觉效果差别很大,一进场就能感觉到"这个环节是准备过的"。
我个人的体会是,这类现场工具的技术难度其实不高,Canvas 画格子、随机数抽样、缓动函数,都是很基础的东西。真正决定成败的是那些不上台面的细节:键盘别让页面滚动、刷新别丢数据、照片别配错人、切换屏幕别错位。这些东西写不进需求文档,只能在一次次现场里攒出来。做完第一次之后,我建议你把这套骨架留着,明年换个配色、换个字体、换个音效,半小时就能改出一版新的,剩下的时间全用来对名单和彩排,那才是真正能让你在年会上安心坐下吃饭的部分。