在前端开发中,进度条是一个常见的视觉组件。无论是直角、圆角还是圆环形,相信大家都接触过。通常,我们可以使用 CSS、canvas 或 svg 等多种技术来实现它们,并附带动画效果。今天,我们将聚焦于一种特定形态——半环形进度条,探讨如何分别使用 canvas 和 svg 来实现它,并在此基础上进一步优化用户体验,例如应对数据更新和“升级”等交互场景。
为了方便演示,本文的代码示例均采用单文件的 Vue 组件形式。


Canvas 实现步骤
一、准备工作
我们首先需要了解 canvas 绘图的基础,特别是画圆的 arc 方法。

ctx.arc() 方法的参数说明如下:
| 参数 |
描述 |
| x |
圆心的 x 坐标。 |
| y |
圆心的 y 坐标。 |
| r |
圆的半径。 |
| sAngle |
起始角,以弧度计(弧的圆形的三点钟位置是 0 度)。 |
| eAngle |
结束角,以弧度计。 |
| counterclockwise |
可选。规定应该逆时针还是顺时针绘图。false = 顺时针,true = 逆时针。 |
接着,在 Vue 组件的 data 中定义一些初始变量:
data() {
return {
canvas: null, // canvas 实例对象
cWidth: 750, // 预设宽度
cHeight: 750, // 预设高度
progress: 50, // 假设从接口获取的进度目前是 50
}
},
然后,我们在 methods 中添加一个 initCircleProgress 方法,定义绘制所需的变量:
// initCircleProgress 方法体中的代码
let radius = 124 // 外环半径
let thickness = 12 // 圆环厚度
let innerRadius = radius - thickness // 内环半径
let startAngle = -180 // 开始⾓度
let endAngle = 0 // 结束⾓度
let x = 0 // 圆⼼x坐标
let y = 0 // 圆⼼y坐标
对 canvas 进行初始化设置:
// html 结构
<canvas id="circleProgress"></canvas>
// initCircleProgress 方法体中的代码
this.canvas = document.getElementById('circleProgress')
let ctx = this.canvas.getContext('2d')
this.canvas.style.width = this.cWidth + 'px'
this.canvas.style.height = this.cHeight + 'px'
this.canvas.height = this.cHeight
this.canvas.width = this.cWidth
// 将绘图原点移到画布中央
ctx.translate(document.body.clientWidth / 2, document.body.clientWidth / 2)
ctx.fillStyle = '#FFF' // 初始填充颜⾊
二、绘制半圆环
借鉴绘制饼图的方法,我们先封装一个角度转弧度的工具函数。
// ⾓度转弧度
function angle2Radian(angle) {
return (angle * Math.PI) / 180
}
为了便于后续动画调用,我们封装一个 renderRing 函数,它接受开始角度 startAngle 和结束角度 endAngle 两个参数。
renderRing(startAngle, endAngle)
function renderRing(startAngle, endAngle) {
ctx.beginPath()
// 绘制外环
ctx.arc(0, 0, radius, angle2Radian(startAngle), angle2Radian(endAngle))
}
执行以上代码,会得到一个半圆饼图。

接下来绘制内环,通过逆时针绘制内圆来形成轨道。这里的 innerRadius 是前面定义的 radius - thickness。
/* 绘制内环 依然参考 canvas 饼图、环形图的一些技巧,通过逆时针绘制内圆形成进轨道
* 这里的 innerRadius 内圆半径在上面定义过,所以是 radius - thickness = 12
*/
ctx.arc(0, 0, innerRadius, angle2Radian(endAngle), angle2Radian(startAngle), true) // 从-180 到 0

现在得到的半圆环两侧是平的,而设计稿中进度条的两端是圆弧形的。因此,我们需要利用三角函数 Math.cos 和 Math.sin 计算出起点和终点小圆的圆心坐标,然后绘制小圆。计算圆上点坐标的公式为:
x = Math.cos(Math.PI * 2 / 360 * 度数) * r
y = Math.sin(Math.PI * 2 / 360 * 度数) * r
由于我们是半圆,将 360 改为 180 即可。我们封装一个 calcRingPoint 函数来计算坐标,并在 renderRing 函数中绘制两端的小圆。
// 计算圆环上点的坐标
function calcRingPoint(x, y, radius, angle) {
let res = {}
res.x = x + radius * Math.cos((angle * Math.PI) / 180)
res.y = y + radius * Math.sin((angle * Math.PI) / 180)
return res
}
// 接着绘制
function renderRing(startAngle, endAngle) {
...
// 计算外环与内环终点连接处的中⼼坐标
let oneCtrlPoint = calcRingPoint(
x,
y,
innerRadius + thickness / 2,
endAngle
)
// 绘制外环与内环终点连接处的圆环
ctx.arc(
oneCtrlPoint.x,
oneCtrlPoint.y,
thickness / 2,
angle2Radian(-90),
angle2Radian(270) // 可任意调整,只要和原来平的轨道合并成一个圆即可
)
// 计算外环与内环起点连接处的中⼼坐标
let twoCtrlPoint = calcRingPoint(
x,
y,
innerRadius + thickness / 2,
startAngle
)
// 绘制外环与内环起点连接处的圆环
ctx.arc(
twoCtrlPoint.x,
twoCtrlPoint.y,
thickness / 2,
angle2Radian(-90),
angle2Radian(270)
)
ctx.fill()
}

此时我们得到了一个有点问题的图形——起点处的小圆被内环路径覆盖了。我们需要调整绘制顺序:先画外环,再画内环,最后画起点处的小圆。调整后的 renderRing 函数核心逻辑如下(示意图):

三、优化,提高还原度
目前绘制的半圆环看起来比设计稿短一些。为了更接近视觉稿,我们调整起始角度,让弧长超过180度,然后旋转画布将其摆正。
let startAngle = -65 // 开始⾓度
let endAngle = 155 // 结束⾓度
ctx.rotate(angle2Radian(225)) // 将画布旋转225度


接下来,我们实现进度增长的动画。思路是不断重绘画布,每次绘制时更新结束角度。
// 进度条颜⾊
let progress = ctx.createLinearGradient(0, 0, 500, 0)
progress.addColorStop(0, '#1075EB')
progress.addColorStop(1, '#FFF')
ctx.fillStyle = progress
// 开始绘画
let tempAngle = startAngle
let total = 100 // 总进度
let percent = this.progress / total // 百分⽐
let twoEndAngle = percent * 220 + startAngle // 半圆原本是180,加长后是220
let step = (twoEndAngle - startAngle) / 100 // 设置步长速度
function animLoop() {
if (tempAngle < twoEndAngle) {
tempAngle += step
renderRing(startAngle, tempAngle)
window.requestAnimationFrame(animLoop)
}
}
animLoop()

大功告成!但你可能会发现图形有锯齿。这是因为 canvas 是位图,放大后曲线边缘容易不清晰。解决方法是先在高分辨率下绘制,再缩放到显示尺寸。
let devicePixelRatio = 4 // 定义一个设备像素倍率变量
this.canvas.height = this.cHeight * devicePixelRatio
this.canvas.width = this.cWidth * devicePixelRatio
// 再缩放抗锯齿
ctx.scale(devicePixelRatio, devicePixelRatio)
经过抗锯齿优化后,效果平滑了许多,高度还原了视觉稿。

SVG 实现步骤
一、先取一个 SVG 整圆,加上圆环
svg 作为矢量图形,没有锯齿问题。实现的关键在于 circle 标签的 stroke-dasharray(虚线间隔)和 stroke-linecap(线头端点样式)属性。
我们可以先从一个基础的 SVG 圆环开始调整:
<svg width="440" height="440" viewbox="0 0 440 440">
<circle cx="220" cy="220" r="140" stroke-width="16" stroke="#FFF" fill="none"></circle>
<circle cx="220" cy="220" r="140" stroke-width="16" stroke="#00A5E0" fill="none" stroke-dasharray="260 879"></circle>
</svg>

根据圆周长公式 c = 2πr,半径140的圆周长约为 879。stroke-dasharray="260 879" 表示虚线长260,间隔879,因此我们看到了大约四分之一(260/879)的蓝色弧线。
二、改造 SVG 整圆
我们需要的是半圆环。半径为 140 的圆,半周长约为 440。通过设置 stroke-dasharray 为半周长值,并添加 stroke-linecap="round" 来获得圆头端点,我们可以得到一个倾斜的半圆环。
<svg width="440" height="440" viewBox="0 -100 440 440" class="svg-out out">
<circle cx="180" cy="220" r="140"
stroke-width="16"
stroke="#FFF"
fill="none"
stroke-dasharray="430"
stroke-linecap="round"
></circle>
</svg>

这仍然是一个倾斜的弧线。我们通过 CSS 旋转并调整弧长,使其达到设计稿的形态。
.out {
width: 366px;
transform: rotate(-200deg);
}


此时,外环弧线的 stroke-dasharray 值调整为了 535。我们再添加一个内环作为进度填充部分,形成一个静态效果。
<svg width="440" height="440" viewBox="0 -100 440 440" class="svg-out out">
<!-- 外环 -->
<circle cx="180" cy="220" r="140"
stroke-width="16"
stroke="#FFF"
fill="none"
stroke-dasharray="535"
stroke-linecap="round"
></circle>
<!-- 内环 -->
<circle class="inner" cx="180" cy="220" r="140" stroke-width="16" stroke="#1075EB" fill="none" stroke-dasharray="0 879" stroke-linecap="round"></circle>
</svg>

三、SVG 实现动态轨道进度条
svg 的动态原理与 canvas 不同。进度值需要映射到弧线的实际长度上。对于超过半圆的弧长,我们需要重新计算其“有效周长”。
mounted() {
this.calcSvgProgress(this.progress) // progress: 50
},
methods: {
calcSvgProgress(progress, delay = 500) {
// 整圆c=2πr,半圆则是c=πr r=180是半圆,我们目前比半圆多一点点,所以取 170
let percent = progress / 100, perimeter = Math.PI * 170
setTimeout(() => {
document.querySelector('.inner').setAttribute('stroke-dasharray', perimeter * percent + " 879");
}, delay)
}
别忘了为内环添加 CSS 过渡动画:
.inner {
transition: stroke-dasharray 1s;
}
通过以上代码,我们动态地将内环的 stroke-dasharray 从 0 879 变为 267 879,实现了进度条从0滑动到50%的效果。至此,我们用 svg 实现了与 canvas 高度一致的视觉效果,并且它是矢量的!

数据更新时的体验优化与差异
在实际活动场景中,用户完成任务后,进度条需要实时更新,有时还会伴随“升级”后进度重置再增长的效果。canvas 和 svg 在这方面的默认表现截然不同。
Canvas 的表现:
由于 canvas 每次变化都是全量重绘,所以进度增加或减少时,它都会从0开始绘制到目标值。这在“进度减少”(如升级后等级提升,当前进度百分比降低)的场景下,会看到进度条先缩回再增长,体验稍显突兀。

SVG 的表现:
svg 作为 DOM 元素,其变化依赖于 CSS 属性过渡。因此,进度变化时,它会平滑地从当前状态过渡到目标状态。这虽然连贯,但在“升级”场景下,直接从一个高百分比过渡到一个低百分比,会让用户感觉进度“倒退”了,逻辑上不合理。

好在 svg 元素本身可以被 JavaScript 操控。我们可以通过一些技巧来模拟“升级”效果:先让进度条快速充满,然后瞬间重置并隐藏,再显示并从0开始增长到新的进度值。
svgLevelUp() {
let circle = document.querySelector('.inner');
let perimeter = Math.PI * 170
circle.setAttribute('stroke-dasharray', perimeter + " 879");
setTimeout(() => {
circle.style.display = 'none'
circle.setAttribute('stroke-dasharray', '0 879')
setTimeout(() => {
circle.style.display = 'block'
this.calcSvgProgress(25, 100)
}, 10)
}, 1000)
},
这段代码的原理是:
- 先将进度条填充至100%(满值)。
- 延迟1秒后,隐藏内环,并将其进度值重置为0。
- 短暂延迟后,重新显示内环,并调用进度计算函数,让其从0动画增长到新的进度(例如25%)。
这样就营造出一种“升级后开启新一段进度”的视觉效果,用户体验更符合逻辑。

总结
- Canvas 实现过程较为复杂,但可控性极强,能够自由调整各种细节,适合应对复杂多变的需求。其进度值(0-100)与实际绘制的角度是直接对应的。
- SVG 实现简单,代码量少,且作为矢量图形具有天然优势。但其进度计算依赖于路径长度,对于非标准圆弧需要额外换算。在复杂交互场景下,需要编写额外逻辑来优化体验。
没有绝对完美的方案,只有最适合具体场景的选择。Canvas 提供了像素级的控制力,而 SVG 则提供了简洁的声明式和基于DOM的交互能力。希望通过本文对两种实现方式的剖析与对比,能为你在前端可视化组件的技术选型与实现上带来一些启发。
欢迎在 云栈社区 与其他开发者交流更多关于 Vue 和 SVG 的前端实践经验。