随机转盘看起来很简单:画一个圆盘,分成若干扇区,让它旋转,最后停在一个选项上。
但当它被用于课堂点名、活动抽奖、团队分组,甚至任何大家在意结果的场景时,真正困难的并不是动画,而是让参与者相信:结果没有被界面偷偷影响,概率规则清楚,个人数据也得到了妥善处理。
我们在开发 GoSpinWheel 的过程中发现,一个可信的在线随机转盘,需要算法、交互和隐私设计共同配合。下面是几条最重要的实践。
一、先决定结果,再播放动画
动画应该负责"揭晓"结果,而不是"产生"结果。
一种常见但不理想的做法,是先启动 CSS 旋转动画,然后根据最终角度反推选中了哪个扇区。这样会把抽取逻辑和浏览器帧率、角度取整、动画中断等渲染细节绑在一起。
更稳妥的流程是:
- 在数据层先选出中奖项;
- 计算指针落在该扇区内的目标角度;
- 让转盘动画旋转到这个角度;
- 动画结束后,展示第一步已经确定的结果。
这样做有两个好处。第一,随机选择函数可以脱离界面单独测试;第二,即使用户开启"减少动态效果"、跳过动画,或者设备性能较差,也不会改变抽取概率。
二、使用 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 的两倍,而不应由扇区动画最后的视觉位置决定。
基本算法是:
- 拒绝负数、
NaN、无穷大等非法权重; - 计算所有正权重的总和;
- 在
[0, 总权重)内生成随机值; - 依次遍历选项,并减去每个选项的权重;
- 当剩余值小于当前权重时,选中该项。
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 当作不可信输入
批量导入名单对老师和活动组织者很实用,但上传文件可能格式错误、体积过大,或包含意外内容。
建议至少设置以下保护:
- 明确的文件大小上限;
- 最大选项数量;
- 只识别
name、option、weight、probability等约定字段; - 覆盖当前转盘前先预览;
- 检查空名称、非法权重和超长文本;
- 不执行表格公式,也不解析为 HTML。
GoSpinWheel 目前支持不超过 300 KB 的文本、CSV 和 TSV 输入,以及最多 1,100 个扇区。这类限制不仅是为了"限制用户",更是为了让解析、校验和渲染的性能保持可预期。
七、公平性要看大量结果分布,不看某一次旋转
某一次结果很意外,并不能证明系统有偏;某一次结果看起来合理,也不能证明系统公平。
对于等概率转盘,应进行足够多次测试,确认每个选项都能被选中,且频率没有明显偏离预期。对于加权转盘,则应比较实际频率与理论概率是否处于合理误差范围。
还要覆盖这些边界情况:
- 只有一个选项;
- 权重为零;
- 不同选项的权重差异非常大;
- 多个选项名称相同;
- 删除、撤销与重置;
- 多个转盘同时旋转;
- 系统开启"减少动态效果"。
统计测试不能代替代码审查,但很擅长发现不易察觉的实现错误。
写在最后
一个让人信任的随机转盘,并不是旋转时间越长、动画越华丽越好。真正重要的是:去掉动画、样式、网络和账号系统后,它的概率规则依然一致。
先选择结果,再播放动画;使用合适的随机源;明确解释加权概率;让每次状态变化都可见;默认把私人名单留在本地。做好这些基础工作,一个看似轻量的小游戏,才会成为真正能用于决策的在线抽签工具。
本文根据我们开发 GoSpinWheel 时的实践整理,并针对中文读者重新编写。英文原文发布于 DEV Community。