前端文件上传实践:进度、取消、重试和分片怎么做

文件上传看起来只是一个 input

1<input type="file" />

但只要文件稍微大一点、网络稍微不稳定一点,这个 input 背后要处理的状态就完全不是"选完就传"这么简单了:进度怎么算、用户能不能取消、传到一半断了怎么办、传多个文件时一个失败要不要连累其他几个。这些问题不是靠一个 onChange 加个 loading 就能糊弄过去的,得先把整条链路的状态机想清楚。

团队里这块功能一直是我在维护,最近一次是把内部后台的图片上传组件从"只处理成功路径"改成"能处理所有路径"。改动不算大,但每一步都有具体的坑,记一下。

前端校验只是第一道关

选完文件之后,第一步是校验类型、大小、数量。这部分逻辑简单,但容易漏掉边界:

1function validateFile(file: File): string | null {
2  const allowedTypes = ['image/png', 'image/jpeg', 'image/webp']
3
4  if (!allowedTypes.includes(file.type)) {
5    return '只支持 png / jpeg / webp 格式'
6  }
7
8  if (file.size > 5 * 1024 * 1024) {
9    return '文件不能超过 5MB'
10  }
11
12  if (file.size === 0) {
13    return '文件内容为空'
14  }
15
16  return null
17}

file.type 是浏览器根据扩展名或文件头猜出来的 MIME 类型,用户改个后缀就能绕过,所以这道校验的作用只是"提前拦住明显不对的文件、减少无效请求",不是安全边界。真正的类型校验(读文件头 magic number)和大小限制必须在服务端重做一遍,这条团队里踩过一次亏——有个内部工具图片上传口子只做了前端校验,后来被发现能传任意文件上去,服务端加了 magic number 校验才补上。

多文件选择时还有一个容易漏的点:input[type=file] multiple 选中同一个文件两次,change 事件不会重复触发,因为浏览器认为"值没变"。如果要支持用户重新选同一个文件重试,得在每次处理完之后手动把 input.value 置空:

1function resetInput(input: HTMLInputElement) {
2  input.value = ''
3}

进度条要来自真实字节数,不是猜的

进度这块,2022 年年中的现状是:XMLHttpRequestupload.onprogress 事件能拿到真实的已上传字节数,fetch 目前还做不到对等的上传进度监听(fetchbody 支持流式发送,但读取上传进度在多数浏览器里还没有对应的原生接口),所以做上传进度条,团队里几个项目还是清一色用 XHR:

1function upload(file: File, onProgress: (percent: number) => void) {
2  const formData = new FormData()
3  formData.append('file', file)
4
5  return new Promise((resolve, reject) => {
6    const xhr = new XMLHttpRequest()
7
8    xhr.upload.onprogress = (event) => {
9      if (event.lengthComputable) {
10        onProgress(Math.round((event.loaded / event.total) * 100))
11      }
12    }
13
14    xhr.onload = () => {
15      if (xhr.status >= 200 && xhr.status < 300) {
16        resolve(JSON.parse(xhr.responseText))
17      } else {
18        reject(new Error(`上传失败:${xhr.status}`))
19      }
20    }
21
22    xhr.onerror = () => reject(new Error('网络异常,上传失败'))
23    xhr.open('POST', '/api/upload')
24    xhr.send(formData)
25  })
26}

这里有个容易被忽略的细节:FormData 提交时浏览器会自动生成 multipart/form-data 的边界字符串(boundary),拼在 Content-Type 头里,比如 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXXXX。如果手动设置了 xhr.setRequestHeader('Content-Type', 'multipart/form-data') 而不带 boundary,服务端就没法正确切分每个字段,请求体解析直接失败。之前排查过一次上传接口偶发 400,原因就是有同学在封装请求的公共层里统一设置了 Content-Type,把 FormData 自带的边界给覆盖掉了。正确做法是遇到 FormData 时不要手动设置 Content-Type,交给浏览器自己算。

进度条另一个容易做错的地方是"假进度":文件传完之后,如果服务端还要做转码、压缩、病毒扫描这类后处理,进度条不能直接跳到 100% 显示"完成",否则用户点了下一步操作,结果服务端还没处理完。合理的做法是进度到 100% 时切换成"处理中"状态,再单独发一个轮询或者等服务端主动回调,最后才标记为真正完成:

1type UploadStatus = 'pending' | 'uploading' | 'processing' | 'success' | 'error' | 'cancelled'

取消和失败必须是两种状态

用户选错文件、或者传到一半想换一个,得能取消。XHR 提供了 abort

1let currentXhr: XMLHttpRequest | null = null
2
3function startUpload(file: File) {
4  currentXhr = new XMLHttpRequest()
5  // ...
6}
7
8function cancelUpload() {
9  currentXhr?.abort()
10}

abort 之后会触发 onabort 而不是 onerror,这两个要分开处理,不然用户主动取消也会看到一条"上传失败"的提示,体验很别扭:

1xhr.onabort = () => {
2  item.status = 'cancelled'
3}
4
5xhr.onerror = () => {
6  item.status = 'error'
7  item.error = '网络异常,请重试'
8}

如果用 fetch,对应的是传入 AbortControllersignal

1const controller = new AbortController()
2
3fetch('/api/upload', {
4  method: 'POST',
5  body: formData,
6  signal: controller.signal,
7})
8
9controller.abort()

无论用哪种方式,取消之后都要记得清理这个文件对应的状态和已经占用的并发槽位,不然上传队列会卡住——这是我们上一版组件里真实出现过的问题:取消了某个文件,但并发计数没有减掉,导致后面的文件排队排不上。

多文件要限制并发,不要一次性全发

批量上传几十个文件时,如果每个文件都立刻发起请求,浏览器本身对同域名的并发连接数有限制(多数浏览器是 6 个左右),超出的请求会排队,而且大文件之间还会互相抢带宽,表现为每个都很慢、超时率也跟着升高。团队里的做法是自己实现一个简单的并发限制器:

1async function runWithLimit<T>(tasks: Array<() => Promise<T>>, limit: number) {
2  const results: Array<Promise<T>> = []
3  const executing: Array<Promise<void>> = []
4
5  for (const task of tasks) {
6    const p = task().then((res) => res)
7    results.push(p)
8
9    const e: Promise<void> = p.then(
10      () => {
11        executing.splice(executing.indexOf(e), 1)
12      },
13      () => {
14        executing.splice(executing.indexOf(e), 1)
15      }
16    )
17    executing.push(e)
18
19    if (executing.length >= limit) {
20      await Promise.race(executing)
21    }
22  }
23
24  return Promise.allSettled(results)
25}

限制并发数一般设在 3~4 个,具体数字看接口能承受的压力和用户网络环境,没有统一答案。这个限制器不只是给"多文件上传"用的,后面讲的分片上传本质上也是"很多个小任务要控制并发",是同一套逻辑。

大文件走分片,前端只是配合方

文件大到一定程度(比如超过几十 MB),不适合整个发一次请求:一旦中途失败就要从头重传,用户体验很差,服务端也扛不住一次性接收超大请求体。分片上传的思路是把文件切成固定大小的块,一块一块传,服务端收齐之后再合并:

1function createChunks(file: File, size = 2 * 1024 * 1024) {
2  const chunks: Blob[] = []
3  let start = 0
4
5  while (start < file.size) {
6    chunks.push(file.slice(start, start + size))
7    start += size
8  }
9
10  return chunks
11}

Blob.slice 是纯前端切片,不需要把整个文件读进内存,性能上没有问题。但分片上传不是前端单方面能做完的事,服务端至少要配合三件事:接收单个分片并按序号存起来、记录已经收到哪些分片、全部到齐后按顺序合并成完整文件,合并完之后还要清理过期的临时分片,不然存储会一直涨。

分片上传自然带来两个延伸问题,团队最近正好都碰到了。

第一个是并发限流。分片可以并发上传(比如同时传 3 片),用的就是前面那个 runWithLimit,但要注意每个分片请求都要带上分片序号和文件标识,服务端才知道怎么归位,乱序到达是正常情况,不能假设分片按顺序到达。

第二个是断点续传怎么判断"哪些分片已经传过"。简单粗暴的做法是用服务端生成的 uploadId 记录进度,前端每次开始上传前先问一句"这个 uploadId 已经有哪些分片了",服务端返回一个已完成的分片序号列表,前端跳过这些,只补传缺的:

1async function resumeUpload(uploadId: string, chunks: Blob[]) {
2  const { uploadedChunks } = await fetch(`/api/upload/status?uploadId=${uploadId}`).then((r) =>
3    r.json()
4  )
5
6  const pending = chunks
7    .map((chunk, index) => ({ chunk, index }))
8    .filter(({ index }) => !uploadedChunks.includes(index))
9
10  return runWithLimit(
11    pending.map(({ chunk, index }) => () => uploadChunk(uploadId, chunk, index)),
12    3
13  )
14}

这里有个更细的问题:uploadId 怎么生成、下次刷新页面还认不认识同一个文件。如果只用文件名加时间戳做 uploadId,用户换个浏览器标签页重新选同一个文件,会被当成一个全新的上传任务,之前传了一半的分片就浪费了。比较扎实的做法是对文件内容算一个 hash(比如用 spark-md5 这类库对文件分块读取再计算 MD5),拿文件 hash 当作 uploadId 的一部分,这样只要文件内容没变,不管从哪个页面发起,服务端都能认出"这个文件之前传过一部分"。

这个思路往前再走一步,就是常说的"秒传":上传前先把文件 hash 发给服务端问一句"这个文件是不是已经存在了",如果服务端存储里已经有完全相同 hash 的文件,直接返回已有的文件地址,前端跳过整个上传过程。秒传对小文件不划算——算 hash 本身也要读完整个文件,比直接传可能还慢,所以一般只对大文件、且预期会有重复内容的场景(比如很多用户会传同一份安装包)才值得做。文件 hash 计算这块要注意别卡主线程,几十 MB 的文件如果同步算 MD5 页面会明显卡顿,得考虑放到 Web Worker 里算,或者分块异步计算、每算一块让出一次事件循环。

每个文件的状态要独立,错误要能落到具体文件上

多文件上传场景下,如果错误处理只是弹一个全局的 toast,用户根本不知道是哪个文件失败了、该重试哪一个。得给每个文件维护自己的状态:

1type UploadItem = {
2  file: File
3  status: UploadStatus
4  progress: number
5  error?: string
6}

接口报错时,只标记到对应的文件项,不要影响其他文件的状态:

1async function handleUpload(item: UploadItem) {
2  item.status = 'uploading'
3
4  try {
5    await upload(item.file, (percent) => {
6      item.progress = percent
7    })
8    item.status = 'success'
9  } catch (error) {
10    item.status = 'error'
11    item.error = error instanceof Error ? error.message : '上传失败,请重试'
12  }
13}

重试的时候,只需要把这一个 item 的状态重置回 pending 再重新发起,不用把整个上传队列推倒重来。分片场景下重试还能更细——如果已经记录了 uploadId 和已完成的分片列表,重试可以直接从断点续传的逻辑里过一遍,不用从第一片重新传。

这次改造真正花时间的地方

把内部图片上传组件从"只处理成功路径"改成"能处理所有路径",最后核对下来,前端校验类型、大小、数量,同时明确这层校验只是体验优化,服务端要独立做安全校验;进度用 XHR 的 upload.onprogress 拿真实字节数,FormData 提交时不手动覆盖 Content-Type;取消用 abort(或 fetch 的 AbortController),取消和网络失败是两种状态,不能混着提示;多文件限制并发,避免互相抢带宽;大文件走分片,分片可并发但要处理乱序到达,断点续传靠文件 hash 而不是文件名;每个文件独立维护状态,错误精确落到具体文件,重试只重试失败的那一个——这些点基本都覆盖到了。

选 XHR 还是 fetch、要不要分片,这类技术选型其实一开始就能定下来,没花太多时间。真正耗精力的是把进度、取消、失败、重试这几种状态一个个拆开、让每种状态都有明确对应的处理路径,中间来回改了好几版才理顺。秒传和更细粒度的取消(比如只取消分片上传中的某一片,而不是整个文件)目前还在评估要不要往里加,主要看后面业务里是不是真的有大文件重复上传的场景——现在团队内部工具用得不算多,加进去之前想先看看实际收益够不够得上维护成本。