基于JavaScript的圆形进度条动态绘制算法解析

作者:页面旅途时间:2026-11-12 23:52:01浏览:608

解析基于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 时偏移归零,前景弧闭合。
基于JavaScript的圆形进度条动态绘制算法解析
图 1:百分比归一化后映射到圆心角与圆弧长度的几何关系

二、Canvas 2D 与 SVG 两种绘制方案的关键差异

同样使用 JavaScript 控制进度值与补间动画,Canvas 2D 与 SVG 的渲染工作方式截然不同。在尺寸、帧率和颜色配置完全一致的条件下,两者在清晰度适配、每帧更新开销以及图形扩展性上的差异,会直接影响实际选型。

基于JavaScript的圆形进度条动态绘制算法解析
图 2: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) 是常用的三次方缓出函数:开头变化快,接近终点时逐渐减速,视觉上比匀速直线插值更自然。

基于JavaScript的圆形进度条动态绘制算法解析
图 3:支持连续打断与平滑衔接的帧插值执行流程

四、基于 Canvas 2D 的完整实现步骤

使用 Canvas 2D 绘制时,必须把画布的逻辑显示尺寸(CSS 像素)和物理像素缓冲区(canvas.width / canvas.height)分开处理。以下给出一个可直接保存为本地 HTML 文件并在现代浏览器中打开运行的完整示例,封装了高清屏缩放、圆弧绘制与连续过渡控制。

关键实现要点

  1. 画布半径留白:圆环半径 radius 不能直接取画布宽度的一半,必须减去线宽的一半 lineWidth / 2,否则圆弧外侧一半的描边会被画布边界裁掉。
  2. 高清屏缩放:读取 window.devicePixelRatio || 1,把画布像素宽高乘以 dpr,再执行 ctx.setTransform(dpr, 0, 0, dpr, 0, 0),随后所有坐标仍按逻辑像素计算。
  3. 零值圆头保护:设置 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>
注意 SVG 的旋转原点与零值可见性:CSS 中为 .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,即可确保闭合。
基于JavaScript的圆形进度条动态绘制算法解析
图 4:基于 JavaScript 的圆形进度条动态绘制算法核心链路总览
作者最新文章
相关文章
更多