axios onUploadProgress 与 Vue 响应式:上传进度条实现与排查指南
2026/9/17 15:56:15 网站建设 项目流程

简介:面向Vue前端开发者的实战资料,主要讲解如何借助axios的onUploadProgress配置与Vue响应式数据绑定,实现文件上传过程中进度条实时更新的效果。适用于大文件上传、需要细腻操作反馈的项目场景,也适合有一定前端基础、想掌握更可靠上传交互的开发者。PDF以完整案例为主线,逐步覆盖Vue实例创建、FormData封装、多文件选择处理、进度条宽度与百分比动态更新等环节,并附可直接复用的HTML、CSS、JavaScript代码。尤其提醒:onUploadProgress回调中进度到达100%仅代表请求已发出,并不等于服务器处理完成,必须等待响应返回后再判定上传成功,否则用户提前关闭页面容易导致文件丢失或异常。资源共1个PDF文档,大小约49KB,内容精炼,代码结构清晰,方便按需改造或嵌入项目。已有2100余人浏览学习,值得作为Vue上传功能设计的参考。

1. 上传进度为什么难:axios 的 onUploadProgress 与 Vue 响应式的边界

给上传文件加进度条,第一反应是翻 UI 组件库文档,但真正决定进度能不能显示的,是请求库对底层上传事件的暴露方式。axios 在浏览器端基于 XMLHttpRequest 实现,配置里预留了 onUploadProgress 回调,能在上传过程中持续拿到已上传字节数 loaded 和总字节数 total。Vue 这边把数值直接写进普通对象不会触发刷新,必须用 ref 或 reactive 包一层。这条功能的核心就两条线:axios 把底层上传事件翻译成业务回调,Vue 把回调数值变成响应式状态驱动进度条。下文从最小封装讲到并发分片再到排查验证,前端可直接照搬;排查章节覆盖代理层与后端缓冲,适合写上传模块的新手,也适合在存量项目里查进度卡顿的老手。

2. 用 axios 的 onUploadProgress 封装可复用的上传进度回调

2.1 XHR 的 upload.onprogress 到 onUploadProgress 的映射

axios 的 post 方法在浏览器端默认走 XMLHttpRequest 适配器,onUploadProgress 配置会被直接挂到底层 xhr 对象的 upload.onprogress 上。进度事件对象是 ProgressEvent,其中三个字段决定进度能不能算出来:loaded 表示已上传字节数,total 表示请求体总字节数,lengthComputable 表示 total 是否有意义。只有 lengthComputable 为 true 时,用 loaded 除以 total 得到的百分比才可靠;当后端启用了 chunked 传输或某些代理重写了响应头,total 可能为 0,强行求百分比会得到 Infinity,进度条直接显示成 NaN。

反直觉的点在于:total 并不总是等于 File.size。当请求体里还带着 FormData 的其他字段时,浏览器为了实现 multipart 格式,会在每个字段外加上 boundary 分隔符和头部信息,真实 total 会比文件本身大几 KB 到几十 KB。组件里如果拿 File.size 当分母,接近末尾时会出现进度先到 100% 又回落的抖动,而直接用事件里的 total 当分母最稳,因为浏览器计算的 total 与实际发送内容一致。

Node 端是另一个高频踩坑点。axios 在 Node 环境下没有 XMLHttpRequest,onUploadProgress 对单文件上传基本不触发(axios 1.x 的 Node 适配器对进度事件支持非常有限),所以这个功能天然是浏览器端方案。排查“回调为什么没反应”时,先确认代码跑在浏览器环境而不是 SSR、预渲染或 Node 脚本里。

2.2 最小封装:uploadWithProgress 函数与参数说明

常见做法是封装一个独立的 upload 工具函数,把 url、file、进度回调、附加表单字段、超时和取消信号都收成参数,组件里只传业务相关内容,避免每个文件中重复组装 FormData。

// utils/upload.js import axios from 'axios' export function uploadWithProgress({ url, file, onProgress, formFields = {}, timeout = 60000, signal }) { const formData = new FormData() Object.entries(formFields).forEach(([key, value]) => { formData.append(key, value) }) formData.append('file', file) // 字段名保持与后端约定一致 return axios.post(url, formData, { timeout, signal, // 浏览器上传阶段的事件回调,loaded/total 单位是字节 onUploadProgress: (e) => { if (!e.lengthComputable) { onProgress && onProgress({ percent: null, loaded: e.loaded, total: 0 }) return } const percent = Math.min( Math.round((e.loaded / e.total) * 100), 99 // 预留 1% 等待后端响应 ) onProgress && onProgress({ percent, loaded: e.loaded, total: e.total }) } }) }

代码逻辑分三段:先用 FormData 把业务字段和文件拼进请求体,再以 post 方式提交且不手动设置 Content-Type,最后在 onUploadProgress 回调里做 lengthComputable 判断并把计算结果交给业务层。percent 压到 99 是刻意为之:axios 回调到 100% 时只代表请求体已经发出,服务端是否接收完成还要等响应返回,组件层在 await 返回后再把进度置为 100%,语义上更准确。

参数说明对照表:

参数类型默认值说明
urlstring必填后端接收上传的接口地址
fileFile/Blob必填待上传文件对象
onProgressfunction进度回调,参数为 { percent, loaded, total }
formFieldsobject{}附加到表单的业务字段,如业务单号
timeoutnumber60000请求超时毫秒数,覆盖上传与响应全过程
signalAbortSignalundefined取消上传用的信号对象,由调用方创建

两个容易写错的地方:Content-Type 不要手动指定,浏览器会在 FormData 提交时自动生成带 boundary 的 multipart 头,手动设置反而让后端解析不到 file 字段;withCredentials 默认 false,只有跨域且后端需要读 Cookie 时才显式打开。

2.3 axios 企业级封装要收口的三个配置

组件里逐次传参容易写散,常见的 axios 企业级封装会在 2.2 的基础上再收三层配置。第一层是 maxContentLength 与 maxBodyLength,axios 默认值对几百 MB 的大文件不够用,要根据业务的单文件上限放大,否则请求还没发完就被拦截。第二层是给 axios 实例统一配置 baseURL 与请求拦截器,把 token、租户 ID 这类公共头在拦截器里注入,上传函数里不需要重复处理。第三层是把 signal 透传到 axios.post 的配置里,对应的取消机制在第 5 章给出完整接法。

需要强调的是,进度回调不应放进响应拦截器。onUploadProgress 是请求过程事件,跑在响应到达之前,拦截器里只能拿到最终响应,进度回调必须在请求配置层单独传递下去。企业级封装里常见的错误是把 onUploadProgress 建成全局单例,多个文件并发时回调互相覆盖,正确做法是每次上传创建独立的回调闭包。

3. Vue 组件里把进度数值变成进度条:ref、百分比和请求状态

3.1 最小组件:progress ref 与进度条渲染

Vue 3 组合式 API 下,上传状态模型可以拆成三个响应式变量:progress 表示当前进度百分比,uploading 表示是否在上传中,errorMsg 表示失败原因。onProgress 回调里只做赋值,不写业务逻辑,避免在事件回调里做高频非必要计算。

<script setup> import { ref } from 'vue' import { uploadWithProgress } from '@/utils/upload' const fileInput = ref(null) const progress = ref(0) const uploading = ref(false) const errorMsg = ref('') async function handleUpload() { const file = fileInput.value.files[0] if (!file) return uploading.value = true progress.value = 0 errorMsg.value = '' try { await uploadWithProgress({ url: '/api/upload', file, // 只做赋值,节流交给响应式系统 onProgress: ({ percent }) => { if (percent !== null) progress.value = percent } }) progress.value = 100 // 响应返回后才算真正完成 } catch (e) { errorMsg.value = e.message || '上传失败' } finally { uploading.value = false } } </script> <template> <input ref="fileInput" type="file" /> <button :disabled="uploading" @click="handleUpload">上传</button> <div class="progress-bar"> <!-- 宽度直接绑定 percent,Vue 会自动更新 style --> <div class="progress-inner" :style="{ width: progress + '%' }" /> </div> <span v-if="uploading">{{ progress }}%</span> <span v-else-if="errorMsg" class="error">{{ errorMsg }}</span> </template>

这段组件演示了完整闭环:文件选择、上传触发、进度赋值、完成态与错误态切换。onProgress 每次回调都重新给 progress.value 赋值,Vue 的响应式系统会把同一帧内的多次赋值合并到一次 DOM 更新,高频回调并不需要手动节流。真正需要节流的是在回调里同步操作 DOM、写 localStorage 或打印日志的写法,那种写法在每 100ms 触发一次的大文件上传里会明显卡顿。模板里宽度绑定建议加一层 Math.max(0, Math.min(100, progress)) 保护,后端返回的文件 URL、唯一 ID 这类结果单独存一个 ref,不要和错误信息混用。

3.2 100% 与“上传完成”不是一回事

onUploadProgress 到达 100% 只代表请求体数据已经从浏览器发出,服务端是否接收完毕、落盘成功,要等响应返回才知道。所以 3.1 的代码在 await 返回后才把 progress 置 100%,与 2.2 里 percent 压到 99 是配套设计,中间留出的 1% 就是“等待服务端处理”的窗口。

后端处理耗时较长时(同步做压缩、病毒扫描、转码),UI 会长时间停在 99%,用户容易当成卡死。常见做法是 99% 阶段显示“服务端处理中”文案,也可以把上传与处理拆成两段:先传完拿文件 ID,再轮询处理进度并复用同一个 progress ref,此时进度含义从“传输字节比”变成“处理完成比”,数值来源不同但 UI 结构不变。

节点progress 值用户看到的行为
onUploadProgress 到达 100%99%(封装层压顶)进度条接近满格,等待响应
服务端响应返回100%(await 之后)显示上传成功
后端处理中(压缩/转码)停留在 99%提示“服务端处理中”

注意:percent 压到 99 后,后端处理超过 30 秒时前端应给出等待提示,否则用户会在 99% 处反复触发上传。

3.3 多文件并发上传的总进度计算

一次选择多个文件并发上传时,总进度不能把各文件的 percent 直接平均,因为文件大小不同,小文件权重大于其实际贡献。正确口径是已上传总字节数除以文件总字节数。

// 多文件总进度计算 const currentLoadedSnapshot = new Map() const overallProgress = ref(0) async function uploadFiles(files) { const totalBytes = files.reduce((sum, f) => sum + f.size, 0) let uploadedBytes = 0 const tasks = files.map((file) => { return uploadWithProgress({ url: '/api/upload', file, onProgress: ({ loaded }) => { // loaded 是累计值,先扣旧值再加新值才能得到增量 const last = currentLoadedSnapshot.get(file) || 0 uploadedBytes += loaded - last currentLoadedSnapshot.set(file, loaded) overallProgress.value = Math.min( Math.round((uploadedBytes / totalBytes) * 100), 99 ) } }) }) await Promise.all(tasks) overallProgress.value = 100 }

这里最容易踩的坑是直接写 uploadedBytes += loaded。每个回调里的 loaded 是累计值不是增量,直接累加会让总进度严重虚高,进度条提前到 100% 后又跳回。用 Map 记录每个文件上次的 loaded,每次回调先扣旧值再加新值,得到的就是真实已上传字节数。如果业务只要“完成率”而不需要实时传输进度,也可以在 Promise.all 完成后用成功文件数除以总数,但那是完成率,和传输进度是两种语义,展示上要区分。

并发数也要控制。浏览器对同一域名的并发连接存在上限,文件一多,排队中的请求会挤占进度事件频率。常见做法是做个 3 到 5 的并发信号量,或者引入 p-limit 这类调度库,把并发上限作为封装函数的参数暴露给调用方。

4. 进度卡在 0% 或 85% 时的排查顺序:从回调触发到响应等待

4.1 卡在 0%:onUploadProgress 没有触发

进度一直停在 0%,先不必怀疑后端,按顺序排除四类原因。第一步确认运行环境是浏览器,Vue 的 SSR、预渲染或 Node 脚本里 axios 走的是 Node 适配器,onUploadProgress 不生效,这是最容易被忽视的环境级问题。第二步打开 DevTools 的 Network 面板看 upload 请求是否真正发出,请求显示 pending 但进度不动,通常是 FormData 组装阶段抛错或 file 对象为空,请求体还没开始传输。第三步确认是否有自定义适配器或 service worker 接管了请求,部分封装会强制 httpAdapter,进度事件会被一并吞掉。第四步检查本地开发代理,vite 或 Webpack proxy 对流式 multipart 的转发能力会影响 loaded 的推进频率,表现为卡在 0% 很久后突然跳到 90% 以上。

第 4 点是本地开发最常见的假阳性,生产直连后端进度正常,本地代理下进度不刷新或跳变。处理方式是在代码里把 loaded 原始值打印出来,区分“回调没触发”和“回调触发了但数值不增长”两种情况,后者的排查重点立刻转到网络层。

提示:本地代理环境下进度条跳变,不代表 axios 封装有问题,先用打印 loaded 的方式区分回调未触发与数值不增长。

4.2 卡在 85% 附近:loaded 与 total 不一致或响应等待

进度卡在 85% 或某个非 0 值不动,通常与 total 失真有关。请求体经过代理、CDN 或网关时,部分网关会重算 Content-Length,如果重算后的字节数与前端事件里的 total 存在偏差,percent 可能先算完但 loaded 还在增长,表现就是先到 100% 再回落,或卡在某个百分比不动。

另一种常见情况是后端做了缓冲或转码。Nginx 的 client_body_buffer_size 设置过大时,代理会等请求体收满才向后端转发,前端任务其实已经传完,loaded 不再增长,progress 却停在 100% 以内。此时在 Network 面板对比请求头里的 Content-Length 与实际请求体大小,偏差来源就清楚了。响应迟迟不来也会造成同样观感:上传已结束,后端在处理,进度停在 85% 只是因为封装层把 percent 压到了 99 且后端耗时超过预期。DevTools 的 Timing 面板里 Waiting (TTFB) 时间段很长,说明问题不在进度计算,而在后端响应速度。

现象优先检查项常见根因
0% 不动运行环境、Network 面板Node 适配器、适配器被替换
0% 后跳变本地代理配置proxy 对流式转发不完整
卡在非 0 值Content-Length 对比网关重算 total、后端缓冲
停在 99%Timing 面板 TTFB后端处理耗时过长

4.3 request aborted、超时与取消的区分

大文件上传中服务端主动断开连接(Node 后端常见的 request aborted)或客户端超时,进度会冻结在中断位置。axios 的错误对象里,code 为 ECONNABORTED 表示超时;用户主动取消会抛出 CanceledError,用 axios.isCancel 可以判断。catch 块里要区分处理:超时提示重试,取消则静默复位,不要弹错误提示。

import axios from 'axios' try { await uploadWithProgress({ url: '/api/upload', file, onProgress }) } catch (e) { if (axios.isCancel(e)) { progress.value = 0 return } if (e.code === 'ECONNABORTED') { errorMsg.value = '上传超时,请检查网络后重试' } else { errorMsg.value = e.response?.data?.message || '上传失败' } } finally { uploading.value = false }

服务端在接收大文件时通常也要配合处理 request aborted 事件,清理已写入的临时分片,否则客户端取消后残片会一直占着磁盘。排查进度问题时记住一点:进度显示不是传输事务本身,即使百分比算错了,请求也会照常走完。最有效的手段是看 Network 面板和打印 loaded 原始值,两者都正常时,问题基本可以锁定在代理层或后端处理耗时。

5. 生产环境验证:用限速模拟与日志对比校准进度显示

5.1 浏览器限速模拟慢上传

Chrome DevTools 的 Network 面板自带限速档位,在 No throttling 下拉里选 Slow 3G 或自定义档位,把上传速率压到 50KB/s 左右再触发上传,能稳定观察到进度条的逐帧变化。分三档验证:几百 KB 的小文件看进度是否一次跳到位;几十 MB 的文件看是否平滑推进;超过 1GB 的文件重点看 99% 等待阶段的文案和按钮禁用状态是否正确。

5.2 服务端日志对比已接收字节数

最扎实的校准方式是在后端接口里记录请求头中的 Content-Length 和实际读取到的请求体字节数,再与前端最后一次回调的 loaded 对比。两者一致但前端 percent 没到 99,说明封装层 total 取错了;后端收到的字节数大于前端 loaded,说明代理层还在缓冲。

5.3 取消上传的验证要点

进度功能上线前,取消链路必须验证。用 AbortController 创建信号传给封装函数,取消后立即复位进度条并清空文件选择框。

const controller = new AbortController() async function handleUpload() { await uploadWithProgress({ url: '/api/upload', file, signal: controller.signal, // 取消信号传给封装函数 onProgress }) } function cancelUpload() { controller.abort() // 触发 axios 抛出 CanceledError progress.value = 0 uploading.value = false fileInput.value.value = '' }

验证时看两点:Network 面板里请求是否被标记为 canceled,以及服务端是否收到中断信号并清理临时文件。若 service 端缺少 request aborted 清理逻辑,已上传残片会留在磁盘,这是分片上传场景中最容易漏掉的一环,排查时优先翻阅服务端访问日志确认连接断开时间点与客户端取消时间点是否吻合。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询