1. 项目概述:HTML+JavaScript打造幸运大转盘
最近在开发一个线上营销活动时,需要实现一个吸引用户参与的互动功能。经过方案对比,最终选择用纯前端技术实现幸运大转盘——这个经典又有效的互动形式。相比使用现成插件,原生开发的方案具有更好的定制性和性能表现,特别适合需要深度定制动画效果和业务逻辑的场景。
这个方案的核心优势在于:
- 零第三方依赖,仅用浏览器原生支持的HTML+JavaScript
- 完整控制转盘动画的每个细节
- 灵活适配各种抽奖规则和奖品配置
- 代码总大小控制在20KB以内,加载速度极快
下面我将完整还原实现过程,包含你可能遇到的坑和对应的解决方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心设计与技术解析
2.1 转盘物理模型设计
转盘的视觉呈现本质是一个等分的圆形扇区。我们需要用数学方式精确计算每个奖品区域的角度和绘制位置。假设转盘有8个奖品,那么每个扇区的中心角度就是45度(360/8)。
关键计算公式:
javascript复制// 计算第n个奖品对应的起始角度
function getSectorAngle(index, total) {
return (index * 360 / total) - 90; // -90是为了让第一个奖品从正上方开始
}
注意:Canvas的绘图坐标系与传统数学坐标系不同,y轴正方向向下,角度计算以顺时针为正方向。这是新手最容易混淆的地方。
2.2 动画实现方案选型
对比了三种主流方案:
- CSS旋转:简单但难以精确控制停止位置
- SVG动画:矢量缩放但性能较差
- Canvas绘制:完全控制每一帧,性能最佳
最终选择Canvas方案,通过requestAnimationFrame实现平滑动画。核心动画逻辑:
javascript复制let currentAngle = 0;
const friction = 0.98; // 摩擦系数
let speed = 0;
function animate() {
speed *= friction;
if (Math.abs(speed) < 0.01) speed = 0;
currentAngle += speed;
drawWheel(currentAngle % 360);
if (speed !== 0) {
requestAnimationFrame(animate);
} else {
// 动画结束处理
resolvePrize();
}
}
2.3 奖品概率控制算法
不同于简单的随机数,商业场景需要精确控制中奖概率。采用区间映射法:
javascript复制const prizes = [
{ name: "一等奖", probability: 0.01 },
{ name: "二等奖", probability: 0.05 },
// ...其他奖品
];
function getRandomPrize() {
const random = Math.random();
let rangeStart = 0;
for (const prize of prizes) {
if (random >= rangeStart && random < rangeStart + prize.probability) {
return prize;
}
rangeStart += prize.probability;
}
return prizes[prizes.length - 1]; // 默认返回最后一个
}
3. 完整实现步骤
3.1 HTML结构搭建
html复制<div class="wheel-container">
<canvas id="wheelCanvas" width="500" height="500"></canvas>
<div class="pointer"></div>
<button id="spinBtn">开始抽奖</button>
</div>
关键CSS样式:
css复制.wheel-container {
position: relative;
width: 500px;
margin: 0 auto;
}
#wheelCanvas {
display: block;
border-radius: 50%;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
}
.pointer {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
border-top: 40px solid red;
}
3.2 Canvas绘制实现
javascript复制const canvas = document.getElementById('wheelCanvas');
const ctx = canvas.getContext('2d');
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const radius = Math.min(centerX, centerY) - 10;
function drawWheel(angle = 0) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制扇形
prizes.forEach((prize, i) => {
const startAngle = getSectorAngle(i, prizes.length) + angle;
const endAngle = getSectorAngle(i + 1, prizes.length) + angle;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius,
degToRad(startAngle), degToRad(endAngle));
ctx.closePath();
ctx.fillStyle = getSectorColor(i);
ctx.fill();
// 绘制文字
drawSectorText(prize.name, startAngle, endAngle);
});
}
function degToRad(deg) {
return deg * Math.PI / 180;
}
3.3 动画控制逻辑
javascript复制document.getElementById('spinBtn').addEventListener('click', () => {
if (isSpinning) return;
isSpinning = true;
speed = Math.random() * 10 + 20; // 初始速度
// 确保至少旋转5圈
const targetAngle = currentAngle + 360 * 5 + getStopAngle();
const duration = 3000; // 3秒
animateTo(targetAngle, duration).then(prize => {
alert(`恭喜获得:${prize.name}`);
isSpinning = false;
});
});
function animateTo(targetAngle, duration) {
return new Promise(resolve => {
const startAngle = currentAngle;
const startTime = performance.now();
function frame(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
currentAngle = startAngle +
easeOut(progress) * (targetAngle - startAngle);
drawWheel(currentAngle);
if (progress < 1) {
requestAnimationFrame(frame);
} else {
const prizeIndex = Math.floor(
((360 - currentAngle % 360) / 360) * prizes.length
) % prizes.length;
resolve(prizes[prizeIndex]);
}
}
requestAnimationFrame(frame);
});
}
function easeOut(t) {
return 1 - Math.pow(1 - t, 3);
}
4. 性能优化与常见问题
4.1 动画卡顿解决方案
- 离屏Canvas缓存:提前绘制静态部分
javascript复制const offscreenCanvas = document.createElement('canvas');
// ...初始化尺寸
const offscreenCtx = offscreenCanvas.getContext('2d');
function cacheStaticParts() {
// 绘制不变化的部分
}
- 减少重绘区域:使用clearRect只清除变化部分
- will-change提示:CSS添加
will-change: transform
4.2 移动端适配要点
- 触摸事件支持:
javascript复制canvas.addEventListener('touchstart', handleStart);
canvas.addEventListener('touchmove', handleMove);
- 高清屏适配:
javascript复制const dpr = window.devicePixelRatio || 1;
canvas.width = 500 * dpr;
canvas.height = 500 * dpr;
canvas.style.width = '500px';
ctx.scale(dpr, dpr);
4.3 常见问题排查
- 文字显示模糊:
- 确保在缩放Canvas前设置字体
- 使用
ctx.translate将文字定位到扇区中心
- 动画突然停止:
- 检查speed变量是否被意外修改
- 确认requestAnimationFrame循环没有被打断
- 中奖结果不准确:
- 添加角度修正补偿:
javascript复制const correctedAngle = 360 - (currentAngle % 360) + 90; // 补偿初始偏移
5. 扩展功能实现
5.1 奖品图片支持
javascript复制function loadImages() {
return Promise.all(
prizes.map(prize => {
if (!prize.image) return Promise.resolve();
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
prize.imageObj = img;
resolve();
};
img.src = prize.image;
});
})
);
}
// 在drawSector中添加
if (prize.imageObj) {
ctx.save();
// 计算图片位置和缩放
ctx.drawImage(prize.imageObj, imgX, imgY, imgW, imgH);
ctx.restore();
}
5.2 音效增强体验
javascript复制const audioContext = new (window.AudioContext || window.webkitAudioContext)();
function playTickSound() {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = 800 + speed * 10;
gainNode.gain.value = Math.min(speed / 30, 1);
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.05);
}
5.3 数据持久化方案
javascript复制function saveResult(prize) {
const history = JSON.parse(localStorage.getItem('wheelHistory') || '[]');
history.push({
prize: prize.name,
time: new Date().toISOString()
});
localStorage.setItem('wheelHistory', JSON.stringify(history));
}
实现这个转盘后,我发现几个值得分享的细节:动画的缓动函数对体验影响极大,经过多次测试最终选择了三次方缓出;移动端的触摸事件需要特别处理点击延迟问题;对于高频触发场景,一定要做好函数节流。整个开发过程中最耗时的部分是精确控制指针停止位置,最终通过数学模型计算+视觉微调达到了理想效果。
