1. 环形进度条的前世今生
作为一名前端开发者,我至今还记得第一次被设计师要求实现环形进度条时的场景。那是一个阳光明媚的下午,设计师轻描淡写地说:"这个环形进度条很简单,就是颜色渐变,头部要发光,进度到80%要变红,完成时要有粒子效果..."
当时我的内心是崩溃的。但经过多年的实战,我发现Canvas实现环形进度条其实并没有想象中那么可怕。关键在于理解其核心原理——它本质上就是两个圆弧的叠加:一个固定不变的底环,和一个根据进度变化的彩色弧线。
2. 为什么选择Canvas?
2.1 Canvas vs SVG vs CSS
在开始编码前,我们需要明确为什么选择Canvas而不是其他技术方案:
- SVG方案:使用stroke-dasharray和stroke-dashoffset可以实现,但频繁更新时性能较差
- CSS方案:conic-gradient最简单,但定制能力有限
- Canvas方案:像素级控制,高性能,完全自定义
Canvas的独特优势在于:
- 可以精确控制每个像素的渲染
- 动画性能优异,适合高频更新
- 支持各种高级效果(发光、粒子等)
3. 基础实现
3.1 画布初始化
首先我们需要设置Canvas元素并处理高清屏适配:
<canvas id="progressCanvas" width="300" height="300"></canvas>function setupCanvas() { const canvas = document.getElementById('progressCanvas'); const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return ctx; } const ctx = setupCanvas();3.2 绘制底环
底环是进度条的背景,通常使用浅色:
function drawTrack() { ctx.save(); ctx.beginPath(); ctx.arc( canvas.width / 2, canvas.height / 2, 100, // 半径 -Math.PI / 2, // 起始角度(12点方向) Math.PI * 1.5, // 结束角度 false // 顺时针 ); ctx.lineWidth = 20; ctx.strokeStyle = '#e6e6e6'; ctx.lineCap = 'round'; ctx.stroke(); ctx.restore(); }3.3 绘制进度弧
进度弧根据当前进度百分比动态变化:
function drawProgress(percent) { const startAngle = -Math.PI / 2; const endAngle = startAngle + (percent / 100) * Math.PI * 2; ctx.save(); ctx.beginPath(); ctx.arc( canvas.width / 2, canvas.height / 2, 100, startAngle, endAngle, false ); ctx.lineWidth = 20; ctx.strokeStyle = '#3498db'; ctx.lineCap = 'round'; ctx.stroke(); ctx.restore(); }3.4 添加文字
在圆环中心添加进度百分比:
function drawText(percent) { ctx.save(); ctx.fillStyle = '#333'; ctx.font = 'bold 40px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(`${percent}%`, canvas.width / 2, canvas.height / 2); ctx.restore(); }4. 动画实现
4.1 基础动画
使用requestAnimationFrame实现平滑动画:
let currentPercent = 0; const targetPercent = 85; function animate() { if (currentPercent < targetPercent) { currentPercent += 1; render(currentPercent); requestAnimationFrame(animate); } } function render(percent) { ctx.clearRect(0, 0, canvas.width, canvas.height); drawTrack(); drawProgress(percent); drawText(percent); } animate();4.2 缓动动画
添加缓动效果使动画更自然:
function animateWithEasing() { const speed = 0.05; if (Math.abs(targetPercent - currentPercent) > 0.5) { currentPercent += (targetPercent - currentPercent) * speed; render(currentPercent); requestAnimationFrame(animateWithEasing); } else { currentPercent = targetPercent; render(currentPercent); } }5. 高级效果实现
5.1 渐变进度条
实现颜色渐变效果:
function drawGradientProgress(percent) { const startAngle = -Math.PI / 2; const endAngle = startAngle + (percent / 100) * Math.PI * 2; // 计算渐变起止点 const x1 = canvas.width / 2 + Math.cos(startAngle) * 100; const y1 = canvas.height / 2 + Math.sin(startAngle) * 100; const x2 = canvas.width / 2 + Math.cos(endAngle) * 100; const y2 = canvas.height / 2 + Math.sin(endAngle) * 100; const gradient = ctx.createLinearGradient(x1, y1, x2, y2); gradient.addColorStop(0, '#00d2ff'); gradient.addColorStop(1, '#3a7bd5'); ctx.save(); ctx.beginPath(); ctx.arc( canvas.width / 2, canvas.height / 2, 100, startAngle, endAngle, false ); ctx.lineWidth = 20; ctx.strokeStyle = gradient; ctx.lineCap = 'round'; ctx.stroke(); ctx.restore(); }5.2 头部发光效果
为进度条头部添加发光效果:
function drawGlowProgress(percent) { const startAngle = -Math.PI / 2; const endAngle = startAngle + (percent / 100) * Math.PI * 2; ctx.save(); ctx.beginPath(); ctx.arc( canvas.width / 2, canvas.height / 2, 100, startAngle, endAngle, false ); ctx.lineWidth = 20; ctx.strokeStyle = '#00d2ff'; ctx.lineCap = 'round'; // 发光效果 ctx.shadowBlur = 20; ctx.shadowColor = '#00d2ff'; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; ctx.stroke(); // 头部高亮点 const endX = canvas.width / 2 + Math.cos(endAngle) * 100; const endY = canvas.height / 2 + Math.sin(endAngle) * 100; ctx.beginPath(); ctx.arc(endX, endY, 10, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.shadowBlur = 30; ctx.fill(); ctx.restore(); }6. 性能优化
6.1 离屏渲染
对静态内容使用离屏Canvas:
const offscreenCanvas = document.createElement('canvas'); offscreenCanvas.width = canvas.width; offscreenCanvas.height = canvas.height; const offCtx = offscreenCanvas.getContext('2d'); // 预渲染底环 function initOffscreen() { offCtx.save(); // 绘制底环代码... offCtx.restore(); } function renderOptimized(percent) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(offscreenCanvas, 0, 0); drawProgress(percent); drawText(percent); }6.2 节流处理
对频繁更新进行节流:
function throttle(func, limit) { let inThrottle; return function(...args) { if (!inThrottle) { func.apply(this, args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } } } const renderThrottled = throttle(render, 16);7. 常见问题解决
7.1 圆环显示不完整
问题:圆环出现缺口或多余线条 原因:未正确使用beginPath或closePath 解决:
// 确保每次绘制前调用beginPath ctx.beginPath(); // 绘制代码... ctx.stroke();7.2 移动端模糊
问题:在高清屏上显示模糊 原因:未处理devicePixelRatio 解决:使用前面介绍的setupCanvas方法初始化
7.3 动画卡顿
问题:动画不流畅 原因:未使用requestAnimationFrame或未做性能优化 解决:
- 使用requestAnimationFrame
- 对静态内容使用离屏渲染
- 对频繁更新进行节流
8. 实际应用建议
- 适度使用:简单进度条优先考虑CSS方案
- 渐进增强:从基础功能开始,逐步添加效果
- 性能监控:特别是在移动端要注意性能影响
- 可访问性:确保进度信息可通过其他方式获取
Canvas环形进度条的实现看似复杂,但拆解后其实非常直观。关键在于理解Canvas的绘图原理和状态管理。通过合理的架构和优化,可以创造出既美观又高性能的进度指示器。