跳到主要内容

文件上传、下载与断点续传

请求层一旦碰到文件,复杂度就会明显上来。普通 JSON 接口关注的是数据结构,文件链路更常见的问题是:

  • 上传进度怎么拿
  • 大文件怎么拆
  • 下载怎么保留文件名
  • 网络断了之后怎么续传
  • 同一个文件重复上传怎么去重

这一篇把这几块收在一起。

最基础的上传

浏览器里最常见的是 FormData

async function uploadFile(file: File) {
const formData = new FormData()
formData.append('file', file)

const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
})

if (!res.ok) {
throw new Error('upload failed')
}

return res.json()
}

如果只求能传上去,这一层已经够了。

上传进度怎么拿

原生 fetch 目前不适合直接拿上传进度,项目里更常见的是交给 axios

import axios from 'axios'

export function uploadWithProgress(file: File, onProgress?: (percent: number) => void) {
const formData = new FormData()
formData.append('file', file)

return axios.post('/api/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress(event) {
const total = event.total || 1
const percent = Math.round((event.loaded / total) * 100)
onProgress?.(percent)
},
})
}

常见的上传分层

src/
api/
file.ts
request/
client.ts
composables/ 或 hooks/
use-upload.ts

大致可以这么分:

  • api/file.ts:上传、下载、合并分片这些接口函数
  • request/client.ts:底层请求实例
  • use-upload.ts:进度、状态、取消、重试

图片预览怎么接

图片上传通常不会等文件传完才看结果,更多时候会先给一个本地预览。

最常见的方式是 URL.createObjectURL

function createPreviewUrl(file: File) {
return URL.createObjectURL(file)
}

React 里常见写法:

function ImagePreview({ file }: { file: File }) {
const [url, setUrl] = useState('')

useEffect(() => {
const nextUrl = URL.createObjectURL(file)
setUrl(nextUrl)

return () => URL.revokeObjectURL(nextUrl)
}, [file])

return <img src={url} alt={file.name} />
}

这里最容易漏掉的是回收对象 URL。不回收的话,长时间多图预览会慢慢吃内存。

图片压缩什么时候值得做

图片上传常见的瓶颈不只在网络,也包括:

  • 原图太大
  • 移动端拍照体积高
  • 列表里多图上传时等待太久

这类场景里,前端先压一轮通常很划算。

前端图片压缩的基本思路

最常见的是:

  1. 读入原图
  2. 画到 canvas
  3. 重新导出成压缩后的 blob
async function compressImage(file: File, quality = 0.8): Promise<File> {
const imageBitmap = await createImageBitmap(file)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

canvas.width = imageBitmap.width
canvas.height = imageBitmap.height

ctx?.drawImage(imageBitmap, 0, 0)

const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/jpeg', quality)
})

if (!blob) {
throw new Error('compress failed')
}

return new File([blob], file.name.replace(/\.\w+$/, '.jpg'), {
type: 'image/jpeg',
})
}

如果图片很多,或者单张非常大,项目里也会考虑用 web worker 把压缩挪到后台线程。

压缩时更常见的控制项

  • 最大宽高
  • 输出格式
  • 压缩质量
  • 是否保留透明通道
  • 是否保留 EXIF 信息

这里没有一套所有项目都通吃的默认值。商品图、头像、截图、设计稿原图,策略往往不一样。

上传时最常见的前端校验

  • 文件大小
  • 文件类型
  • 数量限制
  • 同名文件策略

例如:

function validateFile(file: File) {
const maxSize = 20 * 1024 * 1024
const allowTypes = ['image/png', 'image/jpeg', 'application/pdf']

if (file.size > maxSize) {
throw new Error('file too large')
}

if (!allowTypes.includes(file.type)) {
throw new Error('unsupported file type')
}
}

下载文件的常见写法

方式 1:直接跳转下载地址

如果后端直接返回带权限的下载地址,前端最省事的方式往往是:

window.location.href = downloadUrl

方式 2:请求 blob 再手动触发下载

如果需要带鉴权头、校验权限、统一错误处理,通常会改成 blob。

import axios from 'axios'

export async function downloadFile(id: string) {
const res = await axios.get(`/files/${id}/download`, {
responseType: 'blob',
})

const blob = new Blob([res.data])
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')

a.href = url
a.download = 'report.pdf'
a.click()

window.URL.revokeObjectURL(url)
}

文件名怎么处理

很多接口会通过响应头返回文件名,例如 Content-Disposition。前端如果要保留真实文件名,通常会从这里解析。

const disposition = res.headers['content-disposition']

实际项目里更常见的做法是:

  • 后端直接返回稳定文件名
  • 或前端根据业务信息兜底一个名字

大文件为什么要分片

文件一旦大到几十 MB 或几百 MB,整文件直传的风险会明显上升:

  • 网络中断后要从头再传
  • 单次请求时间太长
  • 前后端超时更容易打架
  • 移动网络下体验很差

这时就会切到分片上传。

分片上传的基本思路

  1. 前端把文件切成多个 chunk
  2. 每个 chunk 单独上传
  3. 后端记录已上传分片
  4. 全部分片完成后,通知后端合并

前端分片怎么切

function createFileChunks(file: File, chunkSize = 5 * 1024 * 1024) {
const chunks: Blob[] = []

for (let start = 0; start < file.size; start += chunkSize) {
const end = Math.min(start + chunkSize, file.size)
chunks.push(file.slice(start, end))
}

return chunks
}

一个常见的分片上传结构

import axios from 'axios'

async function uploadChunk(params: {
fileHash: string
chunkIndex: number
chunk: Blob
}) {
const formData = new FormData()
formData.append('fileHash', params.fileHash)
formData.append('chunkIndex', String(params.chunkIndex))
formData.append('chunk', params.chunk)

return axios.post('/api/upload/chunk', formData)
}

async function mergeChunks(fileHash: string, fileName: string) {
return axios.post('/api/upload/merge', {
fileHash,
fileName,
})
}

文件 hash 为什么常出现

大文件上传里经常会先算一个 hash,比如 md5sha256。作用通常有几类:

  • 标识同一个文件
  • 让服务端查询哪些分片已经传过
  • 方便秒传或去重

秒传怎么理解

秒传并不是“前端把文件瞬间传完”,而是:

  • 前端先算出文件 hash
  • 服务端发现这个文件已经存在
  • 于是直接返回成功结果,不再真的上传文件内容

常见流程是:

  1. 前端选中文件
  2. 计算 hash
  3. /upload/check
  4. 如果服务端确认文件已存在,直接返回文件地址或文件 ID
  5. 如果不存在,再继续正常上传

一个简化例子:

async function checkFileExists(fileHash: string) {
const res = await fetch(`/api/upload/check?fileHash=${fileHash}`)
return res.json() as Promise<{ exists: boolean; fileId?: string }>
}

这种能力最适合:

  • 大文件
  • 重复上传概率高的系统
  • 企业网盘、素材库、文档中心这类场景

常见流程是:

  1. 前端先算文件 hash
  2. 调接口问后端:这个文件之前传过没有
  3. 如果传过,直接秒传
  4. 如果只传了一部分,只补剩余 chunk

断点续传的核心

断点续传并不神秘,本质上就是两件事:

  • 前端知道哪些 chunk 还没传
  • 后端能识别并复用已经传过的 chunk

例如:

async function resumeUpload(file: File, fileHash: string) {
const { data } = await axios.get('/api/upload/status', {
params: { fileHash },
})

const uploadedChunkIndexes: number[] = data.uploadedChunkIndexes
const chunks = createFileChunks(file)

for (const [index, chunk] of chunks.entries()) {
if (uploadedChunkIndexes.includes(index)) continue
await uploadChunk({ fileHash, chunkIndex: index, chunk })
}

await mergeChunks(fileHash, file.name)
}

并发上传怎么做

分片之后,前端一般不会一个一个串行传到底。更常见的是限制并发数,比如同时传 3 到 5 片。

这样做的好处是:

  • 速度更稳
  • 不会把浏览器和后端同时打满
  • 失败重试的粒度更小

如果项目里已经有任务队列工具,这一层通常会单独抽出来。

失败重试要注意什么

分片上传里,重试通常只针对失败的 chunk,不应该整文件重来。

比较常见的策略:

  • 单个 chunk 失败重试 2 到 3 次
  • 连续失败后暂停,等待手动恢复
  • 网络离线后自动暂停
  • 恢复网络后继续上传

取消上传怎么做

如果底层是 axiosfetch + AbortController,取消思路和普通请求一样,但要记住一点:

  • 还要停掉当前 chunk 之后的后续调度
  • 还要停止后续调度

这时如果有上传队列,通常会有一个统一的 cancelUpload(fileHash)

下载断点续传怎么看

浏览器侧如果要做下载断点续传,前端可控空间没有上传那么大。多数项目更现实的做法是:

  • 由后端支持 Range 请求
  • 浏览器或下载器自己续传
  • 前端只负责发起下载和展示状态

所以在前端项目里,“断点续传”更多还是先指上传链路。

更适合先做哪一层

文件链路一般可以按这个顺序补:

  1. 单文件上传
  2. 进度展示
  3. 下载 blob
  4. 多文件队列
  5. 分片上传
  6. 断点续传
  7. 秒传 / 去重

这样推进会稳很多,不容易一开始就把整条链路做得过重。

相关文章