本篇文章基于Vue3前端项目,详细讲解如何搞定文件上传,下载等功能。
一、文件上传
- 用户选择文件
- 前端拿到File对象
- 使用FromData封装文件和额外参数
- 通过axios/fetch发送multipart/from-data请求
- 监听上传进度
- 处理上传成功、失败、取消、大小限制、格式校验等逻辑
二、上传代码示例
1.原生input
<template> <div> <input type="file" @change="selectFile" /> <button @click="uploadFile()">上传</button> </div> </template> <script setup> import { ref } from 'vue' import axios from 'axios' const file = ref(null) const selectFile(event) { file.value = event.target.files[0] } const uploadFile = async() => { if(!file.value) { alert('请先选择文件') return } const formData = new FormData() formData.append('file',file.value) try { const res = await axios.post('/api/upload',formData, { headers: { 'Content-Type': 'multipart/form-data' } }) conosle.log('上传成功',res.data) }catch(error) { console.log('上传失败',error) } </script>一般来说,使用FormData时可以不手动设置Content-Type,浏览器会自动带上boundary。
更推荐:
await axios.post('/api/upload',formData)三、封装上传API
1.vue中axios封装
// src/utils/request.js import axios from 'axios' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 30000 }) service.interceptors.request.use( config => { const token = localStorage.getItem('token') if(token){ config.headers.Authorization = `Bearer${token}` } return config }, error => Promise.reject(error) ) service.interceptors.response.use( response => response.data, error => { return Promise.reject(error) } ) export default service2.上传接口
// src/api/file.js import request from '@/utils/request' export function uploadFileApi(data,onUploadProgress) { return request({ url: 'file/upload', method: 'post', data, onUploadPorgress }) }3.页面中使用
<template> <input type="file" @change="handleChange" /> <button @click="submit">上传</button> <div v-if="progress > 0"> 上传进度:{{ progress }}% </div> </template> <script setup> import { ref } from 'vue' impirt { uploadFile } from '@/api/file' const selectedFile = ref(null) const progress = ref(0) const handleChange = e => { selectedFile.value = e.target.files[0] } const submit = async () => { if(!selectFile.value) return const formData = new FormData() formData.append('file',selectedFile.value) formData.append('type','avatar') const res = await uploadFileApi(formData, event => { if(event.total) { progress.value = Math.round((event.loaded*100)/event.total) } }) console.log(res) } </script>四、使用Element Plus上传组件
1.自动上传
<template> <el-upload action="/api/file/upload" name="file" :headers="headers" :on-sucess="handleSuccess" :on-error="handleError" :befor-upload="beforeUpload" > <el-button type="primary">点击上传</el-button> </el-upload> </template> <script setup> const headers = { Authorization: `Bearer ${localStorage.getItem('token')}` } const beforeUpload = file => { const isLt10M = file.size /1024/1024 <10 if(isLt10M) { Elmessage.error('文件大小不能超过10MB') return false } return true } const handleSuccess = response => { console.log("上传成功",response) } const handlerError = error => { conosle.log("上传失败",error) } </script>2.手动上传
<template> <el-upload ref="uploadRef" :auto-upload="false" :limit="1" :on-change="handleChange" :before-upload="beforeUpload" > <template #trigger> <el-button type="primary">选择文件<el-button> </template> <el-button type="success" @click="submitUpload"> 上传服务器 </el-button> </el-upload> </template> <script setup> import { ref } from 'vue' import { Elmessage } from 'element-plus' import { uploadFileApi } from '@/api/file' const uploadRef = ref() const fileObj = ref(null) const handleChange = uploadFile => { fileObj.value = uploadFile.raw } const beforeUpload = file => { const allowTypes = ['image/png','image/jpeg','application/pdf'] const isAllowed = allowedTypes.includes(file.type) const isLt20M = file.size /1024/1024 < 20 if(!isAllowed) { ElMessage.error('只支持png、jpg、pdf文件') return false } if(!isLt20M){ ElMessage.error("文件不能超过20MB") return false } return true } const submitUpload = async () => { if(!fileObj.value) { ElMessage.warning("请选择文件") return } const formData = new FormData() formData.append('file',fileObj.value) try { const res = await uploadFileApi(formData) ElMessage.success("上传成功") console.log(res) }catch{ ElMessage.error("上传失败") } } </script>五、多文件上传
<template> <input type="file" multiple @change="hangdleFiles" /> <button @click="uploadFiles">批量上传</button> </template> <script setup> import { ref } from 'vue' import { uploadFileApi } from '@/api/file' const file = ref([]) const handleFiles = e => { files.value = Array.from(e.target.files) } const uploadFiles = async() => { const formData = new FormData() files.value.forEach(file = { formData.append('files',file) }) const res = await uploadFileApi(formData) console.log(res) } </script>后端常见接收方式:
files: MultipartFile[]有些后端这样要求:
formData.append('fileList',file)字段名前后端要保持一致。
六、上传时携带额外参数
const formData = new FormData() formData.append('file',file) formData.append('userId',userId) formData.append('bizType','constract') formData.append('remark','合同附件') await uploadFileApi(formData)后端可以同时接手文件和普通字段
七、上传前校验
常见校验包括:
- 文件大小
- 文件类型
- 文件数量
- 文件名长度
- 是否为空文件
- 图片宽高
- 视频时长
- Excel表头格式等
1.校验文件大小和类型
const validateFile = file => { const maxSize = 10*1024*1024 const allowedTypes = [ 'image/jpeg', 'image/png', 'application/pdf' ] if(!allowedTypes.includes(file.type)) { throw new Error('文件格式不正确') } if(file.size > maxSize) { throw new Error('文件大小不能超过10MB') } return true }2.检验图片宽高
const checkImageSize = file => { return new Promise((resolve,reject) => { const img = new Image() const url = URL.createObjectURL(file) img.onload = () => { URL.revokeObjectURL(url) if(img.width === 300 && img.height === 300) { resolve(true) } else { reject(new Error("图片尺寸必须是300X300")) } } img.onerror = () => { URL.revokeObjectURL(url) reject(new Error("图片加载失败")) } img.src = url }) }八、上传进度条
Axios提供onUploadProgress
await axios.post('/api/upload',formData,{ onUploadProgress: progressEvent => { const percent = Math.round( progressEvent.loaded * 100 / progressEvent.total ) console.log(percent) } })页面展示:
<el-progress :percentage="progress" />完整示例:
<template> <input type="file" @change="handleChange" /> <el-button @click="upload">上传</el-button> <el-progress :percentage="progress" /> </template> <script setup> import { ref } from 'vue' import axios from 'axios' const file = ref(null) const progress = ref(0) const handleChange = e => { file.value = e.target.files[0] } const upload = async () => { const formData = new FormData() formData.append('file',file.value) await axios.post('/api/upload',formData, { onUploadProgress: e=> { progress.value = Math.round((e.loaded*100)/e.total) } }) } </script>九、取消上传
Axios新版本可以使用AbortController。
<template> <input type="file" @change="handleChange" /> <button @click="upload">上传</button> <button @click="cancelUpload">取消上传</button> </template> <script setup> import { ref } from 'vue' import axios from 'axios' const file = ref(null) const controller = ref(null) const handleChange = e => { file.value = e.target.files[0] } const upload = async() => { controller.value = new AbortController() const formData = new FormData() formData.append('file',file.value) try { await axios.post('/api/upload', formData, { signal: controller.value.signal }) }catch(error) { if(axios.isCancel?.(error) || error.name === 'CanceledError') { console.log('上传已取消') }else{ console.log(error) } } } const cancelUpload = () => { controller.value?.abort() } </script>十、大文件分片上传
当文件较大,比如视频、压缩包、安装包、直接上传容易失败,需要分片上传
1.分片上传流程
- 前端选择大文件
- 按固定大小切片,例如每片5MB
- 计算文件唯一标识,例如hash
- 调接口查询已上传分片,实现妙传、断点续传
- 上传未完成的分片
- 所有分片上传完成后,通知后端合并文件
2.文件切片
const createChunks = (file,chunkSize=5*1024*1024) => { const chunks = [] let cur = 0 while (cur < file.size) { chunks.push(file.slice(cur,cur + chunkSize)) cur += chunkSize } return chunks }3.上传分片
const uploadChunks = async file => { const chunks = createChunks(file) const fileName = file.name const requests = chunks.map((chunk,index) => { const formData = new FormData() formData.append('file',chunk) formData.append('fileName',fileName) formData.append('chunkIndex',index) formData.append('totalChunks',chunks.length) return axios.post('/api/file/upload-chunk',formData) }) await Promise.all(requests) await axios.post('/api/file/merge', { fileName, totalChunks: chunks.length }) }4.控制并发上传
大文件分片不能一次性全部并发,否则浏览器和服务器压力很大
async function uploadWithConcurrency(task,limit = 3) { const results = [] const executing = [] for(const task of tasks) { const p = Promise.resolve().then(() => task()) result.push(p) if(limit <= task.length) { const e = p.then(() => executing.splice(executing.indexOf(e),1)) executing.push(e) if(executing.length >= limit) { await Promise.race(executing) } } } return Promise.all(results) }使用:
const tasks = chunks.map((chunk,index) => { return () => { const formData = new FormData() formData.append('file',chunk) formData.append('chunkIndex',index) return axios.post('/api/file/upload-chunk',formData) } }) await iploadWithConcurrency(tasks,3)十一、断点续传和秒传
1.前端一般需要计算文件hash
npm install spark-md5import SparkMD5 from 'spark-md5' const calculateHash = file => { return new Promise((resolve,reject) => { const chunkSize = 2*1024*1024 const chunks = Math.ceil(file.size / chunkSize) let currentChunk = 0 const spark = new SparkMD5.ArrayBuffer() const fileReader = new FileReader() fileReader.onload = e => { spark.append(e.target.result) currentChunk++ if(currentChunk < chunks) { loadNext() }else{ resolve(spark.end()) } } fileReader.onerror = reject function loadNext() { const start = currentChunk * chunkSize const end = Math.min(start + chunkSize, file.size) fileReader.readAsArrayBuffer(file.slice(start,end)) } loadNext() }) }2.秒传逻辑
const hash = await calculateHash(file) const checkRes = await axios.get('/api/file/check',{ params: { hash, fileName: file.name } }) if(checkRes.data.upload) { console.log('文件已存在,秒传成功') return }3.跳过已上传分片
const uploadedChunks = checkRes.data.uploadedChunks || [] const tasks = chunks.map((chunk,index) => ({chunk,index})) .filter(item => !uploadedChunks.includes(item.index)) .map(item =>(){ const formData = new FormData() formData.append('file',item.chunk) formData.append('hash',hash) formData.append('chunkIndex',item.index) formData.append('fileName',file.name) return axios.post('/api/file/upload-chunk',formData) })十二、文件下载怎么做
文件下载主要分以下几种情况
- 后端返回文件流
- 后端返回文件URL
- 前端生成文件并下载
- 下载需要鉴权
- 下载Excel、PDF、图片、压缩包等
十三、通过URL直接下载
如果后端直接返回一个文件地址:
window.location.href = fileUrl或者:
const a = document.createElement('a') a.href = fileUrl a.download = '文件名.pdf' a.click()封装:
export function downloadByUrl(url,filenme) { const a = document.createElement('a') a.href = url a.download = filename || '' a.target = '_blank' document.body.appendChild(a) a.click() document.body.removeChild(a) }注意:如果是跨域URL,download属性可能不生效,浏览器可能直接打开文件
十四、下载后返回的文件流
1.API封装
// src/api/file.js import request from '@/utils/request' export function downloadFileApi(params) { return request({ url: 'file/download', method: 'get', params, responseType: 'blob' // 关键点 }) }2.下载工具函数
export function downloadBlob(blob,filename) { const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.dowmload = filename document.body.appendChild(a) a.click() document.body.removeChild(a) window.URL.revokeObjectURL(url) }3.页面中下载
import { downloadFileApi } from '@/api/file' import { downloadBlob } from '@/utils/download' const handleDownload = async row => { const blob = await downloadFileApi({id: row.id }) downloadBlob(blob,row.fileName) }十五、Axios响应拦截器对blob的处理
如果你封装了axios,下载文件时可能出问题
比如普通接口返回:
response => response.data下载接口返回的是Blob,可以正常返回:
service.interceptors.response.use( response => { if(response.config.responseType === 'blob'){ return response.data } return response.data }, error => Promise.reject(error) )更完整一点:
service.interceptors.response.use( async response => { const { data, config } = response if(config.responseType === 'blob') { return data } return data }, error => Promise.reject(error) )十六、从响应头获取文件名
后端通常会在响应头中返回:
Content-Disposition: attachment;filename*=UTF-8''xxx.xlsx前端获取:
export function getFilenameFromDisposition(disposition) { if(!dispositon) return '' const utf8FilenameRegex = filename\*=UTF-8''([^;]+)/i const asciiFilenameRegex = /filename="?([^";])"?/i const utf8Matches = disposition.match(utf8FilenameRegex) if(utf8Matches && utf8Matches[1]) { return decodeURIComponent(utf8Matches[1]) } const asciiMatches = disposition.match(asciiFilenameRegex) if(asciiMatches && asciiMatches[1]) { return decodeURIComponent(asciiMatches[1]) } return '' }但是如果axios拦截器只返回response.data,就拿不到headers。
可以单独封装下载请求:
export function downloadFileApi(params) { return request({ url: '/file/download', method: 'get', params, responseType: 'blob', returnFullResponse: true }) }拦截器:
service.interceptors.response.use( response => { if(response.config.returnFullResponse) { return response } return response.data } error =>Promise.reject(error) )页面:
const res = await downloadFileApi({id: row.id}) const disposition = res.headers['content-disposition'] const filename = getFilenameFormDisposition(disposition) || row.fileName downloadBlob(res.data,filename)后端还要暴露响应头:
Access-Control-Expose-Headers: Content-Disposition否则跨域时前端读不到Content-Disposition
十七、下载Excel文件
export function exportExcel(params) { return request({ url: '/user/export', method: 'get', params, responseType: 'blob' }) }const handleExport = async () => { const blob = await exportExcel(queryParams.value) downloadBlob(blob,`用户列表_${Date.now()}.xlsx` ) }如果需要指定MIME:
const excelBlob = new Blob([blob],{ type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) downloadBlob(excelBlob, '用户列表.xlsx')十八、下载时后端返回错误JSON怎么办?
下载接口设置了responseType:'blob',即使后端报错,前端拿到的也是Blob。需要判断Blob类型
async function handleBlobResponse(blob) { if(blob.type === 'application/json') { const text = await bolb.text() const json = JSON.parse(text) throw new Error(json.message || '下载失败') } return blob }使用:
try { const blob = await downloadFileApi(params) await handleBlobRespones(blob) downloadBlob(blob,'文件.xlsx') } catch(error) { ElMessage.error(error.message) }更严谨:
const blob = await downloadFileApi(params) if(blob.type.includes('application/json')) { const text = await blob.text() const data = JSON.parse(text) ElMessage.error(data.message || '下载失败') return } downloadBlob(blob,'导出.xlsx')十九、预览文件
下载不一定总是保存,是有些文件需要预览
1.预览PDF
const previewFdf = blob => { const url = URL.createObjectURL(blob) window.open(url) }2.预览图片
<template> <img :src="imageUrl" /> </template> <script setup> import { ref } from 'vue' const imageUrl = ref('') const previewImage = blob => { imageUrl.value = URL.createObjectURL(blob) } </script>组件卸载时释放:
import { onBeforeUnmount } from 'vue' onBeforeUnmount(() => { if(imageUrl.value){ URL.revokeObjectURL(imageUrl.value) } )二十、完整项目目录建议
src ├─ api │ └─ file.js ├─ utils │ ├─ request.js │ └─ download.js ├─ views │ └─ file │ └─ FileManage.vue二十一、文件接口封装示例
// src/api/file.js import request from '@/utils/request' export function uploadFile(data,onUploadProgress) { return request({ url: '/file/upload', method: 'post', data, onUploadProgress }) } export function uploadChunk(data) { return ({ url: '/file/upload-chunk', methods: 'post', data }) } export function mergeChunks(data) { return request({ url: '/file/merge', method: 'post', data }) } export function checkFile(params) { return request({ url: '/file/check', method: 'get', params }) } export function downloadFile(params) { return request({ url: '/file/download', method: 'get', params, responseType: 'blob' }) } export function deleteFile(id) { return request({ url: `/file/${id}`, method: 'delete' }) }二十二、下载工具完整封装
// src/utils/download.js export function downloadBlob(blob, filename = 'download') { const objectUrl = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = objectUrl link.download = filename link.style.display = 'none' document.body.appendChild(link) link.click() document.body.removeChild(link) window.URL.revokeObjectURL(objectUrl) } export function downloadByUrl(url, filename) { const link = document.createElement('a') link.href = url link.download = filename || '' link.target = '_blank' document.body.appendChild(link) link.click() document.body.removeChild(link) } export function getFilenameFromDisposition(disposition) { if (!disposition) return '' const filenameStar = disposition.match(/filename\*=UTF-8''([^;]+)/i) if (filenameStar) { return decodeURIComponent(filenameStar[1]) } const filename = disposition.match(/filename="?([^";]+)"?/i) if (filename) { return decodeURIComponent(filename[1]) } return '' } export async function parseBlobError(blob) { if (blob.type && blob.type.includes('application/json')) { const text = await blob.text() return JSON.parse(text) } return null }二十三、完整上传下载页面示例
<template> <div class="file-page"> <el-card> <template #header>文件上传</template> <el-upload :auto-upload="false" :limit="1" :on-change="handleFileChange" > <el-button type="primary">选择文件</el-button> </el-upload> <el-progress v-if="progress > 0" :percentage="progress" style="margin-top: 16px" /> <el-button type="success" style="margin-top: 16px" @click="handleUpload" > 上传 </el-button> </el-card> <el-card style="margin-top: 20px"> <template #header>文件列表</template> <el-table :data="fileList"> <el-table-column prop="fileName" label="文件名" /> <el-table-column prop="size" label="大小" /> <el-table-column label="操作"> <template #default="{ row }"> <el-button type="primary" link @click="handleDownload(row)"> 下载 </el-button> </template> </el-table-column> </el-table> </el-card> </div> </template> <script setup> import { ref } from 'vue' import { ElMessage } from 'element-plus' import { uploadFile, downloadFile } from '@/api/file' import { downloadBlob, parseBlobError } from '@/utils/download' const currentFile = ref(null) const progress = ref(0) const fileList = ref([ { id: 1, fileName: '测试文件.xlsx', size: '20KB' } ]) const handleFileChange = uploadFileItem => { currentFile.value = uploadFileItem.raw } const handleUpload = async () => { if (!currentFile.value) { ElMessage.warning('请选择文件') return } const formData = new FormData() formData.append('file', currentFile.value) try { await uploadFile(formData, e => { if (e.total) { progress.value = Math.round((e.loaded * 100) / e.total) } }) ElMessage.success('上传成功') } catch (error) { ElMessage.error('上传失败') } } const handleDownload = async row => { try { const blob = await downloadFile({ id: row.id }) const errorData = await parseBlobError(blob) if (errorData) { ElMessage.error(errorData.message || '下载失败') return } downloadBlob(blob, row.fileName) } catch (error) { ElMessage.error('下载失败') } } </script>二十四、常见问题总结
1. 上传接口 415 Unsupported Media Type
通常是请求头设置不对。
建议使用:
const formData = new FormData() formData.append('file', file) axios.post('/api/upload', formData)不要强行写错Content-Type。
2. 后端拿不到文件
检查字段名是否一致:
formData.append('file', file)后端也要用file接收。
3. 下载文件乱码
常见原因:
- 没有设置
responseType: 'blob'; - 后端文件名编码不正确;
- 前端没有正确解析
Content-Disposition; - 跨域没有暴露
Content-Disposition。
4. 下载接口返回了 JSON,但前端当文件下载了
需要判断 Blob 类型:
if (blob.type.includes('application/json')) { const text = await blob.text() const data = JSON.parse(text) ElMessage.error(data.message) return }5. 跨域下载读不到文件名
后端需要加:
Access-Control-Expose-Headers: Content-Disposition二十五、实际开发建议
普通小文件上传:FormData + axios 即可;
大文件上传:使用分片上传、断点续传、秒传;
下载文件流:一定设置 responseType: 'blob';
下载文件名:优先从 Content-Disposition 获取;
跨域下载:后端暴露 Content-Disposition;
上传前要做大小、类型、数量校验;
文件上传接口建议统一封装;
下载工具函数建议统一封装;
上传时不要随便手动设置错误的 Content-Type;
后端返回错误时,前端要能识别 Blob 中的 JSON。
简单结论
Vue 项目中文件上传的核心是:
const formData = new FormData() formData.append('file', file) axios.post('/api/upload', formData)文件下载的核心是:
axios.get('/api/download', { responseType: 'blob' })然后通过:
URL.createObjectURL(blob)配合a标签触发浏览器下载。
本文章参考借鉴转载自CSDN博主「zqOrange」的原创文章。
原文链接:https://blog.csdn.net/2302_81102841/article/details/162604591