☰
动态抽奖转盘实现:Canvas动态划分扇区与权重控制
2026/10/6 21:32:32 网站建设 项目流程

刚做完一个被产品经理反复改需求的抽奖转盘,核心要求就是“板块数量不固定”。今天活动要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文本,让读屏用户能了解到奖品配置和中奖规则。第二个是把转盘动画接成“刮风效果”,也就是在旋转过程中叠加光晕或者高光扫过效果,让视觉层次更丰富。实现思路是在绘制层多画一层径向渐变遮罩,不影响任何角度计算逻辑。

如果你准备在自己项目里落地这套方案,我建议从数据层和绘制层开始,先把静态效果调好,再接入动画和接口,最后补边界情况。按这个顺序走,每个步骤都可以单独验证,排查问题会省力很多。抽奖组件的核心不在转盘画得多好看,而在结果计算稳、交互反馈准,这两点立住了,后面怎么加特效都是锦上添花。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询