大文件上传最佳实践:前端分片上传、断点续传与秒传方案详解 做网页开发这些年大文件上传几乎是每个业务系统都绕不过去的坎。视频、安装包、离线数据包、设计稿压缩包动不动几百MB甚至几个GB用普通的input typefile直接 POST 给后端十有八九会超时、卡死、内存爆掉。用 js 实现大文件分片上传就是把一个超大文件切成若干小块一片一片传上去最后在服务端拼回原始文件。这个方案基本成了跨平台网页应用里的标准解法不管你的用户是用PC浏览器、手机浏览器还是套壳App里的WebView同一套代码都能跑。这篇文章我会把分片上传的完整思路、前后端源码、断点续传和秒传的玩法以及我踩过的坑都写出来适合正在做文件上传功能的前端或全栈开发参考也适合刚接触这个方案的人照着抄。先说清楚我不会给一个只能点按钮上传的玩具Demo而是会拆解每一个关键步骤的“为什么”再给能跑的源码。前端用原生 JavaScript Fetch API后端用 Node.js 的 Express 做演示跨平台指的就是浏览器环境不区分系统。你可以在本地把整套东西跑起来然后对着代码理解每一步在做什么。1. 为什么上传大文件必须分片1.1 直传大文件的三个硬伤很多刚入行的同学会有疑惑HTTP 协议本身不限制请求体大小直接把整个文件塞进请求体里发过去不行吗理论上是“能发”但在真实生产环境里直传大文件会撞上三面墙。第一面墙是连接超时。浏览器和后端之间通常经过 Nginx 或网关这些中间层一般有默认的请求超时时间比如 Nginx 默认 60 秒。一个 1GB 文件如果网速一般要传十几分钟连接早被掐断了。就算你调大了客户端超时中间任何一层代理都可能主动断开。第二面墙是服务端内存。后端如果用传统的 multipart 解析通常会把整个请求体读入内存再落盘。一个 500MB 的文件传上去Node.js 默认 1.5GB 的内存可能直接被打满并发几个用户服务直接宕机。即便用磁盘缓冲请求体一旦因为网络抖动中断临时文件也可能残留回收机制还得另做。第三面墙是失败成本。直传一旦失败整个文件就要重新传。文件越大失败概率越高重传成本呈指数上升。用户体验是“传了两个小时最后提示失败”这种产品基本留不住用户。分片上传就是绕开这三面墙的通用思路把大文件物理切割成若干小块每块几MB分别上传。单个分片的请求体很小不会触发超时也不会占用大量内存传失败只需要重传那一片而不是整个文件。1.2 分片上传到底在解决什么问题分片上传的核心可以概括为八个字化整为零合零为整。前端把文件按字节区间切割成多个 chunk每个 chunk 独立发送后端收到所有 chunk 后按照分片序号拼接回原始文件。这个方案天然带来三个能力断点续传已经传成功的分片会被记录下来下次继续传时直接跳过这些分片不用从头再来。并发上传多个分片可以并行发送充分利用带宽比单连接串行上传速度更快。失败隔离某个分片失败只需要重试这一个分片成本极小。用一个生活化的类比搬家时几个大柜子没法整体搬进电梯师傅会先把柜子拆成木板分批搬上楼到屋里再按编号重新组装。如果路上有一块木板磕坏了只需要重新加工这一块不用把整柜子重搬一遍。分片上传就是给文件做的“拆装搬家”。1.3 跨平台网页应用的技术选型所谓的“跨平台网页应用”在文件上传这个场景里绝大多数情况指的是同一套前端代码能运行在 Windows / macOS / Linux 桌面浏览器、Android / iOS 手机浏览器以及微信或企业App内置的 WebView 中。这些环境的底层都是浏览器提供的 Web API因此只要我们只用标准能力就能做到跨平台。实现分片上传我们需要三样东西File API让用户选择的文件对象可以被程序读取。Blob.slice 方法把文件按字节区间切出子片段。XMLHttpRequest 或 Fetch把每个分片异步发送给后端。其中Blob.slice是老早就被浏览器广泛支持的能力现在所有主流浏览器包括移动端都支持。后端用什么语言不挑只要实现接收分片和合并分片的 HTTP 接口即可。这里我用 Node.js 演示是因为前端开发者最容易读懂 JavaScript 后端代码而且同一套语言可以方便地调试。生产环境换成 Java、Go、Python 思路完全一致。2. 前端分片上传的核心设计与源码2.1 整体流程从选文件到上传完成一个完整的分片上传前端流程需要拆成下面这几个步骤用户选择文件。计算文件的唯一标识hash用于断点续传和秒传。确定分片大小将文件切割成若干个分片。初始化上传任务把文件标识和分片信息发给后端后端返回uploadId。启动并发队列逐个上传分片。所有分片上传完成后调用后端的合并接口。后端合并完成返回文件的访问地址。这里的第 4 步不是必须的。如果你只是做最简单的分片上传可以不上传初始化接口直接把文件标识放在每个分片的表单字段里。但有了初始化接口我们能更好地做任务注册、断点续传和分片状态管理。下面我会给出一套带有初始化接口的实现因为这是生产环境里最实用的形态。2.2 分片大小怎么定分片大小没有绝对标准需要结合业务场景权衡。常用的分片大小是1MB ~ 10MB。分片太小比如 128KB一个 1GB 文件会被切成 8192 片产生大量 HTTP 请求。前端每发一个请求都有开销后端每个分片都要落盘和合并整体效率反而很低。分片太大比如 50MB虽然请求数量少了但单个分片失败重传的成本变大而且容易触发网关的超时限制。如果用户网络环境差50MB 的单个请求依然可能断掉。我的经验是如果只是内部业务系统网络相对可靠可以用10MB如果面向公网用户且网络波动大用2MB ~ 5MB会更稳。后面的源码示例用 5MB这是一个比较均衡的数值。做一个简单计算一个 500MB 的视频文件按 5MB 分片一共 100 片。并发数控制为 3那么至少需要 34 轮请求才能完成。如果单片上传平均 300ms理想情况下总耗时大约 10 秒带宽利用率也远高于串行上传。2.3 文件切片与上传源码下面给出前端核心源码。这段代码不依赖任何框架可以直接放在一个 HTML 文件中运行。为了便于理解我把功能拆成几个函数。// 配置项 const CHUNK_SIZE 5 * 1024 * 1024; // 5MB const MAX_CONCURRENT 3; // 最大并发数 const SERVER_BASE http://localhost:3000; // 工具函数生成文件唯一标识简化版抽样 hash async function computeFileIdentifier(file) { // 取第一个分片、中间位置的一个分片、最后一个分片拼接后做 SHA-256 const size file.size; const sampleSize Math.min(CHUNK_SIZE, size); const first file.slice(0, sampleSize); const middle file.slice(Math.max(0, Math.floor(size / 2) - sampleSize / 2), Math.floor(size / 2) sampleSize / 2); const last file.slice(size - sampleSize, size); const parts [first, middle, last]; const buffer await new Response(new Blob(parts)).arrayBuffer(); const digest await crypto.subtle.digest(SHA-256, buffer); return [...new Uint8Array(digest)] .map((b) b.toString(16).padStart(2, 0)) .join(); } // 将文件切片 function createChunks(file, chunkSize) { const chunks []; let start 0; while (start file.size) { const end Math.min(start chunkSize, file.size); chunks.push({ index: chunks.length, blob: file.slice(start, end), start, end }); start end; } return chunks; } // 上传单个分片 async function uploadChunk(uploadId, chunk, fileIdentifier) { const formData new FormData(); formData.append(uploadId, uploadId); formData.append(index, chunk.index); formData.append(fileIdentifier, fileIdentifier); formData.append(chunk, chunk.blob, ${fileIdentifier}-${chunk.index}.part); const response await fetch(${SERVER_BASE}/api/upload/chunk, { method: POST, body: formData }); if (!response.ok) { throw new Error(分片 ${chunk.index} 上传失败: ${response.status}); } return response.json(); } // 初始化上传任务 async function initUpload(fileIdentifier, fileName, totalSize, totalChunks) { const response await fetch(${SERVER_BASE}/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileIdentifier, fileName, totalSize, totalChunks }) }); if (!response.ok) throw new Error(初始化上传失败); return response.json(); } // 通知后端合并分片 async function completeUpload(uploadId, fileIdentifier, fileName) { const response await fetch(${SERVER_BASE}/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ uploadId, fileIdentifier, fileName }) }); if (!response.ok) throw new Error(合并文件失败); return response.json(); }上面这段代码computeFileIdentifier是后续断点续传和秒传的基础我故意用了抽样 hash 而不是对整个文件做 hash是为了避免把大文件全部读入内存。这里用了crypto.subtle.digest但需要注意它只在 HTTPS 或 localhost 环境下可用本地开发没问题部署到生产环境必须有 HTTPS。2.4 并发控制与进度计算并发控制是分片上传里非常容易翻车的地方。如果一股脑把所有分片Promise.all发出去几百个请求同时打向后端服务端连接数、文件描述符很容易被打满如果逐个串行上传速度又太慢。所以需要一个“可同时执行 N 个任务的队列”。下面是一个简易的并发队列实现// 并发执行任务队列 async function runWithConcurrency(tasks, limit) { const results []; let index 0; async function worker() { while (index tasks.length) { const current index; try { results[current] await tasks[current](); } catch (err) { results[current] { error: err }; } } } const workers Array.from({ length: Math.min(limit, tasks.length) }, () worker()); await Promise.all(workers); return results; } // 上传所有分片 async function uploadAll(file) { const fileIdentifier await computeFileIdentifier(file); const chunks createChunks(file, CHUNK_SIZE); // 初始化任务 const task await initUpload( fileIdentifier, file.name, file.size, chunks.length ); const uploadId task.uploadId; // 构造分片上传函数列表 const tasks chunks.map((chunk) () uploadChunk(uploadId, chunk, fileIdentifier)); // 并发执行 await runWithConcurrency(tasks, MAX_CONCURRENT); // 全部完成后触发合并 const result await completeUpload(uploadId, fileIdentifier, file.name); console.log(上传完成:, result.url); return result; }这个实现里每个 worker 都是一个独立的循环不断从任务队列里取下一个任务执行。limit控制同时有几个 worker 在跑。代码里有一个很关键的细节const current index是原子递增的不会有多个 worker 拿到同一个任务这也是队列并发里最常见的写法。进度计算我用了一个比较朴素的口径每个分片上传成功后把已上传分片数加一计算已上传分片数 / 总分片数。如果想更精确地显示字节进度可以维护一个uploadedBytes变量每片成功后就加上这一片的大小。注意最后一片通常小于分片大小所以按“片数”算百分比在最后可能会跳一下。更稳妥的做法是记录每个分片的真实字节数并用字节数计算进度。3. 后端接口设计与合并源码3.1 接口定义初始化、分片上传、合并前端只有接口调用后端才是真正承接分片数据的角色。下面用 Express Multer 实现。需要先安装依赖npm install express multer接口设计如下接口路径方法作用主要参数/api/upload/initPOST创建上传任务返回 uploadIdfileIdentifier, fileName, totalSize, totalChunks/api/upload/chunkPOST接收单个分片并保存uploadId, index, chunk 文件/api/upload/mergePOST合并所有分片为最终文件uploadId, fileIdentifier, fileName/api/upload/statusGET查询某个任务已上传分片序号uploadId这里的核心是uploadId它把一次上传任务的所有分片绑定在一起。实际生产中可以用数据库记录任务信息Demo 阶段我用文件目录结构来模拟每个uploadId对应一个临时目录目录里放着序号命名的.part文件。3.2 后端接收分片与临时存储先初始化一个临时目录然后让 Multer 把上传的分片写入磁盘而不是保留在内存里const express require(express); const multer require(multer); const path require(path); const fs require(fs); const crypto require(crypto); const app express(); app.use(express.json()); const UPLOAD_TMP_DIR path.join(__dirname, uploads/tmp); const UPLOAD_FINAL_DIR path.join(__dirname, uploads/final); fs.mkdirSync(UPLOAD_TMP_DIR, { recursive: true }); fs.mkdirSync(UPLOAD_FINAL_DIR, { recursive: true }); // multer 直接落盘到临时目录 const storage multer.diskStorage({ destination: (req, file, cb) { const uploadId req.body.uploadId; const dir path.join(UPLOAD_TMP_DIR, uploadId); fs.mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (req, file, cb) { cb(null, ${req.body.index}.part); } }); const upload multer({ storage }); // 1. 初始化上传任务 app.post(/api/upload/init, (req, res) { const { fileIdentifier, fileName, totalSize, totalChunks } req.body; const uploadId crypto.randomUUID(); const taskInfo { uploadId, fileIdentifier, fileName, totalSize, totalChunks, uploadedChunks: [], createdAt: Date.now() }; // 生产环境这里应写入数据库这里直接写入 JSON 文件 fs.writeFileSync( path.join(UPLOAD_TMP_DIR, ${uploadId}.json), JSON.stringify(taskInfo) ); res.json({ uploadId }); }); // 2. 上传分片 app.post(/api/upload/chunk, upload.single(chunk), (req, res) { const { uploadId, index } req.body; if (!req.file) { return res.status(400).json({ error: 没有收到分片文件 }); } const taskFile path.join(UPLOAD_TMP_DIR, ${uploadId}.json); if (!fs.existsSync(taskFile)) { return res.status(404).json({ error: 上传任务不存在 }); } // 更新任务信息中的已上传分片 const taskInfo JSON.parse(fs.readFileSync(taskFile, utf-8)); if (!taskInfo.uploadedChunks.includes(Number(index))) { taskInfo.uploadedChunks.push(Number(index)); fs.writeFileSync(taskFile, JSON.stringify(taskInfo)); } res.json({ received: Number(index) }); });注意一个细节分片文件在filename里用的是req.body.index所以 Multer 会按照前端传的序号命名。这样后端不需要关心分片到达的顺序先把它们全部落盘最终合并时按文件名排序即可。3.3 合并分片时的关键点合并接口要做的事情是把临时目录下的所有.part文件按序号拼接成最终文件。这里有一个非常典型的坑如果直接用fs.readdirSync拿到文件名再排序通常会得到1.part、10.part、2.part这样的字典序合并出来的文件必然损坏。必须用自然数排序。// 3. 合并分片 app.post(/api/upload/merge, (req, res) { const { uploadId, fileIdentifier, fileName } req.body; const taskFile path.join(UPLOAD_TMP_DIR, ${uploadId}.json); if (!fs.existsSync(taskFile)) { return res.status(404).json({ error: 上传任务不存在 }); } const taskInfo JSON.parse(fs.readFileSync(taskFile, utf-8)); const chunkDir path.join(UPLOAD_TMP_DIR, uploadId); const partFiles fs.readdirSync(chunkDir).filter((f) f.endsWith(.part)); // 检查分片数量是否完整 if (partFiles.length ! taskInfo.totalChunks) { return res.status(400).json({ error: 分片不完整无法合并 }); } // 按自然数排序而不是字典序 partFiles.sort((a, b) Number(a.split(.)[0]) - Number(b.split(.)[0])); const finalPath path.join( UPLOAD_FINAL_DIR, ${fileIdentifier}_${Date.now()}_${fileName} ); // 用流方式按顺序写入避免一次性把大文件读入内存 const writeStream fs.createWriteStream(finalPath); const writeNext (index) { if (index partFiles.length) { writeStream.end(); return; } const readStream fs.createReadStream(path.join(chunkDir, partFiles[index])); readStream.pipe(writeStream, { end: false }); readStream.on(end, () writeNext(index 1)); readStream.on(error, (err) { writeStream.destroy(); res.status(500).json({ error: err.message }); }); }; writeNext(0); writeStream.on(finish, () { // 清除临时目录和任务文件 fs.rmSync(chunkDir, { recursive: true, force: true }); fs.rmSync(taskFile, { force: true }); res.json({ url: /files/${path.basename(finalPath)}, size: fs.statSync(finalPath).size }); }); });这段代码里用readStream.pipe(writeStream, { end: false })的方式逐片拼接是为了避免对大文件使用Buffer.concat。如果全部读进内存再写又回到了最初“内存爆掉”的老问题。用流式写入无论文件多大内存占用始终稳定。当然这个合并写法是串行的速度会受磁盘影响。如果分片数量很大可以考虑用stream.pipeline配合异步循环或者直接用fs.open按字节写入核心思路都一样保持常量内存占用。3.4 静态文件访问与临时文件清理为了让合并后的文件能被访问加一行静态目录映射app.use(/files, express.static(UPLOAD_FINAL_DIR));临时文件的清理是一个容易被忽略的问题。用户上传到一半关闭页面uploadId对应的临时目录就会残留。生产环境要通过定时任务扫描uploads/tmp目录删除创建时间超过 24 小时且未完成的任务。这里给一个简单的定时清理思路setInterval(() { const files fs.readdirSync(UPLOAD_TMP_DIR); for (const f of files) { const fullPath path.join(UPLOAD_TMP_DIR, f); const stat fs.statSync(fullPath); if (Date.now() - stat.mtimeMs 24 * 60 * 60 * 1000) { fs.rmSync(fullPath, { recursive: true, force: true }); } } }, 60 * 60 * 1000);这个定时器在生产环境通常会被替换成消息队列或定时任务框架但思路不变过期未完成的任务必须清理否则磁盘会被垃圾分片占满。4. 断点续传与秒传的实现方案4.1 文件唯一标识怎么算断点续传和秒传都依赖同一个东西文件唯一标识。浏览器端不可能完全信任用户传入的文件名因为同名文件可能是不同内容也不能只用文件大小因为不同文件大小可能相同。最可靠的是对文件内容计算 hash但大文件全量 hash 在浏览器里会比较耗时。我在前面采用了抽样 hash取文件头部、中间、尾部各一个分片长度的数据拼接后计算 SHA-256。这个方案的优势是快缺点是理论上存在碰撞可能两个内容大部分相同的文件可能产生相同标识。对于一般业务场景基本够用但如果要上传的是重要合规文件需要全量增量 hash。前端可以用 SparkMD5 的增量模式在切片时一边切一边更新 hash文件读完 hash 也就算完了内存占用也不大。战斗在前端的人应该都明白一个道理不能为了“绝对准确”把计算 hash 变成新的性能瓶颈。实际项目中我更倾向于用“抽样 hash 文件大小 最后修改时间”组合成标识以降低碰撞概率。4.2 查询已上传分片并跳过断点续传的典型场景是用户传了 60% 断了刷新页面或者重新选择同一文件后希望能从 60% 继续而不是从零开始。实现断点续传的关键是“查询分片状态”。后端在初始化接口创建任务时已经记录了uploadedChunks数组。前端拿到文件标识后可以调用状态查询接口获取已经上传成功的分片序号然后只提交缺失的分片。后端增加一个查询接口app.get(/api/upload/status, (req, res) { const { uploadId } req.query; const taskFile path.join(UPLOAD_TMP_DIR, ${uploadId}.json); if (!fs.existsSync(taskFile)) { return res.status(404).json({ error: 任务不存在 }); } const taskInfo JSON.parse(fs.readFileSync(taskFile, utf-8)); res.json({ uploadedChunks: taskInfo.uploadedChunks, totalChunks: taskInfo.totalChunks }); });前端在上传前先查询已经上传的分片async function getUploadedChunks(uploadId) { const response await fetch(${SERVER_BASE}/api/upload/status?uploadId${uploadId}); if (!response.ok) return []; const data await response.json(); return data.uploadedChunks; } // 过滤未上传分片 async function uploadRemainingChunks(file, uploadId, fileIdentifier, chunks) { const uploadedChunks new Set(await getUploadedChunks(uploadId)); const pendingTasks chunks .filter((chunk) !uploadedChunks.has(chunk.index)) .map((chunk) () uploadChunk(uploadId, chunk, fileIdentifier)); await runWithConcurrency(pendingTasks, MAX_CONCURRENT); }这里有个前提用户必须能拿到同一个uploadId才能真正实现续传。最简单的方式是把uploadId存在localStorage里key 为文件标识。下次选到同一文件时先用文件标识去localStorage找之前未完成的uploadId找到就直接继续上传找不到才重新初始化。4.3 秒传文件相同直接注册秒传的本质是“这个文件别人已经传过了服务器上已经有相同文件不需要再传”。它依赖文件唯一标识在服务端去重。如果服务端已经有一个文件的 hash 是abc123现在某个用户上传初始化时也带abc123服务端可以检查存储目录里是否已经存在相同 hash 的文件如果存在直接将新任务标记为上传完成返回最终的访问地址。前端甚至不需要上传任何分片。在 Demo 里可以这样实现初始化接口里加一个检查逻辑。app.post(/api/upload/init, (req, res) { const { fileIdentifier, fileName, totalSize, totalChunks } req.body; // 秒传检查已经存在同名 hash 的最终文件 const existingFiles fs.readdirSync(UPLOAD_FINAL_DIR); const existed existingFiles.find((f) f.startsWith(${fileIdentifier}_)); if (existed) { return res.json({ uploadId: null, instant: true, url: /files/${existed} }); } const uploadId crypto.randomUUID(); // ... 其余逻辑不变 });前端在初始化后判断instant true就直接显示上传完成不需要再走切分和上传流程。秒传是用户体感提升最明显的功能。一个 2GB 文件如果别人传过你点击上传的一瞬间就完成了。这里要注意的是秒传的可靠性完全取决于 hash 的质量。抽样 hash 如果碰撞可能把两个不同的文件当成同一个造成用户数据丢失。因此涉及秒传的业务最好使用全量 hash或者在后端对相同 hash 的文件再比较文件大小甚至抽样字节对比做到多重校验。5. 常见问题与排查技巧实录5.1 合并出来的文件损坏这是分片上传最常遇到的问题没有之一。文件合并完打开提示损坏或者解压失败通常原因有四个分片顺序错误没按自然序号排序用了字典序。分片缺失就合并没有检查partFiles.length totalChunks。前端切片时字节偏移算错file.slice的起始位置和结束位置没有正确衔接。后端合并时流式写入没有正确处理end事件导致部分分片写入不完整。排查时先看后端合并日志打印每个参与合并的分片文件大小把它们的字节数加起来和原始文件大小对比。如果总字节数比原文件少说明有分片没接受完整如果大小一致但还是打不开重点看排序逻辑。我自己在写合并逻辑时曾经犯过一个很低级的错误直接对文件名数组调用.sort()结果2.part排在10.part后面。后来我把所有分片名改成在初始化时用String(index).padStart(6, 0)补零命名比如000002.part、000010.part这样字符串排序也等于自然排序一劳永逸。5.2 并发过高导致服务端大量报错并发数设得过高后端的文件句柄、内存、网络连接都会吃紧。我见过有人直接把并发设成 10传一个 1GB 文件后端 Nginx 瞬间冒出大量Connection reset by peer。控制好前端并发数是最直接的解决办法通常3 ~ 5就够了分片上传主要靠减少单请求字节数和失败重试成本来提升体验而不是靠无限并发。另一个容易被忽略的点是后端要支持“分片乱序到达”。前端并发上传时分片到达后端的顺序是不确定的后端不能假设分片会按序号依次到达。所以存储分片时一定用独立文件名落盘不要用“追加写入”的方式。之前有同学用fs.createWriteStream(path, { flags: a })来写分片并发一开两个分片就交错写进同一个文件文件数据全乱了。5.3 浏览器的兼容性细节现在的浏览器对Blob.slice支持已经非常好但如果你还需要兼容老版本 Safari注意它的 API 曾经是file.webkitSlice()。稳妥的写法是做一个兼容封装function sliceFile(file, start, end) { if (file.slice) return file.slice(start, end); if (file.webkitSlice) return file.webkitSlice(start, end); throw new Error(当前浏览器不支持文件切片); }跨平台网页应用最容易翻车的不是桌面浏览器而是各种 App 里的 WebView。部分安卓 WebView 对FormData里同时传输普通字段和文件的支持有历史 bug如果遇到上传分片时后端收不到index字段可以尝试把index放在 URL query 上而不是表单字段里// 兼容写法把业务参数放到 URL 参数上 fetch(${SERVER_BASE}/api/upload/chunk?uploadId${uploadId}index${chunk.index}, { method: POST, body: formData });5.4 进度条跳动和百分比不准进度条常见的问题是每传一个分片进度条跳一格到了最后几片跳得特别快。原因是前面提到过的采用“分片数量”计算进度而每个分片大小不完全相同。特别是最后一个分片可能只有几KB显示时就会出现“从 98% 跳到 100%”的突兀感。更好的做法是用字节数统计。初始化时拿到总大小每次分片上传成功后记录该分片的end - start字节数累计uploadedBytes然后用uploadedBytes / file.size计算百分比。这样即使每个分片大小不齐进度条也是平滑的。如果要做断点续传的进度恢复还要把本地缓存的uploadedBytes和后端返回的已上传分片字节数对齐否则刷新后进度会倒退。5.5 取消上传与网络切换用户点了取消按钮还在进行中的所有分片请求应该被中止。Fetch 可以用AbortController实现const controller new AbortController(); async function uploadChunk(uploadId, chunk, fileIdentifier, signal) { // ... fetch(${SERVER_BASE}/api/upload/chunk, { method: POST, body: formData, signal }); } // 取消时调用 controller.abort();中断之后服务端的临时分片并不会自动删除所以最好在取消时调用一个清理接口或者依赖前文提到的过期清理任务。网络切换是移动端的高频问题。Wi-Fi 切到 4G 时TCP 连接大概率会断。这种情况下前端应该监听navigator.onLine或online/offline事件在恢复在线后自动检查未完成任务并续传。window.addEventListener(online, () { // 重新查询未完成任务并继续上传 resumePendingUpload(); });6. 从 Demo 走向生产还要做的几件事到这里一套可以跑通的分片上传已经完成了前端切片、并发上传、后端接收、断点合并、断点续传、秒传。但要把这套代码放到真实生产环境我建议你至少再补齐以下内容。第一件事是鉴权。文件上传接口必须校验用户登录状态和上传权限否则任何人都可以把文件扔到你的存储目录里。常见做法是在初始化接口时校验 Token然后把用户信息写入任务记录后续每个分片请求都带 Token后端在uploadId对应的任务里校验是否属于当前用户。第二件事是文件元数据入库。Demo 里任务信息和最终文件都只有目录里的 JSON生产环境要把文件标识、上传用户、文件路径、大小、上传时间、状态存进数据库否则没法做文件管理、统计和权限控制。第三件事是存储层替换。现在的代码是把文件存到本地磁盘生产环境通常要接对象存储服务。很多云厂商的 SDK 本身就支持分片上传思路和你现在看的这套完全一致只是把后端的“接收分片并合并”换成了“调用对象存储的分片上传接口”。第四件事是上传进度心跳。如果用户在超长上传过程中长时间没有任何分片完成后端需要知道任务还活着。可以让前端定期上报心跳或者用“最近一次分片上传时间”来判断任务是否超时。第五件事是失败重试策略。我在 Demo 里没有写重试逻辑生产环境需要给每个分片设置最大重试次数比如 3 次。重试要有退避策略不能失败后立刻疯狂重发尤其并发场景下会放大服务端压力。我习惯的做法是指数退避第一次等 1 秒第二次等 2 秒第三次等 4 秒超过上限就中断任务并提示用户。最后分享一个我个人的习惯分片上传调试时不要一上来就用几个 GB 的文件。先用一个 10MB 左右的小文件把分片大小临时调成 1MB确认整个流程能跑通、合并后的文件 MD5 和原文件一致再逐步加大文件体积。我调试这套代码时光是合并顺序的坑就踩过两次。把基础流程跑稳后面加断点续传和秒传都是在稳定地基上添砖加瓦排查问题时也能更快定位到具体环节。希望这份源码和踩坑记录能帮你少走几段弯路。