
1. 圆环进度条为什么我优先选择SVG而不是Canvas或CSS先抛一个在团队里争论过很多次的问题前端要做一个圆环进度条第一反应是用什么方案我见过多数人的答案是Canvas毕竟绘图两个字听起来就该交给Canvas还有一部分人会用CSS的border半圆拼接或者依赖ECharts这类图表库。但如果你只是想要一个轻量、可控、不额外增加依赖的圆环进度条SVG其实是更顺手的选择。很多人对SVG有一个误解觉得它天生适合做图标、插画但做动态数据可视化就不够灵活。这个观念需要纠正一下。SVG的stroke体系stroke-dasharray、stroke-dashoffset天然就是为绘制带间隔的线设计的而圆环进度条的本质恰恰就是在一条完整的圆环线上通过控制显示多少、隐藏多少来表现进度。换句话说你根本不需要去计算弧线路径不需要用path的d指令一小段一小段拼只需要一个circle元素再加上两个stroke属性就能搞定大部分场景。举一个实际例子。我们内部运维平台有一个发布进度页面原来用的是ECharts仪表盘组件只为了展示一个构建结果百分比却要加载整套图表库的JS首屏性能被拖累了不少。后来我把它换成一段不到20行的SVG页面加载体积直接少了几百KB而且视觉上更贴合产品风格也没有图表库那套固定的主题干扰。这就是我常说的组件库是为复杂场景准备的一旦你的场景简单到可以手写就绝不要为1%的功能付100%的成本。SVG还有一个重要的优势是声明式。它不像Canvas那样每次更新都要清空画布然后重绘SVG可以通过直接修改属性值比如stroke-dashoffset或者使用CSS transition让浏览器完成状态过渡。这意味着动画性能由浏览器的渲染引擎接管代码量也会少很多。后面我会具体展开这套实现。这个方案适合谁呢如果你是前端开发需要在项目里快速实现一个圆环进度条或者你在做可视化大屏、数据报表不想为了一个小组件引入重型依赖又或者你刚接触SVG想搞懂stroke-dasharray和stroke-dashoffset这两个最容易劝退新手的属性那么这篇内容正好适合你。我会从数学基础、手写实现、样式增强到踩坑复盘完整走一遍尽量做到看完就能直接抄作业。2. 核心原理拆解strokе-dasharray与stroke-dashoffset的配合逻辑2.1 一个反直觉的事实圆环进度不是画出来的新手很容易陷入一个思维定式进度条是画出来的所以应该是一个弧形路径随着进度增加弧线变长。于是他们去研究SVG的path语法试图用A指令画圆弧还要计算起始角度、结束角度、大弧标志最后写出一大串很难维护的坐标数据。但实际上行业内用SVG做圆环进度条的通用做法并不是画弧线而是画完整的圆然后用虚线遮住不需要的部分。这个思路和CSS中通过遮罩实现进度条属于同一种思维只是SVG提供了更直接的属性支持。我把这个原理用绳子类比来解释假设你有一根完整的绳子围成一个圆环绳子的总长度就是圆的周长C。现在你把这根绳子裁剪成一段实线加一段空白的循环模式这就是stroke-dasharray的作用。stroke-dasharrayC表示整条绳子只有一段实线、没有空白于是你看到的就是一个完整的圆。stroke-dasharrayC C就表示实线C、空白C形成一个完整的圆和一个完整的空白段看起来还是一样的圆。真正让进度走起来的是stroke-dashoffset。它的作用是把绳子的起点向后移动。比如stroke-dashoffsetC就把实线段的起点推移了整整一圈于是我们看到的圆环上没有任何实线全部是空白。设置stroke-dashoffsetC * 0.5就会露出半圈实线。这就是进度条的完整原理。2.2 周长的计算一切都围绕这个数字展开既然原理是总长度C显示长度C - offset那么周长成了所有计算的核心。值得强调的一点是这个周长并不是圆的周长而是stroke中线位置的周长。什么意思呢我们在SVG里画一个圆实际上绘制的是这个circle元素的stroke它本身的宽度是stroke-width所以从视觉上看宽笔画的圆环中心线半径才是实际用于计算周长的半径。举例来说如果viewBox是0 0 200 200圆心坐标(cx, cy)是(100, 100)stroke-width是20而圆的半径r设定为80那么stroke的中心线半径就是r本身80周长为2 * Math.PI * 80 ≈ 502.65。为什么不是90或70因为如果你把圆画在半径80的位置宽20的笔画会从70延伸到90视觉上圆环的外沿是90、内沿是70但绳子中线的周长还是80对应的周长这在数学计算上最准确。实际开发中除非是极其较真的视觉还原否则直接用circle的r属性做计算即可偏差在整数像素以内基本看不出问题。计算周长时JavaScript代码就是const radius 80; const circumference 2 * Math.PI * radius; console.log(circumference); // 502.6548245743669这个circumference会被同时用于stroke-dasharray和stroke-dashoffset的初始值。2.3 为什么必须rotate(-90deg)还有一个新手常踩的坑SVG的圆环默认起点在3点钟方向。也就是说如果直接设置stroke-dashoffset让实线露出50%露出的部分是从右侧水平位置开始这个进度看起来是顺时针从右上方长出来的而不是从顶部12点方向开始。绝大多数UI需求都要求进度从12点方向开始因此必须给circle加上旋转属性circle cx100 cy100 r80 fillnone stroke-width20 strokeurl(#gradient) stroke-dasharray502.65 stroke-dashoffset0 transformrotate(-90 100 100) /这里的transformrotate(-90 100 100)表示以圆心(100,100)为轴心逆时针旋转90度这样原本的3点钟起点就被转到了12点钟方向。注意这里的三个参数前两个是旋转中心的x、y坐标和circle的cx、cy保持一致第三个是旋转角度负值表示逆时针。如果你需要进度从其他角度开始比如做成仪表盘从7点钟方向开始扇形展开只需调整这个旋转角度即可。例如旋转-135度能让起点跑到左上方配合stroke-linecapround就能做出类似汽车仪表盘的风格。3. 从零手写圆环进度条一套可以直接落地的实现3.1 静态的基础结构先写一个最基础的静态版本包含一个完整的背景圆环和一个前景进度圆环再放一个文本元素显示百分比。svg classprogress width200 height200 viewBox0 0 200 200 !-- 背景圆环 -- circle classprogress-track cx100 cy100 r80 fillnone stroke-width20 stroke#e9ecef / !-- 前景进度圆环 -- circle classprogress-bar cx100 cy100 r80 fillnone stroke-width20 stroke#3b82f6 stroke-linecapround stroke-dasharray502.65 stroke-dashoffset251.33 transformrotate(-90 100 100) / !-- 居中文本 -- text x100 y100 text-anchormiddle dominant-baselinecentral font-size28 fill#333 50%/text /svg这里的关键点有几个。首先是stroke-dasharray设为周长进度条的实线长度就是整个圆环的长度。其次是stroke-dashoffset设为251.33正好是周长的一半所以显示出来的进度是50%。这里我故意写死数字是为了让你直观理解dashoffset周长时实线被完全推移出视野进度为0dashoffset0时实线完整显示进度为100%dashoffset周长*(1 - 进度百分比)时显示对应比例的进度。还有stroke-linecapround这个属性会让虚线段的端点变成圆头。在圆环进度条里它能让进度条的头部和尾部呈现圆润的收口效果视觉上非常精致。不过后面我会讲到这个属性会带来一个微小的多出一小截问题在做精确进度时需要处理。3.2 用JavaScript动态更新进度静态版本只是热身真实项目中的进度值一定是动态变化的。下面给出一个标准实现通过修改circle元素的style或属性来更新进度。div classprogress-container svg classprogress width200 height200 viewBox0 0 200 200 circle classprogress-track cx100 cy100 r80 fillnone stroke-width20 stroke#e9ecef / circle idprogressBar cx100 cy100 r80 fillnone stroke-width20 stroke#3b82f6 stroke-linecapround stroke-dasharray502.65 stroke-dashoffset502.65 transformrotate(-90 100 100) / text idprogressText x100 y100 text-anchormiddle dominant-baselinecentral font-size28 fill#3330%/text /svg /divconst R 80; const C 2 * Math.PI * R; const bar document.getElementById(progressBar); const text document.getElementById(progressText); function setProgress(percent) { const validPercent Math.min(100, Math.max(0, percent)); const offset C * (1 - validPercent / 100); bar.style.strokeDashoffset offset; text.textContent Math.round(validPercent) %; } // 模拟异步加载进度 let current 0; const timer setInterval(() { current 3; setProgress(current); if (current 100) { clearInterval(timer); } }, 50);这里有一个细节我把stroke-dasharray和stroke-dashoffset的初始值都设成了周长而不是在CSS中设置。为什么因为JavaScript直接操作style属性时如果CSS里也写了stroke-dashoffset优先级会覆盖JS的设置造成改了但没反应的诡异现象。为了减少这类不可控问题建议把核心进度参数交给JS统一管理CSS只负责颜色、宽高等样式无关属性。另外setProgress中的Math.min和Math.max是对传入百分比做边界限制避免进度条超出圆环范围。实际项目中接口返回的数据偶尔会出现101%或-5%这种脏数据这一层防护必须加。3.3 让过渡动画顺滑地走起来上面的setInterval版本只是硬切数值视觉上是一格一格跳动谈不上动画。更好的做法是利用CSS transition让stroke-dashoffset在改变时自动产生平滑的过渡动画.progress-bar { transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1); }这样每当JS更新stroke-dashoffsetSVG圆环就会在0.6秒内平滑地从当前进度滑动到新进度。cubic-bezier曲线选用了Material Design标准的加速减速曲线效果比较自然你也可以换成linear或者自定义曲线。注意一个坑transition对stroke-dashoffset的支持在现代浏览器上是没问题的但如果你同时设置了stroke-dasharray的transition又希望进度从0开始展现某些浏览器可能会出现虚线部分闪烁的bug。我的建议是stroke-dasharray始终保持不变只过渡stroke-dashoffset这是最稳妥的做法。如果你需要更精细的数字动画比如从47%到63%数字同步滚动到对应值只靠CSS transition是不够的因为text节点的内容变化是瞬间的。这时可以引入requestAnimationFrame做补间动画同时更新数字和偏移量function animateProgress(from, to, duration 700) { const startTime performance.now(); function tick(now) { const elapsed now - startTime; const progress Math.min(1, elapsed / duration); const eased 1 - Math.pow(1 - progress, 3); // easeOutCubic const current from (to - from) * eased; setProgress(current); if (progress 1) { requestAnimationFrame(tick); } } requestAnimationFrame(tick); } // 使用animateProgress(0, 75);这段代码的核心是eased这个变量。直接线性插值会让进度条匀速运动但加上easeOutCubic后开始速度快、接近目标时减速视觉上更有努力加载到最后变缓的感觉相比纯匀速更有质感。这些细节往往决定了一个进度条看起来是专业还是生硬。4. 真实项目落地中的样式增强与优化细节4.1 渐变、圆角端点和阴影提升视觉质感基础版的单色进度条能满足功能需求但如果你面对的是一个面向客户的展示页面通常还需要一些视觉增强。SVG在这方面的扩展非常自然。渐变色的实现方式是用defs定义linearGradient然后让进度circle的stroke引用这个渐变svg classprogress width200 height200 viewBox0 0 200 200 defs linearGradient idgradient x10% y10% x2100% y2100% stop offset0% stop-color#36d1dc / stop offset100% stop-color#5b86e5 / /linearGradient /defs circle classprogress-track cx100 cy100 r80 fillnone stroke-width20 stroke#e9ecef / circle idprogressBar cx100 cy100 r80 fillnone stroke-width20 strokeurl(#gradient) stroke-linecapround stroke-dasharray502.65 stroke-dashoffset502.65 transformrotate(-90 100 100) / /svg这里要注意一个非常隐蔽的坑当页面中存在多个SVG实例时idgradient会发生全局冲突。浏览器在解析url(#gradient)时会在整个文档中查找id如果页面里有两个同时存在的SVG都定义了gradient它们可能引用到同一个渐变定义甚至导致某些浏览器渲染异常。解决办法有两种一是给每个SVG的渐变id加上唯一前缀比如p1-gradient、p2-gradient二是把defs放到页面级的隐藏SVG中统一管理。我在实际项目中更习惯用第一种简单直接。圆角端点前面已经提过这里补充说明一个小细节。stroke-linecapround会让进度条的首尾各多出半个stroke-width的长度。也就是说在进度为100%时圆角会让首尾相接处出现一个微微的溢出重叠看起来像一个凸起。在绝大多数场景下这个效果可以忽略不计但如果设计稿要求的是精确到像素的圆环可以通过将周长计算值减去一个stroke-width来补偿即const C 2 * Math.PI * R - strokeWidth。4.2 多实例场景不要让实例之间互相影响在一个页面上同时存在多个圆环进度条比如展示多项技能熟练度是很常见的需求。如果用上面的方式写多个SVG每个都要改id还要复制一大段结构代码维护起来很痛苦。我的做法是用JavaScript工厂函数动态生成SVG节点或者封装成一个Vue/React组件把公共逻辑收敛起来。这里给一个简单的组件化思路以原生JavaScript为例function createRingProgress(container, options) { const { size 200, strokeWidth 20, color #3b82f6, trackColor #e9ecef, initial 0 } options; const ns http://www.w3.org/2000/svg; const svg document.createElementNS(ns, svg); svg.setAttribute(width, size); svg.setAttribute(height, size); svg.setAttribute(viewBox, 0 0 200 200); const radius 80; const circumference 2 * Math.PI * radius; const track document.createElementNS(ns, circle); track.setAttribute(cx, 100); track.setAttribute(cy, 100); track.setAttribute(r, radius); track.setAttribute(fill, none); track.setAttribute(stroke-width, strokeWidth); track.setAttribute(stroke, trackColor); const bar document.createElementNS(ns, circle); bar.setAttribute(cx, 100); bar.setAttribute(cy, 100); bar.setAttribute(r, radius); bar.setAttribute(fill, none); bar.setAttribute(stroke-width, strokeWidth); bar.setAttribute(stroke, color); bar.setAttribute(stroke-linecap, round); bar.setAttribute(stroke-dasharray, circumference); bar.setAttribute(stroke-dashoffset, circumference); bar.setAttribute(transform, rotate(-90 100 100)); bar.style.transition stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1); svg.appendChild(track); svg.appendChild(bar); container.appendChild(svg); return { setProgress(percent) { const val Math.min(100, Math.max(0, percent)); bar.style.strokeDashoffset circumference * (1 - val / 100); } }; }这段代码不长但覆盖了动态创建的完整逻辑。注意两点一是document.createElementNS而不是document.createElement创建SVG元素必须带上命名空间否则浏览器会直接报错二是返回值暴露setProgress方法外部只要调用实例方法就能更新进度不需要关心内部的style操作。4.3 适配响应式、暗黑模式和百分比文字圆环进度条经常出现在Dashboard页面上免不了要面对响应式布局。如果SVG的width和height是固定像素在移动端就会撑破布局。最简单的做法是用CSS控制SVG的最大宽度并保持宽高比.progress-container { width: 100%; max-width: 200px; } .progress-container .progress { width: 100%; height: auto; }当SVG的viewBox存在时只要设置了height:auto宽度可以根据容器自适应圆环不会变形。这套方案比写各种媒体查询省心得多。暗黑模式方面由于进度条轨道和文字颜色是纯色可以借助CSS变量来适应主题切换:root { --progress-track-color: #e9ecef; --progress-text-color: #333; } [data-themedark] { --progress-track-color: #2d3748; --progress-text-color: #f7fafc; } .progress-track { stroke: var(--progress-track-color); } .progress-text { fill: var(--progress-text-color); }这里提一个很多文章不会讲的细节SVG中文字的颜色不是用color属性设置的而是用fill。如果你把SVG内联在HTML中可以直接通过CSS的fill属性控制文字颜色但如果SVG是通过img标签引用的外部文件CSS是穿透不进去的。所以我的建议是能内联就内联。这不仅是暗黑模式适配的问题也关系到渐变、CSS变量、transition这些动态特性的可用性。用一个内联SVG的性能代价微乎其微但换来的灵活度是巨大的。5. 踩坑实录那些让进度条看起来不对的常见问题5.1 高分辨率屏幕下圆环发虚不是SVG的锅一次在客户大屏项目里我们做的圆环进度条在普通笔记本上一切正常但接到4K大屏后圆环边缘出现了明显的锯齿感。客户以为是SVG矢量图放大了导致的其实不然。SVG本身是矢量的理论上不存在放大变模糊的问题问题出在浏览器对大尺寸SVG的渲染机制上。当SVG的实际渲染尺寸CSS像素远大于viewBox定义的坐标尺寸时浏览器虽然会进行无损缩放但字体和圆头端点的抗锯齿算法在某些GPU组合下会出现降级导致边缘发虚。解决办法有两种一是把viewBox的尺寸调大比如改为0 0 400 400同时把cx、cy、r、stroke-width按比例放大让浏览器在接近物理像素的尺度上渲染二是确认屏幕的devicePixelRatio尽量让SVG的CSS像素尺寸与设备像素尺寸成整数比。实际上后者才是核心原因。如果CSS把SVG设置成宽度199.5px这种非整数值就会触发浏览器的亚像素采样这种情况下发虚是必然的。解决办法是给SVG容器设置整数的width或者使用CSS的image-rendering属性做调整。不过从经验来看99%的看起来模糊都能通过方法一解决即放大viewBox后再等比缩小渲染尺寸。5.2 stroke-dasharray值过小导致进度条变成虚线碎段有个同事在自己写圆环进度条时把stroke-dasharray设成了进度值本身比如进度50%时他设置stroke-dasharray50 50结果圆环上出现了密密麻麻的小虚线段完全不像一个进度条。这个坑的根源还是对dasharray机制理解不透。上文中已经解释过dasharray定义的是实线长度、空白长度的循环模式。如果你希望进度只显示一段实线那么这段实线的最大长度必须等于或大于圆环的周长否则实线会在圆环上断成多截。正确做法是始终把stroke-dasharray设为周长C然后通过stroke-dashoffset来控制显示实线的起点位置而不是动态修改dasharray的实线长度。遇到这类问题我的排查经验是先看dasharray值是否大于等于周长。这是一个很简单的验证但真的能排查掉80%的圆环进度条显示异常问题。顺便提一句如果你用的是SVG的path元素而不是circle请记住path的总长度不一定等于圆的周长由于绘制的曲线复杂度不同需要用getTotalLength()方法动态获取实际长度。5.3 多个渐变id冲突引发的颜色错乱前面提到过的渐变id冲突我再展开讲一个真实案例。在一个多标签页的后台系统中每个Tab页都有一个带圆环进度条的卡片它们共用一套公共CSS和组件脚本。结果发现在切换Tab后某些卡片的进度条颜色变成了绿色但代码里明明写的是蓝色。排查过程是这样的因为所有Tab页都是同一个页面内的不同区域它们被同时挂载在DOM中。当浏览器解析url(#gradient)时会找到文档中第一个匹配的id而不是最近的defs。于是后面创建的卡片虽然定义了各自id但前面的组件先渲染导致后续卡片的circle实际引用到了第一个卡片的渐变颜色自然就不对了。最终的修复方案是给每个实例id加上动态序号比如gradient-0、gradient-1。如果你用的是Vue或React可以用组件的uid做后缀。这个坑极其隐蔽因为它不会报错只是颜色意外地变了。排查它时最简单的验证方法是打开DevTools检查circle元素的stroke属性看url(#gradient)是否指向了错误的defs节点。5.4 百分比文本抖动问题最后一个问题比较细节但很影响观感。当进度数字从4%变化到5%时由于数字宽度不同4%比5%窄text元素宽度在变化导致SVG中的文本在水平方向出现轻微抖动。尤其是文本居中放置时宽度变化会被均分到两侧抖动更明显。解决这个问题有几个思路。最简单的是给文本设置固定宽度并水平居中比如把text-anchormiddle和x坐标配合已经能消除水平抖动因为居中始终以x为基准。但如果文字包含会变化的后缀比如4% vs 10%宽度不同导致的整体视觉偏移还是存在。更彻底的办法是使用等宽字体比如font-variant-numeric: tabular-nums这个CSS属性可以让数字在文本中占据相同宽度。SVG的text元素支持font-variant-numeric只要在使用文字时带上这个样式数字变化就不再引起宽度跳动。还有一个常见场景是进度条数字和图标组合比如旁边放一个加载中的旋转图标。这种情况下建议把数字放在独立的绝对定位元素中而不是放在SVG内部的text节点里这样排版自由度更高也不受SVG缩放的影响。6. 从圆环进度条延伸出去SVG stroke属性能做的更多事掌握了stroke-dasharray和stroke-dashoffset之后你会发现这两兄弟能做的远不止圆环进度条。比如页面顶部的滚动阅读进度条本质上就是一条横向的细线同样可以用SVG或者纯CSS实现再比如一个加载完成打勾的动画用stroke-dasharray控制一条打勾路径的绘制过程又比如手写签名或笔迹动画思路也是一样的把路径长度算出来然后从0到全长逐渐增加dashoffset的可视部分。我平时写SVG动画时有一个百试百灵的方法先给路径设置stroke-dasharray为路径总长再设stroke-dashoffset为0此时路径完整显示。然后利用CSS的transition或者Web Animations API让dashoffset从总长过渡到0就得到了一笔一画描出来的效果。这个方法对直线、弧线、贝塞尔曲线都适用只要你记得用getTotalLength()获取真实长度。再提一个偏实战的方向前端可视化大屏上经常需要数字滚动 进度环 光晕的组合效果这些都能用SVG配合filter滤镜实现。比如给进度圆环加一个subtle的阴影filter idglow feGaussianBlur stdDeviation3 resultcoloredBlur/ feMerge feMergeNode incoloredBlur/ feMergeNode inSourceGraphic/ /feMerge /filter把filterurl(#glow)加到进度circle上就能产生光圈效果。不过这里同样要注意filter的id全局唯一和gradient的坑一致。如果你在多个组件里复用了同一个filter定义也可能出现类似id冲突的问题。说到底SVG的知识点并不零散它是一套属性驱动的绘图体系。把stroke、fill、transform、滤镜这四个核心维度掌握了再复杂的视觉效果都能拆解成这些基础属性的组合。圆环进度条只是这套体系的一个入门窗口但顺着这个窗口往里看你会发现很多有意思的玩法。我在实际项目里踩过一圈坑之后总结出一条几乎适用于所有前端场景的经验遇到一个具体的小需求先别急着上框架或图表库花十分钟想想原生技术栈能否解决。很多时候一个SVG circle加两行CSS比引入几千行的依赖更优雅也更好维护。圆环进度条就是这些看似简单但深挖有料的需求代表。