别的不说单是前端上传这四个字在面试和实际项目里都能延伸出完全不同的两个层次。你得先搞清楚一个事实能传上去只是及格线传得好、传得稳、断了还能接着传才是真正的分水岭。这篇内容我打算从最基础的FormData一直写到断点续传的完整落地思路。无论你是刚接手项目的新人还是被上传大文件卡死折磨过的老手读完应该都能在自己的项目里直接动手改造。1. 为什么一个上传按钮背后藏着这么多事很多人在第一次写上传功能时都会觉得这有什么难的把input的files[0]塞进FormData然后fetch出去不就行了吗是基础逻辑确实是这样。但当你开始处理真实场景——比如用户上传一段 500MB 的现场视频或者一个正当使用的数据文件时你会发现事情远没有那么简单网络稍微抖动一下整个请求就断了用户得从头再来。后端接口有超时限制大文件一次性传根本撑不到结束。用户无法直观看到进度以为页面卡死了直接刷新。文件稍微大一点浏览器内存就吃紧。这些问题背后牵扯的正是浏览器 API、网络请求和文件处理这三块核心知识。你需要在浏览器端做很多额外工作读取文件内容、合理分片、计算文件指纹、记录上传状态、并发控制、断点恢复……每一样都有现成 API 可以用但组合起来却是一件相当考究的工程。为什么很多人写上传总是能跑但不敢上生产多半是因为只知其表不知其里。这篇文章就带你把里子挖一遍从基础的File对象原理开始讲逐步递进到断点续传的完整套路。我只说真实的实现逻辑和踩坑经验不说假大空的架构名词。1.1 浏览器到底把文件包装成了什么当你通过input typefile选择一个文件时浏览器并不是直接把硬盘上的二进制数据丢给你。它交给你的是一个File对象而这个File对象其实继承自更底层的Blob。Blob是什么你可以把它理解成二进制大对象——一段不可变的原始二进制数据。而File在Blob的基础上额外增加了name文件名、lastModified最后修改时间、typeMIME 类型这几个元信息属性。这里有个关键认知File对象是懒读取的。也就是说当你拿到files[0]时浏览器并没有立即把整个文件内容读进内存。它更像是一个引用或句柄只有在真正调用读取 API比如FileReader、arrayBuffer()、stream()时数据才会被按需读取。这个特性非常重要因为它是我们做文件分片的前提。你可以用Blob.prototype.slice()方法像切蛋糕一样把文件切成多个小块每一块都是一个独立的Blob对象然后逐个上传。举个例子如果你有一个 100MB 的文件切片就是这样的const file input.files[0]; const chunkSize 2 * 1024 * 1024; // 2MB 一块 const chunks []; for (let i 0; i file.size; i chunkSize) { const chunk file.slice(i, i chunkSize); chunks.push(chunk); }这样你不会一次性占用 100MB 内存每次只处理 2MB浏览器不卡内存占用也能控制住。1.2 网络请求的数据结构逻辑文件上传说到底就是往服务器发送二进制数据。浏览器提供了三种主流方式方式核心特点适用场景FormDataXMLHttpRequest原生支持文件字段可监听上传进度最通用兼容性最好FormDatafetch写法简洁但上传进度支持较弱不需要进度条或配合其他库File/Blob直传PUT二进制流直接放 body适合预签名 URL 场景云存储直传这里有一个很多新手会搞混的点FormData虽然是表单数据但它内部是multipart/form-data编码的。你往里面append一个File对象时浏览器会自动处理二进制部分你不需要手动设置Content-Type让浏览器自动带boundary才是正确做法。如果你手动设置了Content-Type: application/json上传必然出问题。进度监听这一块XMLHttpRequest是天然支持的这也是我为什么强调要做上传功能最好还是从 XHR 说起的原因之一。fetch虽然现代但它在上传进度这件事上一直是短板后面章节我会给出实际对比。2. 基础上传用FormData把一个文件送出去这一小节我们不谈任何花哨的东西先把一个可用、可靠的基础上传流程走通。这是后面一切逻辑的地基。2.1 从 input 事件到 FormData 组装先写好 HTMLinput typefile idfileInput / progress idprogressBar value0 max100/progress button iduploadBtn开始上传/button然后写 JS。这里我直接给出完整的 XHR 版本const fileInput document.getElementById(fileInput); const uploadBtn document.getElementById(uploadBtn); const progressBar document.getElementById(progressBar); let selectedFile null; fileInput.addEventListener(change, (e) { selectedFile e.target.files[0]; if (!selectedFile) return; console.log(已选择文件${selectedFile.name}, 大小${formatSize(selectedFile.size)}); }); uploadBtn.addEventListener(click, async () { if (!selectedFile) { alert(请先选择文件); return; } const formData new FormData(); // append 的第三个参数可以自定义上传后的文件名避免中文名/特殊字符问题 formData.append(file, selectedFile, encodeURIComponent(selectedFile.name)); // 可以携带业务参数 formData.append(bizType, avatar); formData.append(userId, 123456); // 创建 XMLHttpRequest const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload, true); // 上传进度监听 xhr.upload.addEventListener(progress, (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); progressBar.value percent; console.log(上传进度${percent}%); } }); // 完成/失败处理 xhr.addEventListener(load, () { if (xhr.status 200 xhr.status 300) { console.log(上传成功); } else { console.error(上传失败, xhr.status); } }); xhr.addEventListener(error, () { console.error(网络错误); }); xhr.send(formData); }); function formatSize(bytes) { if (bytes 0) return 0 B; const units [B, KB, MB, GB]; const i Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, i)).toFixed(2) units[i]; }这里有几个细节值得展开唠一唠。为什么用xhr.upload.addEventListener而不是xhr.addEventListenerxhr.addEventListener(progress)监听的是下载进度——也就是服务器返回响应时的进度。而xhr.upload.addEventListener(progress)才对应上传进度。很多人搞混了导致进度条一直加载完了才突然跳到 100%。这是一个非常典型的低级但高频的错误。为什么文件名要encodeURIComponent如果你直接传中文文件名某些后端框架接收时会出现乱码或者因为特殊字符导致解析错误。提前编码后端拿到后再解码能省掉一部分跨端兼容的麻烦。当然这个问题不同后端有不同的处理习惯但是对前端来说编码是一种低成本高收益的做法。为什么xhr.open是 POST语义上POST 适合向服务器提交数据文件也属于数据的一种。文件过大时某些网关对 GET/PUT 的 URL 长度和 body 解析方式有限制POST multipart 是兼容性最好的选择。除非你走云存储预签名 URL那种场景一般用 PUT 直传。2.2 文件校验大小、类型和数量真实项目里绝对不能进来就传。大部分文件上传漏洞和用户体验问题都是从缺乏校验开始的。const MAX_SIZE 100 * 1024 * 1024; // 100MB const ALLOWED_TYPES [image/jpeg, image/png, video/mp4]; function validateFile(file) { // 大小校验 if (file.size MAX_SIZE) { throw new Error(文件大小不能超过 ${formatSize(MAX_SIZE)}); } // MIME 类型校验 if (!ALLOWED_TYPES.includes(file.type)) { throw new Error(不支持的文件类型); } // 后缀名校验补充 const validExtension /\.(jpg|jpeg|png|mp4)$/i; if (!validExtension.test(file.name)) { throw new Error(文件后缀名不合法); } return true; }关于类型校验我想多说一层MIME 类型是可以伪造的。一个用户可以把非图片文件的type属性改成image/jpeg绕过前端的 MIME 检查。所以更稳妥的做法是读取文件的前几个字节文件魔数签名来判断真实类型。图片的魔数校验逻辑大致是这样async function checkFileSignature(file) { const buffer await file.slice(0, 8).arrayBuffer(); const view new DataView(buffer); // JPEG 开头两个字节通常是 0xFF 0xD8 if (view.getUint8(0) 0xFF view.getUint8(1) 0xD8) return image/jpeg; // PNG 是 89 50 4E 47 0D 0A 1A 0A const bytes new Uint8Array(buffer); if (bytes[0] 0x89 bytes[1] 0x50 bytes[2] 0x4E bytes[3] 0x47) return image/png; return unknown; }这样做不是说要替代 MIME 校验而是说安全和格式校验是分层的。前端做基础校验真正的强校验在后端。你只管在前端把体验做好后面的事情交给服务端处理。2.3 为什么基础上传不适合大文件如果你只做基础上传遇到大文件就会出现三类问题请求体过大网关或应用服务器的内存缓冲可能爆炸出现请求直接断开、连接被重置。一次请求耗时过长用户等得非常焦躁一旦网络波动整个上传必须全部重来挫败感极强。服务器接收端要把整个请求体完整读入才能处理内存和临时磁盘的占用都非常夸张。这三条每一条都够让一个看起来正常的上传功能在真实网络环境里翻车。你可能想说那我用 fetch 不就行了其实 fetch 并不能天然解决这些问题它只是把同样的负担从一个 API 换到另一个 API。要彻底解决大文件上传问题必须从把一次请求拆成多次请求这个思路上找答案。3. 从上传到续传先想清楚为什么要分片断点续传这个词听起来好像很复杂但它背后的逻辑其实用一句话就能说清楚把一个大文件拆成多个小块一块一块地传服务器记下已经收到了哪些块下次继续传没传完的块。这就好比你要搬一百箱书从旧家到新家。一次搬一百箱你累垮了不说中途摔一跤全完蛋。而一次搬十箱来回十趟就算某一趟摔了也只有那十箱需要重新搬已经搬过去的九十箱安稳无恙。分片上传就是这个思路。3.1 单请求上传大文件的三条硬伤第一个硬伤是不可恢复。一次请求发送的整个 body 是一个完整数据流只要连接中断服务端接收到的数据几乎肯定是残缺的。没法说接着上次的字节数继续传因为 HTTP 请求是离散的服务端不会为半截请求保留状态。第二个硬伤是内存压力。你选择了一个大文件浏览器要把整个文件读入内存才能发送。现在文件动辄几百 MB如果再有多个文件并发上传页面崩溃是大概率事件。分片之后每次只读取和发送一小块内存始终维持在一个低水位。第三个硬伤是超时限制。中间的反向代理、负载均衡器一般都有请求超时配置比如 60 秒或 120 秒。如果大文件在限时内没传完连接会被中间层主动掐断。分片之后每个分片都很小能在短时间内完成就不容易触发超时。3.2 分片大小怎么选2MB 只是一个经验起点分片大小不是随便拍脑袋定的。我在项目里通常从两个维度考虑网络环境差、超时时间短用偏小的分片1MB~2MB保证单个分片能快速传完。网络环境好、需要减少请求次数用偏大的分片5MB~10MB。有人会问是不是分片越小越好不是。分片太小会导致请求数量爆炸每个请求都要经过建立连接、发送头部、服务端校验这一套流程这些开销加起来可能比传输数据本身还大。2MB 是一个比较均衡的经验值请求次数在可接受范围内单片的失败风险也低。这里我想强调一个经常被忽略的点分片大小对断点续传的粒度有直接影响。续传的最小单位是一个分片也就是说如果你用 10MB 的分片网络恢复后至少要从某个 10MB 的开头继续传。分片越小续传的粒度越细重传量就越少但请求次数和开销也越多。这就是一个典型的权衡问题。3.3 并发数练习别把所有分片一窝蜂发出去既然要把文件切成几十块甚至上百块那这些分片是一次性全部发出去还是一个一个排队传全部并发发出去会瞬间建立大量 TCP 连接浏览器和服务器都扛不住而且带宽有限并发再多也不会更快只会让每个请求互相抢资源。排队一个个传又会浪费带宽的并行能力。合理的做法是限制并发数。比如同时最多传 3 个或 5 个分片完成一个再补下一个。这样既能充分利用带宽又不至于把服务器打挂。这里给一个简单的并发控制代码骨架async function uploadChunksInConcurrency(chunks, concurrency 3) { const queue [...chunks]; const workers []; for (let i 0; i concurrency; i) { workers.push( (async () { while (queue.length) { const chunk queue.shift(); await uploadChunk(chunk); } })() ); } await Promise.all(workers); }这段代码的逻辑是先把所有分片放进一个队列然后启动固定数量的 worker每个 worker 不停从队列里取任务执行队列空了就退出。这是并发控制里最简单实用的一种实现比手写复杂的异步调度更可靠。4. 断点续传的第一块基石文件指纹与切片策略分片上传是手段断点续传是目标。但要让断点续传真正成立前端必须能回答一个问题用户传的是哪一个文件传到了哪一步要回答这个问题你得先给文件算一个唯一标识也就是文件指纹。4.1 文件指纹为什么要用内容哈希最朴素的想法是文件名。但文件名不靠谱因为不同用户完全可能传同一个名字的文件同一个文件也可能被改名。正确的姿势是用内容哈希来标识文件。浏览器的原生环境没有直接提供 MD5 或 SHA-1 这种哈希算法我们可以借助 SparkMD5 这类工具库。它的优势是支持增量读取不需要把整个文件一次性读入内存这对大文件的指纹计算至关重要。全量计算文件哈希在大文件场景下耗时太长。我实际测过一个 500MB 的文件全量做 MD5在普通笔记本上可能要好几秒甚至十几秒用户已经等得不耐烦了。所以在生产环境中我经常使用采样哈希的策略读取文件头部 2MB。读取文件中间位置 2MB。读取文件尾部 2MB。把这几个片段拼接起来做哈希速度极快文件越大效果越明显。这种方案牺牲了理论上的唯一性理论上存在碰撞可能但在工程实践中已经足够了。毕竟我们的目的是区分不同的文件不是做密码学安全认证。来看采样哈希的实现import SparkMD5 from spark-md5; async function calculateFileFingerprint(file) { if (file.size 8 * 1024 * 1024) { // 小于 8MB 的文件直接全量计算避免采样复杂度 return fullHash(file); } const chunkSize 2 * 1024 * 1024; const spark new SparkMD5.ArrayBuffer(); // 头部 2MB spark.append(await file.slice(0, chunkSize).arrayBuffer()); // 中间 2MB const midStart Math.floor(file.size / 2) - chunkSize / 2; spark.append(await file.slice(midStart, midStart chunkSize).arrayBuffer()); // 尾部 2MB spark.append(await file.slice(file.size - chunkSize, file.size).arrayBuffer()); return spark.end(); } function fullHash(file) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload (e) { const spark new SparkMD5.ArrayBuffer(); spark.append(e.target.result); resolve(spark.end()); }; reader.onerror reject; reader.readAsArrayBuffer(file); }); }注意这段代码里的FileReader和arrayBuffer()是两套 API前者是传统 API后者是较新的 Promise 式 API。两者的数据获取效果类似但file.slice(...).arrayBuffer()会更贴合现代异步写法也方便后续处理。4.2 切片时还要保留哪些元信息确定文件指纹之后前端切片时还需要为每一个分片维护足够丰富的元信息包括fileMD5整个文件的指纹用来标识这是哪个文件。chunkIndex分片的序号服务端按序号拼接。totalChunks总共有多少个分片。chunkSize每个分片的大小最后一片可能不满。originalName原始文件名。totalSize文件总大小。这里有一个常见的坑请务必记住最后一个分片通常不等于设定的分片大小。假设一个文件是 5.5MB你按 2MB 切会得到 3 个分片分别是 2MB、2MB、1.5MB。如果你认为所有分片大小都一样合并时就会因为长度对不上而拼接错误。正确做法是让服务端以实际接收到的分片大小为准不要做任何假设。这种元信息一旦约定好就要前后端严格遵守否则拆数据传到后端合并的时候对不上号功亏一篑。5. 断点续传的完整实现从初始化到合并落地现在我们有了理论基础可以开始写一套完整的断点续传实现。我会从前端切片、状态持久化、续传恢复、服务端合并四个环节逐步展开。5.1 前端切片与上传状态维护先定义分片任务的数据结构class Uploader { constructor(file, options {}) { this.file file; this.chunkSize options.chunkSize || 2 * 1024 * 1024; this.concurrent options.concurrent || 3; this.fileKey ; // 文件指纹 this.totalChunks Math.ceil(file.size / this.chunkSize); this.uploadedChunks new Set(); // 已上传的分片序号 this.abortController null; } generateChunkList() { const chunks []; for (let i 0; i this.totalChunks; i) { const start i * this.chunkSize; const end Math.min(start this.chunkSize, this.file.size); chunks.push({ index: i, blob: this.file.slice(start, end), size: end - start, }); } return chunks; } }uploadedChunks用Set来存储已上传的序号因为Set天然不会存重复值而且查找效率高。接下来是核心的上传方法。这里我有意把查询已上传分片和实际上传分片分成两步因为断点续传本质上就是先问服务器要一份进度清单再只传缺失的部分。class Uploader { async upload() { if (!this.fileKey) { this.fileKey await calculateFileFingerprint(this.file); } // 先向服务器发起一个握手请求问这个文件已经传了哪些分片 await this.checkExistingChunks(); // 构建任务队列过滤掉已上传的分片 const chunks this.generateChunkList().filter( (chunk) !this.uploadedChunks.has(chunk.index) ); if (chunks.length 0) { console.log(所有分片都已上传直接发起合并请求); await this.mergeRequest(); return; } // 并发控制上传 await this.runWithConcurrency(chunks); // 全部上传完成后通知服务器合并 await this.mergeRequest(); } async checkExistingChunks() { const response await fetch(/api/upload/progress?fileKey${this.fileKey}); const data await response.json(); const uploaded data.uploadedChunkIndexes || []; uploaded.forEach((index) this.uploadedChunks.add(index)); console.log(已有 ${uploaded.length}/${this.totalChunks} 个分片上传完成); } async uploadChunk(chunk) { const formData new FormData(); formData.append(file, chunk.blob); formData.append(fileKey, this.fileKey); formData.append(chunkIndex, chunk.index); formData.append(totalChunks, this.totalChunks); // 这里可以根据需要增加超时控制 const response await fetch(/api/upload/chunk, { method: POST, body: formData, signal: this.abortController?.signal, }); if (!response.ok) { throw new Error(分片 ${chunk.index} 上传失败); } // 上传成功后立即记录到 Set防止重复上传 this.uploadedChunks.add(chunk.index); } }关于uploadedChunks.add(chunk.index)这一步有一个容易被忽略的好处它会让并发环境下同一个分片不会被重复上传。因为并发控制的 worker 每次从队列里取任务前都会先检查一下这个分片是否已经传过传过了就直接跳过。在网络抖动导致的任务重试场景下这个保护机制非常关键。5.2 状态持久化刷新页面后怎么恢复网络上最尴尬的场景是用户传了 80%不小心按了 F5。如果前端不做本地状态存储这 80% 的白传了。解决办法是把文件指纹、分片进度保存到浏览器本地存储localStorage或IndexedDB里。每次上传成功后把分片序号存下来下次选择同一个文件时先查localStorage再设计一次服务器确认请求。方案 A简单粗暴——用localStorage。const STORAGE_KEY upload_progress; function saveProgress(fileKey, uploadedChunks) { const progressMap JSON.parse(localStorage.getItem(STORAGE_KEY) || {}); progressMap[fileKey] { uploadedChunks: [...uploadedChunks], updateTime: Date.now(), }; localStorage.setItem(STORAGE_KEY, JSON.stringify(progressMap)); } function getProgress(fileKey) { const progressMap JSON.parse(localStorage.getItem(STORAGE_KEY) || {}); return progressMap[fileKey] || null; }localStorage虽然简单但容量只有 5MB 左右而且在存储大量的分片序号时会有 JSON 解析速度慢的问题。如果文件分得很小、数量很大我更推荐用IndexedDB存储。IndexedDB 是异步的更适合存储结构化的、可能较大的数据。方案 B服务器确认——本地记录做参考服务器数据为准。本地记录会因为换电脑、清缓存等原因失效所以更可靠的做法是每次续传前仍然发送一次握手请求让服务器返回它实际收到的分片序号跟本地记录做比对取并集。这样即使本地记录丢了也不会影响到服务器上已有的进度。在实际项目中我通常让服务器作为分片进度的唯一真相来源。本地存储只是为了减少一次握手请求和给用户展示上次的进度的体验优化真正决定传什么的永远是服务端告诉我们的数据。5.3 服务端合并逻辑与验收规范前端把分片传完之后必须主动通知服务器可以合并了。合并请求里要带上文件指纹服务端根据指纹找到所有分片按序号依次拼接成完整文件。这个合并逻辑在服务端通常长这样伪代码风格实际语言随你的后端技术栈def merge_chunks(file_key, original_name, total_chunks): upload_dir os.path.join(TMP_DIR, file_key) complete_file_path os.path.join(FINAL_DIR, original_name) with open(complete_file_path, wb) as output: for i in range(total_chunks): chunk_path os.path.join(upload_dir, f{file_key}_{i}) if not os.path.exists(chunk_path): return error(f缺少分片 {i}) with open(chunk_path, rb) as chunk_file: shutil.copyfileobj(chunk_file, output) # 合并完成后清理临时分片目录 shutil.rmtree(upload_dir) return success(file_path)这段逻辑的关键点在于合并前必须校验分片是否齐全一旦发现有缺失分片立即返回错误信息前端拿到错误信息后自动补传缺失的分片。到这里断点续传的基本闭环就完成了指纹计算 → 切片 → 握手查询 → 并发上传 → 服务端合并。但真正拿得出手的工程实践还需要处理失败重试和取消上传的边界情况。6. 并发控制、失败重试与取消让上传真正做到可控如果说前面的切片和合并是断点续传的骨架那并发控制、失败重试和取消就是让它能在真实网络环境下站稳的肌肉。6.1 并发控制的完整实现我不想只给你一段简单的并发 Promise 代码因为在真实项目里你还需要知道哪个分片失败了重试几次哪些分片成功了这些信息。下面是一个更实用的runWithConcurrency实现class Uploader { async runWithConcurrency(chunks) { const failedChunks []; const queue [...chunks]; let activeWorkers 0; return new Promise((resolve, reject) { const next async () { if (queue.length 0 activeWorkers 0) { return resolve(); } while (queue.length 0 activeWorkers this.concurrent) { const chunk queue.shift(); activeWorkers; this.uploadChunk(chunk) .catch((err) { failedChunks.push(chunk); }) .finally(() { activeWorkers--; next(); }); } }; next(); }).then(() { if (failedChunks.length 0) { // 如果有失败的分片自动补充进队列重新执行 return this.runWithConcurrency(failedChunks); } }); } }这段代码里有几个值得注意的点任务队列的shift()从队头取任务保证每个分片最多被一个 worker 拿取。失败的分片会被收集起来不是放弃而是走自动重试的逻辑。递归调用runWithConcurrency处理失败分片时要小心死循环。如果服务器持续故障就会无限递归因此必须设置最大重试次数。改进一下加入重试次数上限async runWithConcurrency(chunks, remainingRetries 3) { // ... if (failedChunks.length 0 remainingRetries 0) { await this.runWithConcurrency(failedChunks, remainingRetries - 1); } // remainingRetries 为 0 时抛出错误进入全局错误提示 }6.2 取消上传与浏览器内存清理断点续传还有一个不起眼但很重要的技能取消上传。用户传着传着发现传错了文件或者想临时暂停这时候你需要一个AbortController来中止所有进行中的 fetch 请求。initAbortController() { this.abortController new AbortController(); } cancelUpload() { this.abortController?.abort(); this.abortController new AbortController(); }把signal传入每个分片请求const response await fetch(/api/upload/chunk, { method: POST, body: formData, signal: this.abortController.signal, });取消上传还有一个容易被忽略的问题内存里的分片 Blob 是否被释放。由于Blob是懒读取的切片后你持有的每个Blob引用并不占大量内存只是对文件数据的指针。但如果你把这些Blob对象保存在数组里数组本身会一直持有引用GC 无法回收。所以取消上传后最好把chunks数组清空cancelUpload() { this.abortController?.abort(); this.chunks []; this.uploadedChunks.clear(); }这不是什么高深的技术但很多项目的上传功能在多次取消/重传之后变得卡顿就是这个细节没做到位。6.3 进度条的正确计算方式因为分片上传是并发的你不能简单地把成功分片数/总分片数当作整体进度。更精确的计算方式是$$ 整体进度 \frac{已上传字节数 正在上传的字节数}{文件总字节数} $$在代码里的体现class Uploader { updateProgress() { let uploadedBytes 0; // 把已上传的分片字节数加起来 this.uploadedChunks.forEach((index) { const start index * this.chunkSize; const end Math.min(start this.chunkSize, this.file.size); uploadedBytes (end - start); }); const percent Math.round((uploadedBytes / this.file.size) * 100); console.log(整体进度${percent}%); } }如果你只按照分片数量来计算进度当文件最后一个分片很小的时候进度条会出现一种诡异的现象明明只剩一点点内容进度条却卡在接近 100% 的位置很久。按字节数计算就没有这个问题。7. 断点续传的边界测试哪些场景会让续传失效你写好了断点续传不能只在理想网络下测试就上线。我在这里整理几个实测中遇到的问题和边界情况帮你在上线前排查一遍。7.1 服务端缺少进度查询接口有些后端只做了接收分片的接口没有查询已上传分片的接口。没有这个握手接口前端就不知道服务器收到了哪些分片断点续传自然无法实现。这是前后端接口设计问题不能靠前端自己绕过。你需要推动后端实现一个GET /upload/progress?fileKeyxxx的接口返回已上传的分片序号列表。如果客观条件不允许后端改接口前端有个折中方案把进度本地持久化默认服务器未接收到任何分片把已上传的分片序号统一带上也就是盲传所有本地记录过的分片之外的内容。这个方案有风险因为本地记录可能与服务器状态不同步比如服务器清理过临时文件。做的话一定要做好错误兜底。7.2 文件在传输过程中被修改用户开始上传后如果在本地把同一个文件改动了哪怕只改了 1 个字节文件指纹就变了续传自然不会匹配上。但要注意的是分片是file.slice()切出来的引用文件改动后这些 Blob 引用对应的数据会不会变这里有个关键点File对象一旦创建它对应的就是文件在某一时刻的快照。如果你在读取分片之前修改了原文件file.slice()的行为取决于浏览器实现主流浏览器通常绑定的是文件被选择时的数据。但为了稳妥我建议在上传期间对文件对象加一个只读锁定状态或者在开始上传时把文件大小、最后修改时间记录在元数据里合并时校验这些值是否一致。一旦发现不一致直接判定上传失败并提醒用户重新选择。7.3 并发控制下的分片任务丢失手动实现并发控制时最容易出现的问题是某个分片明明没有完成但队列已经空了导致整个上传流程提前结束。这种问题通常源于对 Promise 生命周期的不当理解。我自己曾经的代码里就出现过worker 在 awaituploadChunk的过程中queue.shift()已经取出了分片如果这个 Promise 被 reject 了queue里却没有了这个分片重试逻辑想发却无处可发。所以要强调的是用failedChunks收集失败分片。重试时把失败分片重新加入队列。结束条件是队列为空且没有活跃 worker。如果你用上面我给出的runWithConcurrency骨架这个逻辑已经在里面了但自己改写出错是挡不住的。这部分的单元测试建议多写几个场景全部成功、部分失败、全部失败、一个分片重复执行。7.4 断网模拟最有效的验证方式给前端上传功能做测试时我最常用的验证手段是用浏览器开发者工具的网络面板把网络节流调到Slow 3G让上传速度慢下来。上传到中途直接断网或者把开发者工具的离线模式打开。等待请求失败恢复网络。重新选择同一个文件观察前端是否通过握手接口跳过已完成的分片只上传缺失的部分。最终合并成功文件的 MD5 与原始文件完全一致。这套流程走一遍能发现的问题比代码 review 十遍还有效。断点续传的成败最终取决于是否真的能少传几个分片以及合并后的文件是否完整。我在多个项目里实测的结论是分片上传配合 2MB 分片、3 个并发、失败重试 3 次在 4G 网络环境下非常稳定。5G 或同机房内网环境下可以把并发放到 5~6 个分片调到 5MB 左右体验会更好。但千万别把并发调到 10 以上浏览器会报Too many requests或TCP 连接数上限不划算。8. 给想要继续深入的人一份扩展清单断点续传做完之后如果你还想继续完善上传体验下面这几个方向是我觉得实际价值较高的补充项按实现成本从低到高排列第一上传前的图片压缩。图片类文件可以在前端用 Canvas 压缩到合适尺寸再上传减少 80% 以上的传输量对用户体验提升非常明显。第二上传中 Web Worker 的使用。文件切片如果文件很大且数量很多切片过程本身可能阻塞主线程把切片逻辑挪到 Web Worker可以保证页面不卡。第三上传速度预测。根据最近一段时间上传速率估算剩余时间展示给用户预计还剩 X 分 X 秒虽然只是一个小细节但对用户耐心很有帮助。第四文件的秒传处理。如果服务端发现这个文件指纹已经存在比如前一秒有人传过完全一样的文件可以直接返回上传成功前端连传都不用传。在企业网盘、云盘场景里这个功能能极大地节省存储和带宽。这些扩展方向不会改变断点续传的核心逻辑它们只是在核心逻辑外面套上更多层的产品体验。等你把核心流程跑通再回头审视这些扩展项会发现每新增一个上传模块就离生产级更近一步。我这边的经验是上传功能的难点从来不再是能不能传上去而是传中断了能不能快速恢复传错了能不能及时停传完了能不能保证文件完整。这三件事值得每一个做前端的人认认真真过一遍。