前置说明:Hover 按钮即可看到环形图动画效果。
一、为什么选择 SVG 方案
在实现环形进度图时,通常有两条技术路线:
| 方案 | 优势 | 劣势 |
|---|---|---|
| CSS conic-gradient | 实现简单,一行代码搞定 | 动画困难,难以精细控制分段 |
| SVG strokeDasharray | 动画友好,可精确控制每段圆弧的起止 | 需要一定的几何计算 |
当需求涉及”从 0% 动画到目标值”的进入效果,或需要分段环形图(每段独立颜色和占比)时,SVG 方案的优势就体现出来了。
SVG 的 circle 元素通过 stroke-dasharray 控制”描边长度”,配合 stroke-dashoffset 控制”起点偏移”,就能绘制任意长度的圆弧。
二、核心类型定义
type RingPieItem = {
value: number; // 数值(绝对值)
color: string; // 颜色
label: string; // 标签文本
hideLabel?: boolean; // 是否隐藏图例
};
数据模型非常简洁:一个分段只需要知道”数值”、“颜色”、“标签”三个必填字段,以及一个可选的”隐藏图例”开关。
三、几何计算
这部分是整个组件的核心难点。我们要把一堆数值转换成 SVG 能画的东西,需要三步走:
3.1 预处理:圆环的基础参数
const radius =
ringRadius / 2 < strokeWitdh
? ringRadius / 2 - 2
: ringRadius / 2 - strokeWitdh / 2;
const stroke = ringRadius / 2 < strokeWitdh ? 2 : strokeWitdh;
const centerPointer = ringRadius / 2;
radius:圆弧的半径,注意要减去strokeWidth / 2,否则描边会溢出容器stroke:描边线宽,同样做了边界保护,防止描边比半径还大centerPointer:圆心坐标(相对于 SVG viewBox)
3.2 计算每个分段的起始角度
let angle = startAngle - 90;
const total = patterns.reduce((acc, cur) => acc + cur.value, 0);
patternsData = patterns.map((item, index) => {
const preItemPercent =
total === 0 ? 0 : (patterns[index - 1] || { value: 0 }).value / total;
const percent = item.value / total;
// ...
angle += preItemPercent * 360; // 累计角度
// ...
});
这里有一个关键细节:
startAngle - 90:初始角度减 90 度,是因为 SVG 的 0° 在右侧(3点钟方向),减 90° 后 0° 在顶部(12点钟方向)- 每个分段的角度 = 前一个分段的占比 × 360°
- 通过累计计算,保证所有分段无缝衔接
3.3 分段间隙的处理
const gapPercent = 0.005; // 间隙占周长的 0.5%
const gapAngle = gapPercent * 360; // 转换为角度
每个分段之间留 0.5% 的间隙,防止颜色粘连。
3.4 计算分割线的端点坐标
这是最抽象的部分——我们需要在每个分段之间画一条白色分割线,而分割线的坐标需要三角函数来计算:
x1 =
centerPointer + Math.sin(((angle - gapAngle / 2) / 180) * Math.PI) * radius;
y1 =
centerPointer - Math.cos(((angle - gapAngle / 2) / 180) * Math.PI) * radius;
x2 =
centerPointer + Math.sin(((angle + gapAngle / 2) / 180) * Math.PI) * radius;
y2 =
centerPointer - Math.cos(((angle + gapAngle / 2) / 180) * Math.PI) * radius;
几何解释:
(x1, y1)
.
/ ← 间隙角的一半
/
(x, y) ———————→ (3点钟方向 = 0°)
\
\
.
(x2, y2)
(x1, y1)是分段起点,(x2, y2)是分段终点±gapAngle / 2是在分段角度两侧各减去一半间隙Math.sin控制水平偏移,Math.cos控制垂直偏移(注意这里是y = center - cos,因为 SVG 的 Y 轴向下)
四、图例百分比精度处理
图例中的百分比直接用 (value / total) * 100 会有浮点精度问题,导致所有分段加起来不等于 100%。解决方案:
// 第一遍:计算原始百分比(保留4位小数精度)
let legendValue = Math.floor((item.value / total) * 10000) / 100;
// 第二遍:第一个非零分段补偿误差
if (!further && item.value !== 0) {
further = true;
legendValue =
(10000 -
patternsData.reduce(
(i, j, k) => i + (index === k ? 0 : j.legendValue * 100),
0
)) /
100;
}
思路:先用 Math.floor 保留两位小数(乘以 10000 再除以 100),然后让第一个非零分段来补偿剩余误差,确保总和为 100%。
五、SVG 圆弧绘制与动画
5.1 strokeDasharray 的工作原理
strokeDasharray={`${3.1415 * radius * 2 * (anim ? percent : initPercent)} ${3.1415 * radius * 2}`}
stroke-dasharray 的语法是两个值:
[可见段长度] [不可见段(间隙)长度]
- 第一个值:
圆周长 × 占比,即该分段应该显示的长度 - 第二个值:总周长,确保间隙足够容纳剩余部分
初始状态 initPercent = 0,动画触发后变为 percent,配合 CSS transition 产生动画效果。
5.2 旋转定位
style={{
transition: 'stroke-dasharray 0.2s ease-in-out',
transform: `rotate(${angle}deg)`,
transformOrigin: 'center'
}}
每个 <circle> 元素绕自身圆心旋转到对应的角度。由于 stroke-dasharray 的”起点”默认在右侧(0°),所以旋转角度 angle 就决定了哪段圆弧是可见的。
5.3 分割线渲染
{
anim &&
patternsData.map(({ x1, x2, y1, y2 }, index) => (
<line {...{ x1, x2, y1, y2 }} />
));
}
- 只在动画状态下渲染分割线
- 白色描边 + 相同线宽,产生”切割”效果
- 注意
transform: rotate(90deg)是固定旋转,和圆的rotate(angle)是配合关系
六、动画触发机制
const [anim, setAnim] = useState(false);
useEffect(() => {
setTimeout(() => {
setAnim(visible);
}, 100);
}, [visible]);
用 visible prop 控制显示/隐藏,但加了 100ms 延迟,原因:
- 防止 Hover 卡顿时频繁闪烁
- 让 CSS transition 有执行时间差(先设置
visible→ 再触发anim)
图例的动画效果配合得很好:
style={{
opacity: anim ? 1 : 0,
transform: `translateX(${anim ? 0 : -8}px)`,
transition: 'all 0.35s ease-in-out'
}}
淡入 + 轻微位移,让图例的出现更自然。
七、完整使用示例
// RingPie.tsx
import { useEffect, useState, useMemo } from "react";
type RingPieItem = {
value: number;
color: string;
label: string;
hideLabel?: boolean;
};
type RingPieProps = {
ringRadius?: number;
strokeWidth?: number;
startAngle?: number;
patterns: RingPieItem[];
visible: boolean;
};
type PatternData = RingPieItem & {
angle: number;
percent: number;
legendValue: number;
preItemPercent: number;
initPercent: number;
x1: number;
y1: number;
x2: number;
y2: number;
};
const GAP_PERCENT = 0.005;
const ANIMATION_DURATION_MS = 100;
const CIRCLE_CIRCUMFERENCE_MULTIPLIER = 2 * Math.PI;
const PERCENTAGE_PRECISION = 10000;
const DEGREES_IN_CIRCLE = 360;
const SVG_ANGLE_OFFSET = 90;
const calculateGapAngle = (gapPercent: number): number =>
gapPercent * DEGREES_IN_CIRCLE;
const calculateTotal = (patterns: RingPieItem[]): number =>
patterns.reduce((sum, item) => sum + item.value, 0);
const calculateRadius = (ringRadius: number, strokeWidth: number): number => {
const halfRadius = ringRadius / 2;
return halfRadius < strokeWidth
? halfRadius - 2
: halfRadius - strokeWidth / 2;
};
const calculateStrokeWidth = (
ringRadius: number,
strokeWidth: number
): number => (ringRadius / 2 < strokeWidth ? 2 : strokeWidth);
const degreesToRadians = (degrees: number): number => (degrees / 180) * Math.PI;
const calculatePointOnCircle = (
centerX: number,
centerY: number,
radius: number,
angle: number
): { x: number; y: number } => ({
x: centerX + Math.sin(degreesToRadians(angle)) * radius,
y: centerY - Math.cos(degreesToRadians(angle)) * radius,
});
const calculateLegendValue = (value: number, total: number): number =>
total === 0 ? 0 : Math.floor((value / total) * PERCENTAGE_PRECISION) / 100;
const adjustLegendValuesForRounding = (
patternsData: PatternData[]
): PatternData[] => {
let hasAdjusted = false;
return patternsData.map((item, index) => {
if (hasAdjusted || item.value === 0) {
return item;
}
hasAdjusted = true;
const sumOfOthers = patternsData.reduce(
(sum, other, otherIndex) =>
sum + (index === otherIndex ? 0 : other.legendValue * 100),
0
);
return {
...item,
legendValue: (PERCENTAGE_PRECISION - sumOfOthers) / 100,
};
});
};
export default function RingPie({
ringRadius = 100,
strokeWidth = 4,
startAngle = 0,
patterns,
visible,
}: RingPieProps) {
const [isAnimated, setIsAnimated] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsAnimated(visible);
}, ANIMATION_DURATION_MS);
return () => clearTimeout(timer);
}, [visible]);
const calculations = useMemo(() => {
const radius = calculateRadius(ringRadius, strokeWidth);
const effectiveStrokeWidth = calculateStrokeWidth(ringRadius, strokeWidth);
const center = ringRadius / 2;
const gapAngle = calculateGapAngle(GAP_PERCENT);
const total = calculateTotal(patterns);
let currentAngle = startAngle - SVG_ANGLE_OFFSET;
const patternsData: PatternData[] = patterns.map((item, index) => {
const previousItem = patterns[index - 1] || { value: 0 };
const previousPercent = total === 0 ? 0 : previousItem.value / total;
const percent = item.value / total;
const legendValue = calculateLegendValue(item.value, total);
currentAngle += previousPercent * DEGREES_IN_CIRCLE;
const startGapAngle = currentAngle - gapAngle / 2;
const endGapAngle = currentAngle + gapAngle / 2;
const startPoint = calculatePointOnCircle(
center,
center,
radius,
startGapAngle
);
const endPoint = calculatePointOnCircle(
center,
center,
radius,
endGapAngle
);
return {
...item,
angle: currentAngle,
percent,
legendValue,
preItemPercent: previousPercent,
initPercent: 0,
x1: startPoint.x,
y1: startPoint.y,
x2: endPoint.x,
y2: endPoint.y,
};
});
const adjustedPatternsData = adjustLegendValuesForRounding(patternsData);
return {
radius,
effectiveStrokeWidth,
center,
total,
patternsData: adjustedPatternsData,
};
}, [ringRadius, strokeWidth, startAngle, patterns]);
const { radius, effectiveStrokeWidth, center, patternsData } = calculations;
const renderCircle = (pattern: PatternData, index: number) => {
const circumference = CIRCLE_CIRCUMFERENCE_MULTIPLIER * radius;
const dashArray = `${circumference * (isAnimated ? pattern.percent : pattern.initPercent)} ${circumference}`;
return (
<circle
key={index}
cx={center}
cy={center}
r={radius}
strokeWidth={effectiveStrokeWidth}
stroke={pattern.color}
fill="none"
style={{
transition: "stroke-dasharray 0.2s ease-in-out",
transform: `rotate(${pattern.angle}deg)`,
transformOrigin: "center",
}}
strokeDasharray={dashArray}
/>
);
};
const renderGapLine = (pattern: PatternData, index: number) => {
if (!isAnimated) return null;
return (
<line
key={`gap-${index}`}
x1={pattern.x1}
x2={pattern.x2}
y1={pattern.y1}
y2={pattern.y2}
stroke="#fff"
strokeWidth={effectiveStrokeWidth}
style={{
position: "relative",
zIndex: 100,
transform: `rotate(90deg)`,
transformOrigin: "center",
}}
/>
);
};
const renderLegendItem = (pattern: PatternData) => {
if (pattern.hideLabel) return null;
return (
<div
key={pattern.label}
className="legend-item"
style={{ height: 18, whiteSpace: "nowrap" }}
>
<div
className="legend-item-color"
style={{
background: pattern.color,
display: "inline-block",
width: 14,
height: 8,
borderRadius: 2,
marginRight: 8,
}}
/>
<div
className="legend-item-text"
style={{
display: "inline-block",
width: 14,
textAlign: "center",
color: "rgba(0, 0, 0, 0.85)",
marginRight: 8,
fontSize: 12,
}}
>
{pattern.label}
</div>
<div
className="legend-item-value"
style={{
display: "inline-block",
color: "rgba(0, 0, 0, 0.85)",
fontSize: 12,
textAlign: "left",
}}
>
{pattern.legendValue}%
</div>
</div>
);
};
return (
<div
style={{
display: "flex",
flexWrap: "nowrap",
justifyContent: "space-between",
}}
>
<div style={{ flexBasis: ringRadius }}>
<svg
style={{ width: ringRadius, height: ringRadius }}
viewBox={`0 0 ${ringRadius} ${ringRadius}`}
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
{patternsData.map(renderCircle)}
{patternsData.map(renderGapLine)}
</svg>
</div>
<div
className="legend"
style={{
transition: "all 0.35s ease-in-out",
opacity: isAnimated ? 1 : 0,
transform: `translateX(${isAnimated ? 0 : -8})`,
marginTop: -5,
display: "flex",
flexDirection: "column",
paddingBottom: 3,
justifyContent: "space-between",
marginLeft: "24px",
}}
>
{patternsData.map(renderLegendItem)}
</div>
</div>
);
}
// index.tsx
const patterns = [
{ color: "#74D1B8", value: 60, label: "优" },
{ color: "#EBD03D", value: 10, label: "良" },
{ color: "#FFB06F", value: 10, label: "中" },
{ color: "#FE6B4A", value: 10, label: "差" },
];
<RingPie
ringRadius={88} // SVG 尺寸
strokeWitdh={14} // 描边线宽(也是圆环粗细)
patterns={patterns}
visible={isHovered} // 控制动画触发
/>;
八、总结
SVG 的 stroke-dasharray 是一个被低估的属性。它本质上是用”描边长度”来控制图形,和 CSS 的 clip-path 类似,但更强大。结合三角函数做几何计算,你几乎可以用 SVG 画出任何你想要的弧形效果。
环形图只是一个起点,掌握了这个思路,还可以做:
- 进度指示器
- 雷达图
- 环形菜单
- 仪表盘指针
核心公式:stroke-dasharray = 周长 × 占比 + rotate = 累计角度。