手把手实现高性能Progress进度条组件

a'ゞ蒙蒙 组件 阅读 878
赞 36 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

前阵子在做一个后台管理系统的仪表盘页面,里面有个资源上传模块,用户上传大文件的时候得给个进度反馈。本来以为就是加个简单的 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、变化阈值,这些组合起来才能让体验真正丝滑。

如果你也在做文件上传进度,希望这篇能帮你少走点弯路。有更好的实现方式欢迎评论区交流,我也想看看别人是怎么处理的。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论