news 2026/9/16 5:02:21

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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,可选colortextColor
sizeNumber300转盘直径,单位 px
durationNumber4000旋转动画时长,单位 ms
speedNumber5额外转动的圈数,控制视觉上的“力度”
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 组件模板和样式

我的组件是基于 Vue3<script setup>语法写的,Canvas 通过uni.createSelectorQuery查询节点获取。先看模板部分:

<template> <view class="wheel-box" :style="{ width: size + 'px', height: size + 'px' }"> <view class="wheel-pointer"></view> <view class="wheel-rotate" :style="{ width: size + 'px', height: size + 'px', transform: `rotate(${rotateAngle}deg)`, transitionDuration: running ? duration + 'ms' : '0ms' }" @transitionend="onTransitionEnd" > <canvas type="2d" id="wheelCanvas" class="wheel-canvas"></canvas> </view> <button class="wheel-btn" :disabled="running" @click="onDraw">{{ 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')交给父组件处理。如果pendingfalse,组件内部直接随机一个索引,走旋转动画。

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 ref="wheelRef" :prizes="prizes" :pending="true" @draw="handleDraw" @finish="handleFinish" /> <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 绘制、角度映射和组件通信这三个基本功。希望这篇文章能把该讲的坑都给你踩平了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 5:01:41

gods-eye-view:空间认知重构的工程实践方法论

1. 什么是“gods-eye-view”&#xff1a;不是玄学&#xff0c;是可落地的空间认知重构“gods-eye-view”这个词最近在设计、城市规划、工业仿真、甚至短视频创作圈里突然密集出现——它既不是宗教概念&#xff0c;也不是新出的AI模型代号&#xff0c;更不是某种加密货币术语。它…

作者头像 李华
网站建设 2026/9/16 5:01:09

线上活动终端实战:PAN9420与R7KA8D2KFLCAC链路搭建指南

线上活动开了两年多&#xff0c;我最大的感受是&#xff1a;真正决定观众体验的&#xff0c;往往不是嘉宾讲得多精彩&#xff0c;而是画面稳不稳、声音清不清楚、切换顺不顺。去年年底我给自己配了一套以PAN9420为核心的线上活动终端&#xff0c;配合R7KA8D2KFLCAC完成整条链路…

作者头像 李华
网站建设 2026/9/16 5:00:30

高性能计算工具链与混合精度训练实践指南

很多人一提“高性能计算”&#xff0c;第一反应就是超算中心、气象预报、天体模拟这类离普通人很远的东西。实际上&#xff0c;这几年高性能计算最密集的应用场景&#xff0c;恰恰是深度学习训练&#xff0c;尤其是大模型时代到来之后&#xff0c;几乎所有上规模的训练都离不开…

作者头像 李华
网站建设 2026/9/16 5:00:24

ASP.NET大文件上传实战:断点续传与AES加密完整方案

在医院信息化干了这些年&#xff0c;碰上最让人头疼的需求之一&#xff0c;就是“大文件上传”。CT影像、病理切片、手术录像、远程会诊记录&#xff0c;动辄几百MB甚至几个GB&#xff0c;网络一抖动&#xff0c;传了半小时直接失败&#xff0c;患者那边等着报告&#xff0c;科…

作者头像 李华
网站建设 2026/9/16 5:00:09

DeepSeek Harness升级0.1.5-rc插件兼容性排查与修复全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 5:00:03

嵌入式以太网设计:独立PHY+带变压器RJ45座实用指南

做嵌入式这些年&#xff0c;但凡项目要联网&#xff0c;很多人第一反应是换一颗带MAC的MCU再外挂一颗PHY&#xff0c;或者干脆上Linux接USB网卡。实际在工业设备、仪器仪表、网关这类场景里&#xff0c;很多时候只需要最简单可靠的10/100M有线以太网&#xff0c;一颗独立的10/1…

作者头像 李华