给公司年会写了个大屏抽奖系统:动画在前端跑,凭什么说结果没被改?

去年年会,行政同事拿着一个从网上下载的抽奖 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
  // ...
}

三个约定救了我:

  1. 一次只挂一个场景。 切换时先 destroy() 旧的,再 mount() 新的,中间用一个自增序号作废过期流程------因为 mount 里有 await(加载贴图、编译 shader),不作废的话快速点两下就会两个场景同时活着。
  2. destroy() 必须释放 GPU 资源。 three 系的场景要显式 dispose() geometry / material / renderer,光把 canvas 从 DOM 里摘掉是没用的。
  3. 重特效按需加载。 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 款效果看,滚动、揭晓、复位的完整循环都在里面。想看某个效果具体长什么样,比看我文字描述直观得多。

如果你也在做类似的"前端负责表演、服务端负责判定"的场景(不只是抽奖,直播打赏、开奖、竞猜都是同一类问题),欢迎在评论区聊聊你的切分方式。我自己最大的收获是那条铁律:凡是需要事后解释的结果,就不能让表演层参与决定。

相关推荐
yzy852 小时前
Vue中下载或打开文件的JavaScript方法
前端·javascript·vue
用户298698530142 小时前
在 React 中用 JavaScript 将 Word 转换为文本
前端·javascript·react.js
李少兄3 小时前
深入解析 JavaScript 中的 `document` 对象
javascript
paopaokaka_luck3 小时前
考研政治刷题小程序(AI推荐题目、ECharts数据分析、章节练习与专项训练、模拟考试、错题本与收藏夹、学习打卡与目标管理、题库和组卷维护)
前端·javascript·spring boot·spring·数据分析·echarts
刃神太酷啦3 小时前
前端入门第一课:HTML 基础语法 + 常用标签 + 实战全解
服务器·c语言·前端·javascript·css·c++·html
mldong3 小时前
给流程设计器加国际化,我没引 vue-i18n
前端·javascript·vue.js
开开心心就好10 小时前
批量提取PDF中的图片,直接导出原图
前端·javascript·支持向量机·智能手机·pdf·html·启发式算法
默_笙14 小时前
🏛 给 AI 配一间办公室:Harness Engineering 六大模块与它的实现
前端·javascript
linux_cfan15 小时前
videojs v10 源代码系列解读:14 · 谓词守卫:在运行时安全地调用能力
前端·javascript·音视频