资讯详情

Vue 3 + axios 实现文件上传进度条的完整方案

📅 2026/10/21 6:06:21 | 华诺云谱 👁 阅读
Vue 3 + axios 实现文件上传进度条的完整方案
简介本资源是一份面向前端开发者与Vue.js初学者的实用技术实践指南聚焦解决大文件上传场景下的用户体验痛点——实时进度反馈。通过axios的onUploadProgress事件结合Vue响应式数据绑定完整实现文件选择、进度条动态渲染、百分比显示及上传完成状态校验等核心功能避免常见误区如仅凭进度事件误判100%完成。资源为单文件PDF文档49KB内容涵盖HTML结构、CSS样式、Vue实例配置与axios请求封装的完整代码示例并附关键注释与注意事项说明便于快速理解原理并直接复用。目前已有2125人学习下载适合希望夯实Vueaxios工程化能力、提升文件上传交互质量的中初级前端工程师参考落地。1. 为什么用 axios Vue 做文件上传进度显示比直接用input typefilefetch更稳你有没有遇到过这样的现场用户拖进一个 200MB 的设计稿 ZIP页面只显示“上传中…”三秒然后突然弹出“网络错误”——但控制台没报错onerror没触发catch里打印出来是Cancel再试一次又成功了。这不是玄学是原生fetch默认不暴露上传阶段的底层XMLHttpRequest进度事件而axios在浏览器环境底层仍走XMLHttpRequest且原生支持onUploadProgress钩子能拿到实时字节数、总大小、完成百分比。Vue 侧只需把progress绑定到响应式数据配合v-model或:style就能驱动进度条、数字、甚至暂停/续传按钮。这不是炫技而是真实产线需求某高校实验室的实验数据归档系统要求上传超 500MB 的 HDF5 文件时必须让用户明确感知“已传 382MB / 497MB”否则客服每天接到 20 通“到底传没传成功”的电话。本文就带你从零搭起这个能力——不依赖任何 UI 库不用element-plus的el-upload它封装太深出问题时黑匣子纯 axios Vue 3 Composition API 原生progress标签跑通最小可验证路径并把三个最常翻车的坑写进骨髓里。2. 用 axios 在 Vue 3 中发起带进度监听的文件上传请求2.1 创建可复用的上传 HookuseFileUpload.ts我们不写一次性组件而是抽成组合式函数Hook方便在多个页面复用。核心是axios.post的config中传入onUploadProgress回调并确保该回调能访问到 Vue 的响应式状态// composables/useFileUpload.ts import { ref, Ref } from vue import axios from axios interface UploadProgress { loaded: number total: number percent: number } export function useFileUpload() { const isUploading ref(false) const uploadProgress refUploadProgress | null(null) const uploadError refstring | null(null) const uploadSuccess refboolean(false) const uploadFile async ( file: File, url: string, config: Omitaxios.AxiosRequestConfig, onUploadProgress | data {} ) { isUploading.value true uploadProgress.value null uploadError.value null uploadSuccess.value false try { // ✅ 关键onUploadProgress 必须是普通函数不能是箭头函数否则 this 指向丢失 const response await axios.post( url, // 构造 FormData后端接收 multipart/form-data 的标准格式 (function buildFormData() { const formData new FormData() formData.append(file, file) // file 是后端约定的字段名 return formData })(), { ...config, // ⚠️ 注意headers 不能手动设 Content-Type否则浏览器会忽略 boundary // axios 会自动设置为 multipart/form-data; boundary... onUploadProgress: (progressEvent: ProgressEvent) { if (progressEvent.lengthComputable) { uploadProgress.value { loaded: progressEvent.loaded, total: progressEvent.total, percent: Math.round((progressEvent.loaded / progressEvent.total) * 100) } } } } ) uploadSuccess.value true return response } catch (err: any) { uploadError.value err.response?.data?.message || err.message || 上传失败 throw err } finally { isUploading.value false } } return { isUploading, uploadProgress, uploadError, uploadSuccess, uploadFile } }逻辑说明uploadFile接收File对象来自input typefile的event.target.files[0]、目标 URL 和可选配置FormData构造必须在axios请求体中动态生成不能提前定义否则file引用可能失效onUploadProgress回调中progressEvent.lengthComputable为true才代表后端返回了Content-Length此时total可信若为false如服务端未设Content-Length头则percent无法计算需降级显示“上传中未知进度”错误处理捕获err.response?.data?.message适配常见 RESTful 后端返回结构避免裸err.message显示 “Network Error” 这类无意义信息。2.2 在 Vue 组件中调用 Hook 并绑定 UI创建UploadDemo.vue使用 Composition API 调用上述 Hook并将进度数据映射到原生progress和文本!-- components/UploadDemo.vue -- template div classupload-container input typefile changehandleFileSelect :disabledisUploading classfile-input / div v-ifselectedFile classfile-info p 选择文件{{ selectedFile.name }}/p p 大小{{ formatFileSize(selectedFile.size) }}/p /div button clickstartUpload :disabled!selectedFile || isUploading classupload-btn {{ isUploading ? 上传中... : 开始上传 }} /button !-- 进度条原生 progress语义化且无需额外 CSS -- div v-ifuploadProgress classprogress-wrapper progress :valueuploadProgress.percent :max100 classprogress-bar /progress span classprogress-text{{ uploadProgress.percent }}%/span div classprogress-detail {{ formatFileSize(uploadProgress.loaded) }} / {{ uploadProgress.total ? formatFileSize(uploadProgress.total) : 未知大小 }} /div /div div v-ifuploadError classerror-message ❌ {{ uploadError }} /div div v-ifuploadSuccess classsuccess-message ✅ 上传成功服务器返回{{ uploadSuccessMessage }} /div /div /template script setup langts import { ref, computed } from vue import { useFileUpload } from /composables/useFileUpload const { isUploading, uploadProgress, uploadError, uploadSuccess, uploadFile } useFileUpload() const selectedFile refFile | null(null) const uploadSuccessMessage refstring() const handleFileSelect (e: Event) { const input e.target as HTMLInputElement if (input.files input.files[0]) { selectedFile.value input.files[0] } } const startUpload async () { if (!selectedFile.value) return try { const res await uploadFile( selectedFile.value, /api/upload, // 替换为你的后端地址 { timeout: 300000, // 5分钟超时大文件必须设 headers: { // 如需 token放这里 // Authorization: Bearer ${token.value} } } ) uploadSuccessMessage.value JSON.stringify(res.data, null, 2).substring(0, 100) ... } catch (err) { // 已在 Hook 内处理此处可加埋点 } } // 格式化文件大小1024 进制保留 1 位小数 const formatFileSize (bytes: number): string { if (bytes 0) return 0 B const k 1024 const sizes [B, KB, MB, GB, TB] const i Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) sizes[i] } /script style scoped .upload-container { max-width: 600px; margin: 2rem auto; padding: 1.5rem; border: 1px solid #e0e0e0; border-radius: 8px; } .file-input { margin-bottom: 1rem; } .file-info p { margin: 0.25rem 0; font-size: 0.95rem; color: #555; } .upload-btn { background: #409eff; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: 1rem; } .upload-btn:disabled { background: #c0c4cc; cursor: not-allowed; } .progress-wrapper { margin-top: 1.5rem; } .progress-bar { width: 100%; height: 8px; border-radius: 4px; background-color: #f0f0f0; -webkit-appearance: none; } .progress-bar::-webkit-progress-bar { background-color: #f0f0f0; } .progress-bar::-webkit-progress-value { background-color: #409eff; border-radius: 4px; } .progress-text { display: block; text-align: right; margin-top: 0.25rem; font-weight: bold; color: #409eff; } .progress-detail { font-size: 0.85rem; color: #666; margin-top: 0.25rem; } .error-message, .success-message { margin-top: 1rem; padding: 0.5rem; border-radius: 4px; } .error-message { background-color: #ffebee; color: #c62828; border-left: 4px solid #c62828; } .success-message { background-color: #e8f5e9; color: #2e7d32; border-left: 4px solid #2e7d32; } /style参数说明timeout: 300000是硬性要求默认 axios 超时是0永不超时但大文件上传卡住时用户需要明确反馈5 分钟是经验阈值formatFileSize使用Math.log(bytes) / Math.log(1024)计算层级比循环除法更稳定避免浮点误差progress的value和max属性原生支持语义化屏幕阅读器可识别比divwidth更合规uploadSuccessMessage截断显示前 100 字符防止 JSON 返回体过大撑爆 UI。3. 后端必须做的三件事让 axios 进度条真正“动起来”前端再完美后端不配合progressEvent.total永远是0进度条永远卡在 0%。这不是 axios 的 bug是 HTTP 协议层限制XMLHttpRequest的progress事件依赖服务端响应头中的Content-Length而文件上传是请求体request body不是响应体response body。所以进度监听的是“请求发送进度”而非“响应接收进度”—— 这一点必须刻进 DNA。3.1 确保服务端正确解析 multipart/form-data 并透传原始字节流以 Node.js Express 为例必须用busboy或formidable这类能流式解析的库禁用body-parser它会把整个请求体读入内存破坏流式上传// server.js (Express) const express require(express) const busboy require(busboy) const path require(path) const fs require(fs).promises const app express() app.use(express.static(public)) // ✅ 正确用 busboy 流式处理不缓存整个文件 app.post(/api/upload, (req, res) { const bb busboy({ headers: req.headers }) // 监听文件字段 bb.on(file, async (fieldname, file, info) { const { filename, encoding, mimeType } info // 生成唯一文件名防止覆盖 const ext path.extname(filename) const uniqueName ${Date.now()}-${Math.random().toString(36).substr(2, 9)}${ext} const savePath path.join(__dirname, uploads, uniqueName) // ✅ 关键file 是 ReadableStream直接 pipe 到文件写入流 // 这样 axios 才能持续收到上传进度 const writeStream fs.createWriteStream(savePath) file.pipe(writeStream) // 监听写入完成 writeStream.on(finish, () { res.json({ success: true, filename: uniqueName, originalName: filename, size: writeStream.bytesWritten }) }) writeStream.on(error, (err) { console.error(写入文件失败:, err) res.status(500).json({ error: 文件保存失败 }) }) }) bb.on(error, (err) { console.error(busboy 解析错误:, err) res.status(400).json({ error: 请求格式错误 }) }) bb.on(close, () { // busboy 解析结束 }) req.pipe(bb) }) app.listen(3000, () console.log(Server running on http://localhost:3000))为什么body-parser不行body-parser会等待整个请求体接收完毕才触发req.body此时上传早已完成onUploadProgress收不到中间事件。busboy则边收边解析file流与网络传输实时同步axios 才能拿到loaded的递增值。3.2 Nginx 反向代理必须透传Content-Length和禁用缓冲如果你的前端请求经过 Nginx以下配置是刚需否则进度条永远 0%# nginx.conf location /api/upload { proxy_pass http://backend:3000; # ✅ 必须透传原始请求头尤其是 Content-Length proxy_pass_request_headers on; # ✅ 禁用代理缓冲否则 Nginx 会等整个请求体收完再转发 proxy_buffering off; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; # ✅ 设置超时匹配前端 timeout proxy_connect_timeout 300; proxy_send_timeout 300; proxy_read_timeout 300; # ✅ 允许大文件根据业务调整 client_max_body_size 2g; }关键点解释proxy_buffering off是灵魂开启时 Nginx 会缓存整个请求体导致 axios 只在最后收到loaded total关闭后Nginx 边收边转进度事件实时触发client_max_body_size 2g防止 Nginx 直接拒绝大文件默认 1Mproxy_send_timeout 300必须 ≥ 前端timeout否则 Nginx 先断连。3.3 Spring Boot 后端的等效配置供 Java 同学参考若后端是 Spring Boot需在application.yml中显式配置# application.yml spring: servlet: context-path: /api web: resources: static-locations: classpath:/static/ # ✅ 关键禁用 Spring 默认的 multipart 缓存 servlet: multipart: enabled: true max-file-size: 2GB max-request-size: 2GB # ⚠️ 必须设为 0禁用内存缓冲启用流式处理 file-size-threshold: 0 # ✅ Tomcat 配置若用内嵌 Tomcat server: tomcat: connection-timeout: 300000 max-swallow-size: -1 # 不限制吞咽大小并在 Controller 中使用RequestPart接收流PostMapping(/upload) public ResponseEntityMapString, Object upload( RequestPart(file) MultipartFile file) { try { // ✅ 使用 InputStream 流式处理不调用 file.getBytes() InputStream inputStream file.getInputStream(); String fileName System.currentTimeMillis() - file.getOriginalFilename(); Path targetPath Paths.get(uploads, fileName); Files.createDirectories(targetPath.getParent()); Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING); MapString, Object result new HashMap(); result.put(success, true); result.put(filename, fileName); result.put(size, file.getSize()); return ResponseEntity.ok(result); } catch (Exception e) { return ResponseEntity.status(500).body(Map.of(error, e.getMessage())); } }血泪经验Spring Boot 的MultipartFile默认会把小文件file-size-threshold加载进内存file.getSize()返回的是内存中字节数但inputStream仍是流式。设file-size-threshold: 0强制所有文件走流式才能保证 axios 进度事件连续触发。4. 避坑axios Vue 文件上传进度功能的 4 个高频翻车点4.1 现象进度条始终卡在 0%progressEvent.total恒为 0原因后端未正确设置Content-Length响应头或 Nginx 启用了proxy_buffering on导致 axios 无法获取上传总量。progressEvent.lengthComputable为falsepercent无法计算。解决前端加兜底判断onUploadProgress: (e) { if (e.lengthComputable) { // 正常计算 uploadProgress.value { /* ... */ } } else { // 降级只显示“上传中”不显示百分比 uploadProgress.value { loaded: e.loaded, total: 0, percent: 0 } // 可在此处启动一个定时器模拟“上传中...” } }后端检查curl -v -F filetest.zip http://localhost:3000/api/upload的请求头确认Content-Length存在且数值合理Nginx 配置proxy_buffering off并重载。4.2 现象上传大文件时页面卡死、内存暴涨Chrome 报RangeError: Maximum call stack size exceeded原因onUploadProgress回调中频繁触发 Vue 响应式更新如uploadProgress.value {...}而大文件每毫秒触发多次回调导致响应式依赖收集爆炸。解决节流用lodash.throttle或手写节流限制每 200ms 最多更新一次import { throttle } from lodash const throttledUpdate throttle((p: UploadProgress) { uploadProgress.value p }, 200) onUploadProgress: (e) { if (e.lengthComputable) { throttledUpdate({ loaded: e.loaded, total: e.total, percent: Math.round((e.loaded / e.total) * 100) }) } }避免响应式对象嵌套过深uploadProgress用ref{loaded:number, total:number, percent:number}不要用reactive({})减少依赖追踪开销。4.3 现象上传成功后uploadSuccess.value为true但uploadProgress.value仍显示 99%原因onUploadProgress的最后一次回调可能在axios.then()之前未执行完或网络抖动导致loaded total时请求已结束。解决在try块末尾强制补全try { const response await axios.post(/* ... */) // ✅ 强制设为 100% if (uploadProgress.value) { uploadProgress.value.percent 100 uploadProgress.value.loaded uploadProgress.value.total } uploadSuccess.value true return response }或更优雅监听axios的onDownloadProgress虽然这是响应进度但可作为上传完成的辅助信号onDownloadProgress: () { // 响应开始接收大概率上传已完成 if (uploadProgress.value uploadProgress.value.percent 100) { uploadProgress.value.percent 100 uploadProgress.value.loaded uploadProgress.value.total } }4.4 现象同一页面多次上传进度条错乱显示上一次的percent原因uploadProgress.value是全局 ref未在每次上传前重置且onUploadProgress回调可能因闭包持有旧引用。解决严格生命周期管理在uploadFile函数开头重置uploadProgress.value null避免闭包陷阱onUploadProgress内部不要直接修改外部ref而是通过函数参数传递最新值添加防重复提交锁let isProcessing false const uploadFile async (file, url, config) { if (isProcessing) return isProcessing true try { // ... 上传逻辑 } finally { isProcessing false } }否则用户狂点按钮多个onUploadProgress回调并发修改同一个ref必然错乱。5. 进阶技巧实现暂停/续传 断点续传的轻量方案真正的断点续传如 TUS 协议需要服务端深度配合但日常业务中90% 的“续传”需求其实是用户点了暂停网络恢复后点继续而不是从头再来。我们可以利用axios.CancelToken实现暂停再用Blob.slice()实现分片续传。5.1 用CancelToken实现“暂停”功能axios的CancelToken可中断请求但注意中断后无法 resume只能重新发请求。所以“暂停”本质是“取消当前请求保存已上传字节数下次从该位置开始”。// composables/useFileUpload.ts 新增 import axios, { CancelTokenSource } from axios interface UploadState { file: File uploadedBytes: number cancelToken: CancelTokenSource | null } let currentUpload: UploadState | null null export function useFileUpload() { // ... 原有代码 const pauseUpload () { if (currentUpload?.cancelToken) { currentUpload.cancelToken.cancel(Upload paused) currentUpload.cancelToken null } } const resumeUpload async (url: string, config: any {}) { if (!currentUpload) return const { file, uploadedBytes } currentUpload const blobSlice file.slice.bind(file) // 保存 slice 方法 // ✅ 关键构造新 FormData只传剩余部分 const remainingBlob blobSlice(uploadedBytes) const formData new FormData() formData.append(file, remainingBlob, file.name) // 重命名避免后端混淆 // 传已上传字节数给后端让它追加写入 formData.append(uploadedBytes, uploadedBytes.toString()) try { const response await axios.post( url, formData, { ...config, onUploadProgress: (e) { if (e.lengthComputable) { // 进度 已传 当前片进度 const totalPercent Math.round( ((uploadedBytes e.loaded) / file.size) * 100 ) uploadProgress.value { loaded: uploadedBytes e.loaded, total: file.size, percent: totalPercent } } } } ) uploadSuccess.value true return response } catch (err) { throw err } } const uploadFile async (file: File, url: string, config: any {}) { // ... 原有重置逻辑 // ✅ 创建 cancel token const cancelToken axios.CancelToken.source() currentUpload { file, uploadedBytes: 0, cancelToken } try { const response await axios.post( url, (function buildFormData() { const formData new FormData() formData.append(file, file) return formData })(), { ...config, cancelToken: cancelToken.token, // 注入 token onUploadProgress: (e) { if (e.lengthComputable) { currentUpload!.uploadedBytes e.loaded // 实时更新已传字节数 // ... 更新进度 } } } ) // 上传完成清空状态 currentUpload null return response } catch (err: any) { if (axios.isCancel(err)) { console.log(Upload paused:, err.message) // 暂停时不清空 currentUpload留待 resume } else { currentUpload null throw err } } } return { // ... 原有返回 pauseUpload, resumeUpload } }后端需配合接收uploadedBytes字段打开文件以append模式写入Node.js 用fs.createWriteStream(path, { flags: a })Spring Boot 用Files.write(path, bytes, StandardOpenOption.APPEND)。5.2 为什么不用XMLHttpRequest.upload原生 API有人问既然 axios 底层是XMLHttpRequest为何不直接用原生xhr.upload.onprogress答案是Vue 3 的响应式系统与原生事件监听耦合困难。xhr.upload.onprogress function(){...}中的this是xhr.upload无法直接访问ref需手动bind或闭包代码臃肿且易出作用域 bug。而 axios 的onUploadProgress是配置项天然支持闭包访问 Composition API 的ref开发体验和可维护性碾压原生。5.3 生产环境必须加的 3 个验证步骤上线前请务必执行验证项操作预期结果小文件1MB上传 100KB txt进度条平滑从 0%→100%无卡顿大文件100MB上传 200MB 视频进度条持续更新内存占用稳定Chrome 任务管理器看 JS 堆内存 ≤ 100MB弱网模拟Chrome DevTools → Network → Slow 3G进度条缓慢但持续更新不报错超时后正确提示我在线上项目踩过最深的坑是本地测 100% 通过一上生产 Nginx 就卡 0%。后来发现运维同学悄悄把proxy_buffering改回了on还说“为了性能”。从此我养成了习惯每次部署后用curl -v抓包看Content-Length再用nginx -t nginx -s reload确认配置生效。技术落地一半靠代码一半靠对生产环境的敬畏。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑