上个月给一个老后台系统加模块,需求很简单:首页要放一个半圆形的完成率仪表盘,数据从接口拉,刷新要顺滑。我习惯性地先搜了一圈jQuery插件,结果不是体积太大,就是样式死活套不进现有设计,最后只能自己动手。折腾完我才意识到,“jQuery画半圆”这个说法本身就容易把人带偏——真正负责“画”的是CSS和SVG,jQuery在里面只是干调度和控制的活。这篇文章就把我这几天的完整实践拆开来讲,把CSS方案和SVG方案从原理到实操彻底说透,最后再用一个半圆进度条的实战案例,把三者的分工串起来。不管你是刚接触前端的新人,还是要在老项目里维护这种组件的老人,应该都能找到能直接拿去用的东西。
1. 为什么“用jQuery画半圆”本身是个伪命题
很多人在后台系统里待久了,遇到“画个图形”的第一反应就是“找个jQuery插件”。这个思路不能说错,但很容易踩进坑里。我先说说jQuery的边界在哪里,再说清楚CSS和SVG在这个任务里到底各自扮演什么角色。
1.1 jQuery擅长什么,不擅长什么
jQuery最擅长的事情从来不是“画”,而是“操作DOM”。选择器、事件绑定、Ajax请求、链式修改元素样式和属性,这些是它的主场。你给它一个页面,它能非常方便地找到元素、改内容、绑交互。但图形绘制本身不是它的能力范围,它的底层没有Canvas、没有2D渲染上下文、没有任何跟像素打交道的东西。市面上那些所谓的jQuery画图插件,本质上都是包装了Canvas或者SVG,jQuery只是在外面做了一层“方便调用”的外壳。
这就带来一个后果:你为了画一个简单的半圆去引一个几十KB甚至上百KB的插件,里面有大量你可能永远用不到的图表配置项,最后渲染出来的效果还不一定符合你的设计稿。更麻烦的是,这类插件很多已经停止维护,在老项目里埋雷。
1.2 在“画半圆”这个任务里,三者应该怎么分工
我的实践经验是,这种任务应该按下面的思路拆:静态的形状和视觉样式交给CSS,复杂的路径和动态数据图形交给SVG,jQuery只负责“在合适的时机把数据写进DOM和SVG属性”。
CSS负责的是“形状感”的东西,比如一个装饰性的半圆色块、一个半圆形的按钮背景、一个拱门的视觉样式。这类需求不涉及数据变化,不需要路径计算,用CSS圆角就能搞定。SVG负责的是“路径精确”的东西,比如半圆环的进度条、仪表盘的刻度弧、需要跟随数据变化而改变角度的弧线。SVG的Path天然支持精确坐标,而且作为DOM节点,可以被JavaScript直接控制,这正好是jQuery发挥作用的舞台。
jQuery在这里面有点像一个施工现场的调度员:不搬砖、不和水泥,但它知道什么时候该让哪个工人上。具体来说,它负责三件事:测量容器尺寸、切换CSS类名、更新SVG属性。这三件事分别对应半圆组件落地时最常见的三个需求:自适应、交互态切换、数据刷新。
1.3 先按场景选路线,再动手写代码
我做这个半圆仪表盘之前,先列了一张简单的判断表,逼着自己不跳进“抄代码”的思维里:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 纯装饰半圆、静态色块 | CSS border-radius | 零JS开销,浏览器渲染快 |
| 需要描边、渐变、阴影的半圆轮廓 | SVG | 路径精确,样式表达能力更强 |
| 动态数据驱动的半圆弧线 | SVG + jQuery | 数据变化时需要改Path或者stroke参数 |
| 复杂交互动画、粒子效果 | Canvas(本文不展开) | 图形元素多、帧率高时才值得用Canvas |
这条决策线走完,下面就开始动手。先看CSS方案,再看SVG方案,最后用jQuery把SVG方案串起来做一个能交付的组件。
2. CSS实现半圆:三套思路,按场景选型
CSS实现半圆的方案不止一种,但很多人只会其中一种,遇到特殊场景就抓瞎。我平时常用的有三套写法,各自适合不同的场景。下面逐个拆开讲,包括原理和常见的坑。
2.1 border-radius切圆法:最经典,也最容易栽在圆角值上
先看最经典的做法。半圆本质上是一个矩形,让上面两个角变成“半圆轮廓”,下面两个角保持直角。写起来很直观:
<div class="semi-circle"></div>.semi-circle { width: 200px; height: 100px; background: #4a90d9; border-radius: 100px 100px 0 0; }这个代码的含义是:一个宽200px、高100px的矩形,左上角和右上角的圆角半径设为100px。因为矩形高度只有100px,而圆角半径也是100px,上面两个角就成了两个完美的半圆,连起来就是上半圆。关键点就在于圆角半径要等于容器高度,而不是漫无目的地写border-radius: 50%。我在代码评审里见过太多人写border-radius: 50%,结果椭圆不是椭圆、半圆不是半圆,整个形状像个鸡蛋。
border-radius: 50%这个写法有一个常见的误解:百分比是相对元素自身宽高的。对一个宽200px、高100px的元素来说,50%圆角的水平半径是100px、垂直半径是50px,两个半径不相等,渲染出来是椭圆角,不是正圆的四分之一。只有宽高相等的正方形,border-radius: 50%才能裁出正圆。所以做半圆的时候,要用具体的px值去控制圆角半径,并且这个值必须等于高度。
如果要做下半圆,就把圆角反过来:
.semi-circle-bottom { width: 200px; height: 100px; background: #4a90d9; border-radius: 0 0 100px 100px; }这个方案的优点是简单直观,缺点是样式比较“死”:它填充的是一个实心半圆,想在里面放文字得另加定位;想做出描边半圆,要用border属性而不是background,并且要把下方边框去掉:
.semi-circle-outline { width: 200px; height: 100px; border: 10px solid #4a90d9; border-bottom: none; border-radius: 110px 110px 0 0; }注意这里的圆角半径变成了110px,因为border-radius的半径是相对于border盒子的外边缘计算的,元素本身宽200px,加上左右各10px border之后外宽是220px,上面两个角如果还是100px,圆弧就不够饱满。你实际测试时可能发现110px也未必完全贴合,因为圆角半径的计算起点在不同浏览器里有细微差异。这个只能靠肉眼微调。我的建议是,凡是要做描边半圆,优先考虑SVG,别在CSS的border上跟自己较劲,后面第三章会讲为什么。
2.2 拆盒法:利用border-bottom做扁半圆和拱形
第二种方案是“拆盒法”,它的核心思路是把元素的某一条边框当成弧线来画。比如一个元素高100px、宽200px,给它设置下边框和上下圆角,就能得到一个拱形:
<div class="arch"></div>.arch { width: 200px; height: 100px; border-bottom: 24px solid #f5a623; border-radius: 100px 100px 0 0; }这个写法的视觉效果是:下半部分是背景色(这里没设置背景,默认透明),底部有一条24px粗的边框,边框上面两个角被圆角半径“顶”成了拱形弧线。它和border-radius切圆法的区别在于,它只描了边,没有填充整个半圆。这种形状非常适合做拱门、分割线、仪表盘下方的那条“弧线装饰”。
拆盒法还有一个变体,可以做出扁半圆(半椭圆):
.flat-semi { width: 240px; height: 90px; background: #50b96a; border-radius: 240px 240px 0 0 / 90px 90px 0 0; }这里用了border-radius的斜杠语法,斜杠前面是水平半径,斜杠后面是垂直半径。水平半径设为宽度的一半,垂直半径设为高度,渲染出来就是一个标准的半椭圆。有些UI场景需要这种扁半圆来做数据可视化装饰,比正半圆更柔和。
拆盒法的坑在于视觉效果依赖border-width和border-radius的配合,这两个值不是随便写的。比如border-bottom: 24px配合border-radius: 100px,圆弧的曲率中心会随着边框宽度发生偏移,你调完在普通屏幕上看刚好,换一个缩放比例又可能胖一圈。所以拆盒法我一般只用于不追求像素级精确的装饰性元素。
2.3 clip-path裁切法:最灵活,但注意裁剪边界
第三种方案是用clip-path,它跟前面两种思路完全不同:先准备一个方形或圆形元素,然后按半圆的几何边界去裁剪它。CSS的clip-path: circle()可以指定圆心和半径,天然适合做半圆裁剪。
<div class="clipped-semi"></div>.clipped-semi { width: 200px; height: 200px; background: linear-gradient(180deg, #9b59b6 0%, #6c3483 100%); clip-path: circle(100px at 100px 0); }这段代码的意思是:以一个200px乘200px的元素为基础,圆心的位置在(100px, 0),也就是元素上边缘的中点,半径100px。裁出来之后,下半部分全部被裁掉,只保留上半圆。这个方案最大的好处是裁剪不改变元素本身的盒模型,你可以在里面继续放文本、放图标,甚至放一个独立的子元素来分别控制样式。
clip-path方案还有一个很实用的延伸:配合CSS动画做“涟漪扩散”效果。很多人搜过的“css涟漪光圈扩散”,有一种实现方式就是多个半圆或圆形元素叠加,用clip-path控制初始形状,再通过animation放大和淡出。虽然完整涟漪是用整圆做的,但思路是一样的——裁剪区域 + 变换 + 透明度,可以组合出很多看似复杂的形状。
这个方案的坑主要在兼容性。比较老的浏览器(比如IE)完全不支持clip-path: circle(),部分老版Chrome需要加-webkit-前缀。另外,clip-path裁掉的区域不会响应鼠标事件,如果你需要用户点击半圆内部触发交互,得在裁剪区域之外补一个透明的、覆盖半圆范围的点击层,或者干脆用SVG。还有一个细节:clip-path: circle()的百分比半径计算方式跟border-radius不一样,它是基于元素的对角线来计算的,容易产生“半径到底有多长”的困惑。我的建议是写固定px值,位置也写px,这样在各个浏览器里表现更稳定。
2.4 渐变、阴影这些“半圆美化”怎么补
CSS半圆不是只能填纯色。背景渐变可以直接写在background上,跟border-radius方案完全兼容:
.gradient-semi { width: 200px; height: 100px; background: linear-gradient(90deg, #ff6b6b, #ee5a24); border-radius: 100px 100px 0 0; }但阴影有一个大坑:box-shadow是跟着盒模型的矩形走的。一个宽200px、高100px、上面是圆弧的梯形,你给它加box-shadow: 0 10px 20px rgba(0,0,0,0.3),阴影会从下面直着延伸出去,底部是一条直线而不是圆弧,看起来像是在圆拱下面挂了块板子。想要阴影贴合半圆的形状,应该用filter: drop-shadow():
.gradient-semi-shadow { width: 200px; height: 100px; background: linear-gradient(90deg, #ff6b6b, #ee5a24); border-radius: 100px 100px 0 0; filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.25)); }drop-shadow是按元素实际渲染出来的像素形状去计算阴影的,圆弧的部分会得到圆滑的阴影,底部直角的部分也是直角阴影,非常贴合。这个技巧代价是filter本身比box-shadow更消耗性能,如果页面上有成百上千个带阴影的半圆,要注意别滥用。
到这里,CSS方案大概够用了。但如果你要做的不是装饰色块,而是能跟随数据变化的半圆弧,那CSS就有点力不从心了——圆弧的起止点、弧度大小、描边宽度这些都是动态的,CSS写死一套样式无法应对。这时候就该SVG上场了。
3. SVG实现半圆的核心:Path弧线参数绕不开
SVG实现半圆的入门姿势很多,有人用circle元素加clip-path,有人直接写path。我的建议是,把path的弧线命令彻底搞懂,因为半圆只是圆弧的一个特例,你迟早会遇到“画一个从30度到150度的弧”这种需求。到了那种场景,circle + clip-path做不了,只能靠path。
3.1 SVG坐标系统:y轴向下的世界,视觉顺时针才是正方向
先建立坐标系的概念。SVG的坐标原点在左上角,x轴向右,y轴向下。跟我们在数学课上学到的直角坐标系(y轴向上)相比,y轴方向是反的。
这个反掉的y轴引发了一个特别常见的混乱:角度方向。在数学坐标系里,从x轴正方向逆时针旋转是正角度;但在SVG这种y向下的坐标系里,正角度方向变成了视觉上的顺时针。也就是说,SVG中的sweep-flag = 1表示“正角度方向”,画出来是顺时针;sweep-flag = 0表示负角度方向,画出来是逆时针。
我之前见过不少人在这一步栽跟头:明明想画上半圆,结果画出来一个下半圆,以为是终点坐标写错了,其实是sweep-flag这个方向位搞反了。下面用一个例子把这块彻底讲明白。
3.2 A命令拆解:六个参数逐个说清
path里的圆弧命令是A(也可以小写a,区别在于大写用绝对坐标、小写用相对坐标)。完整的语法长这样:
A rx ry x-axis-rotation large-arc-flag sweep-flag x y六个参数分别是:
rx和ry:椭圆弧在x轴和y轴方向的半径。画正圆弧时,rx = ry。x-axis-rotation:椭圆弧整体旋转的角度。对半圆来说一般写0。large-arc-flag:取0还是1。两个点之间用给定半径可以画出两条弧,一条大于180度,一条小于180度。取1表示画大弧,取0表示画小弧。恰好半圆时两条弧长度相同,取0还是1视觉结果一样,但规范上建议按“是否超过180度”来算。sweep-flag:方向位,取0或1。1表示沿正角度方向(视觉顺时针),0表示沿负角度方向(视觉逆时针)。x和y:终点坐标。起点由当前画笔位置决定,一般紧跟着一个M命令来设置。
这一堆参数光看定义记不住。我自己的记忆方法是把起点和终点想象成钟表上的两个刻度:起点是钟表指针站的位置,终点是它要走到哪里;sweep-flag = 1就按顺时针方向从起点走到终点,sweep-flag = 0就按逆时针方向走。
3.3 一个上半圆的手算过程
以我之前画的半圆为例,起点(30, 100),终点(170, 100),半径70,圆心自然就是(100, 100)。画上半圆的path是这样:
<svg viewBox="0 0 200 120" xmlns="http://www.w3.org/2000/svg"> <path d="M 30 100 A 70 70 0 0 1 170 100" fill="none" stroke="#4a90d9" stroke-width="10" /> </svg>我们来逐一对应:M 30 100把画笔移动到起点;A 70 70 0 0 1 170 100表示以半径70画弧,不旋转,取小弧,sweep-flag = 1,终点是(170, 100)。
现在用钟表思维验证方向:起点(30, 100)在圆心(100, 100)的正左方,相当于钟表的9点位置;终点(170, 100)是正右方,相当于3点位置。从9点沿顺时针走到3点,会经过12点方向,也就是屏幕正上方(100, 30),所以画出来是上凸的半圆弧。如果我把sweep-flag改成0,从9点沿逆时针走到3点,会经过6点方向,也就是屏幕正下方(100, 170),画出来就是下半圆弧。这个例子能帮你把坐标和方向一次性理清。
这里还要提醒一个新手常踩的坑:SVG的path默认是闭合的实心图形。如果你写了一句<path d="M 30 100 A 70 70 0 0 1 170 100">,不写fill="none",浏览器会自动补一条从终点回到起点的线,然后填充内部区域,你会看到一个类似“吃豆人”的扇形。画线条的时候,一定记得写fill="none",要的只是描边效果就用stroke来控制。
3.4 任意角度的弧:手写一个Path生成函数
实际项目中很少会每次手算Path,更多时候是给一个圆心、半径和起止角度,让程序自动生成。我自己写一个辅助函数:
function getArcPath(cx, cy, r, startAngle, endAngle) { // 约定:0度在12点方向,角度顺时针增加(符合视觉直觉) var startRad = ((startAngle - 90) * Math.PI) / 180; var endRad = ((endAngle - 90) * Math.PI) / 180; var x1 = cx + r * Math.cos(startRad); var y1 = cy + r * Math.sin(startRad); var x2 = cx + r * Math.cos(endRad); var y2 = cy + r * Math.sin(endRad); var largeArc = endAngle - startAngle > 180 ? 1 : 0; var sweep = 1; // 视觉顺时针 return 'M ' + x1 + ' ' + y1 + ' A ' + r + ' ' + r + ' 0 ' + largeArc + ' ' + sweep + ' ' + x2 + ' ' + y2; }角度规则是:0度在12点方向,90度在3点方向,顺时针增加。这个约定跟钟表一致,写业务逻辑的时候非常顺手。函数内部通过(角度 - 90)把12点方向转换成数学上的标准0度方向,然后换算成弧度、算终点的x和y坐标。
用这个函数画一个“从0度到180度”的上半圆:
var d = getArcPath(100, 100, 70, 0, 180); $('#arc').attr('d', d);得到的d属性就是M 30 100 A 70 70 0 0 1 170 100,跟我手写的完全一致。以后要画“从30度到120度”的弧,只需要改角度参数,坐标和sweep-flag都不用管。
3.5 半圆环可以更简单:radius + stroke-dasharray方案
如果你要的不是一条实线弧,而是半圆环进度条,还有一条更简单的路:直接用circle元素,然后用stroke-dasharray和stroke-dashoffset控制可见部分的长度。虽然画的是整圆,但通过进度控制只显示其中一段,在很多场景下比手动算Path更方便。
<svg viewBox="0 0 200 120" xmlns="http://www.w3.org/2000/svg"> <circle cx="100" cy="100" r="70" fill="none" stroke="#e6e9ed" stroke-width="12" /> <circle id="progress" cx="100" cy="100" r="70" fill="none" stroke="#4a90d9" stroke-width="12" stroke-linecap="round" stroke-dasharray="219.9" stroke-dashoffset="219.9" transform="rotate(180 100 100)" /> </svg>解释一下:第一个circle是底环,第二个是进度环。stroke-dasharray设为圆弧周长(半径为70的半圆弧,周长 = π × 70 ≈ 219.9),stroke-dashoffset初始设为同样的值,表示偏移掉全部长度,进度为0。将circle旋转180度,是为了让进度从左侧开始、往右侧增长。然后通过JavaScript把stroke-dashoffset改小,进度环就会逐渐显示出来。
如果我只想显示半圈的进度,为什么用完整circle而不是直接画半圆Path?因为circle计算周长简单,而且进度动画只需要改一个属性,不用重新生成Path。但有一个代价:circle画的是整圆,你最终看到的那“一段”到底是不是严格的半圆,要看你把进度控制在什么区间,进度条两端还会因为stroke-linecap: round多出半截圆头。所以这个方案适合“半圆环 + 动态进度”的组合,如果只画静态半圆,直接用Path更精确。
4. jQuery在两条技术路线里的角色:不是画手,而是调度
前面说了,jQuery在这个任务里不负责“画”。那它到底怎么用?这一章讲我在这个半圆仪表盘项目里实际用到的几个jQuery能力,每一块都对应一种真实需求。
4.1 用jQuery拿容器尺寸,做自适应半圆
CSS方案和SVG方案都有一个共同问题:容器宽度可能不是固定的。老后台系统的左侧菜单栏伸缩、右侧面板宽度变化,都会影响半圆大小。如果写死200px宽,一刷新就变形。
CSS方案的自适应可以用百分比宽度,但高度没法跟着宽度走(CSS里高度百分比不依赖宽度)。SVG方案好处理一些,因为可以用viewBox统一描述坐标系,但如果你希望图形的物理大小跟随容器宽度变化,还是要动态调整viewBox的宽度或者图形的最大尺寸。
我用得最多的jQuery尺寸方法,是outerWidth()。它返回元素包含padding和border的完整宽度,配合resize事件可以做一个自适应更新:
function resizeSemi() { var width = $('#semi-wrapper').outerWidth(); var height = width / 2; $('#semi-wrapper').css('height', height + 'px'); // 如果使用SVG,可以在这一步更新viewBox或半径 $('#semi-svg').attr('viewBox', '0 0 ' + width + ' ' + (width / 2)); } $(window).on('resize', function() { clearTimeout(window.__resizeTimer); window.__resizeTimer = setTimeout(resizeSemi, 200); }); resizeSemi();resize事件加了一个简单的防抖,200毫秒内连续触发只执行一次。这种写法在老的jQuery项目里非常实用,不需要引入额外工具库。
4.2 用jQuery切换class,控制CSS半圆的交互态
CSS半圆本身不带交互,但半圆作为UI组件,经常需要“选中态”“高亮态”“禁用态”的切换。朴素写法是直接改style,但那样样式和逻辑混在一起,维护性很差。更好的方式是用class管理状态:
// 假设有多个半圆卡片,点击一个,点亮它,熄灭其他 $('.semi-card').on('click', function() { $(this) .addClass('is-active') .siblings() .removeClass('is-active'); });.siblings()会选中当前元素的所有兄弟节点,然后统一移除is-active类。这比遍历所有.semi-card逐个判断简单得多。如果你要精确选中第一个半圆,可以用:first-child选择器;如果半圆上有name属性,也可以按name取对象,这些都是jQuery选择器的基本功,但在组件里非常管用。
CSS侧只需要定义好:
.semi-card { opacity: 0.6; transition: opacity 0.3s ease; } .semi-card.is-active { opacity: 1; }这种“用类名表达状态”的做法,比直接操作颜色值好调得多。以后设计稿改色,只动CSS,不动JavaScript。
4.3 用jQuery更新SVG属性,驱动数据变化
SVG图形是DOM节点,jQuery操作DOM的老本行在这里派上了大用场。两个常用方法:.attr()和.css()。
.attr()主要用来更新SVG的路径参数和形状属性,比如直接改Path的d属性:
var newD = getArcPath(100, 100, 70, 0, currentPercent * 1.8); $('#dynamic-arc').attr('d', newD);.css()则用来更新stroke相关的样式,可以实现平滑过渡。这里有一个细节:stroke-dashoffset既是SVG属性,也是CSS属性。用.attr('stroke-dashoffset', value)设置时,很多浏览器不会触发CSS的transition过渡;改用.css('stroke-dashoffset', value)设置内联样式,过渡就正常了。所以我的习惯是描边动画相关的都用.css(),Path数据相关的用.attr()。
来看一个更新进度环的例子:
var $bar = $('#progress'); var fullLength = 219.9; function setProgress(percent) { var offset = fullLength * (1 - percent / 100); $bar.css('stroke-dashoffset', offset); $('#percent-text').text(Math.round(percent) + '%'); } setProgress(72);配合CSS:
#progress { transition: stroke-dashoffset 0.6s ease; }进度从旧值跳到新值时,stroke-dashoffset会从当前值平滑动画到目标值。我第一次跑通这个效果时还挺意外,没想到SVG的描边属性在老式jQuery项目里也能这么顺滑地做动画。
4.4 与Ajax数据联动:数据回来再更新图形
实际业务里,百分比不是写死的,要从接口拿。jQuery的$.ajax在这里承担数据拉取任务:
$.ajax({ url: '/api/completion-rate', method: 'GET', dataType: 'json' }).done(function(res) { if (res && typeof res.percent === 'number') { setProgress(res.percent); } else { setProgress(0); $('#percent-text').text('--'); } }).fail(function() { setProgress(0); $('#percent-text').text('--'); });接口失败时,不要把页面搞成一个报错大弹窗,直接把进度归零、百分比文本显示为--,用户至少知道这个区域“暂时没有有效数据”。这是很多后台组件做不好的细节。
5. 实战案例:一个半圆形完成率仪表盘
理论讲了三大板块,现在把完整代码串起来,做一个半圆形的完成率仪表盘。这个案例我会直接给出可以在页面里跑起来的代码,并把每一块的取舍讲清楚。
5.1 需求拆解和技术选型
需求是:页面头部展示一个“本月任务完成率”的半圆仪表盘,数值从接口获取,要求刷新时有平滑动画,容器宽度变化时自适应。
选型结论:
- 外层用SVG的
circle+stroke-dasharray方案做半圆环,因为数据是动态的、描边动画要顺滑。 - 底环和进度环都用
circle,进度环加transform="rotate(180 100 100)"让起点从左下角开始。 - jQuery负责获取容器宽度、刷新百分比、监听resize。
- CSS负责
transition过渡和字体样式。
5.2 搭建SVG骨架
先写HTML结构:
<div id="gauge-wrapper"> <svg id="gauge" viewBox="0 0 200 120" xmlns="http://www.w3.org/2000/svg"> <circle cx="100" cy="100" r="70" fill="none" stroke="#e6e9ed" stroke-width="12" /> <circle id="progress" cx="100" cy="100" r="70" fill="none" stroke="#4a90d9" stroke-width="12" stroke-linecap="round" stroke-dasharray="219.9" stroke-dashoffset="219.9" transform="rotate(180 100 100)" /> <text id="percent-text" x="100" y="80" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" font-weight="bold" fill="#333">0%</text> </svg> </div>这里有几个细节值得解释:
viewBox="0 0 200 120"给了一个宽200、高120的坐标系。半径70的圆,圆心在(100, 100),圆会向下延伸到底部边界之外,但半圆部分落在120高度的可视范围内,刚好呈现上凸半圆。- 底环
stroke="#e6e9ed",浅灰色,代表“总量”。 - 进度环初始
stroke-dashoffset="219.9",等于半圆弧周长,表示进度为0。 stroke-linecap="round"会让进度环两端圆润,视觉更友好,但也会让端点略微超出理论弧长。如果你后面发现动画最后那一点点“串”出去了,可以考虑把fullLength微调小一点,或者改用stroke-linecap="butt"。
5.3 CSS控制过渡和容器自适应
#gauge-wrapper { width: 100%; max-width: 300px; margin: 0 auto; } #gauge { display: block; width: 100%; height: auto; } #progress { transition: stroke-dashoffset 0.6s ease; }#gauge { width: 100%; height: auto; }是SVG自适应的关键。因为我设置过viewBox,SVG的宽高比是固定的,宽度一撑开,高度会按比例自动调整。放在#gauge-wrapper这个宽度容器里,就实现了跟随父容器宽度自适应。父容器限制max-width: 300px,是为了防止半圆在宽屏上太大。
5.4 jQuery初始化与刷新逻辑
JavaScript部分:
$(function() { var $progress = $('#progress'); var $percentText = $('#percent-text'); var fullLength = 219.9; function setProgress(percent) { var safePercent = Math.max(0, Math.min(100, percent)); var offset = fullLength * (1 - safePercent / 100); $progress.css('stroke-dashoffset', offset); $percentText.text(Math.round(safePercent) + '%'); } // 模拟接口数据,实际项目换成$.ajax function loadData() { // 此处模拟随机数据,便于演示 var randomPercent = 35 + Math.floor(Math.random() * 60); setProgress(randomPercent); } // 自适应容器尺寸,重设viewBox或半径(简单场景下viewBox固定即可) function resizeGauge() { var wrapperWidth = $('#gauge-wrapper').outerWidth(); // 如果需要严格等比例缩放,viewBox不需要改;这里留给读者按需使用 } $(window).on('resize', function() { clearTimeout(window.__gaugeResizeTimer); window.__gaugeResizeTimer = setTimeout(resizeGauge, 200); }); loadData(); });这里我把setProgress写成了一个独立函数,好处是后面接真实接口时,只需要在loadData里把$.ajax的返回值传进来,调用处不用动。
5.5 进度更新动画的两个注意点
第一,CSStransition要写在初始状态上。我在#progress上直接写了transition: stroke-dashoffset 0.6s ease,意味着任何一次stroke-dashoffset变化都会触发动画。如果数据每隔几秒刷新一次,动画每次都会播,这不一定是你想要的。如果只希望首次加载动画,而后续刷新静默更新,可以改成这样:
// 首次加载后移除过渡,避免每次都播放动画 $progress.css('transition', 'none');第二,用.css()而不是.attr()去更新stroke-dashoffset。前面已经解释过,.attr()在很多浏览器里不会触发CSS过渡,动画会变成“瞬移”。这种细节不实际跑一遍很难发现。
5.6 进阶:多数据源动态更新
一个仪表盘往往不止一个半圆。比如“销售目标完成率”“库存周转率”并列展示,每个半圆都要独立绑定数据。这里我把组件抽成函数:
function initGauge(svgSelector, textSelector) { var $svg = $(svgSelector); var $progress = $svg.find('.progress'); var $text = $svg.find('.percent-text'); var fullLength = 219.9; return { setProgress: function(percent) { var offset = fullLength * (1 - percent / 100); $progress.css('stroke-dashoffset', offset); $text.text(Math.round(percent) + '%'); } }; } var salesGauge = initGauge('#sales-gauge', '#sales-text'); var stockGauge = initGauge('#stock-gauge', '#stock-text'); salesGauge.setProgress(86); stockGauge.setProgress(42);多个半圆互不干扰,这是我在实际项目里比较得意的写法。老系统里如果再遇到类似的仪表盘需求,直接复用这个initGauge就可以。
6. 绕不开的布局、对齐和兼容性坑
半圆图形画出来了,不等于组件能直接用。布局、对齐、兼容性这些边角问题,往往才是验收时被提bug的重灾区。我把这段时间实测踩过的坑集中整理在这里。
6.1 SVG元素的对齐:baseline问题比你想的麻烦
<svg>作为行内元素时,默认是按文本基线对齐的。你把它放在一个高度不定的容器里,经常会出现底部多出几像素空白,或者SVG跟旁边的文字错位。最省心的解决办法是让SVG变成块级元素:
#gauge { display: block; }如果它要和旁边的文本排在一行,更推荐用flex容器统一对齐:
.gauge-wrapper { display: flex; align-items: center; gap: 12px; }flex会把SVG和文本按中心对齐,视觉上最稳。我很少用vertical-align: middle去微调SVG,因为不同字体、不同字号下,基线位置的差异很大,调完这个浏览器,换个浏览器又歪了。
6.2 半圆边缘的毛边和高清屏显示
CSS半圆在普通屏幕上看着还行,但放到高清屏或放大缩小的场景里,圆弧边缘容易出现锯齿感,尤其是用border-radius做出来的半圆配合filter阴影时。SVG在这方面有天生的优势:默认抗锯齿,弧线边缘非常平滑。
如果CSS半圆在放大时发虚,可以做一个散列技巧:给元素加transform: translateZ(0)或者will-change: transform,把它提升到自己的合成层,不少情况下能改善渲染。但这个方法不是万能的,如果仍然发虚,说明这种形状就不适合CSS硬扛,换成SVG是更靠谱的方案。
6.3 无障碍和文本可读性
SVG图形对屏幕阅读器来说默认是不可读的。半圆仪表盘的数值,视觉上在中心显示了,但读屏用户可能只能听到一个没有意义的“圆形”。我做的半圆仪表盘加了这些无障碍补充:
<svg id="gauge" role="img" aria-label="本月任务完成率,当前72%"> <!-- ... --> </svg>role="img"告诉读屏软件这是一个独立图形,aria-label直接把关键信息暴露给读屏用户。这个做法成本极低,但对无障碍体验提升很大。另外,如果页面里有<text>节点显示百分比,要注意SVG里的文本跟HTML文本不一样,它不会被普通CSS的字体选择器命中,需要单独用font-family、font-size等SVG属性去控制,或者通过CSS属性选择器写[class^="gauge-text"]这类规则。
6.4 兼容性:老浏览器和跨端复用
CSS半圆的老兼容性其实比很多人想象的好,border-radius从IE9开始就没问题。但如果用的是clip-path: circle(),要注意加-webkit-前缀以覆盖旧版Chrome和Safari。SVG在老IE(IE8及以下)完全不支持,这种环境建议直接用CSS做静态半圆降级:样式丑一点没关系,至少内容能看。IE9到IE11对SVG基本可用,stroke-dashoffset动画也勉强能跑,但过渡效果要打折,做好心理预期。
另外,如果你所在团队有用Compose开发移动端的趋势,SVG图形其实是很好的素材中间格式,很多工具链都能把SVG转换成Compose的ImageVector。这意味着一个半圆仪表盘的视觉稿,放在Web端是SVG,将来迁移到移动端时转换成本也低。我在做这个仪表盘的时候特意保留了SVG源文件,就是这个原因。
最后再补一句实操体会
这次做半圆仪表盘,我最大的收获是重新理解了工具选择的优先级:能用CSS写出来的静态形状,就不要引入JS;能用SVG表达的数据图形,就不要一上来就搬Canvas。jQuery在2024年之后虽然不再是新项目的第一选择,但在存量系统里,它依然是连接数据和界面的称手工具。我在排错过程中还有一个习惯:动手写Path之前先在纸上画一个坐标系,把起点、终点、圆心标注出来,再决定sweep-flag和large-arc-flag。这个习惯帮我少走了很多弯路,也分享给正在跟圆弧命令搏斗的你。