
简介这是一款基于uni-app与纯canvas实现的小程序转盘组件适配微信、支付宝、抖音等主流小程序端适合需要快速为活动页加入抽奖或随机决策功能的初级、中级前端开发者。压缩包共13个文件以vue页面组件、js逻辑脚本、json项目配置和scss样式表为主并附带说明文档与项目入口文件整体仅19KB用HbuilderX直接导入即可运行轻量且不依赖复杂第三方库。转盘支持自定义选项内容与配色可设置选项权重实现按概率抽奖并提供转速减速调节以模拟真实转盘的物理手感抓阄、活动抽奖、选择困难时“今天吃什么”“周末去哪玩”等场景均可直接套用。工程内包含canvas绘制示例、跨端运行配置和组件拆分结构方便读者理解小程序与canvas的结合方式也可在此基础上二次扩展动画、音效等交互。目前已有318人学习下载对想低成本落地互动组件的人来说是个不错的起步模板。1. uni-app 小程序纯 canvas 转盘一张设计稿到真机交付要跨过的坎一张抽奖转盘设计稿里漂漂亮亮一上真机就翻车指针停偏、文字发虚、快速连点会连抽好几次。uni-app 加小程序用纯 canvas 画转盘就是绕开图片素材和现成插件自己用 canvas 2D 画扇区、写文字、算角度、跑动画从绘制到交付全链路可控的做法。它适合不想多引依赖、又想把转盘外观和抽奖概率完全掌握在自己手里的团队。这篇我按自己接活动页时的顺序写先定接口选型再建坐标系然后动画、概率、避坑最后给封装。整篇代码可以直接抄进一个页面跑起来。2. 绘制转盘前先定两件事canvas 接口选型与角度模型转盘不同于普通图表它有两个特点盘面需要旋转指针固定不动扇区和文字如果每帧重绘性能扛不住。所以动手前必须解决两件事一是用哪套 canvas 接口二是所有角度统一用什么基准。这两件事不定后面全是返工。2.1 新接口还是老接口转盘场景我选 type2duni-app 里 canvas 有两套写法。老接口用canvas-id配合uni.createCanvasContext代码短适合静态图。新接口在 canvas 标签上写type2d通过 SelectorQuery 拿到底层 node再node.getContext(2d)能力更接近浏览器端 canvas能做离屏绘制。转盘场景我选新接口核心原因只有一个静态盘面画一次就行旋转时主 canvas 只做 drawImage性能远好于每帧重绘十几个扇区和文字。老接口没有离屏概念动画中每帧都要重新画扇形和 fillText低端机上掉帧明显。维度type2d 新接口canvas-id 老接口获取上下文SelectorQuery nodeuni.createCanvasContext离屏绘制支持 drawImage 离屏 canvas无离屏概念只能整盘重绘文字清晰度可手动按 dpr 放大缓冲由框架处理部分机型偏糊动画性能静态层复用主画布轻量每帧重绘扇区容易掉帧新接口获取 canvas 的完整写法如下。注意三点必须在onReady里拿v-if渲染的要等nextTick自定义组件里uni.createSelectorQuery()后面必须跟.in(this)否则查不到节点canvas.width 要乘设备像素比 dpr这一步直接决定真机清晰度。onReady() { this.initCanvas(); }, methods: { initCanvas() { const query uni.createSelectorQuery().in(this); query.select(#wheelCanvas).fields({ node: true, size: true }).exec((res) { const info res[0]; const canvas info.node; const ctx canvas.getContext(2d); const dpr uni.getSystemInfoSync().pixelRatio; // info.width 是 css 逻辑宽度绘制缓冲按 dpr 放大才清晰 canvas.width info.width * dpr; canvas.height info.height * dpr; ctx.scale(dpr, dpr); this.state { canvas, ctx, width: info.width, height: info.height }; }); } }ctx.scale(dpr, dpr)这行很关键。它让后续所有绘图命令都按逻辑像素书写比如画半径 150实际缓冲里是 150 乘以 dpr 的物理像素上屏才能点对点。如果不乘 dpr绘制坐标和 css 像素一比一真机上会被拉伸文字和圆弧边缘必然发虚。这个坑我踩了不只一次第五章节会再展开。2.2 奖品表转扇形角度起点统一从 12 点开始canvas 的 0 弧度在三点钟方向角度顺时针增加。转盘习惯上第一格从十二点方向开始也就是 -π/2。如果直接用 0 起步第一格会偏到右侧指针停下时怎么算都别扭。所以我定义一个buildSectors函数把奖品配置直接转成扇区数据。这里有一个既是绘制依据、又是后续角度计算依据的量每个扇区的中心角 mid后面所有旋转目标都由 mid 推出来。function buildSectors(list, startAngle -Math.PI / 2) { const totalWeight list.reduce((sum, item) sum item.weight, 0); let cursor startAngle; return list.map((item) { const sweep (item.weight / totalWeight) * Math.PI * 2; const sector { name: item.name, weight: item.weight, color: item.color, start: cursor, end: cursor sweep, mid: cursor sweep / 2, }; cursor sweep; return sector; }); }sweep是扇区弧度跨度按权重占比计算权重越大扇区越宽视觉效果和真实概率一致。这里全部用弧度不要混入角度制否则后面取模、归一化、比较都会出问题。startAngle默认 -π/2含义是第一个扇区从十二点方向开始如果某天产品要求第一格从三点开始只改这个参数就行。扇区数据出来后画盘面。文字我选择水平放置而不是沿半径旋转。水平放的好处是哪怕奖品名超过四个字也基本能读坏处是长名字容易出扇区边界所以字号要做小一号并配合半径动态计算。function drawWheelBoard(ctx, cx, cy, radius, sectors) { sectors.forEach((sector) { ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, radius, sector.start, sector.end); ctx.closePath(); ctx.fillStyle sector.color; ctx.fill(); ctx.strokeStyle #ffffff; ctx.lineWidth 2; ctx.stroke(); ctx.fillStyle #333333; ctx.font ${Math.max(12, Math.floor(radius / 10))}px sans-serif; ctx.textAlign center; ctx.textBaseline middle; const textR radius * 0.68; ctx.fillText( sector.name, cx Math.cos(sector.mid) * textR, cy Math.sin(sector.mid) * textR ); }); }textR radius * 0.68是文字半径位置太靠外容易压描边太靠内会压圆心装饰。Math.cos(sector.mid)和Math.sin(sector.mid)其实就是把中心角的极坐标转成 x、y 坐标这一点理解后后面所有旋转计算都是同一套数学。2.3 离屏 canvas 只画一次盘面真机不闪烁的前提盘面绘制要分离屏和主画布两层。离屏层负责把扇区、文字、描边全部画到一张静态 canvas 上之后再也不动它主画布每帧只清屏、旋转、贴离屏图再画一个固定指针。这样动画期间没有 fillText也没有 arc只有 drawImage低端机也能跑顺。微信小程序里做离屏最常见可靠的做法是页面里藏一个移出屏幕的 canvas 节点而不是用createOffscreenCanvas。原因很简单隐藏节点方式所有基础库都能查行为好预测。模板和样式如下。canvas type2d idwheelOffscreen classwheel-offscreen/canvas.wheel-offscreen { position: fixed; left: -9999px; top: 0; width: 600rpx; height: 600rpx; }不要用display: none藏离屏 canvas。部分真机基础库里 display none 的节点通过 SelectorQuery 查不到 node转盘画出来是一片空白。移出屏幕是绕开这个问题的稳妥办法。获取离屏节点并绘制盘面代码与主 canvas 几乎一样只是不再做动画initBoard() { const query uni.createSelectorQuery().in(this); query.select(#wheelOffscreen).fields({ node: true, size: true }).exec((res) { const canvas res[0].node; const ctx canvas.getContext(2d); const dpr uni.getSystemInfoSync().pixelRatio; canvas.width res[0].width * dpr; canvas.height res[0].height * dpr; ctx.scale(dpr, dpr); drawWheelBoard(ctx, this.state.width / 2, this.state.height / 2, this.state.radius, this.sectors); this.offscreenCanvas canvas; }); }离屏绘制必须等两个 canvas 都拿到 node 再开始否则offscreenCanvas为空主画布 drawImage 直接报错。所以我在 initCanvas 和 initBoard 里都会做状态标记两个都完成后才允许点击抽奖。离屏 canvas 同样要乘 dpr很多人只给主画布乘了离屏不乘结果盘面文字依然糊。3. 转起来旋转动画、缓动函数与停止时机盘面画好了接下来是让转盘转起来。这一步要解决三件事用什么方式旋转、旋转到哪个角度停下、怎么知道该停了。顺序不能乱先确定目标角度再写动画循环最后处理停止回调。3.1 旋转画布而非重绘扇区render 函数的唯一职责旋转方式有两种。一种是每帧把扇区全部重画一遍每个扇区的 start/end 加一个偏移量。另一种是把整个离屏盘面当作图片用 canvas 变换旋转它。前者每帧都有大量绘图指令后者只有一次 drawImage我始终用后者。render 函数只有一个职责根据当前累计旋转角把盘面贴到正确位置再画指针。render(rotation) { const { ctx, width, height, cx, cy, radius } this.state; ctx.clearRect(0, 0, width, height); ctx.save(); ctx.translate(cx, cy); ctx.rotate(rotation); ctx.drawImage(this.offscreenCanvas, -radius, -radius, radius * 2, radius * 2); ctx.restore(); this.drawPointer(ctx, cx, cy, radius); }ctx.rotate(rotation)接受弧度rotation 超过 2π 也没关系canvas 内部会按角度取余。drawImage 图片的左上角在圆心偏移 (-radius, -radius)宽高是直径正好把整张盘面铺在圆心周围。指针画在 restore 之后这样它不会被旋转始终指向屏幕固定方向。指针的绘制同样简单。我习惯让尖端指向十二点方向也就是 -π/2和扇区模型的零点保持一致。drawPointer(ctx, cx, cy, radius) { ctx.save(); ctx.translate(cx, cy); ctx.beginPath(); ctx.moveTo(0, -radius 12); ctx.lineTo(-10, -radius 42); ctx.lineTo(10, -radius 42); ctx.closePath(); ctx.fillStyle #dd3b3b; ctx.fill(); ctx.restore(); }指针坐标里moveTo(0, -radius 12)是尖端两个lineTo是尾部左右角。如果产品设计里指针在左侧或者其他位置改这三个点即可但记住第 3.2 节里的 pointerAt 常量也要同步改否则停止位置对不上。3.2 目标角度推算先定中奖位再反推要转多少这一步是整个转盘最容易算错的地方也是网上代码最含糊的地方。直接说结论不要转完再判断停在哪个扇区而是先定中奖扇区反推需要旋转的总角度。推理过程只有四行。盘面旋转累计 rotation 后某个扇区的中心角 mid 在主画布上的实际角度是 mid rotation。指针在 -π/2 方向。要让 mid 对准指针需要 mid rotation ≡ -π/2mod 2π所以目标总旋转量 delta normalizeAngle(pointerAt - mid)。于是 getTargetRotation 可以写成纯函数不依赖任何 canvas 实例方便单独测试function normalizeAngle(angle) { const twoPI Math.PI * 2; return ((angle % twoPI) twoPI) % twoPI; } function getTargetRotation(sectors, targetIndex, fromAngle, minTurns 4) { const pointerAt -Math.PI / 2; const mid sectors[targetIndex].mid; const delta normalizeAngle(pointerAt - mid); const current normalizeAngle(fromAngle); const rotation normalizeAngle(delta - current); return fromAngle (minTurns Math.random() * 2) * Math.PI * 2 rotation; }四个参数的含义sectors 是扇区数组targetIndex 是本次中奖扇区下标fromAngle 是当前累计旋转角可能已经很大minTurns 是最小圈数。Math.random() * 2是为了让每次总圈数在 4 到 6 圈之间浮动避免用户觉得抽奖规律。这里有一个常见误区要说明。有人先随机一个角度动画停了再判断落在哪个扇区。这样做有两个毛病一是随机落点恰好在扇区边界时判定逻辑怎么取都勉强二是扇区大小不一样时随机角度产生的概率是均匀分布和扇区面积权重对不上最后活动概率和视觉不一致。先定 targetIndex 再算角度命中是数学上精确的。3.3 requestAnimationFrame 循环从点击到停稳的状态机目标角度有了接下来是动画执行。我用固定时长加缓动函数而不是每帧按百分比递减。easeOutCubic 前快后慢符合转盘物理直觉代码也短。startSpin(targetIndex) { if (this.state.animating) return; const fromAngle this.state.rotation; this.state.lastTargetIndex targetIndex; this.state.targetAngle getTargetRotation(this.sectors, targetIndex, fromAngle, 4); this.state.fromAngle fromAngle; this.state.duration 4000; this.state.startTime Date.now(); this.state.animating true; this.state.frameId this.state.canvas.requestAnimationFrame(this.tick); }, tick(now) { const { fromAngle, targetAngle, duration, startTime } this.state; const progress Math.min(1, (now - startTime) / duration); const eased 1 - Math.pow(1 - progress, 3); // easeOutCubic this.state.rotation fromAngle (targetAngle - fromAngle) * eased; this.render(this.state.rotation); if (progress 1) { this.state.frameId this.state.canvas.requestAnimationFrame(this.tick); } else { this.state.animating false; this.$emit(finish, this.state.lastTargetIndex); } }progress是线性时间进度从 0 到 1eased是缓动后的进度前快后慢所以视觉上转盘先猛后缓。startTime用 Date.now 而不是 performance.now是为了兼容部分低端机基础库。动画结束后animating置回 false这是防连点的关键位置必须放在 tick 的结束分支里不能放在 startSpin 开头。$emit(finish) 携带的是lastTargetIndex也就是进入动画前就固定好的中奖下标。整个动画过程中这个值不变停止回调拿它做后续展示不会出现“动画停了但不知道中了哪个”的问题。4. 概率与结果校验让转盘听服务端的话转盘最容易出问题的不是动画而是概率。如果抽奖结果在客户端用 Math.random 生成用户抓包改参数就能刷奖活动运营上这是事故。所以我把结果判定和视觉效果分开服务端决定中哪个前端只负责把那个扇区转到位。4.1 抽奖接口只回目标索引前端不再自己做随机我的做法是前端把抽奖请求封装成一个 Promise服务端结果只回两个字段中奖下标和奖品名。前端拿到下标后直接进 startSpin不做任何二次随机。function requestPrize() { return new Promise((resolve, reject) { uni.request({ url: https://your-domain.example/lottery/draw, method: POST, success: (res) { if (res.data res.data.code 0) { resolve({ index: res.data.data.index, prizeName: res.data.data.name }); } else { reject(new Error((res.data res.data.msg) || 抽奖失败)); } }, fail: reject, }); }); }这里有一个前提服务端返回的 index 必须和前端扇区数组的下标一致。所以前端维护一份固定顺序的奖品配置服务端也维护一份两边的顺序用同一个配置文件生成。奖项顺序固定后颜色、文字、扇区角度都可以在前端本地映射服务端不关心视觉。如果接口还没就绪联调阶段可以用 4.2 的前端加权随机顶一下但上线前必须替换成服务端返回。这个替换不是可选项是安全底线。4.2 权重到扇区角度一份数据同时给绘制和判定用扇区角度由权重算出来这在 2.2 已经实现了。这一节重点说权重如何同时服务于视觉和概率以及一个重要的解耦设计。前端临时加权随机函数如下仅用于接口未就绪时的本地联调function pickByWeight(sectors) { const total sectors.reduce((sum, s) sum s.weight, 0); let rand Math.random() * total; for (let i 0; i sectors.length; i) { if (rand sectors[i].weight) return i; rand - sectors[i].weight; } return sectors.length - 1; }逻辑是生成一个 0 到总权重的随机数依次减去每个扇区的权重减到负数时所在的扇区就是结果。这段代码的优点是扇区越大越容易被选中视觉效果和真实概率一致。生产环境的解耦设计是服务端按自己的权重配置抽奖返回 index前端扇区角度在画盘面时参考权重但判定过程完全不看扇区角度。这样即使运营临时调了服务端概率前端不用发版转盘视觉面积和服务端概率可以不同步也不会出现“显示中大奖实际不给”的投诉因为页面说的是以活动规则为准而概率配置本来就在服务端。4.3 停止后自测命中角度归零与断言函数动画停稳后应该验证一下指针到底有没有对准目标扇区。我习惯写一个纯函数的断言不进正式包只在 debug 模式下调用。function assertHit(sectors, targetIndex, finalRotation) { const twoPI Math.PI * 2; const angle ((finalRotation % twoPI) twoPI) % twoPI; const mid sectors[targetIndex].mid; const actual ((mid angle) % twoPI twoPI) % twoPI; const diff Math.abs(actual - (-Math.PI / 2)); const ok Math.min(diff, twoPI - diff) 0.01; console.log(target${targetIndex} mid${mid.toFixed(3)} angle${angle.toFixed(3)} hit${ok}); return ok; }actual是扇区中心转到主画布后的实际角度理论上应该等于 -π/2。Math.min(diff, twoPI - diff) 是处理角度环绕比如 diff 是 6.27等于差了一整圈减一点点实际只差 0.01。0.01 弧度大约是 0.57 度肉眼看不出来足够判定命中。我在真机调试时会固定 forceIndex连转五十次每次在 finish 回调里跑这个断言。如果出现 false基本就是角度基准问题去查指针方向和扇区起点是否统一这一招帮我节省了大量时间。5. 避坑排查纯 canvas 转盘项目里反复出现的 5 个坑转盘项目代码量不大真机的坑倒是一个都不少。下面五条按出现频率排序都是我实际翻过车或者帮人排查过的问题每条都按现象、原因、解决三个层次写可以直接对照自己的项目。5.1 开发者工具正常、真机一片空白现象开发者工具里转盘渲染正常扫码到真机上 canvas 区域全白没有任何报错。原因canvas 2D 的 node 获取失败。常见原因有两个。第一canvas 放在 v-if 条件渲染里页面 onReady 时节点还没渲染出来SelectorQuery 返回空。第二转盘封装成了自定义组件但获取节点时没写.in(this)导致在组件作用域里找不到节点的具体实例。解决canvas 统一放在非条件渲染的节点下initCanvas 必须在 onReady 和this.$nextTick之后执行。组件内部必须写成uni.createSelectorQuery().in(this)。另外加一个兜底判断拿不到 node 时降级到老接口 canvas-id 重新绘制至少保证功能可用。query.select(#wheelCanvas).fields({ node: true, size: true }).exec((res) { if (!res || !res[0] || !res[0].node) { // 拿不到 node 时降级用 canvas-id 老接口重绘 this.fallbackToLegacy(); return; } // 正常初始化 });5.2 文字和圆边发虚canvas 没乘 dpr现象转盘在开发者工具里清晰在 iOS 真机上文字边缘发虚圆弧锯齿明显。原因canvas.width 默认按 css 逻辑像素创建绘制缓冲。现在真机像素比普遍是 2 到 3逻辑像素缓冲会被系统直接拉伸到物理屏拉伸必然产生模糊和锯齿。开发者工具是按照 1 倍像素模拟的所以看不出问题。解决所有 canvas包括主画布和离屏画布都必须按设备像素比放大缓冲再 scale 回来。这一步在 2.1 和 2.3 都写了这里强调一个容易被忽略的点离屏 canvas 也要乘 dpr。很多人只给主画布乘了结果盘面文字还是糊的。const dpr uni.getSystemInfoSync().pixelRatio; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; ctx.scale(dpr, dpr);提示cssWidth 用 uni.upx2px(600) 算出来的逻辑像素值别先四舍五入再乘 dpr保留小数乘完再交给 canvas像素对齐更准。5.3 指针停在不该停的位置角度基准不统一现象动画停稳后指针落在两个扇区中间或者离目标扇区中心差一小截。自测时 assertHit 返回 false。原因角度模型里扇区起点用了 -π/2但指针尖端画在了别的方向或者 getTargetRotation 里 pointerAt 写的是 0和指针实际方向不一致。三处角度基准互相矛盾停止位置自然对不上。解决定死一条规则全项目只认一个基准。扇区起点是 -π/2指针尖端指向 -π/2getTargetRotation 里 pointerAt 写 -Math.PI / 2。如果哪天改指针位置必须同步改这三个地方否则必翻车。console.log(sectors[0].start, sectors[0].end); // 第一格从 12 点开始 console.log(normalizeAngle(-Math.PI / 2)); // 指针基准约 4.715.4 快速连点时抽奖被穿透动画状态位放错了时机现象用户快速连点抽奖按钮同一个奖品发了好几次请求或者上次动画没停第二次动画就开始了把旋转角直接打乱。原因animating 状态位设置得太早。常见错误写法是在 startSpin 开头 setTimeout 之后立刻把 animating 置为 false结果动画还没结束状态就放开了。还有一种是请求还没回来时没加状态位用户在等待接口期间又点了一次。解决两个状态位配合。animating表示动画是否在跑只能在 tick 的结束分支里置 falserequesting表示接口是否在请求中请求发出到返回期间挡掉重复点击。两者都要判断。onTapSpin() { if (this.state.animating || this.state.requesting) return; this.state.requesting true; requestPrize() .then(({ index }) this.startSpin(index)) .catch(() uni.showToast({ title: 抽奖失败, icon: none })) .finally(() { this.state.requesting false; }); }如果基础库不支持 finally就在 then 和 catch 里分别把这句赋值写一遍。这个坑是转盘翻车率最高的一个状态机一定不要省。5.5 低端机闪烁掉帧盘面重绘太勤现象安卓千元机上转盘边缘闪烁文字轻微跳动转速快时明显掉帧。原因每帧 clearRect 后把十几个扇区全部重画一遍还要填充和 fillText主线程被绘图指令占满掉帧自然严重。转盘是合成动画正确做法是静态盘面只画一次。解决用第 2 章讲的离屏 canvas。主画布每帧只画指针和 drawImage整个 render 函数里禁止出现 arc 和 fillText。如果离屏做了还闪把 canvas 的 style 宽高从 rpx 换成整数 px避免子像素引起的重采样。// render 里只允许出现这些操作 ctx.clearRect(); ctx.save(); ctx.translate(); ctx.rotate(); ctx.drawImage(); ctx.restore(); drawPointer();6. 进阶把转盘做成组件顺带两个省事习惯转盘这种组件活动页大概率要复用。与其每个页面复制粘贴不如封成一个自定义组件外部只管传奖品配置和监听结果canvas 的绘制细节全部收进组件内部。这样一个页面接转盘只需要十几行代码。6.1 组件 props 与事件UI 层不关心概率逻辑组件对外暴露的接口要克制只给五个配置和两个回调。核心思路是页面不关心转盘怎么画只关心“这一抽中了什么”。prop类型说明prizesArray奖品配置每个元素含 name、weight、colordurationNumber动画时长默认 4000 毫秒minTurnsNumber最小圈数默认 4forceIndexNumber调试用固定中奖扇区下标disabledBoolean外部控制是否可抽组件的模板结构非常简单主画布加离屏画布点击事件绑在容器上。template view classwheel-wrap tap.stoponTap canvas type2d idwheelCanvas classwheel-canvas/canvas canvas type2d idwheelOffscreen classwheel-offscreen/canvas /view /template父组件调用方式是这样监听finish事件拿中奖结果通过 ref 调用start方法触发一次抽奖。组件内部在 finish 里回传 index 和奖品名页面拿到后弹窗提示。wheel refwheel :prizesprizes finishonFinish /6.2 后台暂停、debug 固定结果真机验证的两个习惯封装完组件后有两个习惯我会强制自己保留。第一个是页面 onHide 时取消动画帧避免用户切到后台再回来时动画已经跳过终点出现“停在奇怪位置”的视觉效果。onHide() { const state this.state; if (state state.frameId) { state.canvas.cancelAnimationFrame(state.frameId); state.frameId null; } }第二个是 forceIndex 调试模式。我给组件加一个 props当它大于等于 0 时抽奖请求会被忽略直接进入 startSpin 指定扇区。真机验证固件我一般固定到某个扇区连转五十次每次 finish 回调里跑一个断言检查指针是否准确命中。只有五十次全过我才放心把组件交给运营配置。我自己的习惯是把 normalizeAngle 和 getTargetRotation 这类纯函数单独放在一个工具文件里和 canvas 实例完全解耦。这样再写九宫格、刮奖这类活动组件角度计算可以直接复用只有渲染层需要重写。这个习惯帮我少踩了半数的坑希望帮到你。本文还有配套的精品资源点击获取