资讯动态

大文件分片上传核心原理与前后端源码实现详解

发布时间:2026/10/20 19:57:41 来源:尧图企业网站定制
做上传功能这几年我踩过的坑比很多人写过的代码都多。特别是网页里传大文件这件事表面上看是“选个文件、发个请求”的事一旦文件体积上了几个GB问题就全冒出来了——进度条卡死、连接中断、服务端超时、内存飙升。最后你会发现老老实实把文件切成一块一块传才是唯一靠谱的路。所谓“网页插件实现大文件分片上传”本质就是做一套可复用的上传模块前端负责切块、调度、重试、续传后端负责接收、合并、校验。这篇博文我会直接把前后端的核心源码和设计思路都拆给你看全程用原生实现讲清楚原理。这套内容适合正在做后台系统、文件管理平台或者纯粹被大文件上传逼疯的前后端开发者。看完你能落地一套自己的分片上传方案而不是只会调第三方库。1. 为什么大文件上传必须重新造轮子先别急着写代码。我见过太多人拿着几十MB的文件测完就说“没问题”结果一上生产环境就被用户教做人。普通表单上传的局限不是服务器配置不够而是架构上就扛不住大文件。1.1 普通上传接口的三大硬伤第一个问题是请求体和内存限制。浏览器把文件通过表单提交时本质上是一整个二进制流发给服务端。如果前端用FileReader.readAsDataURL去读整个文件会被直接塞进内存文件一上GB页面基本就卡死了。服务端这边Nginx默认的client_max_body_size通常是1MB到2MB就算你把它调成500MB网关和代理层的超时时间也会成为新的瓶颈——一个1GB文件在不理想的网络下传半小时很正常中间任何一次波动就断给你看。第二个问题是用户体验极差。普通上传对用户来说就是一个无法取消、无法续传的漫长等待。一旦失败进度条清零用户骂娘你还得在工单系统里解释“网络波动导致上传失败”。第三个问题是没有断点续传的基因。你当然可以前端做一次性提交、后端配合做断点接收但没有分片断点续传根本无从谈起。服务端拿着一个巨大的请求体要么整体成功要么整体失败根本没法只补传缺失的那一段。我用一个生活类比来总结一次性上传大文件就像雇了一辆大卡车搬家货全部装进一个集装箱路上爆胎一次就得卸下来重装。分片上传则像搬家公司的标准箱一箱一箱搬哪一箱磕坏了就单独补一箱整体不受影响。这个思路就是分片上传的本质。1.2 分片上传的核心能力拆解从需求倒推一套能用的分片上传方案至少要具备四个能力前端切片把大File对象按固定大小切成多个Blob每个分片独立上传。并发控制不是把几百个分片一次性全发出去而是限制同时进行的请求数避免带宽和内存被瞬间打满。进度可计算能精确算出“已上传字节 / 总字节”而不是靠猜。失败重试与续传单个分片失败不拖垮整个任务页面刷新后还能从已完成的分片继续传。再往上一层还要考虑秒传和断点续传。秒传依赖文件内容的哈希指纹文件已存在就直接返回成功。断点续传则依赖服务端保存已上传分片的信息前端重新请求时跳过这些分片。这四个能力加两个进阶项就是整套源码的地基。下面我按模块拆开讲。2. 前端整体架构设计2.1 模块划分与整体流程分片上传不是一个单一函数能搞定的我建议把代码分成六个模块各司其职FileSelect负责文件选择、类型校验、大小提示。Uploader核心类管理文件元数据、分片列表、状态流转待上传、上传中、已完成、失败。HashWorker通过Web Worker计算文件内容哈希避免阻塞主线程。Scheduler并发调度器控制同时执行的上传任务数。Transport真正发请求的模块我用原生XHR实现方便拿到上传进度事件。ProgressEmitter汇总进度、发布事件驱动页面上的进度条。整体执行流程是一条逻辑链用户选择文件读取文件名、大小、类型。启动Web Worker对文件做增量哈希计算得到fileHash。前端调用后端/check接口传入fileHash。后端返回三种结果fileExists为真秒传、返回uploadedChunks数组断点续传、返回空数组全新上传。前端根据结果生成或过滤分片任务列表。调度器按固定并发数上传分片每个分片独立处理进度和重试。全部分片完成后调用/merge接口服务端合并文件。这套流程把“分片”、“并发”、“进度”、“续传”四个关键点串成了一条线下面我逐个讲实现。2.2 核心数据结构与文件指纹方案分片上传的代码里最核心的数据结构就是“分片任务”。我一般这样定义// 每个分片任务的数据结构 { file, // 原始File对象 blob, // 当前分片的Blob通过file.slice()切出 index, // 分片序号从0开始 total, // 总分片数 fileHash, // 整个文件的哈希用于续传/秒传 chunkHash, // 当前分片的哈希可选用于服务端校验 size, // 当前分片实际字节数 status, // pending / uploading / success / error retryCount // 当前分片已重试次数 }之所以要用fileHash作为续传和秒传的依据而不是用“文件名大小”是因为文件名和大小完全相同的两个文件内容可能完全不同。你不想用户传了一个1GB的文件结果服务端因为文件名对得上就返回“秒传成功”实际上传的是另一个文件。哈希算法上用SparkMD5做增量计算就够了。注意我这里说的是增量不是一次性把文件读完再算。大文件一次性读进内存做哈希内存直接炸。正确姿势是用FileReader配合Blob.slice分块读取每读一块就往哈希算法里喂一块最后拿到整个文件的指纹。2.3 工具选型为什么不用现成上传库前端这边我故意不用任何上传库网络请求也是用原生XHR而不是Axios。原因很简单上传库封装了大量逻辑出了问题你很难定位到底是库的bug还是自己的业务bug。自己在核心模块上做一层薄封装代码全部掌控在自己手里出了问题可以一行行排查。后端我用Node.js Express multer。你可能会问为什么用multer其实它在这里只干一件事解析multipart/form-data把上传的分片二进制数据拿出来。你完全可以用busboy或者formidable替代我选multer纯粹是因为它生态稳定、文档丰富读者拿到代码就能跑通。Web Worker不是可选项而是必须项。计算大文件哈希非常吃CPU放在主线程会导致页面操作卡顿用户动一下滚动条都费劲。后面我会给Worker的代码示例。3. 前端源码实现从分片到并发上传3.1 分片逻辑与关键参数选择分片的核心就一个APIBlob.prototype.slice。它可以从一个Blob中截取指定字节范围生成一个新的Blob。这个新Blob可以直接放进FormData发送。先看切分代码function createChunks(file, chunkSize) { const chunks []; let start 0; let index 0; while (start file.size) { const end Math.min(start chunkSize, file.size); chunks.push({ index, start, end, size: end - start, blob: file.slice(start, end) }); start end; index; } return chunks; }这段代码没有太多玄机但有两个细节容易掉坑。第一file.slice()返回的Blob是懒加载的并不会立刻把数据读进内存真正读数据是在XHR发送时。第二最后一个分片的大小往往小于chunkSize如果你在后端合并时假设所有分片大小一致就会出问题。合并逻辑必须按实际大小处理。分片大小怎么选我梳理一下取舍逻辑如果设512KB一个1GB文件会产生2048个分片HTTP请求数量大服务端合并时文件句柄开销高进度事件也会有压力。如果设50MB分片数少了但单分片上传时间长失败重试的代价大而且服务端如果要把整个分片放在内存里并发几路内存就爆了。经验值在2MB~10MB之间。文件平均体积小的项目用5MB几个GB起步的项目可以用10MB并发数控制在3~5路。我自己的默认配置是const CHUNK_SIZE 5 * 1024 * 1024; // 5MB const MAX_CONCURRENCY 3; // 同时3个分片在上传 const MAX_RETRY 3; // 单个分片最多重试3次3.2 并发池实现控制同时上传的分片数很多人第一次做分片上传时会写一个循环把所有分片一次性发出去。这是新手最容易踩的坑浏览器对同一域名有并发连接限制目前Chrome是6个超出之后HTTP请求会排队你发100个请求实际同时只有6个在跑剩下的全在隐形排队这还不算内存中堆了100个FormData对象。真的没必要并发池才是正解。我写了一个很经典的并发池实现/** * 并发执行任务 * param {Array} tasks 任务数组 * param {number} limit 最大并发数 * param {Function} workerFn 处理单个任务的异步函数 */ async function parallelRun(tasks, limit, workerFn) { const results new Array(tasks.length); let idx 0; async function worker() { while (idx tasks.length) { const currentIndex idx; try { results[currentIndex] await workerFn(tasks[currentIndex], currentIndex); } catch (err) { results[currentIndex] { error: err }; console.error(任务 ${currentIndex} 执行失败, err); } } } const workers Array.from({ length: limit }, () worker()); await Promise.all(workers); return results; }这个实现的核心是一个共享的idx计数器。多个worker并发执行时每次从idx取一个任务idx是原子性的JavaScript的事件循环保证了不会有两个worker拿到同一个任务。Promise.all等所有worker跑完任务就全部执行完毕。配合分片上传你只需要把每个分片的XHR上传函数包装成workerFn传进去就行。这几十行代码的价值是让你完全掌控“同一时间到底有几个请求在飞”。单分片上传我用XHR实现关键在于监听xhr.upload.onprogress拿真实的上传进度function uploadChunk(chunkTask, onProgress) { return new Promise((resolve, reject) { const formData new FormData(); formData.append(file, chunkTask.blob, chunkTask.fileName); formData.append(fileHash, chunkTask.fileHash); formData.append(index, chunkTask.index); formData.append(total, chunkTask.total); const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload/chunk); xhr.upload.onprogress (event) { if (event.lengthComputable) { onProgress onProgress(event.loaded, event.total); } }; xhr.onload () { if (xhr.status 200 xhr.status 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(HTTP ${xhr.status})); } }; xhr.onerror () reject(new Error(网络异常)); xhr.ontimeout () reject(new Error(请求超时)); xhr.send(formData); }); }这里用XHR而不是fetch是因为fetch至今没有提供原生的上传进度事件。如果你非要用fetch得自己去包装ReadableStream收益不大没必要折腾。3.3 全局进度计算与失败重试机制全局进度的计算代码可能只有一行但思路要拎清必须按字节数累计而不是按分片数累计。因为除最后一个分片外其他分片大小一致最后一个分片可能特别小。若按“已完成分片数 / 总分片数”计算进度条会走到“99%”后停很久最后一下跳到100%用户看了会疑惑。正确做法在调度器里维护一个completedBytes变量每个分片成功上传后累加该分片的实际大小let completedBytes 0; async function handleChunk(task) { try { await uploadChunk(task, (loaded, total) { // 单个分片实时进度可以细分 }); completedBytes task.size; const percent Math.round((completedBytes / file.size) * 100); emitter.emit(progress, { percent, completedBytes, total: file.size }); } catch (err) { throw err; } }失败重试的逻辑也不复杂上传分片失败后不立即向用户报错而是给这个分片安排重试。重试不能间隔固定时间傻等我用指数退避的方式async function uploadChunkWithRetry(task, maxRetry 3) { let delay 1000; for (let attempt 0; attempt maxRetry; attempt) { try { return await uploadChunk(task); } catch (err) { if (attempt maxRetry - 1) { throw err; } await sleep(delay); delay * 2; // 1s - 2s - 4s } } }第一次失败等1秒重试第二次等2秒第三次等4秒。如果连续三次都在同一分片上失败说明这个分片的数据或网络有硬问题此时才中止整个上传流程并明确告诉用户“第N个分片上传失败”而不是给一个模糊的“上传失败”。3.4 秒传与断点续传的源码级实现前端做完分片和并发控制还差一个关键的预处理环节询问服务端这个文件之前是否传过。这个动作在正式上传之前执行我封装成一个prepareUpload方法async function prepareUpload(file, fileHash) { const response await fetch(/api/upload/check?fileHash${fileHash}); const result await response.json(); if (result.fileExists) { return { mode: instantSuccess }; // 秒传 } const uploadedSet new Set(result.uploadedChunks || []); const allChunks createChunks(file, CHUNK_SIZE); // 只保留未上传的分片 const pendingTasks allChunks .filter(chunk !uploadedSet.has(chunk.index)) .map(chunk createTask(file, fileHash, chunk)); return { mode: upload, pendingTasks }; }这里关键的优化点是服务端返回已完成分片索引集合前端直接用filter过滤掉已完成的分片。如果上次已经传了300个分片中的250个这次只需要传剩下的50个。这个方案的准确度完全取决于服务端临时目录里分片数据的完整性和正确性所以服务端写入分片时必须确保幂等不能因为重复请求而追加数据。秒传其实就发生在前端拿到fileExists: true的那一刻。用户点击上传进度条直接跳到100%体验极好。但它不是做什么魔法只是服务端通过文件名或哈希定位到了已存在的完整文件。4. 后端接口与分片合并源码前端是这套方案的脸面后端是真正干活的。有些前端开发者以为后端只要无脑把分片拼起来就行真做起来会发现分片接收、状态记录、合并校验各有各的坑。4.1 分片接收接口与临时目录管理我定义一个临时目录tmp/{fileHash}/每个分片独立存储为一个.part文件。目录名用fileHash而不是文件名因为文件名可能重复但哈希是唯一的。分片接收接口的源码const express require(express); const multer require(multer); const fs require(fs); const path require(path); const router express.Router(); const TMP_DIR path.join(__dirname, ../tmp); const FILE_DIR path.join(__dirname, ../files); const upload multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * 1024 * 1024 } // 比前端5MB分片稍大 }); router.post(/chunk, upload.single(file), async (req, res) { const { fileHash, index, total } req.body; const buffer req.file ? req.file.buffer : null; if (!buffer) { return res.status(400).json({ ok: false, message: 缺少分片数据 }); } const partDir path.join(TMP_DIR, fileHash); await fs.promises.mkdir(partDir, { recursive: true }); const partPath path.join(partDir, ${index}.part); // 关键覆盖写入。同一个分片重复上传时直接覆盖不做追加 await fs.promises.writeFile(partPath, buffer); res.json({ ok: true }); });两个细节值得说。第一multer.memoryStorage()会把分片数据放在内存5MB一个分片、并发3路内存峰值在可接受范围。如果你的分片大小定到50MB就必须换diskStorage否则内存会爆。第二写入分片用writeFile覆盖而不是appendFile追加是为了保证接口幂等——如果前端因网络超时重传了同一个分片服务端不会把两段数据拼成一个损坏的.part文件。这台接口逻辑看起来简单实际上有你容易忽视的点客户端上传分片时带上total但服务端不依赖total做完整校验。total交给/merge接口去校验。4.2 合并分片的流式写法所有分片上传完成后前端调用/merge接口。后端要做的事情是先检查分片完整性再按索引顺序把所有.part文件拼接成最终文件最后清理临时目录。合并接口的源码router.post(/merge, async (req, res) { const { fileHash, fileName, total } req.body; const partDir path.join(TMP_DIR, fileHash); // 1. 校验所有分片是否齐全 for (let i 0; i Number(total); i) { const partPath path.join(partDir, ${i}.part); if (!fs.existsSync(partPath)) { return res.status(400).json({ ok: false, message: 缺少分片 ${i} }); } } // 2. 创建最终文件写入流 const ext path.extname(fileName); const targetPath path.join(FILE_DIR, ${fileHash}${ext}); const ws fs.createWriteStream(targetPath); // 3. 按索引顺序逐个pipe for (let i 0; i Number(total); i) { await new Promise((resolve, reject) { const rs fs.createReadStream(path.join(partDir, ${i}.part)); rs.on(error, reject); ws.on(error, reject); rs.pipe(ws, { end: false }); rs.on(end, resolve); }); } ws.end(); // 4. 清理临时目录 await fs.promises.rm(partDir, { recursive: true, force: true }); res.json({ ok: true, url: /files/${fileHash}${ext} }); });合并逻辑里最需要注意的就是每个分片pipe到同一个WriteStream时必须传{ end: false }。这个参数的意思是当前分片的数据流结束后不要自动关闭目标写入流让它继续接收下一个分片的数据。如果你漏了这个参数第一个分片传完写入流就关闭了后面所有分片会写入一个已关闭的流报错。为什么用流而不是把所有分片读进内存再一次性写一个1GB文件如果用readFileSync读分片再拼接内存立刻爆炸。用createReadStream边读边写内存占用始终只有几MB。4.3 check接口续传与秒传的判断逻辑/check接口是断点续传的“大脑”。前端上传前会请求这个接口通过fileHash查询文件的存储状态。router.get(/check, async (req, res) { const { fileHash } req.query; // 1. 完整文件已存在直接秒传 const files await fs.promises.readdir(FILE_DIR); const exists files.some(f f.startsWith(fileHash)); if (exists) { return res.json({ fileExists: true, uploadedChunks: [] }); } // 2. 临时目录存在说明有已上传的分片 const partDir path.join(TMP_DIR, fileHash); if (fs.existsSync(partDir)) { const partFiles await fs.promises.readdir(partDir); const uploadedChunks partFiles .filter(f f.endsWith(.part)) .map(f parseInt(f.split(.)[0])) .sort((a, b) a - b); return res.json({ fileExists: false, uploadedChunks }); } // 3. 全新上传 return res.json({ fileExists: false, uploadedChunks: [] }); });这里有一个体验层面的细节如果文件已经完整上传过服务端直接返回fileExists: true前端就能立即给出“秒传成功”的反馈。如果临时目录还有残留分片返回的uploadedChunks数组就能让前端精准跳过这些分片。哪怕用户中途关了浏览器只要服务端没有清理临时目录下次重新选择同一个文件依旧能继续不用从头再来。5. 实际运营中挖到的坑与性能优化5.1 我踩过的几个真实大坑代码写完只是第一步。我在实际项目中跑这套方案时又遇到了很多文档里不会写的东西这里详述几个影响最大的。坑1文件哈希计算卡死主线程一个2GB文件在Web Worker里算MD5大概需要几秒到十几秒。如果你把这段代码放在主页面线程跑用户能明显感觉到页面卡住点击、滚动都没反应。解决方案就是Web Worker。切分文件本身也要放在Worker里和哈希计算一起做主线程只负责展示状态。在Worker里做增量哈希的代码大致是这个结构// hash-worker.js self.importScripts(/spark-md5.min.js); self.onmessage async (event) { const file event.data.file; const chunkSize 5 * 1024 * 1024; const spark new SparkMD5.ArrayBuffer(); let offset 0; while (offset file.size) { const end Math.min(offset chunkSize, file.size); const chunk file.slice(offset, end); const buffer await chunk.arrayBuffer(); spark.append(buffer); offset end; self.postMessage({ type: progress, percent: Math.round(offset / file.size * 100) }); } const fileHash spark.end(); self.postMessage({ type: done, fileHash }); };主线程只负责postMessage把文件传进去、监听返回消息即可。这样即使在算哈希期间用户也完全无感。坑2并发数开太高导致内存爆炸我曾经把并发数调到6觉得“反正浏览器限制6个连接那就发满”。结果上传一个8GB文件时页面内存直接冲到700MB低配机器直接白屏。原因很简单每个XHR请求在发送时都会把对应的分片Blob读入内存6个同时读内存峰值就是6 × 5MB的缓冲区加各种开销。实际运营中并发数设在3比较稳健2更保守。不要顶着上限跑优先保证稳定性。坑3服务端分片写入用了append模式最早的版本我在服务端用fs.appendFile写入分片想着“反正只写一次追加和覆盖没区别”。直到有一次网络重传同一个分片被前端重发了一次服务端毫无防备地把两段数据都写进了.part文件最终合并出的文件直接损坏而且损坏方式非常隐蔽。修复方案上面也写了分片写入必须用writeFile覆盖写保证接口幂等。前端的重试机制越是完善这类问题就越要提前堵住。坑4Nginx默认限制把你卡死在门外本地联调一切正常部署到测试环境之后上传超过1MB就直接报413查了半天才发现是Nginx的client_max_body_size默认只有1MB。后端代码再正确请求也到不了应用层。这个问题简单但致命部署前记得检查网关层配置包括client_max_body_size和proxy_read_timeout后者要改大因为大文件上传吃力不讨好一个慢请求可能在网关层就被强杀。坑5临时目录垃圾文件堆积断点续传依赖临时目录里的分片文件但用户一旦中途放弃上传这些分片就永远留在服务器上日积月累会吃掉大量磁盘。我在服务端加了一个定时清理任务每隔1小时扫描临时目录删除创建时间超过24小时且未完成合并的分片文件夹。这个逻辑不需要多复杂定期跑一个脚本就行但一定要有不然迟早出磁盘告警。5.2 常见问题速查表我把几个高频问题和对应方案整理成一个表方便你遇到问题时直接查问题现象根本原因解决方案断点续传后文件损坏文件哈希不准确续传时基于错误的分片状态哈希必须基于完整文件内容不能只对文件头部采样合并后文件比原文件小合并前未校验所有分片是否存在合并时逐索引检查.part文件缺失则拒绝合并进度条卡在99%不动最后一个分片太小按分片数计算进度导致跳档全局进度按字节数累计不按分片数累计并发上传时部分分片频繁失败网络丢包严重或服务端连接被网关关闭指数退避重试重试3次仍失败再整体中止上传1GB文件页面卡死哈希计算在主线程执行采用Web Worker做增量计算生产环境上传超过1MB报413Nginx默认client_max_body_size限制调大Nginx请求体限制并同步调大超时时间服务端临时目录越来越大用户弃传导致分片残留加定时任务清理超期未合并的临时目录这些坑不是一次性踩完的很多东西是你上线跑了一两个月才暴露出来。所以上线前的回归测试很重要我建议至少准备一个2GB左右的真实大文件把“全新上传”、“中途断网重传”、“刷新页面续传”、“秒传二次提交”四条路径全部走一遍别让用户替你试错。最后分享一点个人体会这套代码写完之后我最深的感触是分片上传真正考验人的不是“切片”和“合并”这两件事而是细节的闭环。并发控制、进度计算、重试策略、幂等写入、垃圾清理每一项单拿出来都不难难的是把它们组合成一个拼命不出错的整体。我自己在维护一个旧的上传模块时就发现代码跑得是否稳定往往取决于那些你不会写进文档里的判断——比如分片大小和并发数怎么配合比如服务端写分片时必须幂等这些才是真正的经验壁垒。最后提醒一句分片上传不是银弹。如果你的业务文件普遍小于50MB老老实实用单请求上传加超时重试就够了分片带来的复杂度不值得。但如果你的场景是几GB甚至几十GB的文件那这套方案值得你花一个下午把它吃透。

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

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

免费获取报价 →
↑