4.3 文件上传与下载


4.3 文件上传与下载

本节摘要:文件是特殊的请求载荷——体积大、要进度、可能中断。本节覆盖上传链路的完整实现:从 input 与拖拽收集文件、客户端预检与图片预览、FormData 携带、XHR 进度条(Fetch 为什么不行)、大文件分片与断点续传思路;下载侧讲 Blob 与对象 URL 如何实现"不离开页面的保存",以及带鉴权头的下载为什么要走这条链路。

上传的三个特殊难题

普通接口发几百字节的 JSON,瞬时完成;文件上传动辄几十上百 MB,三个新问题立刻出现:

进度问题。JSON 请求不需要进度条(人眼分不清 30 毫秒与 50 毫秒),但 200MB 的文件在普通宽带要传几十秒到几分钟——没有进度反馈的等待是酷刑,用户不知道该等还是该重试。

中断问题。传到 95% 断网,整份重传?几分钟的心血归零。大文件需要分片——切成小块逐个传,失败了只重传坏块。

体积问题。服务端与网关对请求体大小有默认上限(常见几 MB 到几十 MB),超大文件要么调限制(危险),要么分片(正确)。

这三个问题决定了本节的技术选型:XHR(为进度)、FormData(为文件)、分片(为大文件与断点续传)

一、收集文件:input 与拖拽两条路

<input type="file" id="picker" accept="image/*" multiple> <div id="drop-zone">把文件拖到这里</div>

input 路:change 事件后从 files 取 File 对象。拖拽路:监听 dragover(必须 preventDefault,否则浏览器直接打开文件)与 drop,从 dataTransfer 取文件:

dropZone.addEventListener('dragover', e => e.preventDefault()); dropZone.addEventListener('drop', e => { e.preventDefault(); const files = e.dataTransfer.files; // File 对象数组 与 input 同构 handleFiles(files); });

File 对象自带元信息:name、size、type、lastModified。客户端预检在此时做最有价值——体积超限、类型不对,在发出前就拦下,省一次注定失败的上传:

function validate(file, { maxSizeMB = 10, accept = ['image/jpeg', 'image/png'] } = {}) { if (!accept.includes(file.type)) return '不支持的文件类型'; if (file.size > maxSizeMB * 1024 * 1024) return '文件超过 ' + maxSizeMB + 'MB 限制'; return null; // 通过 }

注意预检是体验优化不是安全边界(4.2 节的纪律):类型与大小必须在服务端再验一遍,改个请求体就能绕过前端检查。

图片预览:上传前让用户看到选了什么,用文件读取器把本地文件转成数据地址(不上传就能显示):

function previewImage(file, imgEl) { const reader = new FileReader(); reader.onload = () => { imgEl.src = reader.result; }; reader.readAsDataURL(file); }

大图想省内存可以先压缩再预览:画到 canvas 上缩放尺寸、再导出 Blob——既改善预览也直接减小上传体积,是移动端图片上传的标配动作。

二、FormData 携带与 XHR 进度:上传的主链路

2.3 节说过 JSON 装不下文件,文件走 FormData;2.1 节说过 XHR 的 upload.onprogress 是进度监控的原生能力。这里把两者接成完整链路:

function uploadFile(file, { onProgress, signal } = {}) { return new Promise((resolve, reject) => { const form = new FormData(); form.append('file', file); // 文件字段 form.append('meta', JSON.stringify({ name: file.name, size: file.size })); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload'); xhr.responseType = 'json'; xhr.upload.onprogress = e => { // 上传进度 只有 XHR 有 if (e.lengthComputable && onProgress) { onProgress(e.loaded / e.total); // 0 到 1 的比例 } }; xhr.onload = () => (xhr.status < 300 ? resolve : reject)(xhr.response); xhr.onerror = () => reject(new Error('网络错误')); if (signal) signal.addEventListener('abort', () => xhr.abort()); // 可选取消 xhr.send(form); // 不手动设 Content-Type }); } // 业务侧:进度条 UI const result = await uploadFile(file, { onProgress: ratio => { progressBar.style.width = (ratio * 100).toFixed(1) + '%'; } });

三个细节别漏。其一,别手动设表单格式的 Content-Type(2.1 节的坑:破坏边界符)。其二,进度是"发出字节"的比例,不含服务端落盘处理时间——100% 之后到响应返回之间的空窗,进度条最好停在"99% 处理中"而不是假装完成。其三,e.lengthComputable 为假时(服务端没回报总长)拿不到比例,UI 要有不确定态的兜底。

为什么不用 Fetch:Fetch 早期完全没有上传进度;后来虽能借助流式请求体实现,但代码复杂、兼容性参差,且进度粒度仍不如 XHR 直接。上传场景用 XHR 不是守旧,是选型正确。下载侧 Fetch 反而更顺手,下面会看到。

三、大文件分片与断点续传

超过 50MB 或弱网环境的上传,整份传输的失败代价不可接受。分片的思路:文件按固定大小切片(常见 2 到 5MB),逐片上传,全部成功后通知服务端合并:

async function uploadInChunks(file, chunkSize = 4 * 1024 * 1024) { const total = Math.ceil(file.size / chunkSize); const uploadId = await initUpload(file.name, file.size, total); // 服务端建档 for (let i = 0; i < total; i++) { const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize); // Blob 切片 零拷贝 await uploadChunk(uploadId, i, chunk); // 每片独立请求 失败只重试该片 } await completeUpload(uploadId); // 通知合并 触发校验 }

配套的工程要点:每片带校验值(对切片算哈希随片上传,服务端落盘前核对,防传输损坏);片级重试(单片失败退避重试三次,整份任务才算失败);断点续传(重新上传时先向服务端查"这个文件已传到第几片"——以文件整体哈希为身份标识,从断点继续,这就是网盘"秒传"与"续传"的原理雏形)。

并发分片可以把弱网单连接的利用率拉满:三到五片并发在途(3.6 节的限流池正好用上),注意服务端按 uploadId 归属分片时要支持乱序到达。

四、下载:Blob 与对象 URL

最朴素的下载是一个指向文件地址的链接元素,浏览器导航走原生流程。但两个场景需要 Ajax 链路:

需要鉴权头的下载。原生链接发不出 Authorization 头,受保护的文件走 Fetch(响应类型自动是 Blob)拿字节,再本地触发保存:

async function downloadFile(url, filename) { const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } }); if (!res.ok) throw new Error('下载失败 ' + res.status); const blob = await res.blob(); // 整个文件进内存 const objectUrl = URL.createObjectURL(blob); // 生成浏览器内部地址 const a = document.createElement('a'); a.href = objectUrl; a.download = filename; // 声明下载语义与文件名 a.click(); URL.revokeObjectURL(objectUrl); // 用完释放 防内存泄漏 }

两个要紧的细节。文件名来源:优先读响应头 Content-Disposition(服务端指定的名字),拿不到再前端起名——跨域读这个头需要服务端暴露声明(2.2 节老坑)。revokeObjectURL 别忘:对象 URL 持有对 Blob 的引用,不释放就是一次内存泄漏;批量下载场景泄漏累积很快。

需要进度或需要先处理的下载。大文件下载的进度用 XHR 加 responseType blob 监听下载方向的 progress 事件;下载后要解压、解密、解析的,拿到 Blob 或 ArrayBuffer 再加工。流式下载(边下边处理、不全量进内存)用 Fetch 的响应体读取器逐块读——内存敏感场景(超大文件、低配设备)是必需品。

五、上传下载的体验清单

环节 体面做法 偷懒后果
选择后 类型与大小预检、图片预览、可移除 传完才发现格式不对
上传中 真实进度条、速度与剩余时间、可取消 假进度或转圈,用户盲等
失败时 片级重试、断点续传、明确错误原因 整份重传,用户弃用
完成时 结果校验、成功反馈、自动进入下一步 无确认,用户疑心没传上
下载中 大文件进度、失败重试 白屏等待或静默失败

其中"可取消"值得强调:上传进行中用户改了主意(选错文件、不想传了),没有取消按钮就只能等或刷新页面——AbortController 或 xhr.abort 该接就接(4.4 节的取消机制直接复用)。

常见疑问解答

上传前能在前端压缩图片吗

能,canvas 缩放后导出新 Blob(toBlob),既减体积又统一尺寸。但注意有损:盖章文件、证件扫描件需要原件时别压,或把压缩作为用户可选开关。

分片大小设多少合适

经验区间 2 到 8MB。片太小请求数暴涨、往返开销占比高;片太大单片失败重传代价高、弱网下单片成功率下降。按目标用户网络与服务端并发能力试出来,没有万能值。

秒传是怎么实现的

上传前先算文件整体哈希,拿哈希问服务端"见过这个文件吗"——见过(或已传了一部分)就直接返回地址(秒传)或告知断点(续传)。代价是哈希计算本身要读完整个文件,大文件可抽样哈希折中。

本节要点回顾

  • 文件上传三难题:进度(XHR upload.onprogress)、中断(分片加片级重试)、体积(分片绕开请求体上限)。
  • 主链路:FormData 携带(不手动设头)加 XHR 进度;Fetch 上传进度支持弱,此场景 XHR 是正确选型。
  • 大文件方案:建档、切片、逐片(或限流并发)上传、通知合并;片带哈希校验,整体哈希做身份实现秒传与续传。
  • 鉴权下载:Fetch 拿 Blob、对象 URL 触发保存、文件名读 Content-Disposition、用完 revoke。
  • 体验五环:预检预览、真实进度、失败重试、完成确认、全程可取消——文件交互的口碑就在这些细节里。

综合演练:一个接近生产级的上传组件

把本章内容组装成一个完整的上传组件演练,需求覆盖常见真实场景:支持点击选择与拖拽、多文件队列、图片预览与体积预检、并发上传三个、单文件失败可单独重试、全程可取消、全部完成后统一回调。动手前先做结构设计:组件状态分"队列态"(待传、传中、成功、失败、取消)与"整体态"(进行中、全部完成、部分失败),每个文件是一个独立的小状态机——这个抽象让"单文件重试"变成把某台状态机拨回待传,而不是重新设计流程。

实现要点按顺序过。入队:预检失败的文件直接标失败并给出原因(类型不符或超限),不占队列;图片生成预览缩略图。调度:一个简单的三并发调度器(3.6 节限流池的直接应用),空位时从待传队列取下一个。单文件上传:小文件(十兆以下)走整文件 XHR 加进度,大文件走分片循环——对使用者透明,只暴露统一的进度比例。重试:失败文件的重试按钮触发单机重启,已成功的分片不重传(先查断点,4.3 节的续传思路)。取消:单个取消 abort 在途请求并把状态拨到已取消;整体取消遍历队列批量处理。完成回调:全部终态(成功或失败或取消)后触发,携带结果汇总。

刻意留三个思考题给实现者:并发上传时进度条的"总进度"怎么算才不跳动(提示:按字节数加权而不是文件数平均,三个大文件一个小文件时平均法会前快后慢得很难看);页面离开时在途文件怎么办(提示:离开事件里批量 abort,回到页面再续传还是放弃,要给产品一个决策);重试次数用尽仍失败,队列里其他文件要不要继续(提示:按业务定,但要在界面上说明选择了哪种策略)。三个问题都没有标准答案,有意识地做这些决策并说明理由,正是从"能实现"到"能负责"的距离

补充:上传下载的服务端配合清单

文件链路的很多体验决策要服务端配合才能落地,这里给一份找后端对齐时的清单,免得口头沟通漏项。上传侧:请求体大小上限是多少、能否按接口分开配置(头像接口与视频接口的上限本就该不同);是否支持分片协议的三个端点(建档、传片、合并)与断点查询;分片乱序到达能否正确归位;上传完成后的校验(哈希核对)谁来做。下载侧:受保护文件的鉴权方式(能否用签名地址替代鉴权头,签名地址让下载回到最朴素的链接形态,省掉前端 Blob 中转的内存开销);响应头的 Content-Disposition 是否规范带上文件名;大文件是否支持按范围请求(断点续传下载的前提)。存储侧:直传对象存储是否可行(前端拿临时凭证直传,绕过应用服务器中转,是高并发上传的终极形态);过期清理与重复文件去重的策略。

拿这份清单开一次对齐会,你会发现一半的"前端体验问题"其实卡在服务端配置上——这也是文件章节放在进阶篇的原因:它天然是双边工程。把清单谈妥,前端的实现选择空间会大很多;反过来闷头在前端造巧妙的轮子,常常是在给服务端的缺口打补丁。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U