恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
微信小程序原生转盘实战:从Canvas绘制到抽奖动画
首页
资讯中心
/
微信小程序原生转盘实战:从Canvas绘制到抽奖动画
微信小程序原生转盘实战:从Canvas绘制到抽奖动画
发布时间:2026/10/18 2:06:08
转盘小程序大概是微信小程序实战里被问得最多的入门项目之一。我这次用微信小程序原生框架硬写了一版“转盘小程序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.jsCanvas绘制、抽奖请求、旋转角度计算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 是type2d的新接口而不是老式的wx.createCanvasContext。区别很重要老的接口是一套异步绘制语法无法直接拿到 Canvas 节点去做像素级的尺寸控制新的 Canvas 2D 接口可以拿到节点对象还能设置物理像素大小在高分屏下更清晰。WXML 里这样写view classwheel-wrapper canvas type2d idwheelCanvas classwheel-canvas/canvas view classpointer/view view classcenter-btn bindtapstartLottery {{ 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.requestfetchLotteryResult() { 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写法在开发者工具里一切正常结果一上真机转盘文字偶尔出现绘制顺序错乱、圆环颜色不对的情况。后来查了基础库更新日志才意识到老接口已经进入了维护状态新项目应该直接使用type2d的 Canvas 2D 接口。老接口和新接口的核心差异维度老接口 createCanvasContext新接口 type2d获取方式传 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 记录和源码能帮你少踩几个我踩过的坑快速做出一版能上线的小程序转盘。