基于JavaScript的圆形进度条动态绘制算法解析
解析基于JavaScript的圆形进度条动态绘制算法,涵盖百分比到角度与弧长的几何映射、Canvas 2D与SVG方案差异、requestAnimationFrame时间插值及边界条件处理。
用 JavaScript 实现一个平滑的圆形进度条,核心只解决三件事:把 0 到 100 的百分比线性映射到圆弧的弧度或周长上,选择 Canvas 2D 或 SVG 作为绘制载体,再通过 requestAnimationFrame 结合时间差做缓动插值,让数值变化时圆弧平滑过渡。理解这套数学关系与帧更新逻辑后,无论是单一指标环还是多环仪表盘,都能稳定控制绘制精度与性能。
一、圆形进度映射的数学基础
无论底层采用哪种图形接口,进度条的本质都是“部分圆周占完整圆周的比例”。在进入绘制代码之前,先把输入数值统一归一化为区间 [0, 1] 内的比例值 p,避免负数或超过 100 的异常输入破坏圆弧计算:
const clamped = Math.min(100, Math.max(0, percent));
const p = clamped / 100;
拿到比例值 p 后,两条主流技术路线会采用两种等价的几何换算方式:
- 角度映射(Canvas 2D 常用):完整圆周对应的弧度为
2 * Math.PI。Canvas 的0弧度默认指向 3 点钟方向,而常规进度条从 12 点钟方向顺时针推进,因此起始弧度必须减去Math.PI / 2,终点弧度为startAngle + 2 * Math.PI * p。 - 弧长映射(SVG 常用):已知半径
r,圆周长C = 2 * Math.PI * r。将圆形描边的虚线总长stroke-dasharray设为C后,再把偏移量stroke-dashoffset设为C * (1 - p)。当p = 0时偏移等于整圈周长,前景弧完全隐藏;当p = 1时偏移归零,前景弧闭合。
二、Canvas 2D 与 SVG 两种绘制方案的关键差异
同样使用 JavaScript 控制进度值与补间动画,Canvas 2D 与 SVG 的渲染工作方式截然不同。在尺寸、帧率和颜色配置完全一致的条件下,两者在清晰度适配、每帧更新开销以及图形扩展性上的差异,会直接影响实际选型。
| 比较维度 | Canvas 2D(arc 重绘) | SVG(stroke-dashoffset) |
|---|---|---|
| 高清屏清晰度 | 必须读取 window.devicePixelRatio 并放大内部像素缓冲区,否则边缘发虚 |
基于矢量几何轮廓光栅化,随缩放比例自动保持锐利 |
| 帧更新机制 | 每次重绘都需要擦除整块画布并重新绘制底轨、前景弧与居中文字 | 仅修改一个圆元素的 stroke-dashoffset 与文本节点内容 |
| 复杂视觉效果 | 容易实现锥形渐变(createConicGradient)、发光阴影或动态光点跟随 |
原生支持线性/径向渐变,但沿弧线方向的锥形渐变实现较繁琐 |
| 适用场景倾向 | 同屏密集图表、高频粒子/光点联动、需要严格控制 DOM 数量的画布区 | 常规业务卡片、响应式页面组件、需要直接应用 CSS 变量与无障碍属性的场景 |
三、动态过渡的帧插值与缓动算法
如果直接把目标进度画出来,圆弧会瞬间跳变;如果用固定步长(例如每帧固定加 1%),在 60Hz 和 120Hz 刷新率屏幕上的动画总时长会相差一倍。要让动画在不同设备上保持一致的节奏,必须采用基于时间戳的插值算法。
假设当前显示值为 from,新传入的目标值为 to,期望动画在 duration 毫秒内完成。动画启动后,在每一帧回调函数中读取浏览器传入的高精度时间戳 now,计算已流逝时间比例 t:
const elapsed = now - startTime;
const t = Math.min(1, elapsed / duration);
const eased = 1 - Math.pow(1 - t, 3); // 三次方缓出(easeOutCubic)
const current = from + (to - from) * eased;
这段公式里,t 始终被限制在 [0, 1] 区间内,保证动画到达预定时间后精确停在目标值 to。其中 1 - Math.pow(1 - t, 3) 是常用的三次方缓出函数:开头变化快,接近终点时逐渐减速,视觉上比匀速直线插值更自然。
四、基于 Canvas 2D 的完整实现步骤
使用 Canvas 2D 绘制时,必须把画布的逻辑显示尺寸(CSS 像素)和物理像素缓冲区(canvas.width / canvas.height)分开处理。以下给出一个可直接保存为本地 HTML 文件并在现代浏览器中打开运行的完整示例,封装了高清屏缩放、圆弧绘制与连续过渡控制。
关键实现要点
- 画布半径留白:圆环半径
radius不能直接取画布宽度的一半,必须减去线宽的一半lineWidth / 2,否则圆弧外侧一半的描边会被画布边界裁掉。 - 高清屏缩放:读取
window.devicePixelRatio || 1,把画布像素宽高乘以dpr,再执行ctx.setTransform(dpr, 0, 0, dpr, 0, 0),随后所有坐标仍按逻辑像素计算。 - 零值圆头保护:设置
ctx.lineCap = 'round'后,即使起止角度完全重合(percent === 0),Canvas 仍会在 12 点钟位置画出一个实心圆点。因此当value <= 0时直接跳过前景弧绘制。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Canvas 圆形进度条示例</title>
</head>
<body>
<canvas id="ring-canvas"></canvas>
<button id="btn-75">平滑到 75%</button>
<button id="btn-20">回退到 20%</button>
<script>
function createCanvasRing(canvas, options = {}) {
const size = options.size || 180;
const lineWidth = options.lineWidth || 14;
const trackColor = options.trackColor || '#E5E3DC';
const activeColor = options.activeColor || '#687D8D';
const textColor = options.textColor || '#2C353D';
const duration = options.duration || 600;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
// 物理像素按 DPR 放大,逻辑尺寸由 CSS 控制
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const center = size / 2;
const radius = (size - lineWidth) / 2;
const startAngle = -Math.PI / 2;
let currentValue = 0;
let rafId = null;
function render(value) {
const clamped = Math.min(100, Math.max(0, value));
const p = clamped / 100;
ctx.clearRect(0, 0, size, size);
// 1. 绘制背景底轨
ctx.beginPath();
ctx.arc(center, center, radius, 0, Math.PI * 2);
ctx.strokeStyle = trackColor;
ctx.lineWidth = lineWidth;
ctx.stroke();
// 2. 仅当进度大于 0 时绘制圆头前景弧,避免 0% 出现残留圆点
if (p > 0) {
const endAngle = startAngle + Math.PI * 2 * p;
ctx.beginPath();
ctx.arc(center, center, radius, startAngle, endAngle);
ctx.strokeStyle = activeColor;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.stroke();
}
// 3. 绘制居中百分比文本
ctx.fillStyle = textColor;
ctx.font = '600 28px "PingFang SC", "Microsoft YaHei", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(Math.round(clamped) + '%', center, center);
}
function setProgress(target) {
const to = Math.min(100, Math.max(0, Number(target) || 0));
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
const from = currentValue;
if (from === to) {
render(to);
return;
}
let startTime = null;
function step(now) {
if (startTime === null) startTime = now;
const t = Math.min(1, (now - startTime) / duration);
const eased = 1 - Math.pow(1 - t, 3);
currentValue = from + (to - from) * eased;
render(currentValue);
if (t < 1) {
rafId = requestAnimationFrame(step);
} else {
currentValue = to;
rafId = null;
}
}
rafId = requestAnimationFrame(step);
}
render(0);
return { setProgress };
}
const ring = createCanvasRing(document.getElementById('ring-canvas'));
ring.setProgress(65);
document.getElementById('btn-75').addEventListener('click', () => ring.setProgress(75));
document.getElementById('btn-20').addEventListener('click', () => ring.setProgress(20));
</script>
</body>
</html>
在浏览器中打开上述文件后,画布初始会从 0% 用 600 毫秒平滑过渡到 65%,圆弧从 12 点钟方向顺时针展开,中央同步递增至 65%;如果在动画尚未结束时点击切换按钮,旧动画会被立即取消,圆弧从当前中途位置直接平滑转向新目标。
五、基于 SVG stroke-dashoffset 的动态实现
如果希望进度条直接融入页面 DOM、通过 CSS 控制颜色或自适应不同分辨率容器,SVG 是更轻便的实现路径。它同样遵循前文的 requestAnimationFrame 时间插值算法,只是每帧不再清空重绘,而是把实时进度换算为 stroke-dashoffset 写入前景圆。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SVG 圆形进度条示例</title>
<style>
.ring-box {
width: 180px;
height: 180px;
position: relative;
}
.ring-track {
fill: none;
stroke: #E5E3DC;
}
.ring-progress {
fill: none;
stroke: #879B88;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: 90px 90px;
}
.ring-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 600;
color: #2C353D;
}
</style>
</head>
<body>
<div class="ring-box">
<svg width="180" height="180" viewBox="0 0 180 180">
<circle class="ring-track" cx="90" cy="90" r="83" stroke-width="14" />
<circle id="svg-arc" class="ring-progress" cx="90" cy="90" r="83" stroke-width="14" />
</svg>
<div id="svg-label" class="ring-label">0%</div>
</div>
<script>
function createSvgRing(arcEl, labelEl, radius, duration = 600) {
const circumference = 2 * Math.PI * radius;
arcEl.style.strokeDasharray = String(circumference);
let currentValue = 0;
let rafId = null;
function render(value) {
const clamped = Math.min(100, Math.max(0, value));
const p = clamped / 100;
if (p <= 0) {
arcEl.style.visibility = 'hidden';
arcEl.style.strokeDashoffset = String(circumference);
} else {
arcEl.style.visibility = 'visible';
arcEl.style.strokeDashoffset = String(circumference * (1 - p));
}
labelEl.textContent = Math.round(clamped) + '%';
}
function setProgress(target) {
const to = Math.min(100, Math.max(0, Number(target) || 0));
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
const from = currentValue;
if (from === to) {
render(to);
return;
}
let startTime = null;
function step(now) {
if (startTime === null) startTime = now;
const t = Math.min(1, (now - startTime) / duration);
const eased = 1 - Math.pow(1 - t, 3);
currentValue = from + (to - from) * eased;
render(currentValue);
if (t < 1) {
rafId = requestAnimationFrame(step);
} else {
currentValue = to;
rafId = null;
}
}
rafId = requestAnimationFrame(step);
}
render(0);
return { setProgress };
}
const svgRing = createSvgRing(
document.getElementById('svg-arc'),
document.getElementById('svg-label'),
83
);
svgRing.setProgress(65);
</script>
</body>
</html>
.ring-progress 设置 transform: rotate(-90deg) 时,必须同时声明 transform-origin: 90px 90px(或直接写在 SVG 元素属性 transform="rotate(-90 90 90)" 中),否则默认围绕 (0, 0) 旋转会导致圆环跑出可视区域。另外,当 p <= 0 时通过 visibility: hidden 隐藏前景圆,同样是为了避免 stroke-linecap: round 在起点留下圆点。
六、常见绘制偏差排查与算法总结
在实际接入动态圆形进度条时,以下四类问题最为常见,均可在算法层直接定位并修复:
- 圆环外边缘被截成平边:描边会向半径内外两侧各扩展
lineWidth / 2。检查半径是否满足r <= (size - lineWidth) / 2,例如在180×180的画布中使用14px线宽,最大安全半径就是(180 - 14) / 2 = 83。 - 高分屏下 Canvas 锯齿或发糊:仅修改 CSS 宽高而不乘以
devicePixelRatio,会导致浏览器把低分辨率位图拉伸显示。确认canvas.width等于size * dpr,且在缩放上下文后仍使用逻辑尺寸绘图。 - 数字和圆弧闪烁跳动:通常是因为多次调用
setProgress时没有取消上一次的rafId,导致两个帧循环同时改写同一个画布或属性。每次启动新过渡前务必先执行cancelAnimationFrame(rafId)。 - 100% 时圆环首尾接缝有微小缺口:部分浮点运算下
Math.PI * 2 * (100 / 100)可能存在极微小的精度误差,或 SVG 的stroke-dashoffset接近零未归零。在动画结束分支(t === 1)中强制将当前值锁死为精确的目标值to,即可确保闭合。
js给select设置默认值和免费替代品差距大吗
2026-11-08 20:17:20
怎么实现 JS 统计图的响应式自适应布局?
2026-11-06 22:20:18
wow.js 在移动端浏览器的兼容性问题及优化建议
2026-11-06 15:32:08
reveal.js 移动端显示错乱怎么修复
2026-11-06 11:57:11
怎么自定义 Fabric.js 对象的控制点样式与交互行为
2026-11-05 22:51:42








