简介:本资源是一份面向前端开发者与Vue.js初学者的实用技术实践指南,聚焦解决大文件上传场景下的用户体验痛点——实时进度反馈。通过axios的onUploadProgress事件结合Vue响应式数据绑定,完整实现文件选择、进度条动态渲染、百分比显示及上传完成状态校验等核心功能,避免常见误区(如仅凭进度事件误判100%完成)。资源为单文件PDF文档(49KB),内容涵盖HTML结构、CSS样式、Vue实例配置与axios请求封装的完整代码示例,并附关键注释与注意事项说明,便于快速理解原理并直接复用。目前已有2125人学习下载,适合希望夯实Vue+axios工程化能力、提升文件上传交互质量的中初级前端工程师参考落地。
1. 为什么用 axios + Vue 做文件上传进度显示,比直接用<input type="file">+fetch更稳?
你有没有遇到过这样的现场:用户拖进一个 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 创建可复用的上传 Hook:useFileUpload.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 = ref<UploadProgress | null>(null) const uploadError = ref<string | null>(null) const uploadSuccess = ref<boolean>(false) const uploadFile = async ( file: File, url: string, config: Omit<axios.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 type="file">的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 class="upload-container"> <input type="file" @change="handleFileSelect" :disabled="isUploading" class="file-input" /> <div v-if="selectedFile" class="file-info"> <p>📄 选择文件:{{ selectedFile.name }}</p> <p>📏 大小:{{ formatFileSize(selectedFile.size) }}</p> </div> <button @click="startUpload" :disabled="!selectedFile || isUploading" class="upload-btn" > {{ isUploading ? '🚀 上传中...' : '📤 开始上传' }} </button> <!-- 进度条:原生 <progress>,语义化且无需额外 CSS --> <div v-if="uploadProgress" class="progress-wrapper"> <progress :value="uploadProgress.percent" :max="100" class="progress-bar" ></progress> <span class="progress-text">{{ uploadProgress.percent }}%</span> <div class="progress-detail"> {{ formatFileSize(uploadProgress.loaded) }} / {{ uploadProgress.total ? formatFileSize(uploadProgress.total) : '未知大小' }} </div> </div> <div v-if="uploadError" class="error-message"> ❌ {{ uploadError }} </div> <div v-if="uploadSuccess" class="success-message"> ✅ 上传成功!服务器返回:{{ uploadSuccessMessage }} </div> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue' import { useFileUpload } from '@/composables/useFileUpload' const { isUploading, uploadProgress, uploadError, uploadSuccess, uploadFile } = useFileUpload() const selectedFile = ref<File | null>(null) const uploadSuccessMessage = ref<string>('') 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属性原生支持语义化,屏幕阅读器可识别,比div+width更合规;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 直接拒绝大文件(默认 1M);proxy_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 ResponseEntity<Map<String, 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); Map<String, 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为false,percent无法计算。
解决:
- 前端加兜底判断:
onUploadProgress: (e) => { if (e.lengthComputable) { // 正常计算 uploadProgress.value = { /* ... */ } } else { // 降级:只显示“上传中”,不显示百分比 uploadProgress.value = { loaded: e.loaded, total: 0, percent: 0 } // 可在此处启动一个定时器,模拟“上传中...” } } - 后端检查
curl -v -F "file=@test.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确认配置生效。技术落地,一半靠代码,一半靠对生产环境的敬畏。
希望帮到你。
本文还有配套的精品资源,点击获取