ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

uniapp H5 抽奖转盘组件开发:基于 Canvas 与数据驱动的文字版实现

uniapp H5 抽奖转盘组件开发:基于 Canvas 与数据驱动的文字版实现 早两个月的营销活动后台临时加了个需求要在 uniapp 项目的 H5 页面里塞一个抽奖转盘奖品数据由后端随时改活动页当天就要上线。我一听就头疼因为这类转盘组件网上能找到一大堆但基本都是图片切好扇形、写死静态资源后端一改奖品列表就得重新出图根本不顶用。所以当时我就决定自己写一个“文字版”的 uniapp h5 转盘组件奖品直接用文字渲染在扇区里不依赖本地图片通过 props 传入数组就能动态生成组件内部负责 Canvas 绘制、旋转动画、结果回调用完还能抽出来复用到其他项目。这篇就把这个组件的完整实现思路、核心代码和踩坑过程整理出来给后面要做同样需求的人一个可以直接抄作业的参考。这个组件适合谁大概有三类人一是刚接触 uniapp 组件开发想搞明白父子组件通信、Canvas 绘制和 CSS 动画怎么配合的二是活动页开发手上正好缺一个轻量级转盘不想引大库、不想让运营每次改文案都找 UI 出图的三是把 H5 页面嵌到微信公众号、企业微信或 App WebView 里需要一个跨端表现稳定的前端交互组件。我的实现没有依赖任何第三方库只用了 uniapp 自带的 vue3 语法、canvas 2d 和 CSS transition所以在 H5 端能跑编译到 App 端和小程序端的 H5 组件里也能用移植成本极低。1. 为什么先做“文字版”转盘1.1 从需求到取舍先说我为什么非要做文字版而不是直接用图片。活动页的抽奖转盘常规做法是 UI 设计一张底盘图上面六个扇区、每个扇区一张奖品图开发直接切图嵌入。但这个方案在业务上有个很大的痛点奖品经常变。今天还是“立减10元券”明天运营就换成“定制帆布袋”后端一改配置前端就得找设计重新出图、重新切、重新发版这个链路在小团队里少说半天碰上设计排期直接卡死。文字版的好处就是彻底解耦组件只负责把传入的奖品列表渲染成转盘扇区文字内容、颜色、数量全部由数据驱动。后端接口返回什么奖品前端就画什么运营在后台改完用户刷新页面就能看到新转盘不需要动任何代码和图片资源。对于 H5 活动页这种生命周期短、改动频繁的场景这是最省心的方案。另外这个转盘页如果要嵌到微信公众号里图片资源多也会拖慢首屏加载。一个 Canvas 画的纯色扇区加文字视觉上干净利落性能上几乎零开销对低端 Android 设备的 WebView 也比较友好。1.2 转盘组件要解决的三个问题我拆了一下需求一个可用的抽奖转盘组件无论什么实现方式核心就三件事第一转盘怎么画。扇区数量不固定可能是四个、六个也可能是八个每个扇区的颜色、文字内容都要可变还要处理文字太长放不下的问题。这个我选择用 Canvas 动态绘制因为 CSS 只能画固定样式的扇形做不到动态生成任意数量扇区并塞入文字。第二转盘怎么转。抽奖必须有一个“转起来然后停到指定位置”的动画过程转动时长、停下的位置都要可控。我用的方案是 CSS transform 控制旋转角度配合 transition 的缓动函数模拟物理减速效果这样实现简单跨端兼容性也好。第三组件怎么跟业务通信。父组件要知道“用户点了抽奖按钮”“转盘转到哪个奖品了”后端接口的异步请求也要跟动画流程串起来。这块就是 uniapp 组件化开发里的 props、emit、defineExpose 三件套我会在后面的章节详细展开。2. 组件整体设计与数据流2.1 组件对外 API我先列一下这个组件定义给外部用的接口。设计组件 API 的时候我习惯站在使用者的角度考虑别人拿到我这个组件最少的配置是什么能不能不改组件源码就应对各种活动页组件名我定义为uni-lottery-wheel对外暴露三个数据入口和一个事件出口外加一个手动方法参数类型默认值说明prizesArray[]奖品列表每项须包含name可选color、textColorsizeNumber300转盘直径单位 pxdurationNumber4000旋转动画时长单位 msspeedNumber5额外转动的圈数控制视觉上的“力度”buttonTextString开始抽奖中间按钮文案pendingBooleanfalse是否由父组件控制抽奖结果drawEvent-用户点击抽奖按钮时触发finishEvent-动画结束返回中奖索引和奖品项settle(index)Method-父组件传入中奖索引触发转盘旋转这里重点说明pending这个配置。活动页抽奖业务里中奖结果通常是由后端算法决定的前端不能自己随机否则用户刷一下接口就能“预测”结果活动就废了。所以默认我不会在组件里自己摇奖号而是把点击动作通过draw事件抛给父组件让父组件去请求后端拿到中奖索引之后再通过settle方法告诉组件该停在哪一格。如果只是本地演示或者做活动预热不需要后端参与把pending设为false组件会自己随机选一个索引转圈方便快速看效果。2.2 界面结构拆解组件模板我设计了三个层级视觉上是一个圆形转盘、一个固定不动的指针、一个中间的抽奖按钮。最外层是一个view用来定位整个转盘容器容器内有一个绝对定位的指针三角形固定在正上方 12 点方向指针下面是一个旋转层Canvas 绘制好的转盘图片就放在这个层里CSS transform 的 rotate 就是作用在这个旋转层上最中间放一个button抽奖按钮盖在转盘中心点击触发抽奖。这里有个细节指针千万不要放在旋转层里面否则它跟转盘一起转用户就看不到指针位置了。指针必须放在旋转层外面作为一个独立层固定在顶部。抽奖按钮则反过来要放在旋转层里面也可以里面外面也可以只要位置居中就行我的实现是放在旋转层外面的这样转盘旋转时按钮不跟着抖动视觉体验更好。2.3 奖品数据如何传入父组件传进来的prizes是一个数组每个元素的格式可以非常自由但组件内部至少需要用到name这个字段。颜色字段是可选的如果不传我会在绘制时根据索引给一组默认色板保证相邻两个扇区颜色不一样有区分度。// 父组件中的示例数据 const prizes [ { name: 谢谢参与, color: #FFF4E6 }, { name: 10元红包, color: #FFD6BA }, { name: 5元话费, color: #FFF4E6 }, { name: 定制帆布袋, color: #FFD6BA }, { name: 20元券, color: #FFF4E6 }, { name: 再来一次, color: #FFD6BA } ]默认色板的逻辑很简单定义一组明度适中的浅色系按索引取模循环使用。列表只在组件初始化或prizes变化时重新绘制不需要每次渲染都重画一遍 Canvas节省性能。3. 转盘绘制与旋转原理3.1 用 Canvas 绘制扇形转盘很多同学第一次接触 Canvas 画扇形以为要用什么高级图形库其实原生 API 就够了。核心就三个步骤beginPath开启一段路径moveTo把起点移动到圆心arc画一段弧线最后closePath闭合路径再填充颜色。我在绘制时把第一个扇区的起始角固定为-Math.PI / 2也就是 12 点方向。为什么要这样因为指针在正上方如果第一个扇区从 3 点方向开始画用户看到的初始状态是第一个扇区偏向右侧视觉上不太美观。从 12 点方向开始第一个奖品正好对准指针整体看起来更规整。const drawWheel () { const count props.prizes.length const arc (Math.PI * 2) / count const radius props.size / 2 const center props.size / 2 ctx.clearRect(0, 0, props.size, props.size) props.prizes.forEach((item, index) { const start -Math.PI / 2 index * arc ctx.beginPath() ctx.moveTo(center, center) ctx.arc(center, center, radius, start, start arc) ctx.closePath() ctx.fillStyle item.color || defaultColor(index) ctx.fill() }) }这段代码的关键是arc方法的参数arc(x, y, r, startAngle, endAngle)角度单位是弧度默认 0 度在 3 点方向顺时针增加。所以我用-Math.PI / 2作为起始偏移把整个坐标系统旋转 90 度让第一块扇区从正上方开始后续每一块偏移index * arc就能均匀铺满整个圆。还有一个我在实战中踩过的坑clearRect必须传像素宽高因为在 H5 端 Canvas 的坐标系跟 CSS 像素不是一一对应的如果没先做高清屏适配画出来的图会发虚。3.2 文字绘制与防溢出文字版组件最烧脑的就是文字怎么摆。如果只是简简单单把奖品名称写在扇区正中六个扇区还能看八个扇区的时候文字就会互相挤压、溢出扇区边界。我的处理思路是每个扇区文字沿半径方向放置文字的中心点落在“扇区中线”上的某个位置同时用ctx.rotate把文字旋转到与该扇区中线垂直的方向。这样每个文字就像“躺”在扇区里扇区窄的时候依然能放得下。const textRadius radius * 0.65 const textCenter start arc / 2 ctx.save() ctx.translate(center textRadius * Math.cos(textCenter), center textRadius * Math.sin(textCenter)) ctx.rotate(textCenter Math.PI / 2) ctx.fillStyle item.textColor || #FFFFFF ctx.font bold ${Math.min(14, 140 / count)}px sans-serif ctx.textAlign center ctx.textBaseline middle const text item.name.length 6 ? item.name.slice(0, 6) … : item.name ctx.fillText(text, 0, 0) ctx.restore()字号这里我做了个自适应逻辑Math.min(14, 140 / count)。当奖品数量少的时候用 14px 保证清晰奖品数量多的时候按总数量均分扇区宽度字号相应调小。这样做比固定字号要稳得多。另外我对超过 6 个字符的奖品名称做了截断强迫用户在设计奖品名时保持简洁实际活动里很少有人会把奖品名写到六七个字以上。文字旋转方向这里需要特别留意ctx.rotate旋转的是整个坐标系所以我在旋转之前先把坐标系translate到目标位置旋转完再fillText(0, 0)这样文字就沿着扇形半径方向了。如果直接fillText(x, y)再旋转文字会绕着画布原点转位置会完全错乱。这个顺序问题我一开始就记混过画出来的文字全部飞到画布外面去了。3.3 旋转角度的精确计算转盘旋转是整个组件里最容易出 bug 的地方。很多人会想转盘转完停下来停在哪个扇区不就是在某个角度上加个随机数吗但真正的难点是“用户指定的扇区”跟“用户看到的扇区”之间如何对应。我在绘制时第一个扇区从 12 点方向开始指针也固定在 12 点方向。所以当转盘未旋转时指针正对的是索引为 0 的扇区中心。那么第 index 个扇区的中心在 Canvas 原始坐标系里对应角度是sectorCenter index * arc arc / 2如果把整个转盘旋转targetAngle度旋转之后扇区中心对应的绝对角度就是sectorCenter targetAngle。我们希望指针12 点方向绝对角度为 0 度正好落在这个扇区中心那么就需要sectorCenter targetAngle ≡ 0 (mod 360)也就是targetAngle 360 - sectorCenter但这样算出来的值可能很小用户看起来就像是转盘轻微抖了一下就停了根本没有“转了几圈”的效果。所以我还要给它加上speed * 360的基础圈数同时因为转盘已经转过好多次每次都要在之前的角度基础上累加确保下一次旋转的方向依然是顺时针不会往回倒转。const getTargetAngle (index) { const count props.prizes.length const arc 360 / count const sectorCenter index * arc arc / 2 const target baseAngle props.speed * 360 (360 - sectorCenter) return target }这段代码我建议你直接记下来。baseAngle是上一次旋转结束后的总角度每次动画结束后把它更新为当前rotateAngle这样连续抽奖的时候动画方向始终一致。我最开始没有维护baseAngle直接取了当前旋转角度的余数来算结果第二次抽奖转盘会突然逆时针回转半圈特别诡异。4. 核心流程完整实现4.1 组件模板和样式我的组件是基于 Vue3script setup语法写的Canvas 通过uni.createSelectorQuery查询节点获取。先看模板部分template view classwheel-box :style{ width: size px, height: size px } view classwheel-pointer/view view classwheel-rotate :style{ width: size px, height: size px, transform: rotate(${rotateAngle}deg), transitionDuration: running ? duration ms : 0ms } transitionendonTransitionEnd canvas type2d idwheelCanvas classwheel-canvas/canvas /view button classwheel-btn :disabledrunning clickonDraw{{ buttonText }}/button /view /template样式部分需要注意几个点Canvas 要设置width: 100%; height: 100%但实际绘制像素通过 JS 动态设置这样在不同屏幕上才能保证清晰旋转层需要有明确的宽高否则 Canvas 节点查询的时候拿不到正确的尺寸指针用一个 CSS 三角形画出来position: absolute定到顶部中间。4.2 绘制逻辑与高清屏适配在 uniapp 的 H5 端获取 Canvas 节点和原生浏览器不太一样不能用document.getElementById必须走uni.createSelectorQuery。这个方法在 H5 和 App 端表现一致是跨端开发里必须掌握的一个 API。const initCanvas () new Promise((resolve) { const query uni.createSelectorQuery().in(instance) query.select(#wheelCanvas).fields({ node: true, size: true }).exec((res) { if (res res[0]) { canvasNode res[0].node ctx canvasNode.getContext(2d) const dpr uni.getSystemInfoSync().pixelRatio || 2 canvasNode.width res[0].width * dpr canvasNode.height res[0].height * dpr ctx.scale(dpr, dpr) resolve() } }) })高清屏适配这段代码必须要写。早期我偷懒直接用 CSS 像素设置 Canvas 宽高在 iPhone 上转盘边缘全是锯齿奖品文字模糊得没法看。原因是手机屏幕的物理像素密度是 CSS 像素的好几倍Canvas 默认按 CSS 像素渲染会被浏览器放大拉伸所以要把 Canvas 的物理宽高设为 CSS 宽高乘以dpr再通过ctx.scale把坐标系缩放回去。这样画出来的图案就是按物理像素绘制的清晰锐利。4.3 抽奖点击与动画控制用户点击抽奖按钮后我先把running置为true防止动画过程中重复点击然后触发emit(draw)交给父组件处理。如果pending为false组件内部直接随机一个索引走旋转动画。const onDraw () { if (running.value) return running.value true if (props.pending) { emit(draw) } else { const index Math.floor(Math.random() * props.prizes.length) spinTo(index) } } const spinTo (index) { if (!props.prizes.length) return activeIndex.value index rotateAngle.value getTargetAngle(index) }当rotateAngle的值发生改变时CSS transition 会自动补间动画从旧角度平滑过渡到新角度。duration控制动画时长我默认设置了 4 秒配合 CSS 的cubic-bezier(0.23, 1, 0.32, 1)缓动函数能达到“先加速后减速再缓缓停下”的物理效果观感很像真实的转盘在惯性转动。4.4 组件通信父组件拿到结果父组件通过draw事件触发一个异步请求等后端返回中奖索引之后怎么把这个索引传回给组件呢两种方式都行一种是用 ref 调用子组件暴露的settle方法另一种是在父组件里维护一个响应式变量watch变化后传给组件。我推荐用 ref defineExpose因为这样调用意图最清晰。!-- 父组件中 -- uni-lottery-wheel refwheelRef :prizesprizes :pendingtrue drawhandleDraw finishhandleFinish / script setup const wheelRef ref(null) const handleDraw async () { const res await requestDraw() // 请求后端抽奖接口 wheelRef.value.settle(res.index) // 把中奖索引告诉组件 } const handleFinish (result) { console.log(中奖的是, result.item.name) uni.showToast({ title: 恭喜获得${result.item.name} }) } /script这里就有几个项目里容易忽略的点。第一handleDraw不能是同步的因为抽奖接口有网络延迟组件内部的running已经为true在等待接口返回的这段时间里按钮处于禁用状态避免用户连点刷接口。第二如果接口请求失败必须在 catch 里调用一个重置方法把running恢复为false让用户能重新点击否则转盘会一直处于“卡死”状态。第三settle传入的索引一定要校验范围后端偶发返回一个越界索引转盘就会停在一个空白位置给用户造成“转盘出 bug 了”的印象。5. 常见问题与实战排查5.1 常见问题速查表我把开发过程中遇到的高频问题整理成一个表方便大家对照排查现象原因解决方案转盘画面模糊、文字发虚未做高清屏适配按 dpr 设置 Canvas 物理尺寸并ctx.scale第二次点击按钮转盘会逆时针回转未维护基准角度baseAngle每次动画结束后把baseAngle更新为当前总角度页面在部分 Android 手机上动画不生效CSS 属性拼写或 transform 层级问题确保transition写在基础状态而非终态文案过长溢出扇区未限制文字长度截断超过 6 个字符的名称并动态调整字号动画结束回调不触发部分浏览器对 transitionend 兼容性差同时用一个setTimeout兜底连续快速点击导致动画混乱running状态未及时置位点击后立即锁定接口返回前禁用按钮5.2 我的两个踩坑记录第一个坑是transitionend事件在部分安卓 WebView 里不触发。这个事件的浏览器兼容性比想象中差尤其是一些国产浏览器的 X5 内核在页面切后台、系统锁屏的情况下动画结束事件会丢失。我一开始把“中奖提示弹窗”的逻辑完全放在transitionend里结果测试同学反馈部分安卓手机上抽完奖没反应转盘停了但 toast 不弹。后来我改成rotateAngle更新后同时挂一个setTimeout兜底时长比动画时间长 100ms用 Promise.race 竞争谁先触发算谁问题就解决了。第二个坑是 Canvas 重新绘制的时机。如果父组件在转盘已经渲染之后动态修改prizes比如活动页的奖品列表是异步加载的必须保证数据回来之后再调用绘制函数。我在watch(() props.prizes, () { nextTick(drawWheel) }, { deep: true })里面用了nextTick因为 Canvas 节点可能还没挂载完毕。如果没加nextTick第一次绘制经常会报“Cannot read property width of null”因为这个组件挂载的时候 Canvas 节点还没准备好。6. 几个值得继续扩展的方向文字版转盘做完后这个组件还有几个方向可以扩展我把思路一并分享出来。第一个是把奖品图片也加进来让组件同时支持文字和图片两种模式。文字版的优点是轻量和动态化缺点是视觉效果朴素有些品牌客户还是希望扇区里有漂亮的商品图。可以给prizes的元素增加一个image字段绘制时如果有图片就drawImage加载图片没有就绘制文字图片加载完成的回调需要配合 Canvas 重绘。第二个是增加转盘背景、边框、灯光闪烁等装饰层让组件更接近运营们喜欢的“高大上”风格。可以多叠加一层 SVG 或者绝对定位的 view 来做灯光动画我这里没有做是因为活动排期太紧后续有需要可以直接在这套结构上加。第三个是把抽奖结果回传的时机和动画切分做得更精细。比如先快速转动让用户情绪起来再慢速停在指定区域这需要在 CSS transition 里做分段关键帧通过修改transition-timing-function和分段更新rotateAngle来实现。这个改造不影响组件外部 API是可以向后兼容迭代的。我个人在实际操作中的体会是转盘组件这种业务组件最重要的不是代码写得有多炫而是数据驱动、流程稳定、边界处理完整。文字版方案牺牲了一部分视觉上限换来了“改奖品不改代码”的自由度在 H5 活动页场景里价值非常大。把这套实现吃透之后同类问题其实都是一通百通核心还是 Canvas 绘制、角度映射和组件通信这三个基本功。希望这篇文章能把该讲的坑都给你踩平了。
返回列表