
做营销活动需求的时候抽奖转盘几乎是标准配置。它看起来就是一个圆形盘面加一根指针但一旦运营过来说“这期活动奖品数量不固定有的值钱要占比小有的便宜占比大最好后台改一下前端马上变”原来的固定板块方案就顶不住了。这种时候需要的是一个“动态划分板块的抽奖转盘”板块数量、角度比例、颜色、奖品名全部由数据驱动拉取到奖品配置后原地生成不用每次改代码、切图、提审。这篇文章就围绕这个组件从数据模型、Canvas 绘制、动画停止位置、上线自测几个方面完整拆一遍我实际做过的方案。无论是刚开始接触 Canvas 的前端还是被运营追着改页面的开发都可以拿走直接参考。1. 一个转盘背后的真实需求为什么需要动态划分板块1.1 固定转盘只能应付固定场景早些年我做抽奖转盘最常见的做法是把盘面切成 N 张等分图片或者一张整图叠一个小三角指针然后用 CSS 旋转那张整图。奖品数量固定、位置固定一切都很简单。第一次接到需求是“6 个奖品”切 6 块扇形图每块写死角度 60°写一个旋转动画测试通过就能上线。问题就出在“固定”两个字上。运营第二天提了一个需求某个奖品要临时换成另一个或者某个奖品的概率下调要调整扇区大小。我第一反应是“重新切图吧”但切图不是运营能操作的必须等设计师出图、前端替换、测试回归。一套流程下来最快也要一两个小时活动运营等不了。更麻烦的是如果奖品数量从 6 个变成 8 个切图数量、角度、旋转停止位置的计算全要改一遍代码里到处是魔法数字长期维护就是一场灾难。1.2 动态板块转盘的本质是什么所谓动态划分板块本质上是把“转盘长什么样、每个扇区占多大角度、中标概率多大”这些原本硬编码在页面里的东西全部变成可配置的数据。数据变了界面自动重新划分板块。它不再是一个一次性页面而是一个配置化组件可以承接不同活动、不同奖池甚至同一个页面挂多个转盘每个转盘读取不同的配置都能独立渲染。我后来对这个组件的核心目标做了一个提炼板块数量由奖品数组长度决定数组加一项就多一块减一项就少一块不需要单独维护“总块数”。每块的角度由权重动态计算同权重就是均分不同权重就按比例分配。颜色、文案、图标等展示字段全部跟随数据配置前端不做任何写死的展示逻辑。渲染和动画分离静态盘面可以缓存旋转动画只负责转动保证流畅度。这样做的好处非常直接运营拿到配置后台后自己改奖品、调颜色、调权重前端页面立即生效整个过程不需要开发介入。我做完这套组件后后续三个活动的转盘配置几乎零开发量改数据、发配置、上线一气呵成。1.3 技术选型Canvas、SVG 还是 DOM做动态转盘技术选型是绕不开的第一步。我对比过 Canvas、SVG 和纯 DOM 三种方案最后长期用的是 Canvas。方案优点缺点适用场景Canvas绘制性能好扇区数量多时依然流畅角度、弧度计算直观可缓存离屏画布文字布局需要手动计算不提供 DOM 事件需要自己做命中检测动态生成扇区、频繁重绘、追求性能SVG本质是 DOM 节点样式和文本排版轻松可以绑定点击事件扇区数量多、动画频繁时节点开销明显旋转动画容易产生锯齿奖品数少、交互重、需要逐块 hoverDOM CSS实现思路最简单整张图加 rotate 动画即可盘面图片固定动态切块难度极高基本无法支持实时配置永久固定奖品的极简页面我的选择逻辑很简单转盘本质是“随角度变化的绘制任务”Canvas 的重绘模型与动画循环天然匹配。每次动画帧只需要改变一个 rotation 值然后重新绘制整个盘面即使有 20 个扇区也毫无压力。SVG 在少量扇区下体验不错但扇区超过 10 个以后旋转和重绘时每帧都要更新大量节点低端 Andriod 机上掉帧明显。纯 DOM 方案基本不做考虑因为要让一块块扇形图根据配置动态生成成本比 Canvas 高得多。另外一个关键技巧是离屏 Canvas。我会把静态的扇区、文字、图标先画到一个不可见的 canvas 上生成缓存动画时每帧只把这个缓存画到主 canvas 的对应角度上。这样动画循环里不需要重复计算每个扇区的路径和文字位置性能能提升一个档次。2. 核心设计数据模型与概率权重2.1 奖品配置与板块数据模型动态转盘的第一步不是写绘制代码而是先约定好数据模型。我习惯用这样一个数组[ { id: p1, name: 手机, color: #FFB6C1, fontColor: #333333, weight: 2, icon: https://example.com/phone.png }, { id: p2, name: 优惠券, color: #F5D0A9, fontColor: #333333, weight: 30 }, { id: p3, name: 谢谢参与, color: #D3D3D3, fontColor: #666666, weight: 68 } ]字段说明id唯一标识抽奖结果返回时用它来匹配扇区。name显示在扇区里的文字可以包含中文、数字甚至是 emoji但要注意长度。color扇区填充色可以直接写十六进制也可以写rgba。fontColor文字颜色默认值可以设定为跟随色系但最好允许单独配置。weight权重决定了扇区的角度比例。icon可选如果奖品有图片可以放在扇区中段提高视觉效果。注意我刻意没有把“概率”直接放进前端配置。权重在前端只负责画扇区大小真实的中奖概率由服务端计算。这既是为了安全也是为了让前端职责单一后面讲概率控制时再展开。2.2 扇形角度的动态计算逻辑角度计算并不复杂核心公式是sliceAngle weight / totalWeight * 2π每个扇区的角度等于该奖品权重除以总权重后再乘以 360 度弧度制下是 2π。举个例子奖品权重分别是 2、30、68总权重 100则三个扇区角度分别为 7.2°、108°、244.8°。这样动态划分直观准确无论数组多长最终所有扇区的角度之和必然等于 360°。分享一个我踩过的细节绘制扇区时起始角不要从 0 度开始画。Canvas 的 0 度是三点钟方向而用户对转盘的默认认知是从顶部 12 点方向开始。所以我统一把第一个扇区的起始角度设为-Math.PI / 2也就是从 12 点方向开始按顺时针排列。这样既不违背直觉也让后面指针停止位置的计算更好理解。权重极值的情况也要提前防一手。如果运营手滑把某个奖品权重配成 0.1总权重很大时这个扇区会细到几乎看不见文字根本无法显示甚至绘制出来只是一条线。我的处理策略是给最小角度设一个门槛当计算出的 sliceAngle 小于某个阈值比如 0.05 rad时按最小角度 0.05 rad 绘制。同时要在配置后台给出提示告诉运营“这个扇区展示面积过小建议提高权重或合并奖品”。2.3 概率控制的关键细节很多初次接触转盘的人会理所当然地认为扇区面积越大中奖概率就越高让前端直接按扇区比例随机不就行了吗这在单机演示里可以但正式运营项目里千万不要这么做。前端随机可以被篡改。用户打开浏览器的开发者工具直接替换Math.random理论上就可以控制每一次抽奖结果即便服务端对接口做了次数限制恶意用户也能通过反复测试找出规律。更合理做法是服务端根据真实的概率配置算出中奖奖品返回一个prizeId前端拿到这个 ID 后再把指针动画转到对应扇区最后弹出中奖提示。这里必须和运营提前对齐一个口径转盘上扇区的面积比例是“展示权重”还是“真实概率”大多数时候我希望它展示的是预期概率也就是运营想要让用户感知到的概率。但有一种特殊场景运营会给某个奖品设置“保底次数”比如第 10 次必中某商品这种情况下前端看到的概率曲线和真实概率就会不一致体验上可能出现“扇区很大但死活不中”的误解。产品侧最好在文案或规则里加以说明避免引发争议。另外要强调的是前端只负责动画展示抽奖结果、库存扣减、抽奖次数限制都必须走服务端接口。前端维护一个drawing状态在动画过程中禁止重复点击接口失败时恢复状态这样可以最大程度避免并发问题。3. 实现动态划分板块的转盘关键步骤与代码解析3.1 初始化画布与基础参数我先说初始化部分这一步做不好后面高分屏和响应式会连环踩坑。核心思路是canvas 的实际像素尺寸要乘以devicePixelRatio但 CSS 尺寸保持正常的页面尺寸再用ctx.scale放大绘制坐标防止在 Retina 屏上出现模糊。const canvas document.getElementById(wheel); const ctx canvas.getContext(2d); const dpr Math.max(window.devicePixelRatio || 1, 2); const container canvas.parentElement; const size Math.min(container.clientWidth, container.clientHeight); canvas.style.width ${size}px; canvas.style.height ${size}px; canvas.width size * dpr; canvas.height size * dpr; ctx.scale(dpr, dpr); const cx size / 2; const cy size / 2; const padding 6; const radius size / 2 - padding;有几个细节值得注意dpr不建议无限乘。有的手机设备像素比是 3 或更高但中低端机处理超大 canvas 会出现内存飙升。我一般取Math.min(dpr, 2.5)作为上限。转盘整体通常是一个正方形所以我用父容器的宽高取最小值来定边长避免出现椭圆盘面。padding是盘面到画布边缘的间距给外边装饰留出空间。3.2 绘制扇区按数据动态划分绘制扇区的核心是一个startAngle累加循环每次画完一个扇区就把当前扇区的结束角作为下一个扇区的起始角。下面是去掉装饰后的核心代码const prizes [ { name: 手机, color: #FFB6C1, weight: 2 }, { name: 优惠券, color: #F5D0A9, weight: 30 }, { name: 谢谢参与, color: #D3D3D3, weight: 68 } ]; const totalWeight prizes.reduce((sum, p) sum p.weight, 0); let startAngle -Math.PI / 2; function drawSector(radius, startAngle, endAngle, color) { ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, startAngle, endAngle); ctx.closePath(); ctx.fillStyle color; ctx.fill(); ctx.strokeStyle #ffffff; ctx.lineWidth 2; ctx.stroke(); } prizes.forEach((prize) { const sliceAngle (prize.weight / totalWeight) * Math.PI * 2; const endAngle startAngle sliceAngle; drawSector(radius, startAngle, endAngle, prize.color); const midAngle startAngle sliceAngle / 2; // 绘制文字 ctx.save(); ctx.translate(cx, cy); ctx.rotate(midAngle); ctx.textAlign right; ctx.fillStyle prize.fontColor || #333333; ctx.font 14px sans-serif; ctx.fillText(prize.name, radius - 16, 4); ctx.restore(); startAngle endAngle; });这里最容易被新手问住的是文字方向。ctx.rotate(midAngle)之后坐标系会跟着旋转这时在radius - 16的位置画字字会沿着半径方向从圆心往外排列整体看起来像是围绕圆盘放射状分布的符合日常转盘的视觉习惯。如果想放图标也可以在旋转后的坐标系里用ctx.drawImage(icon, radius * 0.55, -height / 2)放在文字内侧。但要注意图标需要预加载否则第一帧会空白。静态盘面绘制完成之后我会把它缓存到一个离屏 canvas 上之后动画循环只取缓存绘制不再重新执行这些循环逻辑。实现很简单const offscreen document.createElement(canvas); offscreen.width canvas.width; offscreen.height canvas.height; const offCtx offscreen.getContext(2d); // 把上面扇区绘制代码中的 ctx 全部替换为 offCtx // 之后每次动画执行 function drawWheel(rotation) { ctx.clearRect(0, 0, size, size); ctx.save(); ctx.translate(cx, cy); ctx.rotate(rotation); ctx.drawImage(offscreen, -cx, -cy); ctx.restore(); }3.3 抽奖动画与指针判定动画部分最关键在于如何让指针精准停在服务端返回的那个扇区上。先明确坐标系所有扇区绘制时第一个扇区的起始角是-Math.PI / 2指针固定在整个画布的顶部 12 点方向也就是角度-Math.PI / 2的位置。旋转动画中我们对整个盘面调用ctx.rotate(rotation)因此盘面上任意一个原始角度angle经过旋转后实际落在画布上的角度为angle rotation。要让某个扇区的midAngle最终对准指针需要满足rotation midAngle -π/2 2π * k所以rotation -π/2 - midAngle 2π * k实际代码中我会让动画至少转 5 圈再叠加到目标角度上function spinTo(index, duration 5000, callback) { const startRotation currentRotation; const midAngle slices[index].midAngle; // 需要在绘制时记录 const targetBase -Math.PI / 2 - midAngle; const diff targetBase - startRotation; const normalizedDiff ((diff % (Math.PI * 2)) Math.PI * 2) % (Math.PI * 2); const endRotation startRotation normalizedDiff Math.PI * 2 * 5; const startTime performance.now(); function tick(now) { const progress Math.min((now - startTime) / duration, 1); const eased 1 - Math.pow(1 - progress, 3); // easeOutCubic currentRotation startRotation (endRotation - startRotation) * eased; drawWheel(currentRotation); if (progress 1) requestAnimationFrame(tick); else callback callback(); } requestAnimationFrame(tick); }关于normalizedDiff的处理目的是保证每次动画都是正向旋转并且最终停下的角度与目标基准角度的余数一致。加了 5 整圈之后视觉效果会自然不会出现只转一点点就停下的尴尬情况。动画期间还要维护一个状态锁let isDrawing false; async function handleClick() { if (isDrawing) return; isDrawing true; const res await fetch(/api/lottery, { method: POST }); const data await res.json(); const index prizes.findIndex(p p.id data.prizeId); if (index 0) { isDrawing false; return; } spinTo(index, 5000, () { showResult(data.prizeId); isDrawing false; }); }3.4 响应式适配与重绘转盘组件往往放在活动首屏不同屏幕宽度下盘面大小不一致。我用ResizeObserver监听容器尺寸变化变化后重新计算 canvas 像素尺寸并重建离屏缓存盘面。const resizeObserver new ResizeObserver(() { rebuildCanvas(); drawWheel(currentRotation); }); resizeObserver.observe(container);一个容易忽略的点用户在动画过程中旋转手机或调整窗口如果直接重建缓存当前旋转角度会被重置盘面会突然弹回初始位置。解决办法是在rebuildCanvas前保留currentRotation重建完后再以当前 rotation 重新绘制。移动端还需要给 canvas 加样式touch-action: none避免手指点击转盘触发页面滚动的默认行为。4. 真实项目中的坑动画卡顿、边界重合与配置兼容4.1 Canvas 高分屏模糊问题我最早做 canvas 转盘时直接写canvas.width 300CSS 宽度也是 300px在普通电脑上看着还行换到 MacBook 后发现锯齿和模糊都很明显。原因是 Retina 屏的物理像素是 CSS 像素的 2 倍甚至更高300 个物理像素去展示 300 CSS 像素宽度的盘面每一个绘制单位都放大了。解决办法就是前面已经写过的devicePixelRatio适配。要特别注意ctx.scale(dpr, dpr)必须在设置 canvas.width 之后执行否则绘制坐标会被错误放大出现只画了一部分的问题。我见过有人把ctx.scale写在canvas.width之前的结果整个盘面只显示左上角四分之一排查半天才发现是初始化顺序不对。4.2 扇区过小导致的绘制与交互问题动态板块最大的特点就是扇区数量不确定一旦超过 10 块单块扇区角度就小了。比如 20 个等分扇区每个只有 18°如果奖品名是“豪华双人游套餐”这种长文字绘制出来必然溢出到相邻扇区严重时可读性为零。我处理这个问题的方法绘制文字时对文本长度做限制超过 4 个中文字符就做截断加省略号。同时允许配置一个“短名称”比如短名称写“套餐”完整名字放在弹出层的结果展示里。如果某个扇区计算后角度小于 0.08 rad就不再绘制文字只保留颜色避免出现叠字。视觉上建议给每个扇区之间加一条白色细线分割清晰否则小角度扇区之间没有边界颜色相近时用户根本分不清有几块。点击热区也会遇到相似问题。转盘上的扇区是 Canvas 绘制的没有 DOM 节点不能直接绑 click。我是在 canvas 的 click 事件里用atan2计算出点击坐标相对圆心的角度再减去currentRotation换算回原始扇区角度然后遍历扇区找到对应索引。只要注意边界重合情况点击位置正好落在两个扇区交界线上取相邻的任一扇区都可以但不要报错。4.3 指针判定与动画停止位置的一致性做转盘最容易翻车的就是“服务端说中了手机动画停下来指针却指着优惠券”。我排查过很多次绝大多数原因都是角度计算时没有把初始偏移量考虑进去。举一个反面例子如果你以为第一个扇区是从 0 度开始想停在第 3 个扇区就直接用2 / total * 2π作为目标角度那每次都会差半个扇区。因为第一个扇区实际上是从-π/2开始的扇区中线是startAngle sliceAngle / 2不是简单的数值索引换算。正确做法一定是先记录每个扇区的midAngle再用我们spinTo中推导的公式计算目标旋转角这样才能保证指针刚好落在扇区中央。另外服务端返回的prizeId如果不在当前奖品列表中比如配置刚好在接口返回后变了前端要做兜底默认停到“谢谢参与”扇区并且提示用户“活动配置更新请刷新页面”。我见过因为奖品列表更新后找不到索引而直接卡死动画的情况给用户留下一个转盘永远转不完的坏印象。4.4 常见问题速查表问题现象可能原因解决建议转盘边缘文字模糊未做 devicePixelRatio 适配按 dpr 设置 canvas 尺寸并ctx.scale文字重叠看不清扇区过小或文字过长限制字数、使用短名称、小角度不绘制文字中奖结果和指针指向不一致角度计算未考虑起始偏移用midAngle和目标旋转公式不要直接按 index 换算低端机动画掉帧每帧重绘所有扇区和文字用离屏 canvas 缓存静态盘面动画只 drawImage快速点击导致连抽缺少状态锁使用isDrawing标志动画期间禁止再次请求窗口尺寸变化后盘面错位resize 后未重建 canvas用 ResizeObserver 重建并保留当前旋转角度服务端返回未知 ID奖品配置中途变更兜底停在“谢谢参与”提示用户刷新点击命中不准确未换算旋转后的角度click 坐标用 atan2 后减去 currentRotation 再查索引5. 从开发到上线运营配置与自测心得5.1 给运营搭建可视化配置面板的思路动态转盘如果只是前端组件运营配置还是要提工单给开发的意义就削弱了一大半。我后来顺手做了一个简单的配置面板核心思路是复用同一个渲染函数页面里放一个实时预览的 canvas运营在左侧表单里编辑奖品列表、权重、颜色右侧立刻重绘预览盘面。表单可以非常简单一个可增删的行式列表就够了奖品名称输入框权重数字输入框颜色选择器上线/下线开关预览面板和线上组件共用同一套数据渲染函数不存在两套实现天然避免“预览好看上线变样”的问题。配置保存时生成一个 JSON发到服务端前端组件配置接口返回后自动刷新。我还加了一个configVersion字段前端轮询时发现版本号变化会重新拉取配置并重建盘面如果此时用户正在抽奖优先完成当前动画再刷新避免体验断裂。5.2 上线前的完整自测清单项目上线前我通常会过一遍下面这些场景奖品数量边界1 个奖品、2 个奖品、20 个奖品分别确认盘面是否正常渲染1 个奖品时整圆都是同一颜色是否符合预期。权重极值某个奖品权重为 1其他奖品权重几千确保没有计算异常扇区依然可见。长文字与特殊字符中文、英文、数字、emoji、半个字符宽度等都要跑一遍确认文字不溢出。服务端返回异常返回未知奖品 ID、返回空数据、接口超时组件是否有兜底提示。动画过程重复点击确认isDrawing锁有效不会发出二次请求。弱网切后台动画过程中用户切到后台再回来requestAnimationFrame会暂停回来时进度要能正常计算不能跳变。多端兼容iOS Safari、Android 微信内置浏览器、PC Chrome 至少各测一遍。概率备份用后端接口连续调用几百次统计中奖分布确认数据与后台配置一致。自测中最好加一个调试模式把动画时长缩短到 0.1 秒每次点击立即结束可以快速验证指针和中奖扇区是否吻合。这个调试参数测试完一定记得关闭否则上线后就是一场光速抽奖事故。5.3 一些个人体会与建议做这个动态划分板块的抽奖转盘我最大的体会是动态化核心不是绘制算法而是接口约定。只要把数据模型约定清楚前端组件就变成了一个“输入配置、输出转盘”的黑盒后续活动基本零开发。但这也要求前端对数据处理做足防御因为配置是运营填的他们永远能填出你想象不到的数据。转盘扇区面积和真实概率的关系一定要提前和产品、运营对齐。从用户视角看一个大面积的扇区会让他们觉得容易中如果实际概率完全不对应带来的负面反馈比功能 bug 更严重。我通常会建议运营把转盘面积就当做概率的可视化保持诚实省去很多后续麻烦。最后分享一个小技巧把静态盘面缓存到离屏 canvas 是我做这个组件时性价比最高的一步优化。10 个扇区和 20 个扇区的性能几乎没差别低端机上动画也足够顺滑。如果你也准备做动态转盘建议从第一天就把缓存机制设计进去而不是等卡顿了再回来补。