资讯动态

JavaScript大文件上传实战:分片上传、断点续传与秒传方案解析

发布时间:2026/9/12 23:12:43 来源:尧图企业网站定制
做前端久了都会遇到一个绕不开的需求上传文件。小文件还好form表单一把梭后端收完就完事。一旦文件变成几个GB比如视频素材、安装包、数据库备份你要是还拿老办法直接提交几乎必翻车——服务端超时、内存被打满、浏览器卡死、传了一半网络断了又从头再来哪个都让人血压飙升。这篇文章纯粹是多年填坑经验的汇总。我会把JavaScript处理大附件上传的几条主流路线、分片上传的核心原理、断点续传和秒传的实现思路、以及实际开发中的各种坑都梳理一遍。不管你是刚接手文件上传功能的新手还是想优化现有上传模块的老手这篇应该都能给你一些可以直接用的参考。1. 方案演进与整体选型1.1 为什么传统上传搞不定大文件先说清楚传统表单上传到底死在哪儿。一个常规的form提交浏览器是把整个文件内容读进请求体里一次性发送的。对小文件这没问题一旦文件大了问题全来了。第一是服务器压力。不管你用Nginx、Node.js还是Java接收请求时基本都要先把整个请求体缓冲下来。PHP默认post_max_size是8MNginx默认client_max_body_size是1M即使你把这些限制调大了服务器在接收几个GB的文件时内存和磁盘I/O都会瞬间拉满处理其他正常请求都会受影响。第二是超时问题。大文件上传耗时动辄几分钟到几十分钟中间只要网络稍微抖动一下TCP连接断了整个请求就废了。代理层一般还有空闲超时配置长时间没有数据传输的请求会被主动掐断用户等半天最后收到一个502或504体验非常糟糕。第三是断点问题。没有分片的上传失败就从头再来。用户传了80%掉线再传还是从0%开始这不是技术问题这是产品口碑问题。第四是浏览器内存。某些实现会先把文件读成DataURL或ArrayBuffer再发送几个GB的文件读进内存Chrome直接给你OOM内存溢出崩溃这个应该有不少人踩过。所以核心思路很简单把一个大的上传任务拆成多个独立的小任务来执行。这就是分片上传能成为主流方案的根本原因。1.2 当前主流的几种技术方案对比目前JavaScript侧真正在用的方案我梳理下来其实就四大类。第一类是传统表单上传只适合小文件大文件场景直接不建议。唯一的优化空间是用XMLHttpRequest或fetch发送FormData这样可以拿到上传进度但本质没变还是整体传输。第二类是分片上传这是目前大文件上传事实上的标准方案。思路是用File.slice()把大文件切成多个几MB的块逐个上传到服务器最后让服务端按顺序合并。单纯分片还不行还要配套做并发控制、失败重试、进度合并。如果你做视频上传、网盘上传这类业务基本用的都是这一套。第三类是断点续传秒传。断点续传要解决的是上一次没传完下一次不用全部重来秒传要解决的是服务器上已经有相同文件时直接跳过上传。这两个功能通常和分片上传是捆绑实现的现在网上能搜到的大文件上传方案基本都是这个组合。第四类是直传对象存储。如果你的后端不强或者干脆想省流量可以采用前端直接把文件上传到云存储服务比如阿里云OSS、腾讯云COS、AWS S3。云端会给你生成带签名的上传URL前端拿到后直接PUT或POST过去。这种方式的分片、续传能力由云厂商帮你实现了很多SDK内部就是在做分片并发续传。对大多数业务来说这是我个人很推荐的方式能省掉自己维护一套上传服务的成本。这四个方案可以对比一下方便快速选型。方案适合场景大文件支持断点续传服务端复杂度开发量传统表单上传头像、文档等小文件差无低极低分片上传视频、压缩包等中大文件好可扩展中高分片续传秒传网盘、素材库等正式产品很好完整较高很高云存储直传有稳定云服务预算的业务好由SDK提供低中1.3 选型时的几个关键判断这里说一些实际工作中沉淀下来的选型经验。你看到网上一堆五花八门的方案很容易挑花眼但真正决定该用哪个的其实就是几个问题。第一文件最大会有多大如果业务场景里基本不会超过200M那传统上传加一个进度条就够了真没必要上分片除非你预料到以后文件会越传越大。如果可能到1GB甚至更大分片是必须的。我经手的项目里用户传一个10GB的工程文件都有这种就只能走分片续传没有任何商量余地。第二需不需要断点续传做内部工具可以不要用户传断了让他重传一次问题不大。但如果你做的是面向公众的产品网络环境不可控断点续传基本是标配。做续传意味着要有文件唯一标识的机制、服务端要记录分片状态开发量会上一个台阶。第三团队有没有人力维护一个后端上传接口如果后端人少或者你只是一个全栈想快速搞定云存储直传是最优解。说句实在话大部分中小项目的上传需求用OSS或者COS的分片上传SDK就够了自己写一套完整的分片上传服务端代价并没有想象中那么小——你要处理分片存储、合并任务、过期清理、并发保护一堆脏活累活。2. 分片上传的核心原理与前端实现细节2.1 分片上传的基本流程用一句话概括分片上传就是前端把大文件切成多个小块逐个上传全部传完后通知服务端合并服务端按顺序把小片拼成完整文件。流程看起来简单但每个环节都有讲究。完整的分片上传一般会经过这几个步骤用户选择文件后前端计算文件唯一标识通常是文件内容的Hash值后面说秒传时再展开。前端把文件按固定大小切成多个分片并为每个分片生成序号从0或1开始。前端先请求后端一个“初始化上传”的接口把文件名、大小、分片总数、文件唯一标识传过去后端记录一条上传任务并返回一个uploadId。前端拿着分片列表在并发数控制下逐个上传分片每个分片请求里带上uploadId、分片序号、分片内容。每个分片传完后后端单独保存并校验大小返回该分片的上传结果。所有分片传完后前端请求后端“合并分片”接口后端按分片序号合并成完整文件并做完整性校验。前端轮询或等待合并结果成功后展示最终的文件地址。这个流程是现在所有主流分片上传组件的底线不管是webuploader、vue-simple-uploader还是各家云SDK本质都绕不开这一套。2.2 File.slice切片与分片大小怎么选切片本身很简单file.slice(start, end)拿到一个Blob实例直接放进FormData里上传就行。真正需要认真考虑的是分片大小。我见过有人把分片设成2MB也有人设成10MB还有人干脆切成1MB。这个值没有标准答案但要结合你的网络、服务端和业务场景来权衡。分片设得太小比如1MB有几个问题一是分片数量太多每个分片都是一次HTTP请求请求数猛增服务端要处理的请求量太大二是HTTP请求本身的握手、传输头部这些开销占比变大效率反而低三是很多品牌的服务端日志会被海量请求刷爆。分片设得太大比如50MB虽然请求数少了但万一某一片传失败需要重传代价就大了而且在弱网环境下一个分片传输时间过长超时和断连的概率也会变大。从我实际测试的经验看普通办公网络和企业服务器场景分片大小设在2MB到10MB之间是比较稳的区间。我个人用得最多的是5MB。假设上传一个1GB的文件按5MB分片就是205个分片并发控制在3到6个整体速度不会太差容错也好。如果业务上传的文件普遍很大几十GB起步可以适当调大到8MB或10MB因为分片总数太多的话光是服务端存小文件的数量就会造成压力。另外切片时要留意最后一个分片可能比设定值小这是正常的不需要特殊处理服务端按实际接收大小保存即可。2.3 并发控制为什么不能一次性全发很多人第一次实现分片上传时会写一个循环把所有分片全部发出去结果发现要么浏览器卡死要么进度条乱跳要么到后面一堆失败。原因很简单。浏览器对同一域名的并发TCP连接数有限制。HTTP/1.1下Chrome是6个Firefox是6个如果分片数量是几百一次性全发出去后面的请求全都在排队而且前6个请求还没完成时就传满了连接池其他页面的正常请求也会被堵住。再一个几十个分片同时上传内存占用会跟着涨网络状况稍差就会整片整片地超时失败。所以必须做并发控制。我自己常用的实现方式是写一个简单的并发池核心逻辑就几条维护一个待上传任务队列。同时最多启动N个上传任务N我通常设成3到6。每当一个任务完成从队列里取出下一个任务开始执行。所有任务完成后进入合并阶段。用Promise实现也就三四十行代码不需要引入额外库。这里给一个可运行的示例。class ConcurrencyPool { constructor(tasks, limit 4) { this.tasks tasks; this.limit limit; this.index 0; this.activeCount 0; this.results []; } async run() { const workers new Array(Math.min(this.limit, this.tasks.length)) .fill(0) .map(() this._worker()); await Promise.all(workers); return this.results; } async _worker() { while (this.index this.tasks.length) { const current this.index; this.activeCount; try { const result await this.tasks[current](); this.results[current] result; } catch (err) { this.results[current] { error: err.message }; } finally { this.activeCount--; } } } }使用的时候把每个分片的上传函数包装成一个() uploadPart()任务塞给ConcurrencyPool设置并发数为4然后await pool.run()就能稳定地按指定数量并发上传分片。有一点要注意并发数不是越大越好。调成10以上网络吞吐并不一定会线性上升反而容易触发服务端的连接限制或限流导致大面积的429、502。我自己实践下来并发数控制在3到6比较理想具体还要看分片大小和服务器能力。2.4 上传进度与体验细节进度条是大文件上传的脸面做得好不好直接影响用户对产品可靠性的判断。分片上传的进度计算和普通上传不太一样不能只看某一个分片的进度要算整体进度。整体进度的计算公式是(已成功上传的分片字节数 当前正在传输的分片已传输字节数) / 文件总大小 * 100。实现时我会给每个分片都挂一个loaded和total通过XMLHttpRequest的upload.onprogress事件更新。整体进度则用所有分片的loaded之和除以total之和来算。注意是“所有分片”包括还没开始传的——没开始传的loaded视为0。如果你是整套自己写还要处理几个常见的体验细节。一是失败重试。单次分片上传失败先别急着报错给用户自动重试两三次再说。我用的是指数退避策略第一次失败等1秒第二次失败等2秒第三次等4秒最多重试3次。这样能扛住大部分网络抖动导致的分片丢失。二是取消上传。用户中途点取消前端要把已经发出去但还没完成的XHR全部abort()同时通知服务端把这个uploadId对应的临时分片清理掉不然服务端磁盘上会堆一堆垃圾文件。三是暂停和恢复。这个和取消不同暂停不是清掉任务而是把未完成的分片列表保存起来等用户点击继续时接着传。实现上依赖断点续传的能力下面会展开说。3. 断点续传与秒传的完整方案3.1 断点续传的两种实现思路断点续传的核心是“记住上次传到了哪里”下次接着传。实现上有两条路。一条是纯前端思路把已完成的分片序号记录在浏览器的localStorage或IndexedDB里。下次用户选择同一文件时先判断本地有没有历史记录有就直接跳过已传分片。这种方式实现简单但问题也明显localStorage容量极小一般5M左右历史记录多了容易爆而且用户换了浏览器或清了缓存续传就失效了记录的是本地状态服务端可能早把临时分片清掉了对不上。另一条是服务端查询思路每次打开页面时前端先调用一个查询接口把文件唯一标识传给后端后端返回这个文件已上传成功且校验通过的分片序号列表前端拿到后只传缺失的部分。这是正规产品里我推荐的做法数据都在服务端所有客户端共享一套状态不依赖浏览器缓存。实际实现时两种可以结合先看本地有没有缓存有就少问一次后端没有就走服务端查询。我个人觉得为了逻辑统一直接走服务端查询就够了本地缓存省下那几个请求意义不大。3.2 文件指纹与秒传原理秒传其实不是“传得快”而是“不用传”。原理是上传前前端算出文件的唯一哈希值比如MD5发给服务端服务端查一下自己的文件库如果发现完全相同的文件已经存在就直接返回“上传成功”前端连传都不用传了。这个功能在实际业务里很实用。比如企业内部网盘里经常有人用即时通讯软件传一个安装包给同事同事再传到网盘——同一个文件被传好几遍。有了秒传后面再传就是一瞬间的事服务器和带宽的压力立刻小很多。做秒传的关键在于文件唯一标识怎么算。最稳妥的是对全文件算MD5或SHA-1但问题是一个大文件算MD5也要把整个文件都读一遍几个GB的文件在浏览器主线程里算Hash页面会卡得没法用。所以实践中常用两个优化手段。第一个是抽样Hash。不读全文件只选取文件开头、中间、结尾几个固定位置的块来算哈希。虽然不是100%准确但对绝大多数业务场景足够用了性能提升明显。注意抽样Hash有碰撞风险出现碰撞后合并结果可能是个损坏文件——如果业务对数据准确性要求极高还是全量Hash加上后端二次校验。第二个是Web Worker。把Hash计算的逻辑放到后台线程去跑主线程就不会被阻塞。这个是真的关键几GB的文件在主线程算MD5用户看着页面卡住第一反应是站点出bug了。我习惯用SparkMD5这个库。它支持增量计算可以一段一段读文件防止一次把整个文件读进内存。配合Web Worker使用基本流程是// 在 Web Worker 内部 importScripts(spark-md5.min.js); self.onmessage function (e) { const file e.data.file; const chunkSize 2 * 1024 * 1024; // 每次读2MB const spark new SparkMD5.ArrayBuffer(); let currentChunk 0; const totalChunks Math.ceil(file.size / chunkSize); const fileReader new FileReader(); fileReader.onload function (ev) { spark.append(ev.target.result); currentChunk; // 上报计算进度 self.postMessage({ type: progress, percent: Math.round((currentChunk / totalChunks) * 100) }); if (currentChunk totalChunks) { loadNext(); } else { self.postMessage({ type: done, hash: spark.end() }); } }; fileReader.onerror function () { self.postMessage({ type: error, message: 文件读取失败 }); }; function loadNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); fileReader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); };这样算出来的Hash就是文件的唯一ID秒传的判断、分片的状态记录、断点续传的恢复都依赖这个唯一ID来关联。3.3 服务端接口设计与合并策略前端流程清晰了服务端接口怎么设计就成了关键。按上面的分片流程服务端最少需要三个接口。第一个是初始化上传接口。接收文件唯一标识、文件名、文件大小、分片大小、分片总数。服务端先查一下这个唯一标识是否已有完整文件有就返回已存在实现秒传没有就创建上传任务返回uploadId。第二个是分片上传接口。接收uploadId、分片序号、分片文件。服务端把分片保存到临时目录校验大小然后返回成功。这个接口是并发被调用最多的要能扛住并发写。第三个是合并分片接口。接收uploadId。服务端检查该上传任务的所有分片是否都已成功缺哪个就返回哪个缺失前端可以补齐再重新请求合并全齐就按序号把分片流式合并成完整文件并做一次文件大小校验。合并策略上有人用先存分片后合并的方式也有人用直接流式追加的方式。对小项目前者简单清晰分片就是以uploadId_index命名的临时文件合并时按顺序读出来拼一起就行。唯一要注意的是合并时要给每个分片的大小做个校验防止部分分片内容不完整导致最终文件损坏。4. 完整实战从零手写一个可落地的分片上传模块4.1 前端核心代码实现理论说了那么多直接上一段能跑的核心代码。这个模块我抽掉了业务层保留了最核心的分片上传逻辑覆盖切片、并发、进度、重试、合并请求这几个关键点。class ChunkedUploader { constructor({ file, chunkSize 5 * 1024 * 1024, concurrency 4, maxRetries 3, initUrl /api/upload/init, chunkUrl /api/upload/chunk, mergeUrl /api/upload/merge, queryUrl /api/upload/query, getUploadFileUrl /api/file/detail, headers {} }) { this.file file; this.chunkSize chunkSize; this.concurrency concurrency; this.maxRetries maxRetries; this.urls { initUrl, chunkUrl, mergeUrl, queryUrl, getUploadFileUrl }; this.headers headers; this.fileId null; // 文件唯一标识由 hash 计算得到 this.uploadId null; // 服务端返回的上传任务ID this.chunkList []; // 分片列表 this.aborted false; } // 1. 生成分片列表 _buildChunkList() { const count Math.ceil(this.file.size / this.chunkSize); this.chunkList Array.from({ length: count }, (_, index) ({ index, blob: this.file.slice(index * this.chunkSize, Math.min((index 1) * this.chunkSize, this.file.size)), size: Math.min(this.file.size - index * this.chunkSize, this.chunkSize), status: pending // pending | uploading | success | failed })); } // 2. 计算文件哈希实际建议放到 Web Worker 中 async _calcFileHash() { // 这里省略 spark-md5 的 Worker 实现可参考上面章节 // 返回一个 Promisestring } // 3. 初始化上传任务 async _initUpload() { const res await fetch(this.urls.initUrl, { method: POST, headers: { Content-Type: application/json, ...this.headers }, body: JSON.stringify({ fileId: this.fileId, fileName: this.file.name, fileSize: this.file.size, chunkSize: this.chunkSize, chunkCount: this.chunkList.length }) }); const data await res.json(); if (data.code 0) { // 如果服务端返回 SKIP说明文件已存在直接秒传 if (data.data.skipUpload) return { skip: true, fileUrl: data.data.fileUrl }; this.uploadId data.data.uploadId; return { skip: false }; } throw new Error(data.message || 初始化上传失败); } // 4. 查询已上传分片用于断点续传 async _queryUploadedChunks() { const res await fetch(${this.urls.queryUrl}?fileId${this.fileId}, { method: GET, headers: { ...this.headers } }); const data await res.json(); if (data.code ! 0) return []; const uploadedSet new Set(data.data.uploadedChunks || []); this.chunkList.forEach(chunk { if (uploadedSet.has(chunk.index)) { chunk.status success; } }); } // 5. 上传单个分片带重试 async _uploadSingleChunk(chunk) { const formData new FormData(); formData.append(uploadId, this.uploadId); formData.append(index, chunk.index); formData.append(chunk, chunk.blob, part-${chunk.index}); formData.append(size, chunk.size); for (let attempt 1; attempt this.maxRetries; attempt) { if (this.aborted) return; chunk.status uploading; try { const res await fetch(this.urls.chunkUrl, { method: POST, headers: { ...this.headers }, // 注意不要手动设置 Content-Type让浏览器自动带 boundary body: formData }); const data await res.json(); if (data.code 0) { chunk.status success; return; } } catch (err) { // 网络异常继续重试 } await this._sleep(attempt * 1000); // 指数退避1s, 2s, 3s } throw new Error(分片 ${chunk.index} 上传失败); } _sleep(ms) { return new Promise(resolve setTimeout(resolve, ms)); } // 6. 并发上传所有分片 async _uploadAllChunks(onProgress) { const pending this.chunkList.filter(c c.status ! success); let completedBytes this.chunkList .filter(c c.status success) .reduce((sum, c) sum c.size, 0); const workers Array.from({ length: Math.min(this.concurrency, pending.length) }, () this._chunkWorker(pending, completedBytes, onProgress) ); await Promise.all(workers); } async _chunkWorker(pending, completedBytes, onProgress) { while (pending.length 0 !this.aborted) { const chunk pending.shift(); try { await this._uploadSingleChunk(chunk); } catch (err) { chunk.status failed; throw err; } // 进度回调直接把整体进度抛出去给UI层 const doneBytes this.chunkList .filter(c c.status success) .reduce((sum, c) sum c.size, 0); onProgress?.(Math.round((doneBytes / this.file.size) * 100)); } } // 7. 合并分片 async _mergeChunks() { const res await fetch(this.urls.mergeUrl, { method: POST, headers: { Content-Type: application/json, ...this.headers }, body: JSON.stringify({ uploadId: this.uploadId, fileId: this.fileId }) }); const data await res.json(); if (data.code ! 0) { throw new Error(data.message || 合并失败); } return data.data.fileUrl; } // 对外入口执行上传 async upload({ onProgress } {}) { await this._calcFileHash(); this._buildChunkList(); await this._queryUploadedChunks(); // 断点续传可选 const initResult await this._initUpload(); if (initResult.skip) { onProgress?.(100); return initResult.fileUrl; } await this._uploadAllChunks(onProgress); const fileUrl await this._mergeChunks(); onProgress?.(100); return fileUrl; } abort() { this.aborted true; } }上面这段代码有几个细节值得说明。FormData上传时不要手动设置Content-Type为multipart/form-data否则要手动拼boundary很容易出错。让浏览器自己生成Content-Type和boundary是最省心的做法。重试逻辑里指数退避的await this._sleep(attempt * 1000)是必要的连续快速重试只会让本来就抖动的网络雪上加霜。这个策略在线上确实能大大降低最终失败率。断点续传查询接口要在初始化之前调用因为服务端需要先用fileId查到已存在的分片记录。如果顺序反了初始化接口会新建一个空的uploadId之前的进度就丢了。4.2 关键参数到底怎么调很多参数不是凭感觉设的下面是我在真实项目中调参时的思考逻辑。分片大小默认5MB。理由前面说了2MB太小请求数翻倍10MB以上弱网重试成本高。如果目标用户大多在移动网络环境比如4G建议降到2MB到3MB因为弱网下大分片更容易超时如果用户基本是办公宽带可以升到8MB到10MB减少请求总数服务端压力更小。并发数默认4。同时考虑浏览器连接数限制和服务器承受能力。HTTP/1.1下最多6个所以设4是合理的如果服务器是云函数这类按调用计费的服务可以降到2省点钱如果上传文件普遍在1GB以上且服务器扛得住开到6也行。不要盲目开大我见过有人把并发设成10结果后端日志一堆502。重试次数3次比较合适。低于3次扛不住偶发网络抖动高于5次的话某些分片如果是因为文件内容本身的问题失败比如服务端磁盘满了再重试也是浪费时间和流量。另外建议加一个上传超时时间。分片上传的XHR要设置超时比如60秒。不设超时的话某个分片万一卡在连接阶段整个上传任务会一直挂在那里不动用户永远看不到进展。4.3 服务端要点以Node.js为例前端写好了服务端也得能对接。这里给一个精简的Node.js示例说明接口设计思路实际生产肯定要换成框架和数据库。// 伪代码示意接口逻辑 const uploadTasks new Map(); // 实际应用请用Redis或数据库 // 初始化上传 app.post(/api/upload/init, (req, res) { const { fileId, fileName, fileSize, chunkSize, chunkCount } req.body; // 1. 先查文件是否已存在秒传 if (fileStore.exists(fileId)) { return res.json({ code: 0, data: { skipUpload: true, fileUrl: fileStore.get(fileId) } }); } // 2. 创建上传任务 const uploadId randomUUID(); uploadTasks.set(uploadId, { fileId, fileName, fileSize, chunkSize, chunkCount, uploadedChunks: new Set(), status: uploading }); res.json({ code: 0, data: { uploadId, skipUpload: false } }); }); // 上传分片 app.post(/api/upload/chunk, upload.single(chunk), (req, res) { const { uploadId, index } req.body; const task uploadTasks.get(uploadId); if (!task) return res.json({ code: 1, message: 上传任务不存在 }); // 保存分片到临时目录如 /tmp/uploads/{uploadId}/{index} saveChunk(uploadId, index, req.file.buffer); task.uploadedChunks.add(Number(index)); res.json({ code: 0 }); }); // 合并分片 app.post(/api/upload/merge, (req, res) { const { uploadId, fileId } req.body; const task uploadTasks.get(uploadId); // 检查分片是否全齐 for (let i 0; i task.chunkCount; i) { if (!task.uploadedChunks.has(i)) { return res.json({ code: 1, message: 缺少分片 ${i} }); } } // 按序合并 const output createWriteStream(getFinalPath(fileId)); for (let i 0; i task.chunkCount; i) { const chunkBuffer readChunk(uploadId, i); output.write(chunkBuffer); deleteChunk(uploadId, i); // 合并后清理 } output.end(); res.json({ code: 0, data: { fileUrl: getFileUrl(fileId) } }); }); // 查询已上传分片 app.get(/api/upload/query, (req, res) { const { fileId } req.query; // 找到该 fileId 对应的任务返回已上传分片序号列表 res.json({ code: 0, data: { uploadedChunks: [...task.uploadedChunks] } }); });这里必须强调两个生产环境要点。第一临时分片目录一定要有定期清理策略。用户传了一半就关掉页面服务端临时目录里会留下一堆孤儿分片如果什么定期清理都不做用不了多久磁盘就满了。我在项目里是每小时清理一次超过2小时未完成任务的临时分片。第二合并时要加文件完整性校验。合并完成后检查最终文件大小是否等于初始化时记录的文件大小。不一致要立刻报错否则用户拿到一个损坏文件排查成本会高得多。5. 常见问题速查与避坑记录5.1 高频问题排查表整理了一份我在实际调试中经常遇到的问题和对应解法。问题现象可能原因解决方法上传到一半报502/504代理层或服务端超时时间过短调大Nginx的proxy_read_timeout同时确认分片大小是否太大导致单片传输耗时过长进度条不动或卡住分片上传卡在请求阶段给XHR设置超时检查服务端是否对请求体有大小限制合并后的文件损坏或打不开分片顺序错乱或分片内容不完整合并前校验每个分片大小合并时严格按序号写入合并后做最终大小校验某个分片反复上传失败服务端磁盘满了或网络丢包严重检查服务端磁盘降低并发数增加重试次数和退避时间秒传没生效每次都全量传前端Hash计算逻辑有误或服务端未正确存储文件ID先用同一个文件调试确认前端Hash一致再排查服务端查询逻辑用户关闭页面后重新上传旧进度丢失未实现服务端查询已传分片实现查询接口前端上传前先拉取已传分片列表HBuilder或IDE里报JS语法错误本地方案引用了不兼容的语法或旧API按ES6确认node和浏览器版本FileReader/File.slice检查兼容性写法有一类和“安全检测提示脆弱的JavaScript库”相关的情况也值得说一句。如果你项目里引用了比较老的上传组件用的还是jQuery插件或老版本SDK安全扫描工具经常会报“目标站点存在JavaScript框架库漏洞”之类的风险提示。这不是功能bug但最好及时升级组件版本把那些废弃API换掉不然安全评审这关很难过。5.2 实战中那些文档里不会写的细节这个部分算是老实习生在坑里爬出来的经验不一定写在什么官方文档里但对实际开发非常有用。第一上传进度条的数值不要直接透传给用户。真实的进度不是线性的前5%可能在算Hash后10%可能在合并用户看到卡在99%会以为系统死了。我一般会在UI层做一次平滑处理比如进度到95%后合并阶段故意显示“服务端处理中”避免用户焦虑。第二分片上传不是简单的“切了就完”。并发上传时服务端接收分片的顺序是乱的。如果你的合并逻辑把“先收到的分片写入输出文件末尾”那文件铁定损坏。正确的做法永远是按序号合并而不是按接收顺序合并。第三file.slice(start, end)的end是开区间。切片时不注意边界最后一个分片很容易重复或缺失。我习惯统一用Math.min(start chunkSize, file.size)来截断end避免手滑写错。第四大文件Hash计算一定要用增量读取。直接用readAsArrayBuffer(file)传整个文件给SparkMD5内存立刻爆炸。我在前面代码里用了分块循环读这是必须的。第五关于开发环境如果你用HBuilder这类工具配置JavaScript调试环境要注意设置合适的模拟网络环境否则本地测试时无法模拟真实弱网下分片失败的情况。我在HBuilder里会同时开一个移动端真机预览测试移动网络下的上传表现。5.3 遇到上传组件“漏洞报告”怎么办前面提过安全扫描报“脆弱的JavaScript库”这里单独展开一下。现在很多公司上线前都要过安全扫描如果引用了老版本的上传组件扫描结果里会出现类似“检测到目标站点存在javascript框架库漏洞”的提醒。处理思路不是急着换框架而是先顺着扫描报告定位到具体是哪个库哪个版本。拿我踩过的坑举例之前项目里用了老版webuploader它内置的Flash上传模块已经停止维护安全扫描直接标记。我的做法是先看了看业务侧依赖的API然后升级到了社区维护的版本或者换成基于XHR的上传方案把Flash相关逻辑彻底删掉。升级完成后重新扫描漏洞项就消失了。这里提醒一句如果升级组件发现有接口变化不要硬着头皮改配置先把文档里废弃的API找出来替换一遍。安全扫描过的组件不意味着完美但至少不会再被扫出明显的漏洞提示。6. 写在最后的个人经验文件上传这个功能做起来不难做得好难。它横跨前端、后端、网络、存储四个领域任何一个环节掉链子最终暴露的都是用户面前的失败弹窗。我个人的习惯是先明确文件大小的上限和用户网络环境再决定方案复杂度。小项目能直传云存储就直传不要自己折腾分片服务端大项目宁可前期多花点时间把分片、续传、秒传这套做扎实也别等上线后天天被客服投诉“传不了大文件”。最后分享一个小技巧开发时多准备几个不同大小的测试文件一个几百KB的、一个100M左右的、一个1GB以上的。很小的文件用来测秒传中等文件测分片进度和并发大文件测稳定性和内存。用三个文件跑通全流程基本能覆盖绝大多数线上场景。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价