
前几天有个朋友找我聊天说他们准备做一个资料库用户经常要传几个GB的视频文件结果用普通上传接口传一会儿就断重试又是从头传客户已经骂了好几轮。他问我网页大文件分片上传到底怎么才能做好能不能像别人那样直接用开源组件把源码扒下来改造这是个很常见的问题但也是个很容易被低估的问题。作为过来人我可以负责任地告诉你直接用开源组件确实能省掉八成工作量但如果你不理解它背后的源码逻辑遇到问题还是会两眼一抹黑——比如分片明明传完了服务端合并失败、进度条走到99%卡死、断点续传偶发丢片。本文我会从底层原理讲起再给出一个可运行的实现示例最后把我自己踩过的坑一并分享出来。无论你是想直接用现成组件还是想基于开源库二次封装这篇文章都能帮你少走弯路。1. 大文件上传的痛点为什么分片是绕不开的方案1.1 整包上传遇到的三重阻力很多人第一次做上传功能时会想不就是一个POST请求的事吗浏览器把文件放在FormData里一递后端接收就好。小文件确实没问题但文件一旦超过几百MB第一个跳出来捣乱的就是浏览器内存。整包上传时浏览器要么把整个文件读进内存再发送要么用流式上传慢慢推。前者对大文件来说基本等于内存压力测试用户电脑稍微老一点标签页直接崩溃后者虽然内存压力小但网络一抖动连接断开服务端已经收到的数据全部作废。更麻烦的是很多后端接口甚至Web服务器都有请求体大小限制一个超大上传请求很可能直接被中间层拦掉连后端代码都执行不到。第二个阻力是超时。一个2GB文件在普通宽带下可能要传几十分钟甚至更久。期间任何一次网络波动、DNS切换、代理重连都可能导致TCP连接被重置。传统上传请求一旦断开浏览器只会告诉你“上传失败”但它不会保留已发送的部分下次只能从头再来。对用户来说这种体验几乎等于“白传了”。第三个阻力是并发利用率。单连接上传的速度受限于单个TCP窗口和往返延迟而且在弱网环境下丢包重传会让吞吐量进一步下降。整包上传天然只有一个请求你没法通过同时建立多个连接来提高吞吐。换句话说哪怕你有充足的上行带宽单连接也未必跑得满。1.2 分片上传到底改写了什么分片上传的逻辑并不复杂先把大文件用Blob.slice()切成若干个小块每个小块独立上传。这样带来几个立竿见影的好处。第一单次请求体小了内存压力、代理限制、服务器接收压力都跟着降下来。原来传2GB文件可能需要几分钟切分成500个4MB的分片后每个请求只有4MB随便一个普通上传接口都能扛住。第二失败成本可控。某个分片传失败只需要重传那一片而不是整个文件。只要客户端记录好哪些分片已经成功整个过程就能变成“断点续传”。第三可以多连接并发。分片后我们可以同时发起3个、5个甚至10个上传请求把整体吞吐量提上去。尤其是在CDN或对象存储场景下并发分片上传对大文件的加速效果非常明显。第四衍生能力更强。分片上传天然支持“秒传”先计算整个文件的哈希服务端发现文件已经存在直接返回“合并完成”用户感觉瞬间传完。分片也便于服务端做校验每个分片上传后可以单独校验大小或MD5最后再统一合并。所以分片不是“可选的优化”而是大文件上传这件事的基本盘。理解了这一点你再看开源组件的源码思路就会清晰很多。2. 站在开源组件肩膀上选型和源码思路拆解2.1 开源组件通常替你封装了什么市面上的开源上传组件本质上都在做同一件事把“分片、并发、重试、进度、续传”这几个高频问题封装成统一的API让调用方不必每次自己造轮子。它们通常已经替你做好了以下几层工作分片逻辑根据配置好的分片大小调用Blob.slice()切割文件。队列管理维护一个待上传分片队列记录每个分片的状态等待中、上传中、成功、失败。并发控制同时只发起N个上传请求控制并发数避免把服务器打挂。失败重试单个分片失败后自动重试重试次数可配置。进度计算综合已成功分片数量和文件总大小计算整体上传百分比。断点续传把已上传完成的分片索引持久化到本地存储下次上传先查漏补缺。这些能力如果全部自己写代码量不小而且容易出边界问题。所以大部分项目里先用开源组件把主流程跑通是效率最高的做法。2.2 选型时最容易忽略的边界很多人在选组件时只盯着“Star多不多”“文档全不全”却忽略了一个关键问题上传是个前后端配合的活组件只负责浏览器端服务端接口得你提供。你拿到一个开源上传组件后第一件事不是写页面而是先确认它的“分片协议”。它用POST还是PUT分片信息放在FormData里还是放在URL参数里成功回调返回什么格式服务端怎么区分第一个分片和最后一个分片这些协议直接决定了你后端接口怎么设计。还有一个容易忽略的点是“鉴权”。上传接口通常不能匿名访问开源组件默认不会帮你带Token你得看它有没有提供headers或beforeSend之类的钩子在每次请求前注入认证信息。有些组件还支持自定义query参数这也是携带Token或业务凭证的常见方式。另外要特别留意“服务器端重组”这件事。绝大多数开源上传组件只保证前端把分片发完至于服务端怎么把这些分片按顺序合并成完整文件组件默认不帮你实现。换句话说分片上传的最后一公里——合并——永远是你自己的事。选型时别指望组件全包。2.3 一个可对照的组件行为模型为了帮你更好理解我画一个抽象的行为模型。开源组件再花哨内部基本都逃不过下面这些步骤class Uploader { constructor(file, config) { this.file file; this.config config; this.chunks []; this.pending []; } prepare() { const total Math.ceil(this.file.size / this.config.chunkSize); for (let i 0; i total; i) { this.chunks.push(this.createChunk(i)); } } createChunk(index) { const start index * this.config.chunkSize; const end Math.min(start this.config.chunkSize, this.file.size); return this.file.slice(start, end); } run() { // 从队列里取出分片进入并发控制池 while (this.running this.config.concurrency this.pending.length 0) { const chunk this.pending.shift(); this.running; this.upload(chunk).finally(() { this.running--; this.run(); }); } } }这段代码虽然简略但已经把组件源码的核心骨架展示出来了。理解了这个模型你再去看那些开源实现会发现它们只是在上面加了更多细节事件钩子、重试逻辑、进度上报、本地缓存、UI绑定。3. 从零实现一个分片上传核心源码参考开源组件的设计既然题目问“怎么用开源组件实现源码”我就直接以开源组件常用的设计方式给出一个最小可运行的核心实现。你完全可以拿它当底座再换成你选中的具体组件。3.1 定义分片协议与配置在写代码前先约定前后端接口。这里我采用最简单但足够生产可用的方式分片元数据全部放在URL参数或Header里请求体只放纯二进制分片数据。这样服务端不需要解析multipart接收起来最省事。统一约定以下参数参数含义示例fileId一次上传会话的唯一ID20250101_xxxchunkIndex当前分片的序号从0开始0totalChunks总分片数100fileName原始文件名合并时使用demo.mp4fileSize完整文件大小2147483648前端需要实现的配置项如下const uploadConfig { endpoint: /api/upload/chunk, mergeEndpoint: /api/upload/merge, chunkSize: 2 * 1024 * 1024, // 2MB concurrency: 3, maxRetries: 3, retryDelay: 1000, headers: { Authorization: Bearer getToken() } };这些配置基本是通用设计我用过几个开源组件配置项也都是类似结构只是命名略有差异。3.2 切片与上传任务的完整代码接下来是核心逻辑切片、创建上传任务、发送请求。async function uploadLargeFile(file) { const fileId generateFileId(file.name, file.size); const totalChunks Math.ceil(file.size / uploadConfig.chunkSize); // 先通知服务端初始化上传会话 await createUploadSession({ fileId, fileName: file.name, fileSize: file.size, totalChunks }); // 创建分片上传任务 const tasks []; for (let index 0; index totalChunks; index) { tasks.push({ fileId, index, chunk: file.slice( index * uploadConfig.chunkSize, Math.min((index 1) * uploadConfig.chunkSize, file.size) ), retries: 0 }); } // 进入并发调度 await runWithConcurrency(tasks, uploadConfig.concurrency, async (task) { await uploadChunk(task); }); // 所有分片完成后触发合并 const result await mergeUpload({ fileId, fileName: file.name, totalChunks }); return result; }uploadChunk是真正发出HTTP请求的函数这里我用fetch发送原始二进制async function uploadChunk(task) { const url ${uploadConfig.endpoint}?fileId${task.fileId}chunkIndex${task.index}totalChunks${task.totalChunks}; const response await fetch(url, { method: PUT, headers: { Content-Type: application/octet-stream, ...uploadConfig.headers }, body: task.chunk }); if (!response.ok) { throw new Error(Upload chunk ${task.index} failed: ${response.status}); } return await response.json(); }这里把分片信息放在URL上是为了让服务端解析更简单也避免FormData额外增加解析成本。很多开源组件还支持把元数据塞在FormData里但纯二进制流的方式我实际用下来更稳。3.3 进度、重试与并发控制并发控制我用一个简单的“信号量”实现不引入额外依赖也能跑async function runWithConcurrency(tasks, limit, worker) { const results []; const queue tasks.slice(); let running 0; return new Promise((resolve, reject) { function next() { if (queue.length 0 running 0) { resolve(results); return; } while (running limit queue.length 0) { const task queue.shift(); running; worker(task) .then((result) { results.push(result); running--; next(); }) .catch((error) { running--; handleTaskError(task, error, queue, worker, next, reject); }); } } next(); }); }失败重试的通用处理async function handleTaskError(task, error, queue, worker, next, reject) { if (task.retries uploadConfig.maxRetries) { task.retries; await sleep(uploadConfig.retryDelay * task.retries); queue.unshift(task); next(); } else { reject(error); } }进度计算要同时考虑“传输完成”和“合并完成”两个阶段。纯前端一般显示的是传输进度function getProgress(uploadedChunks, totalChunks) { return Math.round((uploadedChunks / totalChunks) * 100); }如果你用XMLHttpRequest而不是fetch还能拿到更底层的上传进度xhr.upload.onprogress (event) { if (event.lengthComputable) { const percent Math.round((event.loaded / event.total) * 100); console.log(Chunk ${index}: ${percent}%); } };fetch目前没有原生的上传进度回调所以我个人在需要精细进度条时更倾向于用XMLHttpRequest或者让后端主动上报每个分片完成状态来汇总进度。3.4 服务端接收与合并的参考实现服务端我用常见的JavaScript运行时写一个可运行的最小实现。分片接口把请求体原样写入临时分片文件const http require(http); const fs require(fs); const path require(path); const UPLOAD_DIR path.join(__dirname, upload_tmp); const MERGE_DIR path.join(__dirname, output); if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR); if (!fs.existsSync(MERGE_DIR)) fs.mkdirSync(MERGE_DIR); const server http.createServer(async (req, res) { const url new URL(req.url, http://localhost:3000); if (url.pathname /api/upload/chunk req.method PUT) { const fileId url.searchParams.get(fileId); const chunkIndex url.searchParams.get(chunkIndex); const partPath path.join(UPLOAD_DIR, ${fileId}_${chunkIndex}.part); const ws fs.createWriteStream(partPath); req.on(data, (chunk) ws.write(chunk)); req.on(end, () { ws.end(); res.statusCode 200; res.end(JSON.stringify({ ok: true, chunkIndex })); }); } if (url.pathname /api/upload/merge req.method POST) { const { fileId, fileName, totalChunks } await readJsonBody(req); const outputPath path.join(MERGE_DIR, fileName); const ws fs.createWriteStream(outputPath); for (let i 0; i Number(totalChunks); i) { const partPath path.join(UPLOAD_DIR, ${fileId}_${i}.part); if (!fs.existsSync(partPath)) { res.statusCode 400; res.end(JSON.stringify({ ok: false, message: Missing part ${i} })); return; } await pipeTo(partPath, ws); } ws.end(); res.statusCode 200; res.end(JSON.stringify({ ok: true, path: outputPath })); } }); function readJsonBody(req) { return new Promise((resolve) { let body ; req.on(data, (c) (body c)); req.on(end, () resolve(JSON.parse(body))); }); } function pipeTo(partPath, ws) { return new Promise((resolve, reject) { const rs fs.createReadStream(partPath); rs.on(data, (chunk) ws.write(chunk)); rs.on(end, resolve); rs.on(error, reject); }); }这样前端跑完上传再调一次合并接口服务端就会把所有.part分片按顺序写入到最终文件里。真做生产时你需要增加文件锁、目录清理、MD5校验等逻辑但核心链路就是这样。3.5 联调流程与最小验证步骤第一次联调时建议先用小文件比如10MB把链路走通再逐步放大。具体步骤启动服务端确认/api/upload/chunk和/api/upload/merge接口能访问。用浏览器页面选择一个小文件观察Network面板应出现多个分片请求每个请求的状态是200。所有分片完成后确认合并接口被自动调用服务端输出目录下生成了完整文件。用命令行校验文件的字节数和原始文件一致。再试一次大文件把分片大小调到2MB确认并发不超过3。如果合并出来的文件损坏优先检查是不是分片顺序写错或者某些分片请求虽然返回200但实际长度不对。抓包看Content-Length是最直接的办法。4. 把示例变成生产功能四块必须补的拼图示例代码能跑通只是第一步离“能用”还有距离。下面这四块拼图我认为是所有大文件上传功能都躲不开的。4.1 秒传用文件哈希跳过重复上传秒传的核心思路是上传前先算整个文件的哈希发送给服务端查询。服务端如果发现这个文件已经存在直接返回“无需上传合并成功”用户连进度条都不用等。前端计算大文件哈希是个耗时的CPU操作。直接对整个文件算MD5或者SHA-256可能要几十秒甚至几分钟。工程上常见的做法是“抽样哈希”取文件头部、中段、尾部各若干字节合成一个小样本再计算哈希。虽然理论上有极小概率碰撞但对大多数业务够用。如果要求高也可以切片并行计算利用Web Worker避免阻塞页面。async function getSampleHash(file) { const sampleSize 2 * 1024 * 1024; const blobParts [ file.slice(0, sampleSize), file.slice(Math.floor(file.size / 3), Math.floor(file.size / 3) sampleSize), file.slice(file.size - sampleSize, file.size) ]; const sample new Blob(blobParts); return computeHash(sample); }服务端拿到哈希后在文件元数据表里查一下。有就跳过上传流程没有就进入正常分片上传。4.2 断点续传状态持久化与查询已上传分片只靠内存里的任务列表做断点续传是不够的页面一刷新就没了。要真正续传需要把“哪些分片已经传成功”持久化下来。前端常用的是localStorage或IndexedDB。localStorage简单但容量有限且只能存字符串IndexedDB容量更大也适合存索引列表。每次某个分片成功后把{fileId, chunkIndex}写入存储下次进入页面先查这个文件有没有历史记录再询问服务端哪些分片实际已存在。服务端最好提供一个查询接口“查询某个fileId已上传的分片列表”返回已完成序号数组。前端拿到后只需把未完成的分片重新加入队列async function resumeUpload(file, fileId) { const uploadedChunks await queryUploadedChunks(fileId); const uploadedSet new Set(uploadedChunks); const pending []; for (let i 0; i totalChunks; i) { if (!uploadedSet.has(i)) { pending.push({ fileId, index: i, chunk: file.slice(...) }); } } await runWithConcurrency(pending, uploadConfig.concurrency, uploadChunk); await mergeUpload({ fileId }); }这里有个细节断点续传前最好再对比一下文件大小和修改时间因为用户可能替换了同名文件。如果文件大小变了之前的续传记录就不能用得重新开启新会话。4.3 并发数与分片大小的调优依据并发数和分片大小没有标准答案但可以给出经验范围。分片大小建议在1MB到10MB之间。太小了请求数量变多HTTP握手和网络往返很快就会占掉大量开销太大了又回到了整包上传的缺点一个分片失败浪费的数据就多。我的一般做法是让总分片数落在100到500之间这样既不会因为请求太多把浏览器拖慢也不会因为单片太大重传成本过高。按文件大小反推function decideChunkSize(fileSize) { if (fileSize 100 * 1024 * 1024) return 1 * 1024 * 1024; if (fileSize 1024 * 1024 * 1024) return 2 * 1024 * 1024; return 5 * 1024 * 1024; }并发数方面一般服务器建议3到5。如果你用的是对象存储直传且带宽充足可以适当调到8到10。但盲目开高并发服务器容易先扛不住尤其是你还在同一台机器上执行合并操作时。上线前一定要做压力测试观察服务器的CPU、内存和磁盘IO再决定最终并发数。4.4 完整性校验与失败兜底分片传完了不代表文件是对的。网络层可能丢字节服务端磁盘也可能有问题。最稳妥的做法是上传每个分片时让服务端记录分片长度并计算分片的MD5合并前校验总分片长度之和等于原文件大小合并后对整个文件再算一次哈希与前端采样哈希或完整哈希比对。如果发现合并后文件校验失败要有兜底任务删除这个会话的所有分片让用户重新上传或者触发某些分片的定向重传。注意重传整个文件永远是最底层的兜底不代表你可以不做精确重传。还有一个容易被忽视的兜底是“清理过期分片”。用户传了一半放弃退出服务端磁盘上会残留一堆.part文件。一定要设计定期清理任务按fileId的创建时间和最后活跃时间删除过期文件否则存储会被大量碎片占满。5. 实战中踩过的坑和我的处理习惯5.1 分片管理与URL超时之间的平衡我最初做分片上传时把分片大小设成了512KB想着“越小越稳”结果一个500MB文件被切成了1000多个分片。每个分片在弱网环境下都可能失败重试几次之后队列积压严重总时间反而比整包上传还慢。后来我把分片大小改成2MB到5MB情况立刻好转。另一个关联问题是代理层的超时时间。网络设备或反向代理通常有请求超时限制如果你的一个分片在弱网下很久才传完会被中间层强行断开。解决方向有两个一是适当调大超时阈值二是把分片大小控制在“网络往返超时时间”内能传完的量级。实测下来2MB分片配合60秒超时绝大多数弱网场景都能扛住。5.2 浏览器僵死内存和渲染线程的真相分片后浏览器内存压力确实小了但“切片”这个动作本身如果处理不当还是会卡死页面。很多人直接写一个循环从Blob.slice拿回一堆Blob对象然后马上开始并发读取结果发现页面卡住不动。原因在于切片过程和网络上传都在主线程里执行。Blob.slice()本身不会一次性把数据读进内存但当你用fetch发送每个分片时浏览器要读取该分片数据这个过程如果同时进行几十个内存还是可能飙升。解决思路是控制并发同时在计算文件哈希、处理大数组时使用Web Worker把CPU密集任务从主线程挪走。一旦页面“僵死”用户感知到的不是慢而是“浏览器没反应了”。这一点在低配电脑上尤其明显。我的习惯是上传期间始终把并发数控制在5以内并且把大批量状态更新改成节流处理避免每上传一个分片就触发整张页面重绘。5.3 前端重复提交与服务端幂等用户在进度条转圈时经常会忍不住多点几次“上传”。如果你不做防重复同一个fileId会出现两次上传流程服务端分片被覆盖合并时数据错乱。前端可以在上传开始前禁掉按钮但这挡不住用户刷新页面后重新打开。更稳的做法是服务端做幂等同一个fileId、同一个chunkIndex后到达的请求直接返回成功或丢弃重复数据不重复落盘。整个流程里fileId一定要生成得足够唯一我常用时间戳 文件名 文件大小 浏览器随机串拼一个出来避免多个用户上传同名文件时互相覆盖。5.4 代理层限制与分片大小的联动还有一个我栽过跟头的地方是反向代理配置。默认情况下很多常见的HTTP服务中间层对请求体大小有限制如果你不做任何调整分片上传也会被拦。因为分片上传的每一个分片请求体都不大所以只要分片大小没有超过代理限制这个问题通常不会暴露。但如果你为了减少分片数量把分片大小调到50MB甚至更大那么代理层就会开始拦截请求报一个“请求体过大”的错而你的后端日志里什么都看不到。所以要记住分片大小不只要考虑业务还要考虑全链路所有中间层的限制。上线前最好把分片大小和代理层配置一起评审一遍。最后再分享一个小技巧如果你不想自己维护合并逻辑也可以考虑把分片直传到对象存储然后让服务端直接发起合并或拷贝任务。这样前端分片上传的压力更小也不容易受单机磁盘限制。分片上传的思路是不变的变的只是文件最终落在哪里以及由谁来做合并这最后一公里。希望这篇文章能让你少踩几个坑把大文件上传这块硬骨头啃下来。