手把手实现高性能Progress进度条组件
项目初期的技术选型
前阵子在做一个后台管理系统的仪表盘页面,里面有个资源上传模块,用户上传大文件的时候得给个进度反馈。本来以为就是加个简单的 Progress 组件完事,结果实际做起来才发现,事情没那么简单。
一开始我直接用了 Element Plus 自带的 <el-progress>,毕竟项目里已经引入了,图个省事。UI 看着也还行,圆环形和线性都能切换,参数也不多,文档写得挺清楚。但真正接入上传逻辑后,问题就开始冒出来了。
主要问题是:上传进度不平滑、卡顿严重,尤其是上传大文件(比如 500MB 以上)时,UI 卡得像幻灯片,而且偶尔还会出现进度条“倒退”的诡异现象——就是显示到 80% 又掉回 60%,用户肯定要骂娘。
当时第一反应是接口的问题,怀疑是后端返回的进度数据不对。折腾了半天去翻后端日志,发现他们每 5% 返回一次 progress 字段,数值是递增的,没问题。那锅就只能我自己背了。
最大的坑:性能问题
我开始 debug 前端这边的逻辑。代码大概是这样:
const uploadFile = (file) => {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
// 直接更新 Vue 响应式变量
progressPercent.value = percent;
}
};
xhr.open('POST', 'https://jztheme.com/api/upload');
xhr.send(formData);
};
看着没啥毛病,onprogress 每次计算百分比然后赋值。但问题就出在这儿——浏览器的 onprogress 事件触发频率太高了。上传一个大文件,可能在几秒内触发几百甚至上千次,每次都会触发 Vue 的响应式更新,进而引发组件重新渲染。
我用 Performance 面板录了一下,发现那一小段时间主线程被 Vue 的 patch 操作占满了,JS 调用栈堆得老高,页面直接失去响应。这时候别说进度条流畅了,连按钮点击都延迟半秒。
解决思路其实挺常见的:节流。但我一开始写的节流函数也有问题。
// 错误示范:节流写在回调里
let timer = null;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
if (!timer) {
timer = setTimeout(() => {
progressPercent.value = percent;
timer = null;
}, 100);
}
}
};
这个写法看似节流了,但实际上会导致一个问题:如果两次事件间隔小于 100ms,后面的进度就会被丢掉。比如从 45% 到 52%,中间只更新一次,用户体验还是断断续续的。
后来改成使用 lodash.throttle,但发现包体积有点杀鸡用牛刀。最后手写了一个带“最后一次必须触发”逻辑的节流函数,才勉强稳住。
最终的解决方案
现在的处理方式是:节流 + requestAnimationFrame + 强制最后更新。
核心代码如下:
const uploadFile = (file) => {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
let lastPercent = 0;
let scheduledFrame = null;
const updateProgress = (percent) => {
// 只有变化超过 1% 才更新,避免太细碎
if (Math.abs(percent - lastPercent) >= 1) {
lastPercent = percent;
progressPercent.value = percent;
}
};
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
// 使用 rAF 节流
if (!scheduledFrame) {
scheduledFrame = requestAnimationFrame(() => {
updateProgress(percent);
scheduledFrame = null;
});
}
}
};
// 上传完成时强制更新到最后状态
xhr.onload = () => {
if (lastPercent !== 100) {
progressPercent.value = 100;
}
scheduledFrame = null;
};
xhr.onerror = () => {
// 错误处理
progressPercent.value = 0;
scheduledFrame = null;
};
xhr.open('POST', 'https://jztheme.com/api/upload');
xhr.send(formData);
};
这个方案有几个关键点:
- 使用
requestAnimationFrame控制更新频率,保证不丢帧也不卡主线程 - 加入 1% 的变化阈值,避免频繁更新小幅变动
- 在
xhr.onload时强制设置为 100%,防止因节流导致最终状态未更新
实测下来,上传 1GB 文件也能保持 UI 流畅,进度条动画顺滑,没再出现倒退或卡死的情况。
样式上的小纠结
UI 方面,设计师给的是一个渐变色进度条,从蓝色到紫色,带点动效。原生 Element Plus 的 progress 不支持渐变色过渡动画,只能静态配色。
我试过用 CSS 动态修改 background,但因为进度条是通过 width 控制的,底色是父容器,子元素是实心条,改 background 会整体变色,没法实现“已加载部分是渐变,未加载部分是灰色”的效果。
最后方案是:放弃原组件,自己写了个简易的进度条组件,结构如下:
<div class="custom-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: percent + '%', background: getGradient(percent) }"></div>
</div>
<span class="progress-text">{{ percent }}%</span>
</div>
.custom-progress {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.progress-bar {
flex: 1;
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.2s ease, background 0.2s ease;
}
const getGradient = (percent) => {
return linear-gradient(90deg, #409eff ${percent * 0.5}%, #9a66ff ${percent}%);
};
这里 getGradient 是个简单粗暴的实现,根据当前进度生成不同的渐变区间。虽然不是完美的视觉渐变流动效果,但看起来至少比纯色高级点,设计师也没挑刺,就这样上线了。
回顾与反思
回过头看,这个功能看起来简单,但真要做顺滑、稳定、好看,还是踩了不少坑。最大的教训是:别小看 onprogress 事件的性能影响。它触发太频繁,直接绑定到响应式数据上等于自找麻烦。
另外,组件库的 Progress 往往只满足基本需求。一旦涉及定制化样式或复杂交互,大概率得自己撸一个。Element Plus 的进度条这次就没扛住,下次类似项目我可能会直接从零开始写,反而更可控。
还有个小问题到现在也没完美解决:移动端 Safari 上,progress 更新偶尔会有延迟感,可能是 rAF 在 iOS 上的调度策略不同。不过影响不大,用户感知不强,暂时没精力深挖。
这个方案也不是最优解,比如可以用 Web Worker 去处理进度计算,但考虑到项目体量和维护成本,目前这版够用了。
结语
以上是我这次在项目中搞进度条的全部经历。从一开始以为半小时搞定,到最后折腾了一整天,真是一句话:简单功能背后全是坑。
核心代码其实就那么几行,关键是思路要对——节流、防抖、rAF、变化阈值,这些组合起来才能让体验真正丝滑。
如果你也在做文件上传进度,希望这篇能帮你少走点弯路。有更好的实现方式欢迎评论区交流,我也想看看别人是怎么处理的。

暂无评论