公平随机转盘的前端实现:Web Crypto API、拒绝采样与加权抽取

随机转盘看起来很简单:画一个圆盘,分成若干扇区,让它旋转,最后停在一个选项上。

但当它被用于课堂点名、活动抽奖、团队分组,甚至任何大家在意结果的场景时,真正困难的并不是动画,而是让参与者相信:结果没有被界面偷偷影响,概率规则清楚,个人数据也得到了妥善处理。

我们在开发 GoSpinWheel 的过程中发现,一个可信的在线随机转盘,需要算法、交互和隐私设计共同配合。下面是几条最重要的实践。

一、先决定结果,再播放动画

动画应该负责"揭晓"结果,而不是"产生"结果。

一种常见但不理想的做法,是先启动 CSS 旋转动画,然后根据最终角度反推选中了哪个扇区。这样会把抽取逻辑和浏览器帧率、角度取整、动画中断等渲染细节绑在一起。

更稳妥的流程是:

  1. 在数据层先选出中奖项;
  2. 计算指针落在该扇区内的目标角度;
  3. 让转盘动画旋转到这个角度;
  4. 动画结束后,展示第一步已经确定的结果。

这样做有两个好处。第一,随机选择函数可以脱离界面单独测试;第二,即使用户开启"减少动态效果"、跳过动画,或者设备性能较差,也不会改变抽取概率。

二、使用 Web Crypto API,而不是直接依赖 Math.random()

Math.random() 很方便,但它并不是为高可信度的抽取场景设计的。现代浏览器提供了 crypto.getRandomValues(),可以生成质量更高的随机值。

不过,仅仅换成 Web Crypto API 还不够。如果把一个 32 位随机整数直接对选项数量取模,当 2^32 不能被选项数整除时,就会出现非常轻微的"取模偏差":某些索引会比其他索引对应更多原始数值。

可以使用拒绝采样消除这种偏差:

js 复制代码
function randomIndex(length) {
  if (!Number.isSafeInteger(length) || length <= 0) {
    throw new RangeError("length must be a positive safe integer");
  }

  const range = 2 ** 32;
  const limit = range - (range % length);
  const value = new Uint32Array(1);

  do {
    crypto.getRandomValues(value);
  } while (value[0] >= limit);

  return value[0] % length;
}

这里会丢弃 32 位范围顶部那一小段无法均匀分桶的数值。对于常见的转盘选项数量,循环几乎总是只执行一次。

GoSpinWheel 的普通旋转结果也使用浏览器原生的 crypto.getRandomValues()。把随机数来源公开说明很重要,因为"随机"不应该只是一个无法解释的黑箱。

三、加权抽取不仅要算对,还要让用户看懂

加权随机转盘允许不同选项拥有不同概率。比如,权重为 2 的选项,中奖概率应该是权重为 1 的两倍,而不应由扇区动画最后的视觉位置决定。

基本算法是:

  1. 拒绝负数、NaN、无穷大等非法权重;
  2. 计算所有正权重的总和;
  3. [0, 总权重) 内生成随机值;
  4. 依次遍历选项,并减去每个选项的权重;
  5. 当剩余值小于当前权重时,选中该项。
js 复制代码
function pickWeighted(entries, randomUnit) {
  const total = entries.reduce((sum, entry) => sum + entry.weight, 0);
  let target = randomUnit * total;

  for (const entry of entries) {
    if (target < entry.weight) return entry;
    target -= entry.weight;
  }

  return entries.at(-1);
}

实际生产代码中,randomUnit 应来自无偏的随机源。这里把它作为参数传入,是为了方便测试边界值。

交互上也不应只显示"权重 3"。最好同时展示根据总权重计算出的"中奖概率 25%"。权重是配置语言,百分比才是大多数用户能直接理解的结果。

四、任何会影响下一轮的状态变化,都要明确展示

很多随机转盘支持"抽中后移除",用于不放回抽签。但如果结果刚出现,选项就自动消失,用户很容易怀疑发生了什么。

一个透明的交互流程应该做到:

  • 清楚地展示本轮选中的结果;
  • 告知该项是否会在下一轮被移除;
  • 保留可见的抽取历史;
  • 提供撤销或重置操作;
  • 在用户看清结果之前,不要删除选项。

"隐藏标签"和"排除选项"也应该是两种不同状态。为了悬念而隐藏名字,不代表它应该失去被抽中的资格。展示状态和参与资格不能混为一谈。

五、涉及姓名等信息时,默认采用本地优先

随机转盘里经常会放学生姓名、员工姓名、活动参与者名单,甚至尚未公开的候选方案。用户只是在文本框里输入这些内容,并不意味着数据就应该被自动上传到服务器。

更合理的本地优先设计是:

  • 匿名编辑和旋转在浏览器内存中完成;
  • 需要在本机保存时使用本地存储;
  • 提供明确的 JSON 导入、导出;
  • 只有用户主动选择云端保存或公开分享链接时,才把数据发送到服务器。

GoSpinWheel 采用的就是这一方式:匿名用户可以在本地编辑、旋转、导入、导出和保存;公开分享与云端保存是独立且需要主动触发的操作。

本地优先不仅保护隐私,还能减少等待时间,并让核心抽取功能在网络不稳定时继续可用。

六、把 CSV、TSV 当作不可信输入

批量导入名单对老师和活动组织者很实用,但上传文件可能格式错误、体积过大,或包含意外内容。

建议至少设置以下保护:

  • 明确的文件大小上限;
  • 最大选项数量;
  • 只识别 nameoptionweightprobability 等约定字段;
  • 覆盖当前转盘前先预览;
  • 检查空名称、非法权重和超长文本;
  • 不执行表格公式,也不解析为 HTML。

GoSpinWheel 目前支持不超过 300 KB 的文本、CSV 和 TSV 输入,以及最多 1,100 个扇区。这类限制不仅是为了"限制用户",更是为了让解析、校验和渲染的性能保持可预期。

七、公平性要看大量结果分布,不看某一次旋转

某一次结果很意外,并不能证明系统有偏;某一次结果看起来合理,也不能证明系统公平。

对于等概率转盘,应进行足够多次测试,确认每个选项都能被选中,且频率没有明显偏离预期。对于加权转盘,则应比较实际频率与理论概率是否处于合理误差范围。

还要覆盖这些边界情况:

  • 只有一个选项;
  • 权重为零;
  • 不同选项的权重差异非常大;
  • 多个选项名称相同;
  • 删除、撤销与重置;
  • 多个转盘同时旋转;
  • 系统开启"减少动态效果"。

统计测试不能代替代码审查,但很擅长发现不易察觉的实现错误。

写在最后

一个让人信任的随机转盘,并不是旋转时间越长、动画越华丽越好。真正重要的是:去掉动画、样式、网络和账号系统后,它的概率规则依然一致。

先选择结果,再播放动画;使用合适的随机源;明确解释加权概率;让每次状态变化都可见;默认把私人名单留在本地。做好这些基础工作,一个看似轻量的小游戏,才会成为真正能用于决策的在线抽签工具。

本文根据我们开发 GoSpinWheel 时的实践整理,并针对中文读者重新编写。英文原文发布于 DEV Community

相关推荐
程序员黑豆2 小时前
鸿蒙应用开发:@Link 装饰器实现父子组件双向同步
前端·后端·harmonyos
前端炒粉2 小时前
简易实现ssr
开发语言·前端·javascript
顶级自由人2 小时前
【前端菜鸟的补课01】Zod 与 PostgreSQL 全栈数据工程教学
前端·后端·程序员
swipe3 小时前
11|(前端转全栈)购物车不能只存在前端:用户维度数据如何在后端落库
前端·后端·全栈
Chengbei115 小时前
云安全漏洞挖掘SKILL、一站式云漏洞挖掘工具,支持S3爆破、IMDS探测、K8s检测与AK/SK权限利用
前端·人工智能·网络安全·云原生·容器·kubernetes·系统安全
不简说5 小时前
JS 代码技巧 vol.9 — 20 个设计模式在真实项目里的应用
前端·javascript·github
CoderLiu5 小时前
Agent 工程的下一层:为什么「把流程写成图」还不够,还需要 Graph Engineering
前端·人工智能·后端
勇往直前plus5 小时前
Vue3(篇三) Element Plus
前端·javascript·typescript·vue
爱勇宝5 小时前
《道德经》第 8 章:真正高级的能力,像水一样成事
前端·后端·程序员