转盘小程序大概是微信小程序实战里被问得最多的入门项目之一。我这次用微信小程序原生框架硬写了一版“转盘小程序1.0”,没有接任何第三方UI库,也没有用跨端框架,从页面搭建、Canvas绘制转盘、旋转动画到抽奖逻辑全部自己实现,最后打包了一份可以直接跑起来的源码。如果你正准备拿小程序练手、或者想做一个营销活动用的转盘页面,这篇文章应该能帮你省掉不少弯路。
先说结论:原生框架写转盘并不难,真正费时间的不是“画转盘”,而是“把转盘转得自然”、以及处理真机上那些和离线工具里不一样的渲染表现。这篇记录会把选型理由、绘制原理、动效方案、概率设计和踩坑过程都拆开讲,源码我放在最后一段说明目录结构和运行方式,方便直接对照着看。
1. 为什么这个转盘我选择硬写原生框架
1.1 原生 vs 跨端框架的第一轮筛选
动手之前我纠结过要不要直接用 uni-app 或者 Taro,毕竟跨端框架理论上写完还能输出到其他平台。后来评估了一下实际需求:这个转盘只跑微信小程序一个端,活动周期短、逻辑也不复杂,没必要为“未来可能多端”这种不确定需求提前买单。微信小程序原生框架的语法就是 WXML + WXSS + JS,模板渲染、组件生命周期、Canvas能力都是平台自己提供的,遇到问题查官方文档和社区资料最直接,不用隔着一层编译转换去猜“为什么我的代码在跨端框架里表现不一样”。
跨端框架的价值在于“一次开发多端运行”,代价则是你至少要懂它所带的那套DSL语法和组件映射规则。比如转盘这种高度依赖 Canvas 自绘的场景,跨端框架对 Canvas 的包装程度不一,有的甚至要引入额外插件才能用上底层原生的 Canvas 2D API。既然目标平台已经锁定是微信小程序,那原生框架反而是心智负担最小的方案。
1.2 原生框架做转盘的真实成本
原生框架的短板也很明显:没有开箱即用的“旋转动画”组件,没有现成的“转盘绘制”库,所有东西都得自己造。旋转动画可以用 CSS transition 配合 transform 实现,这倒不麻烦;真正的成本在 Canvas 绘制和真机适配这层——不同机型和微信基础库版本对 Canvas 的实现有差异,稍不注意就出现转盘模糊、绘制不居中、动画卡顿这些问题。
我做这个项目的态度是:既然目的是搞懂原理,那就从最底层开始搭。源码里没有依赖任何第三方轮子,Canvas 画扇形、写奖品文字、做旋转位移动画,全部是原生 API。这样后面不管你是要改成“九宫格抽奖”“刮刮卡”还是“幸运转盘升级版”,手里都有一份能改得动的底子,而不是只会调用某个封装好的组件。
2. 转盘小程序的整体设计:先把功能边界划清楚
2.1 页面结构与用户动线
转盘页面看起来只有一个转盘加一个按钮,但动线设计上其实有讲究。我最终的结构是:顶部放奖品说明条,中间是转盘主体和指针,底部是“我的抽奖次数”和“奖品列表”。用户进来第一眼就能看懂“这个转盘能转”,按钮放在转盘正中心,点击区域大,不容易误触。
页面文件划分如下:
pages/index/index.wxml:页面骨架,只有转盘容器、指针、抽奖按钮和奖品列表pages/index/index.wxss:布局样式、旋转动画过渡、指针三角形pages/index/index.js:Canvas绘制、抽奖请求、旋转角度计算pages/index/index.json:页面标题栏配置,允许下拉刷新关闭
转盘的 DOM 结构我用 Canvas 画底图,指针用纯CSS三角形叠在Canvas上面。为什么不直接用 Canvas 画指针?因为指针需要独立于转盘之外保持不动,如果用同一个 Canvas 画指针,每次旋转动画就得在帧回调里重绘指针,逻辑反而复杂;CSS 指针天然固定,Canvas 只负责旋转,职责分离后动画性能更好。
2.2 数据模型先行:奖品、概率与状态
写代码前我先把数据模型定下来,避免画到一半发现奖品个数变了导致角度计算全乱。奖品列表我用数组存储,每一项包含编号、名称、权重、颜色,颜色作为可配置字段方便活动方调整,不用改代码。
const DEFAULT_PRIZES = [ { id: 1, name: '谢谢参与', weight: 40, color: '#FFD700' }, { id: 2, name: '优惠券5元', weight: 25, color: '#FF8C42' }, { id: 3, name: '免单资格', weight: 5, color: '#FF6B6B' }, { id: 4, name: '积分100', weight: 15, color: '#4ECDC4' }, { id: 5, name: '再来一次', weight: 10, color: '#A78BFA' }, { id: 6, name: '谢谢参与', weight: 30, color: '#F7B731' } ]这里有两个细节值得说一下。第一,权重是我定的抽奖概率基础,不是每个奖品各占1/6,后面会用加权随机算法根据weight算中奖结果。第二,我用两个“谢谢参与”占掉高权重区间,这是活动运营常见的做法,目的是控制实际中奖率,代码里保持一致,真实活动时直接在配置接口里改权重即可。
页面状态我用data里三个字段管理:isRunning表示动画是否进行中、transformDeg表示当前转盘已经旋转的总角度、remainCount表示剩余抽奖次数。动画进行中要锁住按钮,否则用户连点十个请求,转盘会疯掉,这个锁必须放在最前面做短路拦截。
3. 转盘绘制与旋转动效的实现细节
3.1 用 Canvas 2D 画转盘扇区
这一版我用的 Canvas 是type="2d"的新接口,而不是老式的wx.createCanvasContext。区别很重要:老的接口是一套异步绘制语法,无法直接拿到 Canvas 节点去做像素级的尺寸控制;新的 Canvas 2D 接口可以拿到节点对象,还能设置物理像素大小,在高分屏下更清晰。WXML 里这样写:
<view class="wheel-wrapper"> <canvas type="2d" id="wheelCanvas" class="wheel-canvas"></canvas> <view class="pointer"></view> <view class="center-btn" bindtap="startLottery"> {{ isRunning ? '转动中' : '抽奖' }} </view> </view>拿到 Canvas 节点并初始化尺寸的代码:
initCanvas() { const query = wx.createSelectorQuery() query.select('#wheelCanvas').fields({ node: true, size: true }).exec((res) => { if (!res || !res[0]) return const canvas = res[0].node const width = res[0].width const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = width * dpr canvas.height = width * dpr const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr) this.drawWheel(ctx, width) }) }这里必须提到一个容易被新手忽略的点:canvas.width = width * dpr不只是“乘以一个系数”那么简单,它决定了 Canvas 内置画布有多少个物理像素。如果你直接写canvas.width = width,在高清屏上转盘文字会发虚;但只设置 size 字段拿到的res[0].width又是 CSS 像素,所以要用pixelRatio转换。ctx.scale(dpr, dpr)则是让后续绘制的所有坐标都基于 CSS 像素,画的时候不用再手动乘,思维负担小很多。
画扇区的核心代码:
drawWheel(ctx, width) { const center = width / 2 const radius = center - 8 const prizes = this.data.prizes const sectorAngle = (2 * Math.PI) / prizes.length ctx.clearRect(0, 0, width, width) prizes.forEach((prize, i) => { const startAngle = -Math.PI / 2 + i * sectorAngle const endAngle = startAngle + sectorAngle ctx.beginPath() ctx.moveTo(center, center) ctx.arc(center, center, radius, startAngle, endAngle) ctx.closePath() ctx.fillStyle = prize.color ctx.fill() ctx.strokeStyle = '#FFFFFF' ctx.lineWidth = 2 ctx.stroke() ctx.save() ctx.translate(center, center) ctx.rotate(startAngle + sectorAngle / 2) ctx.textAlign = 'right' ctx.textBaseline = 'middle' ctx.fillStyle = '#333333' ctx.font = '14px sans-serif' ctx.fillText(prize.name, radius - 16, 0) ctx.restore() }) }绘制扇区的关键是理解 Canvas 的角度系统:默认 0 度在圆心正右方,角度顺时针递增,因为 Canvas 的 Y 轴是向下的。我让第一个扇区从-Math.PI / 2开始,也就是正上方,这样转盘初始时“第一格”在顶部指针位置,视觉上最自然。扇区间用白色描边分隔,文字先旋转到扇区中心方向再fillText,配合textAlign: 'right'让文字尾部落在半径内侧,就不会溢出转盘边缘。
文字这块我还要多说一句:中文奖品名如果超过四个字,建议截断或缩小字号,否则会从扇区里戳出去。我源码里做了一个简单处理,超过四字就替换为四字缩写,真实项目可以把完整名称放到中奖弹窗里展示。
3.2 指针与旋转动画的两套方案
绘制和动画我拆成了两个方案,源码里用的是 CSS 方案,因为它简单、性能好,而且微信小程序的 WXSS 支持transition。做法是:给 Canvas 的容器加transform: rotate(deg),每次抽奖时把目标角度更新到data里,利用 CSS 的transition属性自动补间旋转。
.wheel-canvas { width: 600rpx; height: 600rpx; transition: transform 4s cubic-bezier(0.25, 0.1, 0.25, 1); }注意,这里要把transition放在 Canvas 本体的 class 上,而不是放在外层容器上。我第一版把动画加在父容器上,结果转发分享时父容器样式被页面级样式影响,旋转中心跑偏,排查半天。CSS 旋转默认中心点是元素中心,Canvas 是正方形,所以转盘不会出现偏心问题;但如果你的 Canvas 不是严格正方形,务必用transform-origin显式指定圆心坐标。
除了 CSS 方案,我也把 requestAnimationFrame 驱动的方案在源码注释里保留了一份。两者的区别在于:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| CSS transition | 简单可靠,不阻塞JS,默认硬件加速 | 动画时长固定,中途打断麻烦 | 普通营销转盘 |
| requestAnimationFrame | 可精确控制位移动画曲线、支持掉落音效同步 | 需手动管理帧回调,代码量大 | 对动效节奏有特殊要求 |
如果只是要“转起来”,CSS 方案完全够用。但要注意:微信小程序的transitionend事件在某些安卓基础库版本上不触发,所以不能依赖它去重置状态。我在 JS 里用setTimeout和动画时长对齐,虽然粗暴,但在线工具和真机上都稳定。
3.3 停止位置与中奖结果的联动
这是整个项目里最容易算错的地方。核心问题是:客户端只负责把转盘转到“某个扇区的中线对准指针”,至于这个扇区到底是什么奖品,必须是接口返回的中奖结果决定的,不能由客户端随机决定,否则用户抓包就能伪造中奖。
我的角度计算逻辑是这样的:转盘从正上方开始逆时针编号第 0 格,每个扇区占360 / 扇区数度,扇区 i 的中心线在(i + 0.5) * sectorAngle度。指针固定在正上方,转盘顺时针旋转,如果想让扇区 i 的中心线转到指针位置,那么旋转角度的最终模 360 应该是360 - (i + 0.5) * sectorAngle的补角。
calculateTargetDeg(resultIndex) { const total = this.data.prizes.length const sectorAngle = 360 / total const finalMod = (360 - (resultIndex + 0.5) * sectorAngle) % 360 const currentDeg = this.data.transformDeg const currentMod = currentDeg % 360 const nextDeg = currentDeg - currentMod + 360 * 6 + finalMod return nextDeg }这里的360 * 6是“多转六圈”的视觉缓冲。如果不加整圈数,每次点击都只转动不到 60 度,用户会觉得转盘很敷衍;加上多圈之后,转盘会连续旋转至少 6 圈再停下,更有“抽奖感”。而currentDeg - currentMod + finalMod的作用是在当前角度基础上补到最近一次完整圈再对齐目标,保证每次角度都是单调递增的,不会出现回旋。
有人可能会问:finalMod比currentMod小怎么办?比如当前停在 350 度,目标要停到 30 度。公式里先减掉currentMod回到整圈,再加 6 整圈和新的finalMod,结果仍然比当前角度大很多,所以转盘只会向前转,不会倒着转,这个细节必须想明白,否则真机上会看到诡异的反转。
4. 抽奖逻辑背后的概率设计与接口对接
4.1 加权随机算法
转盘上 6 个格子对应的权重并不相同,所以不能用“随机整数取模”这种方式决定中奖结果。正确做法是加权随机:先把所有奖品权重加起来得到总权重,再生成一个 0 到总权重之间的随机数,依次减去各奖品权重,第一次减到负数时对应的索引就是中奖项。
function pickIndexByWeight(prizes) { const totalWeight = prizes.reduce((sum, item) => sum + item.weight, 0) let random = Math.random() * totalWeight for (let i = 0; i < prizes.length; i++) { random -= prizes[i].weight if (random <= 0) return i } return prizes.length - 1 }我源码里把这个函数保留在utils/lottery.js中,但它仅用于“本地演示模式”。正式项目里这个决策必须在服务端完成:用户点击抽奖后,小程序把用户标识发给后端,后端查数据库里的剩余次数、计算权重、写中奖记录,再返回中奖索引给前端做旋转动画。前端永远不生成结果,只播放结果,这样别人就算看穿你的角度算法,也只能改自己页面的显示,动不了后台的中奖记录。
4.2 抽奖次数控制
剩余次数这层我做了两层保险:本地做前置校验,接口做最终校验。本地校验是为了减少无效请求,交互上友好;接口校验是真正的安全边界,防止用户连续调用接口刷次数。源码里的次数控制简单地用wx.setStorageSync模拟:
decreaseRemainCount() { const remain = this.data.remainCount - 1 this.setData({ remainCount: remain }) wx.setStorageSync('remainCount', remain) if (remain <= 0) { wx.showToast({ title: '抽奖次数已用完', icon: 'none' }) } }真实场景下,剩余次数应该存在服务端,而且每次抽奖要避免并发重复扣减。比较稳妥的做法是数据库里用原子操作扣减次数,扣减失败就直接返回“次数不足”,同时配合后端防重放和用户当日次数限制。我这个 1.0 版本先不做这么重,只把本地模拟的链路打通,等你接真实后端时把fetchLotteryResult里的本地pickIndexByWeight替换成真实请求就行。
4.3 接口对接的占位设计
网络请求在源码里是一个 Promise 封装,方便你后面直接替换成wx.request:
fetchLotteryResult() { return new Promise((resolve) => { // 演示模式:本地加权随机决定结果 // 正式模式:调用 wx.request 请求后端,返回 { index: n } const index = pickIndexByWeight(this.data.prizes) setTimeout(() => resolve({ index }), 300) }) }请求成功后要做三件事:关锁、算角度、渲染中奖弹窗。弹窗不能放进旋转动画里,必须等动画结束后再弹出,否则用户还没看清停在哪个格子就被弹窗遮住了,体验很差。如果你要加音效,可以在动画开始时播放“咔嗒咔嗒”的转盘声,结束时播放“中奖”音效,两个音效和setTimeout的时长对齐就行。
5. 真机调试和兼容性踩坑记录
5.1 新老 Canvas 接口的差异把我坑了一回
我一开始图省事用了老式wx.createCanvasContext写法,在开发者工具里一切正常,结果一上真机,转盘文字偶尔出现绘制顺序错乱、圆环颜色不对的情况。后来查了基础库更新日志才意识到,老接口已经进入了维护状态,新项目应该直接使用type="2d"的 Canvas 2D 接口。
老接口和新接口的核心差异:
| 维度 | 老接口 createCanvasContext | 新接口 type="2d" |
|---|---|---|
| 获取方式 | 传 canvasId 即可 | 需通过 SelectorQuery 获取 node |
| 像素比 | 无法直接控制物理像素 | 可设置 canvas.width/height |
| 坐标系 | 默认逻辑像素 | 逻辑像素,需手动 dpr 缩放 |
| 生命周期 | 绘制命令异步排队 | 同步绘制,可控性强 |
升级到新接口后,我还遇到一个更隐蔽的问题:ctx.scale(dpr, dpr)之后,如果页面从onLoad里立刻初始化 Canvas,某些低端安卓机上会拿不到节点,res[0]为空。解决办法是等页面渲染完成后再执行,或者放到wx.nextTick里。这句代码看着不起眼,但少了它,转盘在低端机上就是一片空白。
5.2 视觉适配:rpx、安全区域和转盘尺寸
布局上我用的 rpx 单位,转盘宽度设为 600rpx,在 375px 宽的机型上显示为 300px,在 414px 宽的机型上会等比放大,中心点始终在屏幕宽度内。但有一个细节:rpx 缩放是线性的,而dpr和屏幕宽度没有线性关系,所以 Canvas 的物理尺寸一定要用wx.getSystemInfoSync().pixelRatio显式计算,不能靠 rpx 猜。
底部按钮和奖品列表要兼容 iPhone 等带安全区域的机型,我在index.wxss里加了安全区适配:
.prize-panel { padding-bottom: calc(env(safe-area-inset-bottom) + 30rpx); }如果不加这一句,底部内容会被设备底部手势条挡住,“我的抽奖次数”按钮没法点,用户会以为程序坏了。
5.3 常见真机问题的排查清单
我把这次调试过程里遇到的真机问题整理成一张表,方便你对照排查:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 转盘文字模糊 | 未乘 dpr | 设置 canvas.width = 元素宽 * pixelRatio |
| 转盘不居中 | 容器或 Canvas 尺寸不一致 | 保持 Canvas 宽高一致,确认 transform-origin 居中 |
| 转了一圈又弹回原位 | 未更新 transformDeg 状态 | 在动画结束后把 targetDeg 写回 data |
| 点击抽奖后无反应 | isRunning 锁未释放 | 检查 setTimeout 结束逻辑,确保 isRunning 复位 |
| 安卓机动画卡顿 | 过度绘制或 transform 加在复杂视图上 | 只对 Canvas 本体做 transform,关闭其他视图的过渡 |
真机调试有一个原则:能用“真机预览”验证的,绝不在开发者工具里下结论。像 Canvas 渲染、transitionend是否触发、字体显示效果这些,工具上的表现和真机差别很大。我强烈建议你每改一版都拿安卓和苹果各测一遍,别偷懒。
6. 源码目录结构、运行方式与后续扩展方向
6.1 目录结构
源码最终是这样组织的:
miniprogram/ ├── app.js ├── app.json ├── app.wxss ├── pages/ │ └── index/ │ ├── index.wxml │ ├── index.wxss │ ├── index.js │ └── index.json ├── utils/ │ └── lottery.js └── images/ └── (奖品图标,可选)lottery.js里放的是加权随机算法和奖品模型相关的常量、函数,和页面解耦,以后要写成独立模块或做测试都方便。业务代码都在pages/index下,结构非常清楚,适合第一次看的人顺着页面文件从头理。
app.json里我配置了页面路径和窗口样式,没有开其他多余能力。如果你想做分享裂变,记得在index.js里补onShareAppMessage,转盘类活动最常见的增长手段就是“分享给好友多一次抽奖机会”,这个功能几乎必做。
6.2 运行方式
运行源码很简单,按下面几步走,两分钟内能看到转盘转起来:
- 打开微信开发者工具,选择“导入项目”,定位到
miniprogram目录 - AppID 可以先用测试号,不影响真机调试;正式上线再改成自己的 AppID
- 编译后点一下转盘中心的“抽奖”按钮,会先转几圈再停下来弹结果
- 真机预览:点击工具右上角“预览”,用微信扫码即可在手机上打开
如果你要接正式后端,把fetchLotteryResult里的本地setTimeout替换成真实wx.request调用即可。接口响应建议统一为{ code, data: { index } }格式,前端只读data.index,然后把服务端返回的serialNo或订单号展示到弹窗里,方便后续对账和追溯。
6.3 后续扩展还能往哪走
1.0 版本做完之后,我给自己列了几个扩展方向,你也可以按需求挑着做:
- 奖品图标化:扇区里不画文字,改画图标,用
ctx.drawImage把本地图片绘制到每个扇区中心附近,视觉冲击力更强 - 服务端接入:把剩余次数、中奖记录、用户黑白名单全部挪到后端,前端只做展示
- 分享裂变:加
onShareAppMessage,分享成功后给用户增加一次抽奖机会,注意做好分享回调的校验,防薅羊毛 - 抽奖动画配置化:把转圈数、动画时长、缓动函数抽成配置文件,运营后台可以直接调,不用发版
- 多转盘并存:页面参数传入
scene,不同活动展示不同奖品配置,用一张表管理所有活动
我个人最推荐先做“服务端接入”,因为转盘项目跑活动时最怕的不是功能缺失,而是被刷。客户端权重算法写得再花哨,只要别人愿意花半小时抓包,就能绕过前端直接调你接口。把抽奖结果的生成权完整收回到服务端,才是这个项目从“玩具”变成“产品”的分水岭。
我在实际调试时最深的体会是:转盘这东西看起来简单,真正跑到真机上才会发现“能转”和“转得稳”是两码事。所有角度计算、状态锁、像素比处理,最终都要落到一台最普通不过的安卓真机上验证一遍,才算真正完成。希望这份 1.0 记录和源码能帮你少踩几个我踩过的坑,快速做出一版能上线的小程序转盘。