文件上传与下载
1. 后端视角看前端文件处理
Java 写文件接口时你已经熟悉:
@PostMapping("/upload")
public String upload(@RequestParam MultipartFile file) { ... }
前端这边做三件事:
- 把用户选的文件打包成 FormData(
multipart/form-data的前端表示) - 用 XHR / fetch 发出去,带上进度(axios 支持
onUploadProgress) - 用 Element Plus 的
<el-upload>包装体验(拖拽、预览、文件列表)
下载更简单:让浏览器 GET 一个 URL 就行——但如果要带 token 或自定义文件名,就得用 blob 流 + URL.createObjectURL。
Java 对照:
| 前端 | 后端 |
|---|---|
FormData |
MultipartFile |
onUploadProgress |
(后端看不到,浏览器内自己统计) |
response.blob() |
响应体 |
URL.createObjectURL |
临时文件 URL |
2. 原生 input[type=file]:底层原理
所有花哨的上传组件背后都是一个 <input type="file">:
<script setup lang="ts">
import { ref } from 'vue'
const fileInput = ref<HTMLInputElement>()
const pickFile = () => fileInput.value?.click()
const onFileChange = (e: Event) => {
const files = (e.target as HTMLInputElement).files
if (!files?.length) return
const file = files[0]
console.log(file.name, file.size, file.type)
// File 对象是 Blob 的子类,可以直接当 blob 用
}
</script>
<template>
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/*"
@change="onFileChange"
/>
<el-button @click="pickFile">选择文件</el-button>
</template>
几个属性:
accept="image/*"—— 只能选图片(浏览器文件选择器会过滤)accept=".pdf,.docx"—— 按扩展名过滤multiple—— 允许选多个capture="camera"—— 手机上直接调摄像头
坑点:accept 只是提示浏览器过滤,用户仍能选"所有文件"扔进来。后端校验文件类型必不可少。
3. FormData:打包上传数据
File 对象有了,怎么发给后端?用 FormData:
const formData = new FormData()
formData.append('file', file)
formData.append('category', 'avatar')
formData.append('userId', '123')
await axios.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, // 通常 axios 会自动设
})
FormData 的含义:把多个字段(含文件)打包成 multipart/form-data 请求体。后端用 @RequestParam MultipartFile file 接。
不要手动序列化 File——它不是普通 JSON 对象,JSON.stringify(file) 只能得到空对象。
4. 简单上传:<el-upload> 基础用法
Element Plus 已经把上面那套封装好了:
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import type { UploadProps, UploadUserFile } from 'element-plus'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const onSuccess: UploadProps['onSuccess'] = (response) => {
ElMessage.success('上传成功')
console.log('后端返回:', response)
}
const onError: UploadProps['onError'] = () => {
ElMessage.error('上传失败')
}
// 上传前校验
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('文件大小不能超过 5MB')
return false
}
if (!file.type.startsWith('image/')) {
ElMessage.error('只能上传图片')
return false
}
return true
}
</script>
<template>
<el-upload
action="/api/upload"
:headers="{ Authorization: `Bearer ${userStore.token}` }"
:data="{ category: 'avatar' }"
accept="image/*"
:before-upload="beforeUpload"
:on-success="onSuccess"
:on-error="onError"
>
<el-button type="primary">选择文件</el-button>
<template #tip>
<div class="text-xs text-gray-500 mt-1">仅支持 jpg/png,不超过 5MB</div>
</template>
</el-upload>
</template>
几个要点:
action—— 上传地址,el-upload内部用原生 XHR 发请求:headers—— 带 token(el-upload 不走 axios,所以要单独注入):data—— 额外字段,一起塞进FormData:before-upload—— 返回false阻止上传accept+file.type双重校验:客户端 + 大小校验
5. 自定义上传:走 axios 实例
<el-upload> 的内置 XHR 绕开了 axios 拦截器(token、错误处理都要自己挂)。实际项目推荐用 :http-request 覆盖,接回自己的 axios 实例:
<script setup lang="ts">
import type { UploadRequestOptions } from 'element-plus'
import { request } from '@/api/request'
interface UploadResponse {
url: string
filename: string
}
const customUpload = async (options: UploadRequestOptions) => {
const formData = new FormData()
formData.append('file', options.file)
formData.append('category', 'avatar')
const res = await request.post<UploadResponse>('/upload', formData, {
onUploadProgress: (e) => {
const percent = Math.round((e.loaded / (e.total ?? 1)) * 100)
options.onProgress({ percent } as any)
},
})
options.onSuccess(res)
return res
}
</script>
<template>
<el-upload action="#" :http-request="customUpload" :show-file-list="true">
<el-button>选择文件</el-button>
</el-upload>
</template>
好处:
- 走 axios 的请求/响应拦截器(token、401 统一处理、错误提示都复用)
- 业务层只写一次上传逻辑
- 进度通过
options.onProgress汇报给 el-upload,进度条依然工作
6. 拖拽上传
<el-upload
class="upload-dragger"
action="#"
:http-request="customUpload"
drag
multiple
>
<el-icon class="text-4xl text-gray-400"><UploadFilled /></el-icon>
<div>拖拽文件到此处,或<em class="text-blue-500">点击上传</em></div>
<template #tip>
<div class="text-xs text-gray-500">支持批量上传</div>
</template>
</el-upload>
只加一个 drag 属性。Element Plus 处理了原生 dragenter/dragover/drop 事件——自己写其实也不难,但既然用了组件库就用现成的。
7. 图片上传:带预览与裁剪
7.1 直接读本地预览(不上传)
const previewUrl = ref('')
const beforeUpload = (file: File) => {
// 生成本地 URL(blob:...)
previewUrl.value = URL.createObjectURL(file)
return true
}
<img v-if="previewUrl" :src="previewUrl" class="w-32 h-32 object-cover rounded" />
坑点:URL.createObjectURL 创建的 URL 会占内存,组件卸载前要释放:
import { onUnmounted } from 'vue'
onUnmounted(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
7.2 上传成功后显示服务端 URL
上传接口返回 url 字段(如 https://cdn.example.com/abc.jpg),存到 state 再显示:
interface UploadResponse {
url: string
}
const avatarUrl = ref('')
const customUpload = async (options: UploadRequestOptions) => {
const formData = new FormData()
formData.append('file', options.file)
const res = await request.post<UploadResponse>('/upload/avatar', formData)
avatarUrl.value = res.url
options.onSuccess(res)
}
对比两种预览:
- 本地 blob URL —— 立即可见,但页面刷新就没了
- 服务端 URL —— 上传成功才有,但持久
常见做法:上传前用 blob URL 先显示,接口成功后替换成服务端 URL。
7.3 裁剪
想让用户上传前裁剪头像?Element Plus 没提供,用第三方库:
- vue-cropper —— 经典选择,API 简单
- cropperjs —— 底层库,功能强大
- vue-advanced-cropper —— 组件化,Vue 3 友好
in4vue 暂时不做头像功能,碰到再集成。
8. 分片上传(大文件)
什么时候要分片:
- 单文件 > 100MB
- 网络不稳定,需要续传
- 上传时间长,需要暂停/恢复
8.1 前端分片逻辑
async function uploadInChunks(file: File, chunkSize = 5 * 1024 * 1024) {
const chunks = Math.ceil(file.size / chunkSize)
const uploadId = crypto.randomUUID() // 本次上传的标识
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('uploadId', uploadId)
formData.append('index', String(i))
formData.append('total', String(chunks))
await request.post('/upload/chunk', formData, {
onUploadProgress: (e) => {
const chunkPercent = e.loaded / (e.total ?? 1)
const total = ((i + chunkPercent) / chunks) * 100
console.log(`总进度 ${total.toFixed(1)}%`)
},
})
}
// 通知后端合并分片
await request.post('/upload/merge', { uploadId, filename: file.name })
}
要点:
file.slice(start, end)—— Blob 的slice方法,和数组的类似- 每个分片独立请求,失败了重发这一片即可
- 最后调
/merge让后端拼接
8.2 秒传:MD5 查重
上传前先算文件 hash,问后端"是否已存在":
import SparkMD5 from 'spark-md5'
async function computeHash(file: File): Promise<string> {
const buffer = await file.arrayBuffer()
return SparkMD5.ArrayBuffer.hash(buffer)
}
const hash = await computeHash(file)
const { exists, url } = await request.get('/upload/check', { params: { hash } })
if (exists) {
// 秒传:后端已经有这个文件,直接拿 URL
return url
}
// 否则走普通/分片上传
大文件 hash 计算慢,可以只 hash 前 1MB + 大小 + 文件名做粗略查重,然后秒传——牺牲极小碰撞概率换速度。
8.3 推荐直接用库
分片、断点、秒传、并发这些自己写很容易翻车。生产项目用现成的:
- uppy —— 最流行的通用上传库,插件丰富
- filepond —— 轻量,UI 漂亮
学习阶段了解原理就好,in4vue 这种文档站不会有大文件上传需求。
9. 文件下载:5 种场景 5 种姿势
9.1 最简单:浏览器 GET 跳转
<a href="/api/download/report.pdf" download>下载报告</a>
download 属性提示浏览器"下载"而不是"打开"。后端响应头要配合:
Content-Disposition: attachment; filename="report.pdf"
适合:公开资源,不需要鉴权。
9.2 需要 token:用 blob
const downloadFile = async (url: string, filename: string) => {
const response = await request.get(url, {
responseType: 'blob',
})
// 注意:axios 响应拦截器里如果 return res.data, 这里 response 就是 Blob
const blob = response instanceof Blob ? response : new Blob([response as any])
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
a.click()
URL.revokeObjectURL(blobUrl) // 清理
}
downloadFile('/api/download/report', 'report.pdf')
关键点:
responseType: 'blob'—— 告诉 axios 不要把二进制当文本解析- 创建
<a>点击下载,不加到 DOM 也能触发 - 用完
revokeObjectURL释放内存
9.3 从后端 header 拿文件名
文件名后端动态生成(如带时间戳),从 Content-Disposition 里解析:
const downloadWithDynamicName = async (url: string) => {
const response = await request.get(url, {
responseType: 'blob',
// axios 响应拦截器如果只返回 data, 这里要重新配置能拿到 headers
})
const disposition = response.headers['content-disposition']
let filename = 'download.bin'
const match = disposition?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
if (match) {
filename = decodeURIComponent(match[1] || match[2])
}
// 然后走 9.2 的逻辑触发下载
}
注意:axios 响应拦截器如果只 return response.data,外层拿不到 headers。对下载接口单独不走拦截器:
const response = await axios.get(url, {
responseType: 'blob',
baseURL: '/api',
headers: { Authorization: `Bearer ${token}` },
})
或者在拦截器里判断 responseType === 'blob' 时返回完整 response。
9.4 前端导出 Excel / CSV
纯前端生成文件(无需后端),用 blob + download:
const exportCsv = (rows: any[]) => {
const headers = Object.keys(rows[0])
const csv = [
headers.join(','),
...rows.map((r) => headers.map((h) => JSON.stringify(r[h] ?? '')).join(',')),
].join('\n')
// 加 BOM 解决 Excel 打开乱码
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `export-${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
}
Excel 格式(.xlsx)更复杂,用 xlsx / exceljs 库。
9.5 打开文件而不是下载
想让浏览器内预览 PDF/图片(不触发下载):
const previewPdf = async (url: string) => {
const response = await request.get(url, { responseType: 'blob' })
const blob = response instanceof Blob ? response : new Blob([response as any])
const blobUrl = URL.createObjectURL(blob)
window.open(blobUrl, '_blank')
}
新标签页打开,浏览器自带 PDF 查看器。
10. 进度条组件
Element Plus 有现成的 <el-progress>:
<script setup lang="ts">
const progress = ref(0)
const onUploadProgress = (e: { loaded: number; total?: number }) => {
progress.value = Math.round((e.loaded / (e.total ?? 1)) * 100)
}
</script>
<template>
<el-progress v-if="progress > 0 && progress < 100" :percentage="progress" />
<div v-else-if="progress === 100" class="text-green-500">上传完成</div>
</template>
视觉反馈很重要:用户上传大文件时最烦的是"不知道还要多久"。1 秒以内的小请求不用进度条,超过 2 秒的必须有。
11. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
| 上传一直 404 | action 写错了或没走代理 |
检查 Vite proxy 配置 |
| Token 没带上 | <el-upload> 内部 XHR 不走 axios 拦截器 |
用 :http-request 走自己的 axios |
| 上传大文件浏览器卡死 | 分片前把整个文件读到内存 | 用 file.slice 逐片读取,别一次 arrayBuffer() |
| 下载的 Excel 打开乱码 | 没加 BOM | '\ufeff' + csv 头部加 BOM |
| blob URL 一直占内存 | 忘了 URL.revokeObjectURL |
用完立刻释放 |
Content-Disposition 拿不到 |
CORS 没暴露这个 header | 后端响应里加 Access-Control-Expose-Headers: Content-Disposition |
<a download> 不生效 |
跨域资源不支持 download | 改用 blob 下载 |
12. 小决策表
| 需求 | 方案 |
|---|---|
| 小图片单个上传 | <el-upload> + :http-request |
| 多文件 | multiple + :http-request 逐个上传 |
| 大文件 (>100MB) | 分片上传 |
| 非常大/频繁 | 直接用 uppy / filepond |
| 上传进度 | onUploadProgress + <el-progress> |
| 下载(公开) | <a href download> |
| 下载(要鉴权) | axios + responseType: 'blob' |
| 前端生成文件 | 构造 Blob + URL.createObjectURL |
| 预览 PDF | blob + window.open |
小练习
- 做一个头像上传组件:点击选图 → 本地预览 → 上传 → 显示服务端 URL
- 实现一个带进度条的视频上传(模拟 50MB 文件,看进度条跑)
- 导出一个简单的 CSV(笔记列表导出)
- 下载接口带 token(blob 方式),并从
Content-Disposition拿文件名 - 体验
URL.createObjectURL不释放的后果——开 DevTools Memory 看 blob 堆积