前段时间在做一个车载HMI相关的展示项目,设计稿里给了一个很典型的边框进度条交互:卡片四周一圈发光描边,随电量增长而增长,同时有一个光点动画效果沿着边框游走,整体风格很像新能源车中控屏上的充电画面。当时第一反应是网上找个现成的vue3组件,搜了一圈发现不少实现都是固定尺寸的Demo,换个容器大小就变形,要么就是光点跟进度完全是两条线在跑,根本没有那种"线头光点"的充电质感。折腾一晚上没满意的,干脆自己动手封装一个。
这篇文章就是把整个开发过程复盘一遍:怎么拆解这个动效、为什么选SVG而不是CSS或Canvas、stroke-dashoffset的底层原理、光点怎么精确跟随进度末端、组件怎么设计对外参数、以及我在落地过程中踩过的一堆坑。适合正在做Vue3后台管理系统、可视化大屏、车载HMI、设备状态面板的同学参考,想直接"抄作业"的话可以从第5节的完整代码开始。
1. 拿到需求先拆效果:所谓"充电感"其实是四个图层
很多人在拿到这种动效需求时容易犯一个错误:上来就开写CSS,结果写了一下午border-radius和animation,效果还是差口气。问题不是技术不够,而是没把"充电感"拆解成可实现的图层结构。
我当时的做法是暂停代码,盯着新能源车的充电动画看了几分钟,把视觉元素一层层剥出来。
第一层是底座轨道。深色半透明的描边,宽度比内部的进度线略宽一点或者等宽,颜色一般是灰白带一点透明,形成一块安静的"待充电区域"。这层决定了组件在页面上的存在感——没有它,进度条就飘在空中没有边界感。
第二层是渐变进度线。这是进度本身,沿边框顺时针增长。关键点是它很少用纯色,通常是从青绿到湖蓝的渐变,对应电池电量那种科技感和健康感。如果是错误状态,也可以整体换成红色渐变,视觉语义会非常明显。
第三层是末端光点。这是整套动效的灵魂。进度线走到哪里,光点就跟到哪里,像一个线头,或者像正在充电的那个发光触角。实车上看到的光点通常比进度线更亮、更大一点,颜色偏白,这样才有"能量正在输入"的感觉。
第四层是辉光与呼吸。光点周围有一圈扩散的亮斑,类似镜头光晕,同时在充电过程中光点会有轻微的明暗变化,像在呼吸。这一层是锦上添花,没有它效果能看,有了它质感和高级感直接上一个台阶。
把这四层理清楚之后,整个实现思路就清晰了:轨道和进度线本质上是同一条路径的两种stroke状态,光点则是在这条路径上按周长比例取一个点,然后做平滑移动。所有问题都收敛到"如何在一条闭合路径上精确控制动画"。
2. 技术选型对比:SVG、CSS、Canvas谁更适合这种东西
同一页面上可能有七八个卡片都需要这种边框进度条,所以选型不只是"能不能做出来",还要考虑多实例性能、维护成本、以及跟Vue3响应式体系的契合度。我把三个方案都过了一遍。
先说CSS方案。网上很多教程用conic-gradient配合mask来做圆角矩形边框进度,思路是圆锥渐变转多少度就显示多少进度,再用mask裁出圆环。效果本身没问题,但一旦涉及"光点沿边框移动",CSS就很尴尬了。光点本质上是一个绝对定位的小圆点,你得手动算出它在边框上的x和y坐标,而且这个坐标和conic-gradient的渲染进度是两套系统,同步起来很容易出偏差。更麻烦的是,如果宽高比例变化、圆角半径变化,所有定位都要重新算一遍。做个固定尺寸Demo可以,做通用组件维护成本偏高。
再说Canvas方案。Canvas做这种动画很自由,每帧重绘即可,进度和光点都能用同一套坐标计算逻辑。但对这个场景来说有点大材小用:一个简单的边框进度条,用Canvas意味着要自己处理高DPI屏幕的缩放、处理组件卸载时的动画销毁、在列表循环场景下每个Canvas都占一块画布内存。如果项目里已经有ECharts这类Canvas重度用户,再叠加一堆小Canvas,对移动端设备的渲染压力不小。
最后是SVG方案。SVG天然适合这个场景,原因有三个。
第一,SVG的<path>元素自带周长计算能力。getTotalLength()可以直接返回路径总长,进度值转成stroke-dashoffset是原生支持的,完全不需要自己推导几何公式。第二,光点定位有getPointAtLength(),传入路径上的长度就能拿到对应坐标,圆角矩形、不规则形状一视同仁。第三,SVG在DOM里,颜色、样式、动画都能用CSS控制,跟Vue3的响应式、指令、过渡系统可以无缝配合。
我在表里简单对比一下:
| 对比维度 | CSS方案 | Canvas方案 | SVG方案 |
|---|---|---|---|
| 进度显示 | 好 | 好 | 好 |
| 光点精确跟随 | 需要手动算坐标,容易不同步 | 自己实现,灵活 | 原生API,路径即坐标系 |
| 可变尺寸适配 | 麻烦 | 需要重绘 | 重建path或动态更新viewBox |
| 多实例性能 | 较好 | 一般,各占画布 | 好,DOM节点轻量 |
| 与Vue3配合 | 一般 | 一般 | 自然,props/watch即可驱动 |
结论很明确:SVG是这个需求的最优解。它不光能满足"充电效果",而且把"路径计算"这个最复杂的问题交给了浏览器原生能力,我们只需要维护一份路径描述和几个数值。
3. 边框路径建模与stroke-dashoffset进度控制原理
3.1 为什么用path而不是rect
SVG里画圆角矩形最直接的方式是<rect>标签加rx属性,但这种做法在这个场景下有一个隐患:部分浏览器对<rect>的路径计算和<path>的getPointAtLength()兼容性略有差异,尤其在某些WebView内核里,<rect>的getTotalLength()返回值是0或者NaN,直接导致进度条消失。
所以我强烈建议:自己构建圆角矩形的path描述。代码并不多,而且可以完全控制起点方向、圆角弧度和路径方向。
export function buildBorderPath(width, height, radius) { const r = Math.min(radius, width / 2, height / 2) return [ `M ${r} 0`, `H ${width - r}`, `A ${r} ${r} 0 0 1 ${width} ${r}`, `V ${height - r}`, `A ${r} ${r} 0 0 1 ${width - r} ${height}`, `H ${r}`, `A ${r} ${r} 0 0 1 0 ${height - r}`, `V ${r}`, `A ${r} ${r} 0 0 1 ${r} 0`, `Z` ].join(' ') }这段代码从左上角偏右侧一点开始,先画上边直线,再画右上角圆弧,依次走完四条边和四个圆角,形成顺时针闭合路径。之所以从M ${r} 0而不是M 0 0开始,是为了把起点放在圆角结束后的直线段上,避免进度线起点正好卡在圆弧接缝处,视觉上更干净。
路径方向统一走顺时针,这样进度增长时人眼会本能地觉得"顺时针=增加",符合仪表盘和充电交互的阅读直觉。
3.2 dasharray/dashoffset到底怎么算的
这是整个组件的核心原理,我尽量用大白话讲清楚。
SVG里任意一条路径都可以用虚线来画。stroke-dasharray定义虚线的"实线段长度和空线段长度",stroke-dashoffset定义虚线从哪里开始画。把dasharray设成路径总长,比如1000px,就等于画了一条整虚线;此时调整dashoffset,这条虚线就会像传送带一样整体平移。
一个很直观的类比:想象一条橡皮筋套在长方形纸板上,红色颜料从橡皮筋的某一点开始涂,dashoffset越大,相当于起点往后缩得越多,涂出来的红色部分就越短。
进度控制的公式很简单:
const dashOffset = totalLength * (1 - progressRatio)progressRatio的范围是0到1。进度为0时,dashoffset = totalLength,整个进度线被完全"藏"在起点位置,看起来就是一条空白轨道。进度为100%时,dashoffset = 0,一条完整的渐变线亮出来。
有一类常见错误是把公式写成totalLength * progressRatio,效果就是进度反向缩减,进度条从满到空。原因是很多人把dashoffset下意识理解成"已画出的长度",但SVG的实际语义是"从起点往回收的长度"。
还有一个边界细节:当进度非常接近100%时,因为getTotalLength()返回的是浮点数,dashoffset可能会残留0.5像素左右的偏差,边框闭合处有一个细微缺口。处理方式是加一个容差判断:
const offset = totalLength * (1 - ratio) const finalOffset = Math.abs(offset) < 0.5 ? 0 : offset这个细节我在Safari里真实遇到过,差一像素在放大屏上很明显。
3.3 进度值如何与总周长联动
getTotalLength()返回的是路径的实际几何长度,单位是SVG用户坐标系的像素。由于我们用的是动态的width和height,路径总长会随容器变化而改变,所以必须把totalLength作为响应式状态来维护,不能写死在初始化阶段。
一个简洁的做法是监听path的d属性变化,在更新后的下一帧重新读取totalLength,再同步刷新dashoffset。这样才能做到容器尺寸变化后,进度百分比保持正确。这部分我放在第5节的完整代码里一起看。
4. 光点动画的核心:getPointAtLength与线头跟随
4.1 getPointAtLength解决什么痛点
如果不用getPointAtLength,光点跟随边框这一步会非常痛苦。你要自己判断当前进度落在哪条边、过了几个圆角、在弧线上的角度是多少,然后分四段写不同的坐标插值函数。万一以后想改成三角形、六边形或者云朵形,整套几何运算全部推翻。
getPointAtLength(length)这个API接收一个路径长度值,返回该长度处对应的{x, y}坐标。这意味着只要拿到当前进度的路径长度,就能立刻得到光点坐标:
const point = pathRef.value.getPointAtLength(totalLength * ratio)我把它封装成响应式计算,只要progress变化,光点坐标自动更新。
4.2 充电中/暂停/已满三种状态的切换
组件设计了一个state参数,三种状态对应三种光点行为。
charging(充电中):光点跟随进度条的末端,进度走多少,光点走多少。此时给光点的transform加一个过渡动画,让移动更顺滑:
.border-progress__dot { transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1); }注意这里用的是transform而不是修改cx和cy。单独修改cx在部分浏览器中不会触发CSS过渡,transform是更可靠的合成器属性,还能避免触发重排。
paused(暂停):光点停在当前位置,辉光逐渐减弱,透明度降到0.6左右。这个状态在真实充电场景里很常见——插枪后不充电、或者等待唤醒时,光点就不动了。
done(已充满):此时进度线已经满圈,如果光点跟着停止,界面会显得死板。我做了一个特殊处理:光点继续沿路径匀速循环游走,进度线保持满圈常亮,模拟"待机呼吸"的效果。
4.3 光点的辉光与呼吸效果
光点拆成两层:一个实心白点作为核心,一个半透明大圆作为辉光。白色核心负责"亮",半透明大圆负责"晕开"。
<circle class="border-progress__dot-halo" :r="dotRadius + 6" /> <circle class="border-progress__dot-core" :r="dotRadius" />辉光层除了半透明填充,还可以加一个SVGfilter或者CSS的drop-shadow。我实测下来,在数量不多的场景里CSSfilter: drop-shadow()性能足够,而且代码更简单:
.border-progress__dot-halo { fill: rgba(0, 230, 176, 0.35); filter: drop-shadow(0 0 8px rgba(0, 230, 176, 0.8)); }呼吸效果用@keyframes控制辉光的透明度和缩放:
@keyframes breath { 0%, 100% { opacity: 0.6; transform: scale(1); } 50% { opacity: 1; transform: scale(1.15); } }这样三层视觉叠加起来,就是标题里那种"充电光点"的感觉。
5. 组件封装与完整代码
5.1 Props设计
封装组件前我给自己定了几个原则:尺寸和样式尽量可配、进度用v-model:progress或者单prop绑定、内部不写业务循环逻辑、父组件负责状态机。
下面是Props设计:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| width / height | number | 容器尺寸 | 组件尺寸,不传则填满父容器 |
| progress | number | 0 | 进度 0-100 |
| lineWidth | number | 4 | 边框描边宽度 |
| radius | number | 16 | 圆角半径 |
| trackColor | string | rgba(255,255,255,0.08) | 轨道颜色 |
| gradientStart | string | #00e6b0 | 进度渐变起始色 |
| gradientEnd | string | #00c1ff | 进度渐变结束色 |
| dotColor | string | #ffffff | 光点核心颜色 |
| state | string | charging | charging / paused / done |
| duration | number | 0.4 | 光点过渡秒数 |
把progress放在props里而不是v-model,是因为这个组件的定位是"数据展示组件",真正的进度源在业务层(比如设备的电量上报、下载任务的完成度),组件只负责把数据映射为视觉状态。如果业务里需要手动拖拽进度那另说,那个场景建议再拆一个带交互的子组件。
5.2 完整组件代码
下面是完整的BorderProgress.vue,基于Vue3<script setup>语法,TS类型标注可删可留。
<template> <div ref="containerRef" class="border-progress"> <svg v-if="svgSize.width > 0" :width="svgSize.width" :height="svgSize.height" class="border-progress__svg" > <defs> <linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="100%"> <stop offset="0%" :stop-color="gradientStart" /> <stop offset="100%" :stop-color="gradientEnd" /> </linearGradient> </defs> <!-- 轨道层 --> <path :d="borderPath" fill="none" :stroke="trackColor" :stroke-width="lineWidth" stroke-linecap="round" /> <!-- 渐变进度层 --> <path :d="borderPath" fill="none" :stroke="`url(#${gradientId})`" :stroke-width="lineWidth" stroke-linecap="round" :stroke-dasharray="totalLength" :stroke-dashoffset="dashOffset" /> <!-- 光点层 --> <g v-if="showDot" class="border-progress__dot" :style="dotTransition" > <circle class="border-progress__dot-halo" :cx="0" :cy="0" :r="lineWidth * 1.8" /> <circle class="border-progress__dot-core" :cx="0" :cy="0" :r="lineWidth * 0.8" /> </g> </svg> </div> </template> <script setup> import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' const props = defineProps({ width: { type: Number, default: 0 }, height: { type: Number, default: 0 }, progress: { type: Number, default: 0 }, lineWidth: { type: Number, default: 4 }, radius: { type: Number, default: 16 }, trackColor: { type: String, default: 'rgba(255, 255, 255, 0.08)' }, gradientStart: { type: String, default: '#00e6b0' }, gradientEnd: { type: String, default: '#00c1ff' }, dotColor: { type: String, default: '#ffffff' }, state: { type: String, default: 'charging' }, duration: { type: Number, default: 0.4 } }) const containerRef = ref(null) const svgSize = ref({ width: 0, height: 0 }) const totalLength = ref(0) let uidSeed = 0 const gradientId = `border-progress-gradient-${++uidSeed}` const showDot = computed(() => props.progress > 0 && props.state !== 'paused') const borderPath = computed(() => { if (svgSize.value.width === 0 || svgSize.value.height === 0) return '' const r = Math.min(props.radius, svgSize.value.width / 2, svgSize.value.height / 2) const { width: w, height: h } = svgSize.value return [ `M ${r} 0`, `H ${w - r}`, `A ${r} ${r} 0 0 1 ${w} ${r}`, `V ${h - r}`, `A ${r} ${r} 0 0 1 ${w - r} ${h}`, `H ${r}`, `A ${r} ${r} 0 0 1 0 ${h - r}`, `V ${r}`, `A ${r} ${r} 0 0 1 ${r} 0`, `Z` ].join(' ') }) const ratio = computed(() => { return Math.min(Math.max(props.progress / 100, 0), 1) }) const dashOffset = computed(() => { const offset = totalLength.value * (1 - ratio.value) return Math.abs(offset) < 0.5 ? 0 : offset }) const dotPoint = ref({ x: 0, y: 0 }) watch([ratio, totalLength], async () => { await nextTick() const pathEl = containerRef.value?.querySelector('.border-progress path:last-of-type') if (!pathEl) return const targetLength = totalLength.value * ratio.value const point = pathEl.getPointAtLength(targetLength) dotPoint.value = { x: point.x, y: point.y } }, { immediate: true }) const dotTransition = computed(() => { if (props.state === 'charging' && props.progress > 0) { return { transform: `translate(${dotPoint.value.x}px, ${dotPoint.value.y}px)`, transition: `transform ${props.duration}s linear` } } return { transform: `translate(${dotPoint.value.x}px, ${dotPoint.value.y}px)` } }) let resizeObserver = null function updateSize() { if (!containerRef.value) return const rect = containerRef.value.getBoundingClientRect() svgSize.value = { width: props.width || rect.width, height: props.height || rect.height } } async function updateTotalLength() { await nextTick() const pathEl = containerRef.value?.querySelector('.border-progress path:last-of-type') if (!pathEl) return totalLength.value = pathEl.getTotalLength() } onMounted(() => { updateSize() resizeObserver = new ResizeObserver(() => { updateSize() updateTotalLength() }) if (containerRef.value) { resizeObserver.observe(containerRef.value) } }) onBeforeUnmount(() => { if (resizeObserver) resizeObserver.disconnect() }) </script> <style scoped> .border-progress { position: relative; width: 100%; height: 100%; display: inline-block; } .border-progress__svg { display: block; overflow: visible; } .border-progress__dot { will-change: transform; } .border-progress__dot-halo { fill: rgba(0, 230, 176, 0.35); filter: drop-shadow(0 0 6px rgba(0, 230, 176, 0.8)); animation: border-progress-breath 1.8s ease-in-out infinite; } .border-progress__dot-core { fill: v-bind(dotColor); } @keyframes border-progress-breath { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } } </style>组件里有一个值得注意的细节:dotTransition里我用transform: translate(...px, ...px)来移动整个光点组,同时让transition只在charging状态下生效。如果让光点在暂停状态下还带过渡,就会出现进度突然跳变但光点慢慢追上去的奇怪效果。
5.3 在项目里怎么用:一个模拟充电的父组件例子
实际接入时,你只需要维护一个progress变量,无论它是定时器涨上来的、还是WebSocket推送的、还是用户拖拽的,组件都会正确地反映出来。
<script setup> import { ref, onBeforeUnmount } from 'vue' import BorderProgress from './components/BorderProgress.vue' const battery = ref(0) let timer = null function startCharging() { timer = setInterval(() => { if (battery.value >= 100) { clearInterval(timer) return } battery.value += 0.4 }, 100) } onBeforeUnmount(() => timer && clearInterval(timer)) </script> <template> <div style="width: 220px; height: 130px; background: #0b1420; border-radius: 16px; padding: 24px;"> <BorderProgress :progress="battery" state="charging" :radius="12" :line-width="5" /> <p style="color: #fff; text-align: center; margin-top: 16px;"> 进度 {{ Math.floor(battery) }}% </p> </div> </template>这样一跑起来,就能看到边框进度条从左上角开始逐步亮起,青色渐变线顺时针蔓延,光点牢牢贴在进度线头部往前走,辉光跟着呼吸。把它塞进Vue3后台管理系统的卡片列表里,视觉上立刻和其他普通进度条拉开差距。
6. 落地阶段踩过的坑
6.1 ResizeObserver的时序问题
我第一次写完组件时发现一个奇怪现象:页面刷新后边框没有进度,但随便改变一下浏览器窗口大小,进度就正常显示了。原因在于onMounted里创建ResizeObserver后,它的第一次回调是异步触发的,而我在onMounted里同步调用了一次updateSize(),紧接着就去取totalLength,此时borderPath还没渲染到DOM里,getTotalLength()自然拿不到有效值。
解决方法是把取totalLength的时机放到nextTick之后,并且通过watch监听borderPath的变化来刷新。组件代码里我同时做了这两件事:watch加immediate: true保证初次加载就触发,ResizeObserver回调里再次刷新保证尺寸变化后同步。
6.2 别用viewBox拉伸,圆角会变形
有朋友看到我代码里动态设置svgSize,会问为什么不直接用viewBox="0 0 200 120"配合preserveAspectRatio="none"做自适应。听起来很美,但实际渲染效果会让你抓狂:preserveAspectRatio="none"会把SVG水平垂直拉伸,圆角矩形会变成椭圆角,光点也被拉扁,完全没有充电质感。
所以这个组件采用的是真实改变SVG像素尺寸的方式。父容器多大,SVG的width和height就设多大,path按比例重建,这样圆角永远是正圆角。代价是容器尺寸变化时需要重新计算总周长,但有了ResizeObserver,这个代价完全可以接受。
6.3 getTotalLength的隐性条件
这个API不是在任何时候都能正常调用。有两个前提:一是元素必须可见,display: none或visibility: hidden状态下,有些浏览器返回0;二是路径必须已经完成布局,刚插入DOM还没渲染时也可能拿到0。
还有就是getTotalLength()在不同浏览器的精度并不完全一致。Chrome返回的很稳定,Safari偶尔会有一个非常小的偏差。最稳妥的做法是不要用它做像素级的判断,所有进度计算都基于"相对比例",并且在计算dashOffset时加上0.5px容差。
6.4 多实例渲染与性能细节
后台管理系统里如果一屏放十几个这样的卡片,每个组件都创建自己的ResizeObserver,占用其实不大,但要注意以下几点。
SVGfilter: drop-shadow()在大量元素同时动画时,GPU压力会比普通CSS动画高一些。如果页面同时有十几个光点在呼吸,建议把辉光层的will-change: transform加上,让浏览器提前做图层优化,或者关闭呼吸动画只保留静态辉光。但如果只有三五个组件,完全不用担心。
另外,不要试图用computed实时去调getPointAtLength。这个API本身就有一部分计算量,放进高频渲染链路里容易掉帧。我在组件里只在ratio或totalLength变化时才重新取值,并且用ref保存结果,走的是Vue的依赖收集,而不是每一帧主动求值。
6.5 尊重系统的动画偏好
最后是一个容易被忽视的工程细节。很多系统都开启了"减少动态效果"的无障碍设置,其实对应的是CSS媒体查询prefers-reduced-motion: reduce。在这个状态下,应该关闭光点的呼吸动画和自动运行效果,只保留进度本身的静态更新。
@media (prefers-reduced-motion: reduce) { .border-progress__dot-halo { animation: none; } }加一行CSS就能让有晕动症的用户舒服很多,在公司内部系统里可能没人注意到,但做对外产品时这是个加分项。
最后分享一个小扩展
组件和代码都完整了,我再分享一个实际调优心得。光点颜色不一定要纯白,我试过把光点改成比进度线末端更亮一档的颜色,比如进度线是#00e6b0到#00c1ff渐变时,光点用#eafffb,视觉上会多一层"发光中心"的感觉,比纯白更细腻。另外在progress归零时记得隐藏光点,否则一个光点停在起始位置空转,用户会以为组件卡住了。想要做成"已充满后循环跑圈"的效果,只需要在done状态下用一个CSS动画把光点的transform从当前位置平滑过渡回起点,再触发一次charging状态即可。这个进阶玩法就留给各位自己发挥了,如果做出有意思的变体,欢迎来和我交流。