1. 项目概述为什么大文件上传必须分片如果你做过Web开发尤其是涉及文件上传功能的后端大概率遇到过用户反馈“为什么我上传一个2G的视频总是失败” 或者在服务器日志里看到过令人头疼的“413 Request Entity Too Large”错误。这背后就是传统单次上传Whole File Upload在面对大文件时的天然缺陷。简单来说Web页面支持大附件分片上传就是将一个大文件在客户端浏览器切割成多个小块即“分片”然后逐个或并发地上传到服务器服务器接收完所有分片后再将它们按顺序合并还原成原始文件。这听起来简单但要做好却涉及前后端协同、网络容错、用户体验等一系列复杂问题。它不仅是提升上传成功率的技术手段更是现代Web应用处理富媒体内容如视频、设计稿、数据集的基石能力。从最近的热搜词也能看出端倪“大文件分片上传”、“minio文件分片上传加密”、“Web前端开发”等词频繁出现说明这不仅是老生常谈更是当前开发中的实际痛点。无论是做网盘、在线视频编辑、数据备份系统还是企业级文档管理分片上传都是绕不开的核心功能。接下来我将结合多年踩坑经验为你拆解其核心原理、主流实现方案以及那些文档里不会写的实操细节。2. 核心需求与方案选型背后的逻辑为什么不用简单的一次性POST原因可以归结为以下四点这也是我们设计分片上传方案时必须解决的四大核心需求2.1 突破网络与服务器的限制这是最直接的原因。无论是Nginx、Apache等Web服务器还是后端框架如Spring Boot、Express通常都有默认的请求体大小限制例如Nginx的client_max_body_size默认仅为1M。虽然可以调整但无限制放大既不安全也不现实。分片上传将大请求拆分为多个符合限制的小请求从根本上规避了这个问题。2.2 实现断点续传提升用户体验网络不稳定是常态。一个5G的文件上传到99%时网络断开如果从头再来用户心态可能会崩溃。分片上传天然支持断点续传。因为每个分片都是独立的HTTP请求我们只需要记录哪些分片已成功上传。当上传中断后重新发起时可以先向服务器查询已上传的分片列表然后只上传剩余的部分。这对移动端或网络环境差的用户至关重要。3.3 充分利用带宽提升上传速度现代浏览器支持对同一域名的多个并发请求。我们可以利用这一点同时上传多个文件分片从而更充分地利用用户的上行带宽理论上可以成倍缩短总上传时间。当然并发数需要合理控制避免对服务器造成过大压力或触发浏览器限制。3.4 便于实现上传进度监控对于单次上传浏览器只能提供整个请求的发送进度粒度很粗。而分片上传后我们可以精确计算已上传大小 已成功分片数 * 分片大小 当前正上传分片已发送大小。这样就能向用户展示一个准确、平滑的进度条极大提升操作的可预期性。基于这些需求目前主流的方案有两种前端分片 后端合并这是最经典和自主可控的方案。前端使用Blob.slice()方法切割文件并逐个上传。后端负责接收、暂存分片并在所有分片到达后合并。我们将重点讨论这种方案。利用云存储服务商SDK如阿里云OSS、腾讯云COS、AWS S3或MinIO它们都提供了支持分片上传的客户端SDK。这种方式将分片逻辑、暂存和合并的复杂性转移给了云服务后端只需生成上传凭证预签名URL并处理最终回调开发量小适合快速集成。从热词“minio文件分片上传加密”可以看出基于MinIO等私有化部署方案也在被深入使用。注意方案选择没有绝对好坏。对于追求快速上线、运维能力有限的团队云服务SDK是优选。对于需要深度定制如自定义分片策略、加密算法、存储逻辑或成本敏感的场景自研前端分片后端合并方案更合适。本文将以自研方案为主线进行深度剖析。4. 前端核心实现从文件切割到并发控制前端是实现分片上传的“发动机”其稳定性和效率直接决定用户体验。我们一步步来看关键实现。4.1 文件分片策略如何确定分片大小分片大小不是随便定的它需要在效率、可靠性和服务器压力之间取得平衡。过小如100KB会导致分片数量极多创建大量HTTP请求增加前端管理和后端处理的开销合并文件时磁盘IO压力大。过大如100MB失去了分片的意义单个请求失败的成本高断点续传的粒度粗并发上传的优势也不明显。一个经过实践检验的经验值是5MB ~ 10MB。这个范围有几点考虑能有效绕过大多数服务器的默认限制。分片数量在可管理范围内。一个1G的文件按5MB分片约为200个按10MB分片约为100个。与TCP传输特性匹配能较好地利用带宽。对于云服务如S3其分片上传API也常推荐5MB作为最小单元。当然这可以做成可配置的甚至可以根据用户的网络速度动态调整。核心代码示例如下// 计算分片数量和创建分片数组 function createFileChunks(file, chunkSize 5 * 1024 * 1024) { // 默认5MB const chunks []; let start 0; let index 0; while (start file.size) { const end Math.min(start chunkSize, file.size); const chunk file.slice(start, end); // 关键APIBlob.slice chunks.push({ chunk, // 分片Blob对象 index, // 分片序号用于后端排序 start, // 在原始文件中的起始位置可选用于校验 end, // 在原始文件中的结束位置 hash: ${file.name}_${index}, // 简易标识生产环境建议用文件内容hash size: chunk.size, percentage: 0 // 上传进度 }); start end; index; } return chunks; }4.2 生成文件唯一标识实现秒传与校验在分片上传前为整个文件生成一个唯一标识如MD5、SHA-256至关重要。这个标识有两个核心作用秒传Instant Upload上传前先将文件hash发送给服务器。服务器检查是否已有相同hash的文件存在。如果存在则直接返回已上传文件的地址无需再传任何分片。这对于网盘类应用节省存储空间和带宽极其有效。分片校验可以用这个文件hash结合分片索引生成每个分片的唯一标识如${fileHash}_${chunkIndex}用于后端去重和校验防止重复上传错误的分片。计算大文件hash是个CPU密集型任务在主线程进行会导致页面卡顿。必须使用Web Worker在后台线程计算或使用增量摘要算法。这里是一个使用spark-md5库在Worker中计算的简化示例// 在Web Worker中 (hash-worker.js) self.importScripts(spark-md5.min.js); self.onmessage function(e) { const { file, chunkSize } e.data; const spark new SparkMD5.ArrayBuffer(); const chunks Math.ceil(file.size / chunkSize); let currentChunk 0; const fileReader new FileReader(); function loadNext() { const start currentChunk * chunkSize; const end start chunkSize file.size ? file.size : start chunkSize; fileReader.readAsArrayBuffer(file.slice(start, end)); } fileReader.onload function(e) { spark.append(e.target.result); currentChunk; if (currentChunk chunks) { // 汇报进度 self.postMessage({ type: progress, percentage: (currentChunk / chunks) * 100 }); loadNext(); } else { // 计算完成返回最终hash const hash spark.end(); self.postMessage({ type: complete, hash }); self.close(); } }; loadNext(); };4.3 并发上传与控制避免浏览器请求池被占满我们不能一次性发起所有分片的上传请求那样会占满浏览器的HTTP请求池通常对同一域名是6个导致其他API请求被阻塞。需要一个队列机制来控制并发数。class ConcurrentUploader { constructor(maxConcurrent 3) { this.maxConcurrent maxConcurrent; // 最大并发数建议3-5 this.queue []; // 任务队列 this.activeCount 0; // 正在执行的任务数 } // 添加上传任务 addTask(taskFn) { return new Promise((resolve, reject) { this.queue.push({ taskFn, resolve, reject }); this._run(); }); } // 执行队列 _run() { while (this.activeCount this.maxConcurrent this.queue.length) { this.activeCount; const { taskFn, resolve, reject } this.queue.shift(); taskFn() .then(resolve) .catch(reject) .finally(() { this.activeCount--; this._run(); // 一个任务完成尝试执行下一个 }); } } } // 使用示例 const uploader new ConcurrentUploader(3); for (let chunk of chunks) { uploader.addTask(() uploadChunk(chunk)).then(() { console.log(分片 ${chunk.index} 上传成功); }); }4.4 进度计算与展示给用户明确的反馈进度计算需要综合考量总文件大小、已上传成功分片的总大小、以及当前正在上传的分片的实时进度。// 假设有一个全局状态 const state { file: fileObject, chunks: chunkArray, hash: fileHash, totalSize: fileObject.size, uploadedSize: 0 // 已上传字节数 }; // 在每个分片上传的进度事件中更新 function onChunkProgress(chunkIndex, event) { if (event.lengthComputable) { const chunk state.chunks[chunkIndex]; // 计算这个分片本次上传的增量 const chunkLoaded event.loaded; // 当前分片已上传字节 const chunkDelta chunkLoaded - (chunk.loaded || 0); chunk.loaded chunkLoaded; chunk.percentage Math.round((chunkLoaded / chunk.size) * 100); // 更新总上传大小 state.uploadedSize chunkDelta; // 计算总进度 const totalPercentage Math.round((state.uploadedSize / state.totalSize) * 100); updateProgressBar(totalPercentage); // 更新UI } } // 分片上传成功时将其大小计入 uploadedSize (避免进度回退) function onChunkSuccess(chunkIndex) { const chunk state.chunks[chunkIndex]; if (!chunk.isSuccess) { state.uploadedSize chunk.size; chunk.isSuccess true; } }实操心得进度条偶尔会“回退”是常见问题。这是因为XMLHttpRequest或Fetch API的progress事件在网络波动时event.loaded值可能短暂减小。更稳健的做法是以上传成功的分片总大小为主要进度依据当前正在上传的分片进度作为辅助增量。只有当分片确认上传成功收到HTTP 200响应后才将其完整大小累加到uploadedSize中。这样进度条只会前进或暂停不会后退用户体验更好。5. 后端核心实现接收、管理与合并后端是分片上传的“大脑”和“仓库”需要设计好API、分片存储和合并逻辑。5.1 API设计清晰的责任划分通常需要设计三个核心接口初始化上传/upload/init请求文件hash、文件名、文件总大小、分片大小。响应返回uploadId本次上传会话的唯一ID和chunkList服务器已存在的该文件分片索引列表用于实现断点续传。如果文件已存在直接返回文件地址秒传逻辑。上传分片/upload/chunk请求uploadId、分片索引chunkIndex、分片数据chunkmultipart/form-data、分片hash可选用于校验。响应成功或失败。合并文件/upload/merge请求uploadId、文件hash、文件名、总分片数。响应合并成功后的文件访问URL。5.2 分片存储策略临时与永久分片在合并前是临时数据存储设计需考虑存储位置可以是服务器本地磁盘的临时目录也可以是独立的对象存储如MinIO或Redis如果分片很小。使用对象存储时每个分片作为一个独立对象上传合并时可能需要触发服务端的compose操作。目录结构良好的目录结构便于管理和清理。例如temp_uploads/{fileHash}/{chunkIndex}.part。以fileHash或uploadId为目录名可以天然隔离不同文件的分片。清理机制必须有一个后台任务定期清理超过一定时间如24小时未合并的临时分片目录防止磁盘被占满。5.3 合并文件的正确姿势效率与安全合并操作是IO密集型操作最直接的方式是顺序读取所有分片并写入新文件。但对于超大文件这可能导致内存溢出或长时间阻塞请求线程。高效安全的合并方法使用流Stream进行合并这是Node.js、Java、Go等语言的高效做法。以Node.js为例const fs require(fs).promises; const path require(path); async function mergeChunks(fileHash, fileName, totalChunks, uploadDir, finalDir) { const chunkDir path.join(uploadDir, fileHash); const finalPath path.join(finalDir, ${fileHash}_${fileName}); // 按分片索引排序 const chunkPaths Array.from({length: totalChunks}, (_, i) path.join(chunkDir, ${i}.part) ); // 使用写流顺序追加每个分片 const writeStream fs.createWriteStream(finalPath); for (const chunkPath of chunkPaths) { const chunkBuffer await fs.readFile(chunkPath); writeStream.write(chunkBuffer); } writeStream.end(); await new Promise((resolve, reject) { writeStream.on(finish, resolve); writeStream.on(error, reject); }); // 合并完成后删除临时分片目录 await fs.rm(chunkDir, { recursive: true, force: true }); return finalPath; }分片校验在合并前或合并后应计算最终文件的hash与前端最初传来的文件hash进行比对确保文件在传输和合并过程中未出错。异步合并对于非常大的文件合并操作可能耗时数十秒。切勿在同步的HTTP请求中执行合并正确的做法是接收到合并请求后立即返回“合并已开始”的响应。将合并任务推入消息队列如Redis、RabbitMQ或交给线程池/后台进程处理。通过WebSocket、Server-Sent Events (SSE) 或让客户端轮询另一个接口来通知用户合并完成。5.4 数据库设计记录上传状态需要一个简单的表来跟踪上传会话这是实现断点续传和清理过期数据的基础。CREATE TABLE upload_sessions ( id bigint(20) NOT NULL AUTO_INCREMENT, upload_id varchar(64) NOT NULL COMMENT 上传会话ID, file_hash varchar(128) NOT NULL COMMENT 文件唯一哈希, file_name varchar(255) NOT NULL, file_size bigint(20) NOT NULL, chunk_size int(11) NOT NULL, total_chunks int(11) NOT NULL, uploaded_chunks text COMMENT 已上传的分片索引列表JSON格式如[0,1,2], status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0:上传中, 1:已完成, 2:已过期, storage_path varchar(500) DEFAULT NULL COMMENT 最终存储路径, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY idx_upload_id (upload_id), KEY idx_file_hash (file_hash), KEY idx_status_created (status,created_at) -- 用于清理任务 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;当初始化上传时插入一条记录。每成功上传一个分片就更新uploaded_chunks字段。合并成功后更新status和storage_path。一个定时任务可以扫描status0且created_at超过阈值的记录清理对应的临时文件并更新状态为“已过期”。6. 高级优化与安全考量实现基础功能只是第一步要让分片上传健壮、高效、安全还需要考虑更多。6.1 秒传与文件去重如前所述利用文件hash可以实现秒传。后端在/upload/init接口中先查询files表存储最终文件信息的表是否存在相同的file_hash。如果存在直接返回该文件的访问地址前端提示用户“秒传成功”。这不仅能提升用户体验更能为公司节省大量存储和带宽成本。6.2 分片上传的完整性校验网络传输可能出错必须校验。有两种级别分片级校验前端在上传分片时可以计算该分片的MD5将其放在请求头如X-Chunk-Hash中。后端接收分片后重新计算MD5进行比对不一致则要求重传。文件级校验在所有分片合并完成后后端计算整个文件的hash与初始化时前端传来的file_hash比对。这是最终的质量关卡。6.3 安全性加固权限验证每一个上传接口init, chunk, merge都必须携带用户身份令牌如JWT验证用户是否有权限上传。防止恶意用户消耗服务器资源。文件类型与大小限制在init接口就要校验文件后缀和MIME类型防止上传可执行文件等危险类型。同时虽然分片规避了单次请求大小限制但仍需对file_size进行限制防止磁盘被塞满。病毒扫描对于合并后的文件尤其是用户上传的可执行文件、文档等应通过异步任务进行病毒扫描确认安全后再对外提供访问。防止目录遍历攻击处理文件名时要过滤掉../等字符防止恶意用户通过构造文件名将分片写入或合并到系统任意目录。6.4 与云存储方案如MinIO的集成如果你的后端使用MinIO可以利用其原生的Multipart UploadAPI。这时后端的角色会发生变化初始化后端调用MinIO SDK的createMultipartUpload方法获取一个uploadId返回给前端。获取预签名URL前端为每个分片向后端请求一个用于上传到MinIO的预签名URLPresigned URL。后端调用presignUrl生成并将uploadId和partNumber即分片索引信息包含在URL中。这样做的好处是分片数据直接从前端传到MinIO不流经你的应用服务器极大减轻了服务器带宽和IO压力。上传分片前端直接用预签名URL上传分片到MinIO。完成上传所有分片上传完毕后前端通知后端。后端调用completeMultipartUpload告知MinIO所有分片已就绪MinIO会自动完成合并。这种“服务端签名客户端直传”的模式是目前最主流的云上传架构兼具安全性和高性能。7. 实战中常见问题与排查技巧即使设计得再完美在实际开发和线上运维中还是会遇到各种问题。以下是我总结的一些典型“坑”和解决方法。7.1 前端常见问题问题一iOS Safari上大文件分片上传到一半失败或进度异常。排查Safari对Blob.slice()方法的实现在某些版本上有兼容性问题特别是当文件非常大时。此外iOS应用进入后台后网络请求可能被挂起。解决使用File.prototype.slice的兼容性写法或者引入blob-util库。对于后台运行问题可以考虑使用Service Worker如果支持来管理上传或者提示用户保持应用在前台。减小分片大小例如降到2MB增加重试机制。问题二并发上传时浏览器控制台报错“net::ERR_INSUFFICIENT_RESOURCES”。排查这是浏览器达到了并发请求或Socket数的资源上限。即使控制了并发数如果分片数量极多快速完成和发起新请求也可能导致此问题。解决除了控制并发数还需要加入延迟队列。例如在一个分片上传完成后不立即启动下一个而是等待100-200毫秒。这给了浏览器网络栈喘息的时间。问题三进度条在接近100%时卡住很久然后才显示完成。排查这通常是后端合并文件耗时过长导致的。前端所有分片已上传完毕进度99%但等待后端合并的HTTP响应。解决如前所述必须将合并操作异步化。前端在调用/merge接口后应轮询另一个接口如/upload/status/{uploadId}来查询合并状态而不是等待合并请求的响应。7.2 后端常见问题问题一服务器磁盘空间被临时分片占满。排查用户上传了超大文件但中途放弃或者合并任务失败导致临时分片未被清理。解决实现强健的清理定时任务。每天扫描upload_sessions表中状态为“上传中”且创建时间超过24小时可根据业务调整的记录删除其对应的临时目录。在/upload/init接口中检查服务器磁盘剩余空间如果低于某个阈值如10%则拒绝新的上传请求。问题二合并超大文件时服务器内存溢出OOM。排查错误地使用fs.readFileSync或一次性读取所有分片到内存再写入。解决务必使用流Stream来合并文件。如Node.js的createWriteStream和createReadStreamJava的Files.copy配合BufferedInputStreamGo的io.Copy等。流式处理只会占用很小的内存缓冲区。问题三高并发下同一分片被重复上传导致合并出错。排查网络不稳定时前端可能因未及时收到响应而重试上传或者用户快速点击了两次上传按钮。解决后端上传分片接口要实现幂等性。在存储分片前先检查uploadId和chunkIndex对应的分片是否已存在且完整可通过校验hash。如果已存在直接返回成功避免重复写入。这需要将分片存储和其元信息如hash的检查作为一个原子操作。7.3 网络与部署问题问题用户网络切换如从WiFi切到4G导致上传失败。解决这是断点续传要解决的核心场景。关键在于uploadId和文件分片信息需要在页面刷新或网络重连后依然存在。可以将这些信息持久化到localStorage或IndexedDB中。当页面重新加载或检测到网络恢复时先从本地存储恢复上传上下文然后向服务器查询已上传分片列表继续上传剩余部分。问题负载均衡环境下用户的不同分片请求可能被分发到不同的后端服务器。解决临时分片存储必须是共享的。不能存在服务器A的本地磁盘上。解决方案有使用共享文件系统如NFS。使用对象存储如MinIO、S3作为临时存储。使用分布式缓存如Redis存储小分片不推荐用于大分片。通过“粘性会话”Sticky Session确保同一用户的上传请求都落到同一台服务器但这降低了负载均衡的灵活性。一个实用的排查清单可以总结如下问题现象可能原因排查方向与解决思路上传进度卡在0%网络问题、CORS错误、初始化接口失败检查浏览器Network面板查看首个init请求是否成功检查后端CORS配置。部分分片反复上传失败分片损坏、网络不稳定、服务器临时存储不可写1. 开启分片hash校验。2. 增加前端重试机制如最多3次。3. 检查服务器磁盘权限和空间。合并接口返回超时文件太大合并耗时过长将合并操作改为异步接口立即返回“处理中”通过其他接口查询结果。秒传功能不生效文件hash计算不一致或后端查询逻辑有误对比前后端计算hash的算法和输入确保计算的是文件内容不包括文件名等元数据。检查数据库files表的file_hash索引。清理任务运行后正在上传的文件出错清理时间阈值设置过短延长临时文件的保留时间如从24小时改为48小时或根据业务活跃时间调整。8. 从零搭建一个简易分片上传Demo理论说了这么多我们用一个极简的Node.jsExpress 前端Vanilla JS的例子把核心流程串起来。注意此示例省略了错误处理、安全校验等生产级代码仅用于演示核心链路。8.1 后端服务 (server.js)const express require(express); const multer require(multer); const fs require(fs).promises; const path require(path); const crypto require(crypto); const app express(); const PORT 3000; // 临时存储分片 const TEMP_DIR path.join(__dirname, temp); const FINAL_DIR path.join(__dirname, uploads); // 确保目录存在 (async () { await fs.mkdir(TEMP_DIR, { recursive: true }); await fs.mkdir(FINAL_DIR, { recursive: true }); })(); // 内存中存储上传会话生产环境需用数据库 const uploadSessions new Map(); // 1. 初始化上传 app.post(/api/upload/init, express.json(), (req, res) { const { fileHash, fileName, fileSize, chunkSize } req.body; const uploadId crypto.randomUUID(); // 模拟秒传检查最终文件是否已存在 const finalFilePath path.join(FINAL_DIR, ${fileHash}_${fileName}); if (fs.existsSync(finalFilePath)) { return res.json({ code: 0, message: 秒传成功, url: /uploads/${fileHash}_${fileName} }); } // 创建上传会话 uploadSessions.set(uploadId, { fileHash, fileName, fileSize, chunkSize, uploadedChunks: new Set() // 记录已上传分片索引 }); // 创建临时目录 const chunkDir path.join(TEMP_DIR, fileHash); fs.mkdir(chunkDir, { recursive: true }); res.json({ code: 0, uploadId, uploadedChunks: [] }); }); // 配置multer处理文件分片 const storage multer.diskStorage({ destination: function (req, file, cb) { const { uploadId, chunkIndex } req.body; const session uploadSessions.get(uploadId); if (!session) return cb(new Error(上传会话不存在)); const chunkDir path.join(TEMP_DIR, session.fileHash); cb(null, chunkDir); }, filename: function (req, file, cb) { const { chunkIndex } req.body; cb(null, ${chunkIndex}.part); // 分片以索引命名 } }); const upload multer({ storage }); // 2. 上传分片 app.post(/api/upload/chunk, upload.single(chunk), (req, res) { const { uploadId, chunkIndex } req.body; const session uploadSessions.get(uploadId); if (!session) { return res.status(404).json({ code: 1, message: 上传会话不存在 }); } session.uploadedChunks.add(parseInt(chunkIndex)); res.json({ code: 0, message: 分片上传成功 }); }); // 3. 合并文件 app.post(/api/upload/merge, express.json(), async (req, res) { const { uploadId, totalChunks } req.body; const session uploadSessions.get(uploadId); if (!session) { return res.status(404).json({ code: 1, message: 上传会话不存在 }); } const { fileHash, fileName } session; const chunkDir path.join(TEMP_DIR, fileHash); const finalFilePath path.join(FINAL_DIR, ${fileHash}_${fileName}); try { const writeStream require(fs).createWriteStream(finalFilePath); for (let i 0; i totalChunks; i) { const chunkPath path.join(chunkDir, ${i}.part); const chunkBuffer await fs.readFile(chunkPath); writeStream.write(chunkBuffer); } writeStream.end(); await new Promise((resolve, reject) { writeStream.on(finish, resolve); writeStream.on(error, reject); }); // 清理临时分片 await fs.rm(chunkDir, { recursive: true, force: true }); uploadSessions.delete(uploadId); res.json({ code: 0, message: 合并成功, url: /uploads/${fileHash}_${fileName} }); } catch (error) { res.status(500).json({ code: 1, message: 合并失败, error: error.message }); } }); // 静态文件服务用于访问上传后的文件 app.use(/uploads, express.static(FINAL_DIR)); app.listen(PORT, () { console.log(Server is running on http://localhost:${PORT}); });8.2 前端页面 (index.html)!DOCTYPE html html langzh-CN head meta charsetUTF-8 title分片上传演示/title script srchttps://cdn.jsdelivr.net/npm/spark-md53.0.2/spark-md5.min.js/script /head body input typefile idfileInput / button onclickuploadFile()开始上传/button div进度: span idprogress0/span%/div div idresult/div script const CHUNK_SIZE 1 * 1024 * 1024; // 演示用1MB async function uploadFile() { const file document.getElementById(fileInput).files[0]; if (!file) return alert(请选择文件); // 1. 计算文件hash (简化版在主线程计算大文件会卡顿) const spark new SparkMD5.ArrayBuffer(); const arrayBuffer await file.arrayBuffer(); spark.append(arrayBuffer); const fileHash spark.end(); console.log(文件hash:, fileHash); // 2. 初始化上传 const initResp await fetch(http://localhost:3000/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileHash, fileName: file.name, fileSize: file.size, chunkSize: CHUNK_SIZE }) }).then(r r.json()); console.log(初始化结果:, initResp); if (initResp.url) { // 秒传成功 document.getElementById(result).innerHTML 秒传成功文件地址: a href${initResp.url} target_blank${initResp.url}/a; return; } const { uploadId, uploadedChunks [] } initResp; // 3. 创建分片 const chunks []; let start 0; let index 0; while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ chunk: file.slice(start, end), index: index, start, end }); start end; index; } // 4. 过滤掉已上传的分片 (断点续传) const chunksToUpload chunks.filter(chunk !uploadedChunks.includes(chunk.index)); const totalChunks chunks.length; let uploadedCount totalChunks - chunksToUpload.length; updateProgress(); // 5. 上传分片 (简易顺序上传无并发控制) for (const chunkInfo of chunksToUpload) { const formData new FormData(); formData.append(chunk, chunkInfo.chunk); formData.append(uploadId, uploadId); formData.append(chunkIndex, chunkInfo.index); try { await fetch(http://localhost:3000/api/upload/chunk, { method: POST, body: formData }); uploadedCount; updateProgress(); } catch (error) { console.error(分片 ${chunkInfo.index} 上传失败:, error); alert(上传失败请检查网络或控制台); return; } } // 6. 所有分片上传完成请求合并 const mergeResp await fetch(http://localhost:3000/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ uploadId, totalChunks }) }).then(r r.json()); console.log(合并结果:, mergeResp); if (mergeResp.code 0) { document.getElementById(result).innerHTML 上传并合并成功文件地址: a href${mergeResp.url} target_blank${mergeResp.url}/a; } else { document.getElementById(result).innerHTML 合并失败: ${mergeResp.message}; } } function updateProgress() { const file document.getElementById(fileInput).files[0]; const totalSize file.size; // 简化进度计算以上传完成的分片数估算 const uploadedSize uploadedCount * CHUNK_SIZE; const percentage Math.min(100, Math.round((uploadedSize / totalSize) * 100)); document.getElementById(progress).textContent percentage; } /script /body /html这个Demo虽然简陋但它清晰地展示了分片上传的完整闭环计算hash、初始化、分片、上传、合并。你可以在此基础上逐步添加并发控制、进度计算、错误重试、Web Worker计算hash等高级功能。最后我想分享一点个人体会分片上传不是一个可以“一劳永逸”的功能它需要根据你的具体业务场景是用户偶尔上传还是高频批量上传、基础设施是否有对象存储、用户体验要求是否需要极致的秒传和续传进行持续地调优和打磨。从确定分片大小、设计重试策略到优化合并性能、完善监控报警每一个环节都有细节可以深挖。最好的学习方式就是动手实现一个基础版本然后在真实的业务流量中去观察、去发现问题再回头来迭代你的方案。