前端文件上传实践:进度、取消、重试和分片怎么做
文件上传看起来只是一个 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 年年中的现状是:XMLHttpRequest 的 upload.onprogress 事件能拿到真实的已上传字节数,fetch 目前还做不到对等的上传进度监听(fetch 的 body 支持流式发送,但读取上传进度在多数浏览器里还没有对应的原生接口),所以做上传进度条,团队里几个项目还是清一色用 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,对应的是传入 AbortController 的 signal:
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、要不要分片,这类技术选型其实一开始就能定下来,没花太多时间。真正耗精力的是把进度、取消、失败、重试这几种状态一个个拆开、让每种状态都有明确对应的处理路径,中间来回改了好几版才理顺。秒传和更细粒度的取消(比如只取消分片上传中的某一片,而不是整个文件)目前还在评估要不要往里加,主要看后面业务里是不是真的有大文件重复上传的场景——现在团队内部工具用得不算多,加进去之前想先看看实际收益够不够得上维护成本。