欢迎光临
我们一直在努力

前端老哥必看:Vue2.x搞定不封闭圆形进度条,拒绝UI提需求就头秃

在这里插入图片描述

前端老哥必看:Vue2.x搞定不封闭圆形进度条,拒绝UI提需求就头秃

  • 前端老哥必看:Vue2.x搞定不封闭圆形进度条,拒绝UI提需求就头秃
    • 开场先唠两句,这破需求咋就轮到我了
    • 这玩意儿到底是个啥,别被名词唬住了
      • 扒一扒"不封闭圆形进度条"的底裤
      • UI设计师为啥总爱这一口
      • Vue2.x里的几种野路子
    • 动手前先磨刀,核心技术点得盘清楚
      • SVG的stroke-dasharray和stroke-dashoffset这对老CP
      • Vue2.x的响应式数据怎么驱动缺口变化
      • 贝塞尔曲线?老板要德芙巧克力般的丝滑
    • 是神是鬼拉出来溜溜,优缺点咱得摊开说
      • SVG方案的好,谁用谁知道
      • 兼容性问题,老安卓的痛
      • 性能那点事,风扇会不会起飞
    • 真刀真枪干一场,代码怎么写才不挨骂
      • 封装通用组件,拒绝CV大法
      • 使用示例:让产品经理随便改需求
      • 平滑过渡动画,拒绝触电式跳动
    • 遇到坑别慌,老司机的排雷指南
      • 进度条突然反向旋转?
      • 不同浏览器缺口位置对不齐
      • 动画卡顿像PPT
    • 几个私藏的小技巧,让你代码看起来像大神写的
      • 计算属性偷懒大法
      • CSS变量动态换色,一套代码搞定深色模式
      • 防抖处理,别让接口数据晃散进度条
    • 最后碎碎念,这活儿干完能不能早点下班

前端老哥必看:Vue2.x搞定不封闭圆形进度条,拒绝UI提需求就头秃


开场先唠两句,这破需求咋就轮到我了

说实话,当我看到产品经理甩过来的需求文档时,我差点把嘴里的枸杞茶喷屏幕上。又是一个"呼吸感",又是一个"高级感",这些词儿我都听出茧子了。但没办法,谁让咱们是前端呢,UI小姐姐眨巴眨巴眼睛说"这个很简单的,就画个圈嘛",你就得硬着头皮上。

昨天那个场景历历在目。下午三点,我刚把上个需求的Bug改完,正准备偷偷摸会儿鱼,产品经理端着咖啡晃悠过来:“哎,那个首页的进度条能不能改成不封闭的?就是那种…有缺口的感觉,像月亮一样,用户看着会觉得产品在’呼吸’。”

我当时的表情大概是这样的:😑

但嘴上还得说:“好的,我看看。”

回到工位上我打开Vue2.x的项目,看着那些熟悉的data、computed、mounted,突然有种"老夫聊发少年狂"的悲壮感。Vue2这把老骨头,从2016年陪我到现在,虽然Vue3都出来好几年了,但公司项目迁移成本太高,还得继续伺候这位老爷子。搞这种花里胡哨的动画,说实话,真得掉几根头发。

不过咱是谁啊?前端老炮儿!今天立个Flag,必须把这玩意儿整明白。不然晚上又得陪测试妹子改Bug——别误会,不是你想的那种浪漫,是她在旁边盯着你改,你紧张得键盘都敲不利索的那种"陪"。


这玩意儿到底是个啥,别被名词唬住了

扒一扒"不封闭圆形进度条"的底裤

咱们先把这个听起来很高大上的名词给扒了。啥叫"不封闭圆形进度条"?说白了就是一个缺了口的圆环。不是那种360度团团围住的死环,而是像吃了一半的甜甜圈,或者像农历月初的月牙儿,留个缺口在那儿,显得"灵动"。

技术实现上,本质上就两种路子:

SVG方案:用<circle>标签画个圆,然后通过stroke-dasharray和stroke-dashoffset这两个属性来控制"画多少、空多少"。缺口就是这么来的——你让虚线的一部分是实线(进度),一部分是空白(缺口),调调参数就能搞出那个效果。

Canvas方案:拿到2D上下文,用arc()方法画圆弧,通过控制起始角度和结束角度来留出缺口。这种方案更底层,可控性更强,但代码量也更大,而且高清屏还得处理像素比问题,烦得很。

当然还有CSS硬搓的邪门歪道,比如用border-radius搞两个半圆拼接,或者用conic-gradient圆锥渐变模拟。但说实话,这些方案要么不够灵活,要么兼容性堪忧,正经项目里我劝你别这么干,除非你想给后人埋雷。

UI设计师为啥总爱这一口

我跟我们公司UI小姐姐聊过(真的只是聊工作),她说封闭圆环给人一种"完成"、“结束"的感觉,而不封闭的缺口圆环则暗示"进行中”、“还有空间”、“正在呼吸”。心理学上这叫"完形心理",人脑会自动把那个缺口补全,反而比完整的圆更吸引眼球。

说白了就是:显得高级。

而且缺口的位置、大小、形状都能做文章。缺口朝上像正在加载,缺口朝右像进度推进,缺口做成圆角像德芙巧克力般丝滑… 设计师的脑洞是无穷的,咱们前端就是负责把脑洞变成代码的苦力。

Vue2.x里的几种野路子

在Vue2.x里搞这个,你有三条路可选:

路子一:手写SVG(推荐) 最纯粹,最可控,最符合Vue的组件化思想。把SVG封装成组件,传几个props进去,完事儿。

路子二:抱ECharts大腿 如果你项目里已经引入了ECharts,那直接用它的gauge仪表盘系列,改改参数也能出效果。但杀鸡用牛刀,体积感人,而且定制性不如手写。

路子三:找现成的库 比如vue-circle-progress这种,npm一装,组件一引,看似省事。但问题是这种小众库维护状况堪忧,上次更新可能是三年前,Vue3都不一定支持,而且样式改起来比手写还麻烦。

我的建议是:手写SVG。咱们前端工程师,该有的尊严还是要有的。


动手前先磨刀,核心技术点得盘清楚

SVG的stroke-dasharray和stroke-dashoffset这对老CP

这俩属性是SVG路径描边的核心,搞懂它们,你就搞懂了90%的进度条原理。

stroke-dasharray:定义虚线的模式。比如stroke-dasharray="10, 5"表示"画10px实线,空5px,再画10px实线,空5px…"。如果只写一个值stroke-dasharray="100",表示实线和空白都是100px。

stroke-dashoffset:定义虚线模式的起始偏移量。正值表示往起点方向偏移,负值表示往终点方向偏移。

来,看个简单的例子:

<svg width="200" height="200">
<!– 这是一个普通的圆 –>
<circle
cx="100" cy="100" r="90"
fill="none"
stroke="#e0e0e0"
stroke-width="10"
/>

<!– 这是带缺口的进度圆 –>
<circle
cx="100" cy="100" r="90"
fill="none"
stroke="#409EFF"
stroke-width="10"
stroke-dasharray="471, 628" <!– 周长是2*PI*90≈565,这里故意留缺口 —>

stroke-dashoffset="0"
stroke-linecap="round" <!– 让端点圆润 –>
/>
</svg>

等等,这里有个坑!圆的周长计算公式是2 * Math.PI * r。半径90的话,周长应该是2 * 3.14159 * 90 ≈ 565.49,但我上面写的是628?这是因为stroke-dasharray的第一个值控制的是实线部分的长度,第二个值控制的是整个周期的长度。

如果你想让缺口占30%,进度占70%,而且缺口在顶部,你得这么算:

// 假设圆半径90,周长565.49
const circumference = 2 * Math.PI * 90; // 565.4867…

// 进度70%,缺口30%
const progress = 0.7;
const gap = 0.3;

// 实线长度(进度部分)
const dashArray = circumference * progress; // 395.84

// 整个dasharray模式:实线长度, 周长(这样能保证只有一个缺口)
const strokeDasharray = `${dashArray}, ${circumference}`;

// 为了让缺口在顶部(也就是-90度位置,SVG默认0度在3点钟方向)
// 我们需要旋转整个圆,或者调整dashoffset
const strokeDashoffset = circumference * 0.25; // 旋转90度,让缺口在顶部

看晕了吧?没事,咱们后面封装组件的时候会把这个逻辑全包进去,你用的时候只需要传个percentage和gap就行了。

Vue2.x的响应式数据怎么驱动缺口变化

Vue2的响应式系统是基于Object.defineProperty的,虽然比不上Vue3的Proxy,但对付这种需求绰绰有余。

关键是把stroke-dasharray和stroke-dashoffset绑定到计算属性上,这样当进度数据变化时,SVG会自动更新。

<template>
<svg :width="size" :height="size">
<circle
:cx="center"
:cy="center"
:r="radius"
fill="none"
:stroke="bgColor"
:stroke-width="strokeWidth"
/>
<circle
:cx="center"
:cy="center"
:r="radius"
fill="none"
:stroke="progressColor"
:stroke-width="strokeWidth"
:stroke-dasharray="dashArray"
:stroke-dashoffset="dashOffset"
stroke-linecap="round"
:transform="`rotate(-90 ${center} ${center})`"
/>
</svg>
</template>

<script>
export default {
name: 'GapProgress',
props: {
percentage: {
type: Number,
default: 0,
validator: val => val >= 0 && val <= 100
},
gap: {
type: Number,
default: 10, // 缺口占圆周的百分比
validator: val => val >= 0 && val <= 100
},
size: {
type: Number,
default: 200
},
strokeWidth: {
type: Number,
default: 10
}
},
computed: {
// 圆心位置
center() {
return this.size / 2;
},
// 半径要减去描边宽度的一半,不然会溢出
radius() {
return (this.size – this.strokeWidth) / 2;
},
// 圆周长
circumference() {
return 2 * Math.PI * this.radius;
},
// 进度部分长度
progressLength() {
// 可用长度 = 周长 – 缺口长度
const availableLength = this.circumference * (1 – this.gap / 100);
return availableLength * (this.percentage / 100);
},
// SVG的dasharray属性值
dashArray() {
// 实线长度, 空白长度(设成周长确保只有一个缺口)
return `${this.progressLength}, ${this.circumference}`;
},
// 偏移量,控制缺口位置
dashOffset() {
// 让缺口在顶部(-90度),需要偏移1/4周长
// 再加上缺口长度的一半,让缺口居中在顶部
const gapLength = this.circumference * (this.gap / 100);
return this.circumference * 0.25 – gapLength / 2;
}
}
}
</script>

这段代码的核心逻辑是:先算出周长,然后根据缺口比例算出可用长度,再根据进度百分比算出实线长度,最后通过dashoffset把缺口转到顶部。

贝塞尔曲线?老板要德芙巧克力般的丝滑

有些挑剔的UI会要求缺口的两个端点是圆角的,像德芙广告那样"纵享丝滑"。这其实很简单,SVG的stroke-linecap属性就能搞定。

  • butt:默认值,平直的端点
  • round:圆角端点,就是我们想要的
  • square:方形端点,会稍微超出路径一点

所以上面代码里我加了stroke-linecap="round",瞬间那个感觉就出来了。

但万一…我是说万一…老板非要缺口本身是圆润的弧形,而不是两个端点圆润呢?那就得用路径path而不是圆形circle了,用贝塞尔曲线画那个缺口。这种需求我遇到过,当时真想掀桌子,但最后还是乖乖写了:

<template>
<svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`">
<!– 背景圆环 –>
<path
:d="backgroundPath"
fill="none"
:stroke="bgColor"
:stroke-width="strokeWidth"
/>
<!– 进度圆环,带圆角缺口 –>
<path
:d="progressPath"
fill="none"
:stroke="progressColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
/>
</svg>
</template>

<script>
export default {
props: {
percentage: Number,
gap: Number, // 缺口角度,比如30度
size: { type: Number, default: 200 },
strokeWidth: { type: Number, default: 10 }
},
computed: {
center() { return this.size / 2; },
radius() { return (this.size – this.strokeWidth) / 2; },

// 计算路径的辅助函数
getArcPath(startAngle, endAngle) {
const start = this.polarToCartesian(this.center, this.center, this.radius, endAngle);
const end = this.polarToCartesian(this.center, this.center, this.radius, startAngle);
const largeArcFlag = endAngle – startAngle <= 180 ? "0" : "1";

return [
"M", start.x, start.y,
"A", this.radius, this.radius, 0, largeArcFlag, 0, end.x, end.y
].join(" ");
},

// 极坐标转笛卡尔坐标
polarToCartesian(centerX, centerY, radius, angleInDegrees) {
const angleInRadians = (angleInDegrees – 90) * Math.PI / 180.0;
return {
x: centerX + (radius * Math.cos(angleInRadians)),
y: centerY + (radius * Math.sin(angleInRadians))
};
},

// 背景路径:完整圆环减去缺口
backgroundPath() {
const gapAngle = this.gap;
const startAngle = -gapAngle / 2;
const endAngle = 360 – gapAngle / 2;
return this.getArcPath(startAngle, endAngle);
},

// 进度路径:根据percentage计算
progressPath() {
const gapAngle = this.gap;
const availableAngle = 360 – gapAngle;
const progressAngle = availableAngle * (this.percentage / 100);

const startAngle = -gapAngle / 2;
const endAngle = startAngle + progressAngle;

return this.getArcPath(startAngle, endAngle);
}
}
}
</script>

这段代码用三角函数计算圆弧的起点和终点坐标,然后用A命令(Arc)画椭圆弧。虽然代码量大了点,但可控性极强,缺口想放哪儿就放哪儿,甚至能做动画让缺口跟着进度走(虽然产品经理没提这个需求,但我偷偷实现了,万一她改主意呢)。


是神是鬼拉出来溜溜,优缺点咱得摊开说

SVG方案的好,谁用谁知道

体积小:就一个<svg>标签,没有外部依赖,打包的时候不会增加额外体积。比起引入ECharts或者D3这种庞然大物,SVG方案简直就是轻量级拳击手。

清晰度无敌:矢量图形,放大一百倍也不糊。在Retina屏、4K屏上照样锐利清晰。你甚至可以用CSS给它加filter: drop-shadow()做出那种发光的效果,逼格瞬间提升。

SEO友好:虽然进度条这东西跟SEO关系不大,但SVG是DOM的一部分,可以被搜索引擎爬虫读取到(虽然也没啥可读的)。

交互性强:因为每个元素都是DOM节点,你可以直接绑定事件。比如鼠标悬停在进度条上显示具体数值,或者点击缺口区域触发什么操作,都很方便。

兼容性问题,老安卓的痛

虽然Vue2老矣,但还得兼容那些古董安卓机。SVG在Android 4.4(KitKat)及以上支持良好,但如果是4.0-4.3那些老东西,可能会有渲染问题。

特别是stroke-linecap="round"在某些国产定制ROM上会出现奇怪的锯齿,或者阴影效果渲染不出来。这时候你得准备降级方案,比如用PNG图片兜底,或者干脆不显示圆角。

// 检测浏览器是否支持SVG的高级特性
function isSVGSupported() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
return svg.createSVGRect !== undefined;
}

// 在组件里判断
mounted() {
if (!isSVGSupported()) {
// 降级到CSS方案或者显示静态图片
this.useFallback = true;
}
}

性能那点事,风扇会不会起飞

如果你页面上只有一个两个进度条,那完全不用担心性能。但要是搞个数据大屏,上面几十个圆环同时转,还都带着动画,那浏览器渲染压力就大了。

SVG的渲染是CPU负责的(虽然现代浏览器有GPU加速,但路径计算还是吃CPU),太多同时动画会导致掉帧。这时候你可以:

  • 减少重绘:用transform和opacity做动画,避免频繁改变stroke-dasharray
  • 虚拟列表:如果进度条很多,只在视口内的才渲染
  • requestAnimationFrame:做动画的时候用这个,别用setInterval
  • will-change:给SVG元素加这个CSS属性,提示浏览器提前优化
  • .progress-ring {
    will-change: stroke-dasharray, stroke-dashoffset;
    }

    但说实话,大部分场景下你根本遇不到性能瓶颈,别 prematurely optimize(过早优化),先把功能做出来再说。


    真刀真枪干一场,代码怎么写才不挨骂

    封装通用组件,拒绝CV大法

    咱们前端工程师,最忌讳的就是在页面里复制粘贴一堆重复代码。今天这个页面用了,明天那个页面也要用,你复制了十遍,然后产品经理说"颜色改一下",你就得改十个地方,累不累啊?

    所以必须封装成组件。下面是我项目中实际在用的一版,功能齐全,注释详细,直接拿去用:

    <template>
    <div class="gap-progress" :style="containerStyle">
    <svg
    :width="size"
    :height="size"
    :viewBox="`0 0 ${size} ${size}`"
    class="progress-svg"
    >
    <!– 定义渐变(如果需要的话) –>
    <defs v-if="gradient">
    <linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="0%">
    <stop offset="0%" :stop-color="gradientStart" />
    <stop offset="100%" :stop-color="gradientEnd" />
    </linearGradient>
    </defs>

    <!– 背景圆环 –>
    <circle
    class="progress-bg"
    :cx="center"
    :cy="center"
    :r="radius"
    fill="none"
    :stroke="bgColor"
    :stroke-width="strokeWidth"
    :opacity="bgOpacity"
    />

    <!– 进度圆环 –>
    <circle
    class="progress-bar"
    :cx="center"
    :cy="center"
    :r="radius"
    fill="none"
    :stroke="gradient ? `url(#${gradientId})` : progressColor"
    :stroke-width="strokeWidth"
    :stroke-dasharray="dashArray"
    :stroke-dashoffset="dashOffset"
    :stroke-linecap="rounded ? 'round' : 'butt'"
    :transform="`rotate(${rotate} ${center} ${center})`"
    :style="transitionStyle"
    />
    </svg>

    <!– 中间插槽,可以放文字或者图标 –>
    <div class="progress-content" v-if="$slots.default">
    <slot :percentage="displayPercentage"></slot>
    </div>

    <!– 默认显示百分比文字 –>
    <div class="progress-text" v-else-if="showText">
    <span class="percentage">{{ displayPercentage }}</span>
    <span class="unit">%</span>
    </div>
    </div>
    </template>

    <script>
    /**
    * 不封闭圆形进度条组件
    * 支持缺口、渐变、动画、自定义内容等特性
    *
    * @author 前端老炮儿
    * @since 2024-01-15
    */

    export default {
    name: 'GapProgress',

    props: {
    // 当前进度 0-100
    percentage: {
    type: Number,
    default: 0,
    validator(val) {
    if (val < 0 || val > 100) {
    console.warn('[GapProgress] percentage 应该在 0-100 之间');
    return false;
    }
    return true;
    }
    },

    // 缺口大小(占圆周的百分比)
    gap: {
    type: Number,
    default: 15,
    validator(val) {
    if (val < 0 || val > 100) {
    console.warn('[GapProgress] gap 应该在 0-100 之间');
    return false;
    }
    return true;
    }
    },

    // 画布大小
    size: {
    type: Number,
    default: 120
    },

    // 描边宽度
    strokeWidth: {
    type: Number,
    default: 8
    },

    // 是否圆角
    rounded: {
    type: Boolean,
    default: true
    },

    // 旋转角度(让缺口在指定位置)
    // 0表示缺口在3点钟方向,-90表示在12点钟方向
    rotate: {
    type: Number,
    default: -90
    },

    // 背景色
    bgColor: {
    type: String,
    default: '#e5e9f2'
    },

    // 背景透明度
    bgOpacity: {
    type: Number,
    default: 1
    },

    // 进度条颜色
    progressColor: {
    type: String,
    default: '#409EFF'
    },

    // 是否显示渐变
    gradient: {
    type: Boolean,
    default: false
    },

    // 渐变起始色
    gradientStart: {
    type: String,
    default: '#409EFF'
    },

    // 渐变结束色
    gradientEnd: {
    type: String,
    default: '#67C23A'
    },

    // 是否显示文字
    showText: {
    type: Boolean,
    default: true
    },

    // 动画时长(毫秒)
    duration: {
    type: Number,
    default: 800
    },

    // 是否开启动画
    animation: {
    type: Boolean,
    default: true
    },

    // 缓动函数
    easing: {
    type: String,
    default: 'ease-out',
    validator(val) {
    return ['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out'].includes(val);
    }
    }
    },

    data() {
    return {
    // 内部动画用的百分比
    displayPercentage: 0,
    // 唯一ID,用于渐变定义
    gradientId: `gradient-${Math.random().toString(36).substr(2, 9)}`
    };
    },

    computed: {
    // 圆心坐标
    center() {
    return this.size / 2;
    },

    // 半径(减去描边宽度的一半,防止溢出)
    radius() {
    return (this.size – this.strokeWidth) / 2;
    },

    // 圆周长
    circumference() {
    return 2 * Math.PI * this.radius;
    },

    // 缺口对应的长度
    gapLength() {
    return this.circumference * (this.gap / 100);
    },

    // 可用于进度的长度
    availableLength() {
    return this.circumference – this.gapLength;
    },

    // 当前进度对应的长度
    progressLength() {
    return this.availableLength * (this.displayPercentage / 100);
    },

    // stroke-dasharray 值
    dashArray() {
    // 实线长度, 空白长度(设为周长确保只有一个缺口)
    return `${this.progressLength}, ${this.circumference}`;
    },

    // stroke-dashoffset 值
    dashOffset() {
    // 为了让缺口位置正确,需要计算偏移
    // 基础偏移是缺口长度的一半,让缺口居中在rotate指定的位置
    return this.gapLength / 2;
    },

    // 过渡动画样式
    transitionStyle() {
    if (!this.animation) return {};
    return {
    transition: `stroke-dasharray ${this.duration}ms ${this.easing}, stroke-dashoffset ${this.duration}ms ${this.easing}`
    };
    },

    // 容器样式
    containerStyle() {
    return {
    width: `${this.size}px`,
    height: `${this.size}px`,
    position: 'relative',
    display: 'inline-block'
    };
    }
    },

    watch: {
    // 监听百分比变化,触发动画
    percentage: {
    immediate: true,
    handler(newVal, oldVal) {
    if (!this.animation) {
    this.displayPercentage = newVal;
    return;
    }

    // 如果有旧值,做动画过渡
    if (oldVal !== undefined) {
    this.animateProgress(oldVal, newVal);
    } else {
    this.displayPercentage = newVal;
    }
    }
    }
    },

    methods: {
    /**
    * 动画过渡方法
    * 使用requestAnimationFrame实现平滑动画
    */
    animateProgress(from, to) {
    const startTime = performance.now();
    const diff = to – from;

    const step = (currentTime) => {
    const elapsed = currentTime – startTime;
    const progress = Math.min(elapsed / this.duration, 1);

    // 应用缓动函数
    const easedProgress = this.applyEasing(progress);

    this.displayPercentage = from + diff * easedProgress;

    if (progress < 1) {
    requestAnimationFrame(step);
    }
    };

    requestAnimationFrame(step);
    },

    /**
    * 应用缓动函数
    */
    applyEasing(t) {
    switch (this.easing) {
    case 'linear':
    return t;
    case 'ease-in':
    return t * t;
    case 'ease-out':
    return 1 – (1 – t) * (1 – t);
    case 'ease-in-out':
    return t < 0.5 ? 2 * t * t : 1 – Math.pow(-2 * t + 2, 2) / 2;
    default: // ease
    return t < 0.5 ? 4 * t * t * t : 1 – Math.pow(-2 * t + 2, 3) / 2;
    }
    }
    }
    };
    </script>

    <style scoped>
    .gap-progress {
    position: relative;
    display: inline-block;
    }

    .progress-svg {
    transform: rotate(-90deg); /* 让0度从12点钟方向开始 */
    transform-origin: center;
    }

    .progress-content,
    .progress-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    text-align: center;
    }

    .progress-text {
    font-size: 24px;
    font-weight: bold;
    color: #303133;
    }

    .progress-text .unit {
    font-size: 12px;
    margin-left: 2px;
    color: #909399;
    }
    </style>

    这个组件的亮点:

  • 功能齐全:支持缺口大小、颜色、渐变、动画、缓动函数等各种配置
  • 作用域插槽:中间可以自定义内容,比如放图标或者自定义文字格式
  • 动画优化:用requestAnimationFrame而不是CSS transition,因为stroke-dasharray的CSS动画在某些浏览器上不支持
  • 参数校验:validator确保传入的参数合法,提前发现错误
  • 使用示例:让产品经理随便改需求

    <template>
    <div class="demo">
    <!– 基础用法 –>
    <GapProgress :percentage="75" />

    <!– 大缺口,有呼吸感 –>
    <GapProgress
    :percentage="60"
    :gap="30"
    :size="200"
    progress-color="#67C23A"
    />

    <!– 渐变色彩,高级感 –>
    <GapProgress
    :percentage="88"
    :gradient="true"
    gradient-start="#FF6B6B"
    gradient-end="#4ECDC4"
    :stroke-width="12"
    />

    <!– 自定义中间内容 –>
    <GapProgress :percentage="45" :show-text="false">
    <template #default="{ percentage }">
    <div class="custom-content">
    <i class="icon-fire"></i>
    <span>{{ percentage }}°C</span>
    </div>
    </template>
    </GapProgress>

    <!– 控制面板,调戏进度条 –>
    <div class="controls">
    <button @click="changeProgress">随机进度</button>
    <input
    v-model.number="currentProgress"
    type="range"
    min="0"
    max="100"
    />
    </div>

    <GapProgress
    :percentage="currentProgress"
    :gap="20"
    :animation="true"
    :duration="500"
    />
    </div>
    </template>

    <script>
    import GapProgress from './components/GapProgress.vue';

    export default {
    components: { GapProgress },
    data() {
    return {
    currentProgress: 30
    };
    },
    methods: {
    changeProgress() {
    this.currentProgress = Math.floor(Math.random() * 100);
    }
    }
    };
    </script>

    看到没?通过props控制一切,产品经理说要"大一点的缺口",你改个:gap="30";说要"那种蓝绿渐变的颜色",你加个:gradient="true"。改需求?不存在的,早就预留好了配置项。

    平滑过渡动画,拒绝触电式跳动

    上面组件里的动画实现,我单独拎出来说一下,因为这里面有坑。

    最开始我直接用CSS transition:

    .progress-bar {
    transition: stroke-dasharray 0.8s ease;
    }

    结果在Safari上动画不生效!查了半天才知道,Safari不支持stroke-dasharray的CSS过渡。得,只能用JavaScript的requestAnimationFrame手动插值。

    而且缓动函数也要自己实现,虽然CSS有ease、ease-in-out这些关键字,但JS里可没有,得用数学公式:

    // ease-out: 先快后慢
    function easeOut(t) {
    return 1 – (1 – t) * (1 – t);
    }

    // ease-in-out: 两头慢中间快
    function easeInOut(t) {
    return t < 0.5 ? 2 * t * t : 1 – Math.pow(–2 * t + 2, 2) / 2;
    }

    这些公式网上都有,直接抄就行,但要知道为啥用。ease-out适合进入动画,给人"快速响应"的感觉;ease-in-out适合循环动画,比较平滑。


    遇到坑别慌,老司机的排雷指南

    进度条突然反向旋转?

    这个问题我调试了两个小时。现象是:进度从80%调到20%,圆环不是往回缩,而是绕着圈转了一圈再回到20%。

    原因:stroke-dasharray的第一个值(实线长度)变小了,但SVG的渲染机制让它看起来像是"从终点往起点画",所以出现了旋转效果。

    解决:确保stroke-dashoffset也跟着调整,或者在动画的时候同时控制两个值。更简单的方法是永远让实线长度从0增加到目标值,而不是来回变。如果进度减少,先重置为0再增加到新值,虽然有点取巧,但视觉上没问题。

    // 错误的动画方式(会旋转)
    this.dashArray = `${newValue}, 1000`;

    // 正确的动画方式(直接缩放)
    // 保持dasharray不变,通过scale或者clipPath来实现?不行,太复杂
    // 还是用JS控制stroke-dasharray的过渡,同时调整dashoffset补偿

    其实最简单的方案是:不要让进度条可逆。大部分场景下进度条都是单向增加的(比如加载、充值),如果确实需要来回变,建议用Canvas方案,可控性更强。

    不同浏览器缺口位置对不齐

    这个问题特别阴间。你在Chrome上看缺口在正上方,到Firefox上偏右了5度,到Safari上又偏左了。调了半天viewBox和transform,发现是SVG的坐标系原点问题。

    原因:SVG的transform旋转默认是围绕原点的,而不同浏览器对transform-origin的解析有差异。

    解决:显式指定旋转中心,而且用SVG的transform属性而不是CSS的transform:

    <!– 这样写,跨浏览器一致 –>
    <circle transform="rotate(-90 100 100)" />

    <!– 别这样写,会有兼容问题 –>
    <circle style="transform: rotate(-90deg); transform-origin: center;" />

    而且viewBox必须设置,并且和width、height比例一致:

    <svg width="200" height="200" viewBox="0 0 200 200">

    动画卡顿像PPT

    如果你在v-for里渲染了几十个进度条,同时更新它们的数据,页面会卡成狗。

    原因:每个进度条都在requestAnimationFrame里做动画,几十个同时跑,主线程被占满了。

    解决:

  • 虚拟滚动:只渲染视口内的进度条
  • 节流:不要每个进度条都单独动画,用同一个requestAnimationFrame循环更新所有实例
  • Will-Change:提前告诉浏览器哪些属性要变
  • .progress-bar {
    will-change: stroke-dasharray;
    }

  • GPU加速:把SVG放在合成层里
  • .gap-progress {
    transform: translateZ(0);
    }

    但说实话,如果真有这种性能需求,你可能得考虑用Canvas或者WebGL了,SVG不是为这种大规模动画设计的。


    几个私藏的小技巧,让你代码看起来像大神写的

    计算属性偷懒大法

    别在template里写数学公式,看着都累。比如:

    <!– 不要这样 –>
    <circle :stroke-dasharray="`${(2 * 3.14159 * radius) * (percentage/100)}, 1000`" />

    <!– 要这样 –>
    <circle :stroke-dasharray="dashArray" />

    然后在computed里写:

    computed: {
    dashArray() {
    const circumference = 2 * Math.PI * this.radius;
    return `${circumference * this.percentage / 100}, ${circumference}`;
    }
    }

    这样不仅代码清晰,还能缓存计算结果,性能更好。

    CSS变量动态换色,一套代码搞定深色模式

    现在的系统都支持深色模式了,进度条也得跟着变。用CSS变量(Custom Properties)可以省很多事:

    <template>
    <div class="progress-wrapper" :class="{ 'dark-mode': isDark }">
    <GapProgress
    :percentage="75"
    :style="cssVars"
    />
    </div>
    </template>

    <script>
    export default {
    computed: {
    cssVars() {
    return {
    '–progress-color': this.isDark ? '#66b1ff' : '#409EFF',
    '–bg-color': this.isDark ? '#2c2c2c' : '#e5e9f2',
    '–text-color': this.isDark ? '#e0e0e0' : '#303133'
    };
    }
    }
    };
    </script>

    <style>
    .gap-progress {
    –progress-color: #409EFF;
    –bg-color: #e5e9f2;
    –text-color: #303133;
    }

    .progress-bar {
    stroke: var(–progress-color);
    }

    .progress-bg {
    stroke: var(–bg-color);
    }

    .progress-text {
    color: var(–text-color);
    }

    /* 深色模式覆盖 */
    .dark-mode .gap-progress {
    –progress-color: #66b1ff;
    –bg-color: #2c2c2c;
    –text-color: #e0e0e0;
    }
    </style>

    这样你只需要切换isDark,所有颜色自动跟着变,不用改组件内部逻辑。

    防抖处理,别让接口数据晃散进度条

    有时候进度条的数据来自WebSocket或者定时轮询,数据可能狂刷。如果每次数据变化都触发动画,进度条会抽风一样抖。

    加个防抖(Debounce)或者节流(Throttle):

    import { debounce } from 'lodash'; // 或者自己实现

    export default {
    data() {
    return {
    rawPercentage: 0,
    displayPercentage: 0
    };
    },

    watch: {
    rawPercentage: debounce(function(newVal) {
    this.animateTo(newVal);
    }, 100) // 100ms内只响应最后一次变化
    },

    methods: {
    animateTo(target) {
    // 动画逻辑…
    }
    }
    };

    或者更优雅一点,在组件内部处理:

    props: {
    percentage: Number
    },

    watch: {
    percentage(newVal) {
    // 取消待执行的动画
    if (this.rafId) {
    cancelAnimationFrame(this.rafId);
    }

    // 延迟执行,如果期间有新值,就取消上一个
    clearTimeout(this.debounceTimer);
    this.debounceTimer = setTimeout(() => {
    this.animateProgress(this.displayPercentage, newVal);
    }, 50);
    }
    }

    这样即使用户疯狂拖动滑块,进度条也不会跟着抽风,而是平滑地过渡到最终值。


    最后碎碎念,这活儿干完能不能早点下班

    写这篇文章的时候,我回头看看自己写的这些代码,突然有点感慨。前端这行啊,真的就是天天跟像素较劲。别人以为我们是程序员,天天写高深算法,实际上大部分时间都在调margin多了2px还是少了2px,在纠结这个圆角的曲率是不是够"德芙",在跟产品经理解释"这个需求做不了"或者"那个需求得加两天"。

    有时候觉得自己像个绣花的大老爷们,拿着鼠标当针线,在屏幕上穿来穿去。但话说回来,当那个进度条终于丝滑地转起来,缺口的位置刚刚好,颜色渐变恰到好处,动画流畅不卡顿,那种成就感也是实实在在的。这大概就是前端工程师的"工匠精神"吧,虽然听起来有点中二。

    几个血泪教训,送给看到这里的兄弟:

  • 注释一定要写:我现在看自己三个月前写的代码,都在想"这坨东西是谁写的?哦,是我啊,那没事了"。特别是那些数学公式,当时觉得"这么简单还要注释?",现在看着dashOffset = circumference * 0.25 – gapLength / 2这种代码,得想半天当时为啥要减这一半。

  • 别过度设计:我一开始想把这个组件做成支持任意形状(方形、星形)的进度条,结果代码复杂度爆炸,最后发现99%的场景只用圆形。YAGNI原则(You Aren’t Gonna Need It),先解决眼前的问题,别为未来假设的需求写代码。

  • 跟UI对齐细节:缺口到底多大?圆角还是直角?动画时长多少?这些一定要在开发前确认清楚,最好让她出个标注图。不然你写完了她说"缺口再大一点",你改完了她又说"还是原来那个大小吧",你会想杀人的。

  • 测试真机:Chrome调试工具里看着完美,到真机上可能缺口位置偏了、颜色淡了、动画卡了。特别是安卓低端机,一定要测,不要心存侥幸。

  • 愿天下没有难做的进度条,愿产品经理的需求都能一次过,愿咱们今晚都能准点撸串去。如果这篇文章帮你省下了加班时间,不用谢我,下次团建的时候帮我多拿两串羊肉串就行。


    完。

    欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。

    推荐:DTcode7的博客首页。 一个做过前端开发的产品经理,经历过睿智产品的折磨导致脱发之后,励志要翻身农奴把歌唱,一边打入敌人内部一边持续提升自己,为我们广大开发同胞谋福祉,坚决抵制睿智产品折磨我们码农兄弟!


    专栏系列(点击解锁)学习路线(点击解锁)知识定位
    《微信小程序相关博客》 持续更新中~ 结合微信官方原生框架、uniapp等小程序框架,记录请求、封装、tabbar、UI组件的学习记录和使用技巧等
    《AIGC相关博客》 持续更新中~ AIGC、AI生产力工具的介绍,例如stable diffusion这种的AI绘画工具安装、使用、技巧等总结
    《HTML网站开发相关》 《前端基础入门三大核心之html相关博客》 前端基础入门三大核心之html板块的内容,入坑前端或者辅助学习的必看知识
    《前端基础入门三大核心之JS相关博客》 前端JS是JavaScript语言在网页开发中的应用,负责实现交互效果和动态内容。它与HTML和CSS并称前端三剑客,共同构建用户界面。通过操作DOM元素、响应事件、发起网络请求等,JS使页面能够响应用户行为,实现数据动态展示和页面流畅跳转,是现代Web开发的核心
    《前端基础入门三大核心之CSS相关博客》 介绍前端开发中遇到的CSS疑问和各种奇妙的CSS语法,同时收集精美的CSS效果代码,用来丰富你的web网页
    《canvas绘图相关博客》 Canvas是HTML5中用于绘制图形的元素,通过JavaScript及其提供的绘图API,开发者可以在网页上绘制出各种复杂的图形、动画和图像效果。Canvas提供了高度的灵活性和控制力,使得前端绘图技术更加丰富和多样化
    《Vue实战相关博客》 持续更新中~ 详细总结了常用UI库elementUI的使用技巧以及Vue的学习之旅
    《python相关博客》 持续更新中~ Python,简洁易学的编程语言,强大到足以应对各种应用场景,是编程新手的理想选择,也是专业人士的得力工具
    《sql数据库相关博客》 持续更新中~ SQL数据库:高效管理数据的利器,学会SQL,轻松驾驭结构化数据,解锁数据分析与挖掘的无限可能
    《算法系列相关博客》 持续更新中~ 算法与数据结构学习总结,通过JS来编写处理复杂有趣的算法问题,提升你的技术思维
    《IT信息技术相关博客》 持续更新中~ 作为信息化人员所需要掌握的底层技术,涉及软件开发、网络建设、系统维护等领域的知识
    《信息化人员基础技能知识相关博客》 无论你是开发、产品、实施、经理,只要是从事信息化相关行业的人员,都应该掌握这些信息化的基础知识,可以不精通但是一定要了解,避免日常工作中贻笑大方
    《信息化技能面试宝典相关博客》 涉及信息化相关工作基础知识和面试技巧,提升自我能力与面试通过率,扩展知识面
    《前端开发习惯与小技巧相关博客》 持续更新中~ 罗列常用的开发工具使用技巧,如 Vscode快捷键操作、Git、CMD、游览器控制台等
    《photoshop相关博客》 持续更新中~ 基础的PS学习记录,含括PPI与DPI、物理像素dp、逻辑像素dip、矢量图和位图以及帧动画等的学习总结
    日常开发&办公&生产【实用工具】分享相关博客》 持续更新中~ 分享介绍各种开发中、工作中、个人生产以及学习上的工具,丰富阅历,给大家提供处理事情的更多角度,学习了解更多的便利工具,如Fiddler抓包、办公快捷键、虚拟机VMware等工具

    吾辈才疏学浅,摹写之作,恐有瑕疵。望诸君海涵赐教。望轻喷,嘤嘤嘤 非常期待和您一起在这个小小的网络世界里共同探索、学习和成长。愿斯文对汝有所裨益,纵其简陋未及渊博,亦足以略尽绵薄之力。倘若尚存阙漏,敬请不吝斧正,俾便精进!

    在这里插入图片描述

    赞(0)
    未经允许不得转载:171主机测评 » 前端老哥必看:Vue2.x搞定不封闭圆形进度条,拒绝UI提需求就头秃
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址