资讯动态

动态抽奖转盘实现:Canvas动态划分扇区与权重控制

发布时间:2026/10/6 21:32:36 来源:尧图企业网站定制
刚做完一个被产品经理反复改需求的抽奖转盘核心要求就是“板块数量不固定”。今天活动要4个奖品明天可能变成12个后天可能变成20个而且每个奖品的中奖概率还要不一样。原本那种写死六宫格、八宫格的静态图彻底失效只能自己撸一个动态划分板块的方案。这篇文章就把我这次踩过的坑、理顺的算法、最终落地的代码逻辑全部拆开讲一遍。如果你也在做类似活动页、营销组件或者单纯对canvas分区绘制感兴趣这篇能帮你少走很多弯路。整个方案不依赖任何第三方库原生JavaScript加canvas就能跑。1. 项目概述为什么“动态划分”是核心难点1.1 需求场景与核心痛点市面上常见的抽奖转盘组件大多有两种实现路径。一种是设计师直接出固定张数的背景图前端只做旋转动画中奖区块天然写死。另一种是用插件比如一些老牌的jQuery转盘插件它们虽然支持自定义扇区数量但接口往往比较笨重改个样式都要去翻源码遇到移动端适配和概率权重需求更是头疼。我这次接到的需求问题就出在“变化”上。运营后台的奖品池是动态配置的接口返回多少条转盘就要实时渲染出多少块。这意味着扇区数量运行时才知道可能是1个也可能是20个每个奖品各自带不同的概率权重有的占5%有的占15%转盘停止后指向的扇区必须严格命中后台计算好的中奖结果视觉效果必须撑得起来不同数量下都不能出现文字溢出、颜色混乱如果在这一步选择妥协比如让运营最多只能配置8个奖品那这个组件就失去了通用性如果硬套固定的绘制方案扇区一多文字就会全部糊在一起。所以动态划分不只是“画几个扇形”那么简单它牵扯到坐标计算、权重换算、文字排版、动画控制一整条链路。1.2 技术选型背后的考量技术方案上我排除了WebGL、SVG和Canvas三选一的纠结最终选了Canvas 2D。原因很直接WebGL虽然性能上限高但为了一个抽奖转盘引入着色器、矩阵变换属于杀鸡用牛刀而且后期维护门槛高团队其他同事接手困难。SVG对形状支持很好但大量扇形路径的动态生成和逐帧旋转动画在节点数量和重绘频率上来的时候性能和代码复杂度都不占优势。Canvas 2D则恰到好处。扇形绘制用arc方法非常直接逐帧旋转只需要rotate一个状态再到动画结束后用角度反查命中哪个扇区整个逻辑链条短、容易调试。兼容性上canvas从移动端到桌面端都是完整的不用做降级处理。实际跑下来哪怕60帧动画跑满canvas的CPU和内存占用都很平稳。还有一个重要的非技术原因canvas最终导出的是一张位图像素级视觉效果完全可控不会像DOM/CSS方案那样受到字体渲染、边框抗锯齿的干扰在活动页这类对视觉统一性要求较高的场景里更稳。1.3 整体架构一览整个组件我拆成了四个模块各管一摊互不牵扯数据层接收奖品数组、权重配置做归一化校验计算出每个扇区的角度范围和对应的概率边界绘制层负责把扇区、文字、指针、装饰元素画到canvas上并处理高清屏的清晰度问题动画层控制旋转的总时长、缓动曲线、最终停止角度用requestAnimationFrame驱动交互层处理按钮点击、动画期间的状态锁、结果回调这样拆完以后就算后面运营后台又改出什么“仅展示不抽奖”的模式或者要加“翻开即中奖”的定制逻辑我也只需要改其中一层其他地方全部不动。这里也建议你写类似组件时先把架构想清楚别把所有逻辑堆在一个函数里。2. 动态扇区分割的核心算法与绘制实现2.1 权重比例到角度的换算逻辑动态划分的第一个难点是把“每个奖品占了总概率的多少百分比”换算成“这个扇区应该画多大的角度”。我用的方案是权重制也就是接口返回每个奖品除了名称、图片还带一个weight字段。有效权重之和作为分母每个奖品的权重除以分母再乘以2π就是它对应的弧度。换算公式非常简单function calcSegments(prizes) { const totalWeight prizes.reduce((sum, p) sum p.weight, 0); let startAngle 0; return prizes.map((p) { const angle (p.weight / totalWeight) * Math.PI * 2; const segment { ...p, startAngle, endAngle: startAngle angle, }; startAngle angle; return segment; }); }这里有一个细节值得提醒所有扇区的角度累加结果会因为浮点计算出现微小的误差最后一块扇区结束时可能不是精确等于2π。我处理的办法是强制把最后一块的endAngle修正为Math.PI * 2避免出现一条肉眼可见的缝隙或者线条错位。2.2 扇区绘制的完整代码绘制阶段每个扇区就是一个以圆心为起点、按照起止角度画出的封闭路径。主流的转盘视觉设计有两种风格一种是所有扇区共用一个半径扇区之间由描边分开另一种是内圈留一个圆形平台区域扇区从内半径开始向外延伸。我选的是后者因为内圈平台可以放一个圆形的运营LOGO或者按钮视觉层次更丰富。核心绘制代码如下function drawWheel(canvas, segments, options) { const ctx canvas.getContext(2d); const centerX canvas.width / 2; const centerY canvas.height / 2; const outerRadius Math.min(centerX, centerY) - options.padding; const innerRadius outerRadius * options.innerRadiusRatio; // 比如 0.35 ctx.clearRect(0, 0, canvas.width, canvas.height); const colorList options.colors || [#FF6B6B, #4ECDC4, #FFE66D, #A8E6CF]; segments.forEach((seg, index) { const color colorList[index % colorList.length]; // 绘制扇区 ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, outerRadius, seg.startAngle, seg.endAngle); ctx.closePath(); ctx.fillStyle color; ctx.fill(); ctx.strokeStyle #FFFFFF; ctx.lineWidth 2; ctx.stroke(); // 绘制内圈装饰环 ctx.beginPath(); ctx.arc(centerX, centerY, innerRadius, seg.startAngle, seg.endAngle); ctx.strokeStyle #FFFFFF; ctx.lineWidth 2; ctx.stroke(); }); // 绘制中心圆 ctx.beginPath(); ctx.arc(centerX, centerY, innerRadius - 2, 0, Math.PI * 2); ctx.fillStyle #FFFFFF; ctx.fill(); }颜色这块我没有直接在数据里写死每块扇区的颜色而是定义了一个循环取色的色板。这样即使奖品列表有20个也能保证视觉上的色彩隔离每个相邻扇区颜色不相同。色板的选色要刻意避开相近色不然两块浅黄色扇区相邻用户根本分不清边界。2.3 文字排布与防溢出处理扇区可以很小但文字不能没有。这是动态转盘最容易翻车的地方。奖品列表有20个时每个扇区只有18度正常尺寸的文字横着放必然重叠。我处理文字用的是分段旋转布局加自适应字号规则如下文字沿着扇区的角平分线方向排列文字整体宽度超出扇区可容纳宽度时逐级缩小字号文案过长时强制截断以省略号结束短文本垂直排布长文本沿径向排布优先保证阅读方向一致具体实现是取扇区起始角度和结束角度的中间值作为文字基线角度。文字的位置在内外半径的中点上这样既不会跑到内圈平台里去也不会贴到外圈边沿。绘制前先用ctx.measureText量出文本宽度估算在弧线上的可容纳尺寸再决定字号。function drawSegmentText(ctx, seg, centerX, centerY, midRadius, maxWidth) { const midAngle (seg.startAngle seg.endAngle) / 2; let fontSize Math.min(16, midRadius * 0.24); ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(midAngle); ctx.font ${fontSize}px PingFang SC, Microsoft YaHei, sans-serif; ctx.fillStyle #333333; ctx.textAlign right; ctx.textBaseline middle; // 按可容纳宽度截断文本 let text seg.name; while (ctx.measureText(text).width maxWidth fontSize 10) { fontSize - 1; ctx.font ${fontSize}px PingFang SC, Microsoft YaHei, sans-serif; } while (ctx.measureText(text).width maxWidth) { text text.slice(0, -1); } ctx.fillText(text, midRadius - 8, 0); ctx.restore(); }这里要特别说明textAlign: right和fillText的坐标设置。我把坐标原点平移到了圆心然后旋转到角平分线方向此时x轴正方向正好指向扇区中心。文本右对齐表示文本末尾落在靠近外圈的位置文字从内向外“长”出去视觉上更自然。如果想让文字反过来贴合外圈阅读也可以改成左对齐但需要把坐标点调整到半径最大处再画。2.4 高清屏与缩放适配canvas在高分屏上会发虚这是绕不开的坑。iPhone的devicePixelRatio是2甚至3如果canvas的像素尺寸和CSS尺寸一样绘制出来的图会被浏览器强行放大边缘全是锯齿。我的处理方式是canvas的实际像素尺寸乘以devicePixelRatio然后通过ctx.scale(dpr, dpr)让绘图坐标系保持在逻辑尺寸上。这样所有坐标计算和文字大小都不用理会设备差异但渲染出来的清晰度是物理像素级的。function setupCanvas(canvas, width, height) { const dpr window.devicePixelRatio || 1; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width ${width}px; canvas.style.height ${height}px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }3. 动画过程控制与中奖结果命中3.1 缓动函数与旋转总计圈数设计动画的核心目标是从当前角度开始经过一定时间停在指定扇区的指定角度上。转动过程中的“手感”由缓动函数决定。我用了常见但效果扎实的easeOutQuart它的特点是前期速度快、后期减速明显很接近真实转盘因摩擦力减速的感觉。function easeOutQuart(t) { return 1 - Math.pow(1 - t, 4); }旋转总角度需要设计成“基础圈数 目标角度修正”。基础圈数设为5到8圈的随机数保证每次转动的视觉效果不会是歪歪扭扭的半圈太短的旋转用户会觉得没刺激感。目标角度修正则根据后端返回的中奖奖品索引计算让转盘最终停在指针正对的位置。3.2 停止角度的精确计算假设指针固定在正上方也就是12点钟方向对应角度为-Math.PI / 2或Math.PI * 1.5要算出让某个扇区的中位线对齐指针的最终旋转角需要三步推理扇区中位线的初始角度(seg.startAngle seg.endAngle) / 2指针指向的固定角度-Math.PI / 2转盘从初始位置旋转到指定位置需要的旋转量中位线当前角度减去指针角度但转盘是会先正传很多圈的所以最终角度还要加上若干个完整的2π。我把这个逻辑封装成一个函数function calcTargetRotation(segments, targetIndex, baseRounds) { const seg segments[targetIndex]; const midAngle (seg.startAngle seg.endAngle) / 2; const pointerAngle -Math.PI / 2; const target pointerAngle - midAngle; // 保证 target 为正数并且加上基础圈数 const normalized target - Math.floor(target / (Math.PI * 2)) * Math.PI * 2; return baseRounds * Math.PI * 2 normalized; }这里有一个特别容易出错的点canvas的arc坐标系里角度是顺时针增长的0弧度在三点钟方向。如果不小心把指针放在正上方却用0弧度对齐最终中奖结果会偏移90度用户看到指针明明指着“谢谢参与”弹窗却提示中了一等奖。我调试的时候就被这个问题坑了一次所以建议你在联调阶段先打开调试模式在canvas中心打印出每个扇区的边界角度核对指针指向与命中的一致性。3.3 requestAnimationFrame动画循环的实现动画执行用requestAnimationFrame驱动每一帧根据缓动函数算出当前总旋转角然后重绘整个转盘。这里所谓的重绘不是把扇区重新画一遍而是通过ctx.save()、ctx.translate(centerX, centerY)、ctx.rotate(currentAngle)、再调用之前封装好的drawWheel绘制到旋转后的坐标系里。function spinTo(targetRotation, duration, onFinish) { const startRotation currentRotation; const delta targetRotation - startRotation; const startTime performance.now(); function frame(now) { const progress Math.min((now - startTime) / duration, 1); const eased easeOutQuart(progress); currentRotation startRotation delta * eased; renderRotated(); if (progress 1) { requestAnimationFrame(frame); } else { currentRotation targetRotation; renderRotated(); onFinish(); } } requestAnimationFrame(frame); }需要注意每次旋转结束后要把currentRotation模到2π以内。不然连续抽奖十几次以后这个角度数值会累积得非常大浮点误差也跟着累积最终停止位置和预期偏差会越来越明显。我在每次结束时做了归一化处理。3.4 防止重复点击与抽奖状态锁交互层必须锁状态。具体来说点击抽奖按钮后立即进入spinning状态按钮置灰再次点击直接返回动画结束后解锁等接口确认后再允许下一次。这个逻辑看起来简单但很容易被各种边界情况击穿比如用户疯狂连点、断网后请求超时、接口返回异常等。我处理的方式是给抽奖流程加了三把锁点击锁点击后立即锁定防止连点请求锁点击后发起接口请求在结果返回前不放开点击锁动画锁拿到后端返回的目标扇区后再开始转转完才解锁有一件事我必须特别强调转盘的最终命中角度必须以后端返回的奖品索引为准不能在前端本地随机。市面上很多简单的demo教程都是前端本地随机一个索引然后转过去这在营销场景里是绝对不可用的。原因很简单——前端本地随机意味着用户可以修改请求参数、甚至直接改代码让某个固定索引每次都命中活动风控直接就崩溃了。我这次接的后端接口会返回prizeIndex和prizeId前端只用这个索引去计算动画角度奖品信息展示也以接口返回为准。4. 边界场景处理与常见问题排查4.1 只有一个奖品时的满圆绘制运营后台配置奖品时如果只配置了一个奖品它的权重就是100%计算出来的角度恰好是2π。画出来是一个完整的圆但它的startAngle和endAngle相等直接用arc绘制路径时可能会导致绘制结果异常。我加了一个特判当角度等于或者非常接近2π时把endAngle设置成2π - 0.0001并额外把扇区填充逻辑改成完整圆绘制。同时在文字排版上单个奖品可以居中放大显示和多个扇区的布局逻辑分开写视觉效果更好。4.2 极小扇区的文字处理策略20个奖品且权重分布极度不均时必然出现某个扇区只有2到3度的情况。此时无论怎么缩小字号文字都不可能放得下。如果强行截断用户只看到两个省略号等于什么都没显示。我最终的策略是优先级降级处理当扇区角度小于8度时不再绘制扇区内文字而是把这个奖品拼成一个图例列表画在转盘两侧的空白区域。图例内容从上到下对应顺时针扇区顺序并用和扇区相同的颜色做色块标注。这样做虽然牺牲了文字和扇区的空间重合但信息传达的准确性反而更高。这也是为什么整个架构里我一直坚持“每个扇区数据都携带索引”的原因图例和扇区才能对应起来。4.3 移动端触摸事件与页面滚动手势冲突活动页通常在手机端打开转盘本身需要用户触摸操作之外页面可能还需要上下滚动。如果touch事件处理不好会出现用户滑动页面时误触转盘按钮或者点击转盘时页面被带动滚动。我的处理方案给canvas容器加上touch-action: none样式阻止触摸手势直接作用于滚动。点击事件统一用click监听不在canvas上绑touchstart。同时抽奖按钮区域单独固定到底部不跟转盘重叠彻底规避误触。组件初始化时还加了视口尺寸监听resize时重新设置canvas的宽度和高度因为很多移动端浏览器在地址栏收起/展开时都会触发尺寸变化不监听会导致转盘变形。4.4 接口异常与中奖结果兜底抽奖接口不可避免会有超时、报错的情况。我处理的原则是拿不到明确结果前绝对不让转盘停下来指向任何扇区。如果接口超时弹窗提示“网络开小差了”转盘保持当前状态按钮解锁允许用户重试。这里有一个细节有的团队为了用户体验会选择前端本地先随机一个扇区转起来等接口结果返回后再修正。我强烈不建议这么干。如果接口结果是异步修正用户会看到转盘突然跳转到另一个扇区体验极其诡异如果接口结果永远不返回用户就卡在一个毫无意义的旋转结果上。抽奖这种涉及真实利益的场景交互上的“慢”可以接受结果错乱才是致命伤。4.5 常见问题速查表整理一下我这次开发过程中遇到的高频问题直接列成表格方便你对照排查。问题现象根本原因解决方案转盘模糊发虚未处理devicePixelRatiocanvas物理像素乘dpr再scale回去指针指向与中奖结果不符角度基准错乱0弧度位置理解错误明确指针基准角计算target时统一换算连续抽奖后位置偏移currentRotation浮点累积每次结束模2π归一化扇区多时文字重叠未做字号自适应和截断measureText动态测宽逐级缩小字号最后一块扇区有缺口浮点累加导致endAngle不等于2π强制修正最后一块为2π移动端点击触发页面滚动touch事件未隔离容器加touch-action:none按钮连点触发多次抽奖状态锁缺失点击锁请求锁动画锁三把锁5. 实测效果与后续可扩展的方向最后说一点实际使用中的体会。这个动态转盘组件上线以后运营那边配置过4个奖品的常规抽奖也配置过18个奖品的周年庆活动两个场景都稳住了。4个扇区的场景视觉上比较饱满18个扇区虽然扇区窄但配合图例列表和色板循环用户依然能快速看懂规则。canvas动画在低端安卓机上也没有掉帧到肉眼可见的程度整体表现符合预期。我觉得最有价值的扩展方向有两个。第一个是接入无障碍支持虽然canvas绘制的内容天然不利于屏幕阅读器但可以在canvas外层加一份等价抽奖说明的DOM文本让读屏用户能了解到奖品配置和中奖规则。第二个是把转盘动画接成“刮风效果”也就是在旋转过程中叠加光晕或者高光扫过效果让视觉层次更丰富。实现思路是在绘制层多画一层径向渐变遮罩不影响任何角度计算逻辑。如果你准备在自己项目里落地这套方案我建议从数据层和绘制层开始先把静态效果调好再接入动画和接口最后补边界情况。按这个顺序走每个步骤都可以单独验证排查问题会省力很多。抽奖组件的核心不在转盘画得多好看而在结果计算稳、交互反馈准这两点立住了后面怎么加特效都是锦上添花。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑