☰
年会照片墙滚动抽奖:Canvas动画、内存控制与随机抽取
2026/10/28 15:11:23 网站建设 项目流程

每年一到这个时候,总有人被拉去给年会写抽奖程序。需求方给的描述通常就一句话:“大屏上照片滚起来,喊停就停,停下来那张就是中奖的。”听着简单,真到现场你就知道,照片墙滚动抽奖这个小程序的坑,八成不在抽奖逻辑上,而在 Canvas 动画的渲染细节、图片加载的内存控制,以及“停下来那一刻必须和中奖结果完全对齐”这三件事上。我做过几场类似的大屏互动,最惨的一次是彩排好好的,正式开场两分钟后画面开始掉帧,主持人喊停,卡片还在滑,最后停在了半格的位置,全场哄笑。这篇就把这套 Canvas 动画加随机抽取的完整实现捋一遍,从技术选型、渲染原理、随机算法,到现场排查清单,给到能直接抄的程度。适合正在赶年会需求的开发者、想做互动小游戏的前端,以及任何要在大屏上跑 Canvas 动画的人参考,代码不依赖框架,原生 JS 就能跑。

1. 一场年会抽奖背后的技术选型与整体设计

1.1 需求拆解:照片墙抽奖真正难在哪

先把需求拆干净。看起来是“滚动+抽奖”,实际上有五个独立的技术诉求:一是照片数量不固定,从几十张到上千张都有可能,渲染方案必须能吃下这个数量级;二是滚动要“顺”,大屏上一卡顿,观感立刻掉档;三是停止位置必须和抽奖结果严丝合缝地对上,不能出现“停在 A 但宣布 B”的尴尬;四是抽过的人不能重复中奖,多轮之间的状态要串起来;五是要有兜底,网络断了、图片挂了、浏览器崩了,得能现场救回来。

这五条里,第三条是最容易被低估的。很多人第一版写出来是“动画先随机滚,滚完停下来,再根据停下来的位置查这张照片是谁”,逻辑上说得通,但实际会有两个问题:一是停止位置受缓动曲线和帧率影响,最后一帧的落点常常差几个像素,视觉上卡片没有正对高亮框;二是如果现场有人较真,你没法解释“为什么停在这张”。所以我后来的做法全部改成“先定结果,再倒推动画”,结果由随机算法在点击开始的那一刻就确定下来,动画只是把这个结果表演出来。这个思路转换,是整套方案里最关键的一次取舍。

还有一个隐性需求:照片要有“人味”。纯色块或者纯 ID 滚动,观众没有代入感,必须是真的照片,最好带姓名或者部门标签。这就把图片加载和内存控制的问题引进来了,后面会专门讲。

1.2 Canvas 与 DOM 的取舍

滚动列表的实现,最直观的是 DOM:一堆<div>或者<img>,外面套一个overflow: hidden的容器,改transform: translateX让它动。这套方案在几十张图的时候完全够用,写起来快,样式好调,圆角、阴影、描边全交给 CSS。

但它有两个天花板。第一是数量。超过两三百个 DOM 节点同时参与 transform 动画,浏览器的主线程和合成线程压力会上来,尤其是节点里还塞了图片,每帧的图层合成开销不小。第二是“无缝循环”的实现成本。DOM 方案做无限循环,常见做法是把列表复制一份首尾相接,滚动到边界时瞬间把偏移量归零,这个“瞬间归零”如果时机没卡准,会出现肉眼可见的跳帧。

Canvas 的优势正好在这两点上。它是命令式绘制,一帧画多少个元素只取决于你写了几次drawImage,没有 DOM 节点开销,也没有样式计算和布局回流;无缝循环更是天然合适,因为绘制坐标是你自己算的,取模就行,不用复制节点。代价是所有视觉效果都得手写:圆角要自己裁,阴影要自己画,文字要自己fillText,高亮框要自己描。调试也没 DOM 那么直观,console只能打状态,看不到“元素在哪”。

我的建议很明确:照片总数超过 100 张、或者需要长时间连续滚动、或者要做无缝循环,就上 Canvas;如果只是二三十个人的小部门,抽完就散场,DOM 完全够用,没必要为了技术而技术。这个判断标准我在几个项目里都用,没失手过。

1.3 三层结构:数据、渲染、调度

定了 Canvas,接下来是结构。我习惯把它切成三层,层与层之间只通过明确的数据接口通信,这样任何一层出问题都能单独替换。

数据层负责三件事:维护全员名单(id、姓名、部门、照片地址、是否已中奖)、维护洗牌后的抽奖池、记录每一轮的中奖结果。这一层是纯数据,不碰 DOM 也不碰 Canvas,方便写单元测试,也方便现场出问题时直接在控制台手动干预。

渲染层只负责“给我一个偏移量,我把当前应该看到的画面画出来”。它不知道什么是抽奖,也不知道谁中奖了,输入是offset、list、highlightIndex这些,输出是画布上的像素。这样设计的好处是,滚动阶段和停止阶段用的是同一套绘制代码,只是偏移量来源不同,视觉上不会有割裂感。

调度层是中间的胶水,管状态机(idle / spinning / decelerating / result)、管requestAnimationFrame循环、管缓动曲线、管什么时候该通知数据层“这一轮结束了”。它也是唯一知道“动画进行到哪一步”的地方。

这个分法听起来有点重,但代码量其实不大,一个文件三四百行就能装下。它的价值在排查的时候体现得特别明显:画面卡,你只需要怀疑渲染层;结果不对,你只需要看数据层;停止位置漂移,那一定是调度层的落点计算出了问题。分层不是为了好看,是为了出问题时能快速定位。

2. Canvas 动画的渲染细节与实操要点

2.1 高清屏与尺寸适配:从 devicePixelRatio 说起

Canvas 最经典的新手坑就是模糊。你写canvas.width = 800,在 2 倍屏上它就是糊的,因为 canvas 的width/height属性是像素缓冲区尺寸,而 CSS 的width/height是显示尺寸,两者不是一回事。缓冲区小了,浏览器就得拉伸,拉伸就模糊。

正确做法是:缓冲区尺寸 = CSS 尺寸 ×devicePixelRatio,然后用ctx.scale(dpr, dpr)把坐标系缩回逻辑像素,之后所有绘制代码都按逻辑像素写,不用关心屏幕倍率。

function resizeCanvas(canvas, cssWidth, cssHeight) { const dpr = Math.min(window.devicePixelRatio || 1, 2); // 上限 2,别盲目吃 3 canvas.width = Math.round(cssWidth * dpr); canvas.height = Math.round(cssHeight * dpr); canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 注意用 setTransform 而不是 scale return ctx; }

这里有两个细节值得说。一是dpr我封了上限 2。三倍屏的缓冲区像素数是 9 倍,绘制开销和显存占用都会明显上升,而大屏投屏场景下肉眼看不出 2 倍和 3 倍的区别,封顶是纯收益。二是一定要用setTransform而不是scale。因为scale是累乘的,你在 resize 里反复调用几次,坐标系就被缩得不成样子了,画出来的东西一次比一次小。这个 bug 我见过不止一次,排查的时候盯着代码看半天看不出来,因为单看每一行都对。

另外,大屏场景通常是固定的 16:9 或 4:3 投影,实际有效分辨率经常是 1920×1080 或者 3840×2160。我的做法是:按设计稿 1920×1080 写死所有坐标,运行时算一个整体缩放比scale = Math.min(w / 1920, h / 1080),然后ctx.setTransform(dpr * scale, 0, 0, dpr * scale, offsetX * dpr, offsetY * dpr),把整个设计稿等比映射到屏幕中央。这样你在设计稿里量出来的每一个像素都是可以直接写进代码的,不用到处算百分比。

2.2 照片卡的等比例裁切与圆角处理

照片卡是画面的基本单元。年会上来的照片尺寸五花八门,横的竖的方的都有,如果直接drawImage(img, x, y, w, h),人会被压扁,观感极差。要用“cover”的方式裁切:按长边撑满,短边溢出部分裁掉,居中显示。

function drawCoverImage(ctx, img, x, y, w, h) { const scale = Math.max(w / img.width, h / img.height); const dw = img.width * scale; const dh = img.height * scale; ctx.drawImage(img, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh); }

圆角就是先beginPath+ 路径 +clip,再画图,最后restore。现代浏览器有roundRect,没有的话用arcTo兜底,逻辑一样。

function drawRoundCard(ctx, img, x, y, w, h, r) { ctx.save(); ctx.beginPath(); if (typeof ctx.roundRect === 'function') { ctx.roundRect(x, y, w, h, r); } else { ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } ctx.clip(); drawCoverImage(ctx, img, x, y, w, h); ctx.restore(); }

注意:clip()是有代价的,它会打断 GPU 的批量绘制。如果一帧里要对 50 张卡做 clip,性能会明显下降。解决办法在 2.3 节讲,用离屏缓存把“裁切+圆角+文字+描边”这一整套预先烘焙成一张图,主循环里只做一次drawImage。

还有一个视觉细节:中奖卡片的放大效果。不要在中奖瞬间去改卡片尺寸重新绘制,那样会触发一整轮完整的绘制流程,容易掉帧。更好的做法是给每张卡维护一个scale值,在主循环里用它去缩放绘制参数,然后用一个短小的缓动(0.3 秒左右)把 scale 从 1 推到 1.15。因为是逐帧插值,视觉上是连续的放大,成本只是每次多算两个乘法。

2.3 图片预加载与内存控制

这一节是我认为整个项目里最容易被低估的部分。很多人写抽奖,直接new Image()然后onload里往数组一塞,跑起来也没事,直到照片数量上来,页面白屏、标签页崩溃。

先算一笔账。一张 4000×3000 的手机照片,解码后在内存里占的字节数是4000 × 3000 × 4 = 48,000,000,大约45.8MB。这是解码后的位图,跟文件大小(可能只有 3MB)完全是两码事。200 张这样的照片同时驻留内存,就是 9GB 量级,任何浏览器都扛不住。

所以图片进来第一件事是降采样,不是先加载再缩,而是尽量在加载阶段就把尺寸压下来。有三种做法,按推荐程度排:

方案做法优点局限
服务端缩略图上传时生成 300×300 的缩略图,前端只加载这个最彻底,内存和带宽都省需要后端配合
Canvas 离屏降采样原图加载后画到小尺寸离屏 canvas,再转成新图纯前端可控原图仍会短暂驻留内存
createImageBitmap用resizeWidth/resizeHeight参数直接解码成小图不用经过中间 Canvas,内存友好兼容性要看目标浏览器

如果后端能配合,一定要走第一条路。年会照片墙展示的卡片尺寸通常不超过 300×300 逻辑像素,在 2 倍屏上也就 600×600,加载原图纯属浪费。实测下来,600×600 的缩略图解码后约 1.4MB,200 张一共 280MB,虽然还是不小,但已经在可控范围内了。如果再压到 300×300,200 张总共约 70MB,非常稳。

预加载本身要写得健壮。Promise.all里如果有任何一个Image既没触发onload也没触发onerror(网络断了、图片地址 404 但没返回),整个Promise.all会永远挂着,你的加载进度条就卡死了。

function preloadImages(urls, onProgress, timeout = 8000) { let done = 0; return Promise.all(urls.map(url => new Promise(resolve => { const img = new Image(); img.decoding = 'async'; const finish = (ok) => { done++; onProgress && onProgress(done, urls.length); resolve(ok ? { ok: true, img, url } : { ok: false, url }); }; const timer = setTimeout(() => finish(false), timeout); img.onload = () => { clearTimeout(timer); finish(true); }; img.onerror = () => { clearTimeout(timer); finish(false); }; img.src = url; }))); }

Promise.all之后,把ok: false的条目用一张本地生成的占位图(画个灰色圆角矩形加姓名首字)顶上去,保证卡片数量不缺口。年会现场最怕的就是“少了一个人”,哪怕照片挂了,名字也得在。

2.4 requestAnimationFrame 的循环与节流

动画循环用requestAnimationFrame,这是常识。但有几个细节决定了它跑得稳不稳。

第一,不要依赖固定步长。很多人写offset += 5,在 60Hz 屏幕上每秒滚 300 像素,到了 120Hz 屏幕上就是 600 像素,速度翻倍。必须用时间差做归一化:

let last = 0; function loop(now) { if (!last) last = now; const dt = Math.min(now - last, 50); // 封顶 50ms,防止切回标签页时瞬移 last = now; update(dt / 16.667); // 1 表示标准 60fps 下的一帧 render(); rafId = requestAnimationFrame(loop); }

dt封顶这一步很关键。浏览器标签页切到后台时rAF会暂停,切回来时dt可能是几万毫秒,不封顶的话偏移量会瞬间跳出去几屏,画面直接乱掉。这个坑我在一次彩排里踩过,切了个窗口回来,抽奖池飞出去了。

第二,渲染层做视口裁剪。一帧里不需要把全部照片都画一遍,只画落在可视区域内的那几张。假设卡片宽 260、间距 24,一张卡占 284 像素,1920 宽的大屏一屏也就显示 7 张左右,加上首尾各留一张做缓冲,一帧画 9 张足够了。哪怕总共有 1000 张照片,绘制成本也是常数级的。

第三,避开发动机级别的 API。ctx.shadowBlur、filter、频繁的createPattern,这些都是逐像素操作,在大屏高分辨率下非常吃性能。中奖卡片发光的效果,不要用shadowBlur实时画,提前生成一张带光晕的图片,主循环里直接drawImage上去,性能差一个数量级。

3. 随机抽取算法怎么才算"经得起质疑"

3.1 Math.random 的脾气与常见误用

Math.random()返回[0, 1)的伪随机数,用它取索引最常见的写法是Math.floor(Math.random() * n)。这个写法本身没问题,均匀性也够用,但它有三个容易翻车的用法。

第一,在循环里连续取,然后去重。有人写“随机取一个,如果已中奖就重取”,在小池子里没问题,但当池子里只剩最后几个名额时,重试次数会指数级上升,极端情况下会出现明显的卡顿,观众能感觉到“点了之后卡了一下才出结果”。

第二,取模偏差。如果你用randomUint32() % n,当n不能整除 2³² 时,前面的数字被选中的概率会略高。年会抽奖这个场景下偏差小到可以忽略,但如果要做严肃的公平性展示,用拒绝采样更稳妥:

function secureRandomInt(max) { if (max <= 0) return 0; const buf = new Uint32Array(1); const limit = Math.floor(0x100000000 / max) * max; // 截断到 max 的整数倍 let v; do { crypto.getRandomValues(buf); v = buf[0]; } while (v >= limit); return v % max; }

用crypto.getRandomValues而不是Math.random,主要是为了“可解释性”——现场如果有人问随机性怎么保证的,你能说出用的是系统级密码学随机源,这比说“用的 Math.random”有说服力得多。代价是速度慢一些,但一秒钟抽一次,完全无感。

第三,忘了排除已中奖的人。这是逻辑错误不是随机错误,但经常一起出现。我的做法是抽奖池单独维护,中奖后立刻从池子里剔除,而不是每次抽的时候去原名单里过滤。前者是 O(1) 的删除,后者每轮都要 O(n) 扫描,而且容易漏。

3.2 洗牌池与不重复抽取

多轮抽奖要保证不重复,最干净的方案是预先洗牌:把所有候选人的 id 打乱成一个序列,然后按顺序往外取。第几轮抽第几个,天然不重复,复杂度 O(1)。洗牌用 Fisher-Yates,从后往前遍历,每次和前面(含自己)的随机一个位置交换:

function shuffle(arr, rand = Math.random) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }

这个算法必须用,不要用arr.sort(() => Math.random() - 0.5)。后者看起来简洁,实际上分布是不均匀的,某些元素被排到前面的概率会显著偏高。原因在于sort的比较函数返回的是随机值,但排序算法本身假设比较关系是传递的,随机值破坏了这个假设,结果就会偏向某些位置。这个问题在网上被讨论过很多次,结论很明确:不能用。

洗牌池还有一个好处是可复现。如果你把洗牌用的随机源换成带种子的伪随机数生成器(比如 mulberry32),那么同一个种子必然生成同一个序列。这在彩排时特别有用——你可以用固定种子跑一遍,验证“第 3 轮的第 2 个人是谁”,正式开始时再换成真随机。这个技巧在需要多机位联调或者和主持人串词的时候非常省事。

3.3 权重、名额与预设序列

真实的年会需求很少是“完全等概率”。常见的有这么几种:按部门分配名额(技术部 3 个、市场部 2 个),保证每个部门都有人中;领导或者嘉宾环节需要指定顺序;某些人因为已经拿过别的奖要降低权重。这些都指向同一个能力:带权重的抽取。

最简单可靠的实现是“权重展开”——把每个人的权重换算成重复项塞进池子,权重 3 的人放 3 个,权重 1 的放 1 个,然后正常洗牌。缺点是池子体积会膨胀,但年会规模下(几百人,权重个位数)完全无所谓。优点是实现简单,不会出错,而且天然支持“抽完自动降权”。

function buildWeightedPool(people) { const pool = []; people.forEach(p => { const w = Math.max(1, Math.round(p.weight || 1)); for (let i = 0; i < w; i++) pool.push(p.id); }); return shuffle(pool); }

名额分配也是同样的思路:先按部门把池子分片,每个分片内部洗牌,然后按轮次从对应分片取。这样能保证“第 3 轮一定出技术部的人”,而不是靠概率去碰。

对于“预设序列”这个需求,我的建议是把接口留出来但默认关闭:数据层允许传入一个presetQueue,如果传了,就按队列顺序出结果,不传就走洗牌池。代码上就多一个if,但现场万一流程临时调整,能立刻响应,不用改代码重新部署。

3.4 先定结果再倒推动画

回到最开始说的那个核心思路。点击“开始”的瞬间,数据层就把这一轮的中奖者算出来了,拿到中奖者的索引targetIndex。接下来所有动画的工作,都是让滚动最终精确停在这个索引上。

具体怎么做?假设卡片是横向排列,每张卡占的宽度是stride = cardWidth + gap,第 i 张卡的左边缘相对原点的偏移是i * stride。我们用一个offset表示“画布原点相对列表原点向左移动了多少”,那么第 i 张卡绘制在屏幕上的 x 坐标就是i * stride - offset。要让第 i 张卡正对中间的高亮框,就需要:

i * stride - offset = 目标屏幕位置 => offset = i * stride - 目标屏幕位置

我们有整数张卡片的索引,所以最终offset一定能算出一个确定值。滚动过程中offset从当前值一路增到目标值,中途穿过若干个整圈,就形成了“一直在滚”的视觉。滚完后offset精确落在算出值上,卡片严丝合缝地对齐高亮框,绝不会出现半格偏移。

这个方案还有一个额外的好处:结束时可以立刻宣布结果,不需要等动画停下再查。主持人喊停的那一刻,结果已经在数据层躺着了,动画只是走个过场,你甚至可以提前把中奖名单渲染到另一个图层上。从“观感一致性”的角度看,这是最稳的。

4. 从零到跑通的完整实操过程

4.1 工程结构与初始化

我的目录结构大概是这样,不复杂,但边界清楚:

lottery/ ├── index.html ├── styles.css └── js/ ├── data.js // 名单、洗牌池、中奖记录 ├── renderer.js // Canvas 绘制 ├── engine.js // 状态机、主循环、缓动 └── main.js // 入口、事件绑定

index.html里就一个全屏 canvas,加一个隐藏的按钮区(开始、停止、重置、切换轮次)。所有视觉内容都在 canvas 里画,按钮用 HTML 是为了好点,也方便加键盘快捷键——现场经常是主持人按空格,不是用鼠标点。

初始化分三步走:先算画布尺寸并设置 DPR,再加载图片(带进度),最后构建数据层和渲染层。

async function bootstrap() { const canvas = document.getElementById('stage'); const ctx = resizeCanvas(canvas, window.innerWidth, window.innerHeight); const people = await fetch('/api/lottery/people').then(r => r.json()); const results = await preloadImages( people.map(p => p.thumb), (done, total) => updateProgress(done / total) ); const withImage = people.map((p, i) => ({ ...p, bitmap: results[i].ok ? results[i].img : makePlaceholder(p), weight: p.weight || 1 })); const store = createStore(withImage); const renderer = createRenderer(ctx, canvas, store); const engine = createEngine(store, renderer); bindUI(engine); engine.renderIdle(); }

这里有个小细节:resizeCanvas返回的ctx用了setTransform,但如果你在后面的绘制里又调了ctx.scale,两者会叠加。我的约定是——初始化时设一次变换,之后所有绘制都在这套坐标系里写,不再动全局变换,需要局部变换的地方一律用save()/restore()包起来。这个约定写进注释里,团队里谁接手都不会踩坑。

4.2 状态机与主循环

状态机我定义了四个状态,转移关系很简单,但每一个都必须有:

状态含义进入条件退出条件
idle静止待机初始化完成、一轮结束用户点击开始
spinning匀速滚动点击开始达到最短滚动时间
decelerating减速停止最短时间已到offset 到达目标值
result中奖展示减速结束用户点击下一轮

spinning要有最短时间限制(我一般设 1.2 到 1.8 秒)。为什么?因为如果这轮刚好抽到“下一张就是中奖者”,动画可能 0.2 秒就滚完了,主持人还没反应过来,观众也没看清,观感上像是“卡了一下就出结果”,容易引起质疑。强制至少滚够圈数,视觉上才成立。

主循环的逻辑就是按状态分派:

function loop(now) { const dt = last ? Math.min(now - last, 50) : 16.667; last = now; const k = dt / 16.667; if (state === 'spinning') { offset += spinSpeed * k; spinElapsed += dt; if (spinElapsed >= minSpinTime) { targetOffset = computeTargetOffset(offset, targetIndex, stride, total, loops); startOffset = offset; decelStart = now; decelDuration = 1400; state = 'decelerating'; } } else if (state === 'decelerating') { const t = Math.min((now - decelStart) / decelDuration, 1); offset = startOffset + (targetOffset - startOffset) * easeOutQuart(t); if (t >= 1) { offset = targetOffset; // 强制对齐,杜绝浮点误差 state = 'result'; onResult(store.currentWinner); } } renderer.draw(offset, state); rafId = requestAnimationFrame(loop); }

offset = targetOffset这一行看着多余,但它能杜绝浮点累加带来的最后半像素误差。加了这一行,停止时的对齐是数学上精确的。

4.3 滚动阶段与减速停止

落点计算是整个动画里唯一需要动点数学的地方,我单独拎出来说。

function computeTargetOffset(currentOffset, targetIndex, stride, totalCount, loops) { const cycle = stride * totalCount; // 一整圈的长度 const ideal = targetIndex * stride; // 目标卡对齐时的偏移 // 从当前位置到 ideal 的最近正向距离,落在 [0, cycle) let delta = ((ideal - currentOffset) % cycle + cycle) % cycle; // 再加上若干整圈,保证滚得够久 delta += cycle * loops; return currentOffset + delta; }

loops我一般取 2 到 3。太少了滚得短,太多了滚得久,主持人会等得心焦。2 圈大概 1.5 秒左右,配合最短滚动时间正好。

缓动函数用easeOutQuart或者easeOutQuint,起手快、收尾慢,最后几十毫秒几乎是贴着目标滑过去的,视觉上有“稳稳停住”的感觉。

const easeOutQuart = t => 1 - Math.pow(1 - t, 4);

这里有个经验值:减速时长我一般设1200 到 1500 毫秒。短于 1 秒,收尾太急,像是被硬拽停的;长于 2 秒,观众会觉得“是不是卡了”。1.4 秒是实测下来最舒服的。

另外,减速阶段不要用物理阻尼(比如每帧speed *= 0.96),那种写法的问题是停止时间不确定,可能 1 秒停,也可能 3 秒停,主持人没法预判。用固定时长的缓动函数,从点击到停止的总时长是可控的,方便和主持人串词。

4.4 中奖展示、二次校验与导出留痕

动画停止后进入result状态,要做四件事。

第一是视觉强调:中奖卡放大到 1.15 倍,加一圈发光描边(用预渲染的光晕图,别用shadowBlur),背景压暗一层半透明遮罩。这一步的重点是让大屏远处的人也能一眼看到是谁。

第二是信息展示:姓名、部门、工号,字号要大,字体权重高。姓名建议用系统中文字体里最粗的那一档,大屏投影下细体字会糊成一团。

第三是二次校验:把中奖者的 id 和当前轮次、已中奖集合做一次比对,确认没有重复。这一步纯属防御性编程,正常情况下不会触发,但年会现场容不得“哎呀抽重了”这种事。

第四是留痕。每一轮结束后,往localStorage里追加一条记录:时间戳、轮次、中奖者 id、姓名、完整的中奖序列快照。同时提供一个“导出 CSV”的按钮。这个功能一开始我觉得没必要,直到有一次主持人临时说“把前面几轮名单给我看一下”,当时如果没记录,就只能靠回忆。

function saveResult(roundId, winner) { const key = 'lottery_records_v1'; const list = JSON.parse(localStorage.getItem(key) || '[]'); list.push({ round: roundId, id: winner.id, name: winner.name, dept: winner.dept, ts: Date.now() }); localStorage.setItem(key, JSON.stringify(list)); }

提示:localStorage只有 5MB 左右,存几百条记录完全够用。但如果要存照片 base64 就不行了,别往里塞大对象。

还有一个容易被忽略的点:重置能力。现场一定会有“这一轮不算,重抽”的情况。所以数据层要暴露一个rollback(),把最近一轮的中奖者从已中奖集合里移除、放回抽奖池、删掉对应的记录。这个接口要在彩排时反复测,因为现场出问题时,你只有十几秒的时间操作。

5. 现场常见问题与排查技巧实录

5.1 问题速查表

这张表是我几次现场下来攒的,基本都是真出过的问题,按发生频率排序。

现象最可能原因排查方法处理方式
画面整体模糊canvas 缓冲区未按 DPR 放大检查canvas.width与style.width是否成比例重新执行 resize,用setTransform
滚动明显掉帧每帧重复做 clip/阴影/文字绘制临时注释掉文字绘制看是否恢复改用离屏缓存烘焙卡片
页面白屏或崩溃原图未降采样,内存爆掉看内存面板的图片占用统一换成缩略图
停止位置偏半格落点未做强制对齐打印最终 offset 与理论值减速结束赋值offset = targetOffset
抽到重复的人中奖后未从池中剔除打印池子长度与中奖数出结果时立即splice
切窗口回来画面乱飞dt未封顶检查 dt 计算Math.min(dt, 50)
图片少量加载失败网络或地址失效统计ok:false数量用占位图顶替,姓名兜底
大屏两侧黑边过大缩放未兼顾宽高比检查Math.min(w/1920, h/1080)底色填充,居中留白

5.2 卡顿、白屏与图片加载失败

掉帧的排查有个简单粗暴的办法:在渲染函数里用performance.now()打时间戳,统计每一帧的绘制耗时,超过 16 毫秒就在控制台警告并打印耗时拆解。跑几分钟,哪个环节最贵一目了然。

我遇到过一次典型的“越跑越卡”:滚动开始前几秒很流畅,十几秒后开始掉帧。查下来是图片的onload里一直在往数组里 push,而渲染循环里每帧都在遍历这个数组做一次“是否在视口内”的判断。数组从 50 涨到 800,虽然只影响几百次比较,但每帧都做,加上 V8 的数组去优化,就拖慢了主线程。改成在图片全部加载完、再一次性构建索引之后,问题消失。这件事的教训是:加载阶段和渲染阶段的数据结构应该分开,不要让渲染循环去碰还在变化的数据。

白屏和崩溃基本都是内存问题。除了前面说的降采样,还有一个容易忽略的地方:Image对象在加载完之后不会自动释放,只要你还持有引用,位图就一直占着内存。如果做了“重新加载名单”的功能,旧的Image数组一定要显式置空,并且如果有ImageBitmap,要调用close()释放。

function releaseImages(list) { list.forEach(item => { if (item.bitmap && typeof item.bitmap.close === 'function') { item.bitmap.close(); // ImageBitmap 才有 } item.bitmap = null; }); list.length = 0; }

ImageBitmap的好处是它已经是解码好的、可以直接喂给drawImage的格式,绘制时不用再做解码,比Image快一截。用createImageBitmap(blob, { resizeWidth: 300, resizeHeight: 300, resizeQuality: 'high' })一步到位,既降采样又预解码。

5.3 大屏投屏与分辨率陷阱

投屏这块的坑跟代码关系不大,但影响巨大。

第一个是缩放模式。笔记本连投影或者大屏,系统或播放软件通常有“复制”和“扩展”两种模式。复制模式下,分辨率会取两者的较小值,如果笔记本是 2880×1800、大屏是 1920×1080,复制后会变成 1920×1080,但系统缩放比例可能还是 200%,实际有效 CSS 分辨率只剩 960×540,你的界面会被放得巨大。这个问题在彩排时一定要确认,别到现场才发现。

第二个是窗口尺寸变化。节目中途如果有人动了显示设置,window.innerWidth会变,你的 canvas 必须跟着重设。所以resize事件的处理要写得完整:重新算尺寸、重设变换、重绘当前帧。千万别只改 CSS 不改缓冲区,那样必然模糊。

let resizeTimer = null; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const ctx = resizeCanvas(canvas, window.innerWidth, window.innerHeight); renderer.attachContext(ctx); renderer.draw(engine.currentOffset, engine.state); // 立刻重绘,别等下一帧 }, 150); });

防抖 150 毫秒是为了避免拖拽窗口时疯狂重建缓冲区,那个开销不小。

第三个是投影的色彩偏差。投影仪的对比度和饱和度普遍偏低,你的深灰背景在显示器上看着有层次,投到大屏上可能就是一片黑。所以配色要往高对比走,文字用纯白,背景用低饱和度的深色,中奖高亮用高亮的暖色,别玩小清新。

5.4 彩排清单与兜底方案

最后分享一份我自己的彩排清单,每次上线前逐条过一遍,比临时抱佛脚强。

  • 全量照片导入后,检查总人数和名单是否一致,重点看有没有重名(重名不罕见,展示时要带部门区分)。
  • 用 3 倍于实际的圈数跑一遍,观察内存和帧率,看有没有随时间劣化的趋势。
  • 抽满全场,确认最后一个人能正常抽出,不出现“池子空了但还在抽”的死循环。
  • 断网测试:关掉 WiFi,看已加载的图片是否正常显示,占位图是否生效。
  • 重置测试:连续重置 5 次,确认状态干净,没有残留的中奖标记。
  • 切窗口测试:滚动中切到别的程序再切回来,确认画面没有跳变。
  • 键盘测试:空格开始、回车停止、Esc 重置,全部走一遍。
  • 兜底方案:本地准备一份静态 HTML 版本(数据内联),主程序万一挂了,直接双击打开继续,别让全场等你 debug。

我个人在实际操作中的体会是,抽奖程序的技术难度并不高,难的是“在现场不确定的环境里稳定输出”。所以我在写这类东西的时候,花在防御性代码和彩排上的时间,往往比写核心逻辑还多。宁可代码丑一点,多几个if兜着,也别在现场表演“优雅地崩溃”。还有一个屡试不爽的小技巧:把关键状态(当前轮次、剩余人数、已中奖名单)用一行小字常驻在画面角落,字号小到观众看不见,但彩排时你一眼就能确认状态对不对,比打开控制台翻对象快多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询