文件上传与下载

1. 后端视角看前端文件处理

Java 写文件接口时你已经熟悉:

@PostMapping("/upload")
public String upload(@RequestParam MultipartFile file) { ... }

前端这边做三件事:

  1. 把用户选的文件打包成 FormDatamultipart/form-data 的前端表示)
  2. 用 XHR / fetch 发出去,带上进度(axios 支持 onUploadProgress
  3. 用 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 只是提示浏览器过滤,用户仍能选"所有文件"扔进来。后端校验文件类型必不可少


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>

几个要点


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>

好处


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

7.3 裁剪

想让用户上传前裁剪头像?Element Plus 没提供,用第三方库:

in4vue 暂时不做头像功能,碰到再集成。


8. 分片上传(大文件)

什么时候要分片

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 })
}

要点

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 推荐直接用库

分片、断点、秒传、并发这些自己写很容易翻车。生产项目用现成的:

学习阶段了解原理就好,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')

关键点

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

小练习

  1. 做一个头像上传组件:点击选图 → 本地预览 → 上传 → 显示服务端 URL
  2. 实现一个带进度条的视频上传(模拟 50MB 文件,看进度条跑)
  3. 导出一个简单的 CSV(笔记列表导出)
  4. 下载接口带 token(blob 方式),并从 Content-Disposition 拿文件名
  5. 体验 URL.createObjectURL 不释放的后果——开 DevTools Memory 看 blob 堆积

延伸阅读