去年年会,行政同事拿着一个从网上下载的抽奖 exe 找我,问能不能"让某个名字更容易抽中"。我说这个我真做不到------然后打开开发者工具看了一眼,发现中奖名单是前端 Math.random() 当场摇的。
也就是说,任何一个会按 F12 的人都能在抽奖进行中把结果改掉。那场年会最后是用摇号器摇的。
后来我自己动手写了一套,前后大概迭代了几个月。这篇把里面几个真正需要想清楚的设计点记一下,尤其是"动画在浏览器里跑"和"结果必须可信"这对看起来矛盾的需求怎么调和。
一、第一个决定:把"表演"和"判定"彻底拆开
抽奖动画天生属于前端------滚动名字、翻牌、砸金蛋、3D 旋转球,这些用 Canvas / WebGL 做起来最舒服。但只要结果是前端算的,公信力就是零。
所以第一版就定了一条铁律:前端动画从头到尾不知道结果是什么。
具体做法是把一次抽奖切成两个阶段,对应到场景接口上是两个方法:
ts
export interface LotteryScene {
mount(ctx: SceneContext): Promise<void>
idle(): void
/** 开始滚动。注意:这里没有参数,场景此刻并不知道谁会中奖 */
startRolling(): void
/** 揭晓。名单由服务端下发,Promise resolve 表示动画播完 */
reveal(winners: Candidate[]): Promise<void>
skip(): void
reset(): void
destroy(): void
}
主持人点"开始"时,前端只发一条 cmd.roll,场景立刻进入滚动状态------它滚的是全体候选人,纯粹是视觉效果。服务端收到指令后才真正执行抽取(支持按权重、按范围、是否允许重复中奖),先把结果写进数据库、生成流水号和审计记录,再把中奖名单广播下去 。前端拿到名单,调 reveal() 收束动画。
这样做带来几个附带的好处:
- 前端就算被改,改的也只是"看起来谁中了",数据库里那条记录不会变,导出的名单也不会变
- 每一次抽奖都能追溯到操作人和时间戳,事后有人质疑,翻审计日志就行
- 滚动阶段完全不依赖网络往返,主持人点下去就动,不会出现"点了没反应"的尴尬
reveal() 返回 Promise 这个设计当时也纠结过。最早是给场景传一个 onFinish 回调,结果控制流散在各处,"动画播完了才能允许下一轮"这个约束很容易漏。改成 Promise 之后,编排代码就是顺着写:
ts
scene.startRolling()
const winners = await waitForServerResult() // 服务端算完推过来
await scene.reveal(winners) // resolve 即动画结束
enableNextRound()
skip() 的语义是"立即 resolve"------现场时间不够的时候主持人要能一键跳到结果,这个需求一定会来。
二、14 款效果,怎么让它们互不干扰
做到第三款效果的时候就发现,如果每个效果都直接往页面上塞 DOM、自己监听 resize、自己管 requestAnimationFrame,很快就会变成一锅粥------切换效果之后上一个的动画循环还在跑,WebGL 上下文也不释放,跑十分钟显存就满了。
后来收敛成一个注册表 + 懒加载工厂:
ts
const registry: Record<string, () => Promise<LotteryScene>> = {
'S-01': () => import('./s01-classic').then(m => m.create()),
'S-02': () => import('./s02-sphere').then(m => m.create()), // three.js
'S-07': () => import('./s07-orbit').then(m => m.create()), // three.js
// ...
}
三个约定救了我:
- 一次只挂一个场景。 切换时先
destroy()旧的,再mount()新的,中间用一个自增序号作废过期流程------因为mount里有await(加载贴图、编译 shader),不作废的话快速点两下就会两个场景同时活着。 destroy()必须释放 GPU 资源。 three 系的场景要显式dispose()geometry / material / renderer,光把 canvas 从 DOM 里摘掉是没用的。- 重特效按需加载。 three 那个 chunk 有 466KB,只有点到 S-02 / S-07 才会去下载。首屏默认用最轻的一款,任何投影设备都不会掉帧。
顺带说个现场经验:别默认上最炫的效果。3D 旋转球在会议室的老投影仪上帧率能掉到十几,反而是最朴素的"背景图滚动 + 大字号定格"最稳、最有仪式感。所以做成了每个奖项单独指定效果------暖场用轻的,压轴才上重的。
三、现场最怕的三件事
写这套东西的时候,需求来源不是"功能列表",而是"现场会出什么事"。
怕断网。 会议室 WiFi 不可靠是常态。大屏和控制台都是 WebSocket 长连接,掉线后自动重连,重连成功立刻拉一次全量状态快照(当前奖项、阶段、已中奖名单、背景、音乐、字幕都在里面),把画面恢复到掉线前的位置。关键是已经产生的中奖记录早就落库了,网络问题不会让结果丢失。现场最怕的其实不是卡顿,是抽完了没人说得清抽的是谁。
怕误操作。 大屏那块屏幕只负责展示,它不接受任何鼠标键盘操作------主持人手里的控制台才是唯一入口。这样投影电脑被人碰一下鼠标不会有任何后果。另外权限分了三档,主持人账号拿到的控制台上,"删除活动""重置名单"这类危险操作根本不渲染。
怕环境。 整套系统编译成单个可执行文件,前端资源和建表 SQL 全部 embed 进去,配一个 MySQL 就能跑:
bash
CGO_ENABLED=0 GOOS=linux go build -o giftbox ./cmd/giftbox
11.9 MB,静态链接,扔到任何发行版上都能起。启动时会 CREATE DATABASE IF NOT EXISTS 再幂等重跑一遍内嵌的 schema(所有 DDL 都是 IF NOT EXISTS),所以升级不需要手工执行迁移脚本,也不存在"忘了建表"这种事故。客户要求数据不出内网的话,直接部署在他们机房就行。
四、几个真实踩过的坑
坑 1:Referrer-Policy: no-referrer 把统计数据全干掉了。
当初为了防止大屏 URL 里的 token 通过 Referer 外泄,图省事直接给全站加了 no-referrer。上线后发现统计后台的"来源"永远是空的,所有访问都被记成直接访问------因为这个值会让 document.referrer 恒为空字符串。
正确的做法是 strict-origin-when-cross-origin:跨源时只发送 origin,不带路径和 query,token 在 query 里所以照样不会泄露,但来源域名保留了下来。这也是现代浏览器的默认值,安全性和可用性的平衡点。安全头不是越严越好,得知道每个值到底掐掉了什么。
坑 2:滑动窗口限流把自己封了。
试用申请接口做了 IP 限流,每小时 5 次。我自己写自动化测试连着跑了一轮,正好把额度用满,然后对着 429 排查了十分钟,怀疑是不是限流器算错了。
后来加了两条自查经验:一是限流计数放在内存里的话,重启即清零,调试期这是最快的恢复手段;二是要想清楚"被拒绝的请求算不算消耗额度"------我的实现里被拒时不再往窗口追加时间戳,否则越试越久,用户会彻底卡死在外面。
坑 3:SPA 的首页在爬虫眼里是一片空白。
官网用的是 Vue + history 路由,HTML 里只有一个 <div id="app">。Googlebot 能执行 JS 还好,但国内搜索引擎抓到的基本就是个空壳。
不想上 SSR 的话,有个轻量做法:把首屏摘要直接写进 #app 内部------Vue mount 的时候会把容器内容整体替换掉,用户几乎看不到,但爬虫不执行 JS 也能读到文字。注意这段静态文案要和渲染后的 h1、首段保持一致 ,否则可能被判成 cloaking。另外后台路由记得按路径打 noindex,那些页面被收录只会得到一个空壳登录页,白占抓取配额还把入口暴露给扫描器。
五、一些做了才知道的小事
- 自定义背景图和背景音乐不走服务器。 用
localStorage+BroadcastChannel在同机的控制台和大屏之间传,代价是换台机器就没了。取舍点在于:一个几 MB 的 mp3 上传下发在现场网络下反而更不可靠,而且这本质上是"这台电脑的临时素材"。跨机不生效时大屏保持静默,不偷偷退回默认曲------不然主持人会以为自己选的没生效。 - 弹幕审核开关必须做成默认关。 一开始默认开审核,结果每场活动主持人都在忙着点通过,观众看着自己的弹幕半天不上墙。真正需要审核的是对外场合,那时候他手动打开就行。
- 滑动条要用
@change而不是@input。 音量条拖一下能触发几十次事件,入站指令有限速,直接就被限流掉了。
最后
这套东西现在跑在 https://giftbox.3qtools.cn,首页上那个"大屏预览"窗口不是录屏,是把真实的场景实例挂在页面里跑的同一套代码------点左边的卡片可以直接切 14 款效果看,滚动、揭晓、复位的完整循环都在里面。想看某个效果具体长什么样,比看我文字描述直观得多。
如果你也在做类似的"前端负责表演、服务端负责判定"的场景(不只是抽奖,直播打赏、开奖、竞猜都是同一类问题),欢迎在评论区聊聊你的切分方式。我自己最大的收获是那条铁律:凡是需要事后解释的结果,就不能让表演层参与决定。