尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

UniApp微信小程序大文件分片上传与断点续传实战指南

UniApp微信小程序大文件分片上传与断点续传实战指南 1. 项目概述为什么小程序大文件上传必须用分片和断点续传做微信小程序开发的朋友尤其是用uniapp框架的肯定都遇到过文件上传的需求。如果只是传个用户头像、几张产品图那直接用uni.uploadFileAPI几行代码就搞定了轻松愉快。但业务场景稍微复杂一点比如用户要上传一段自己录制的长视频、一份包含大量高清图片的设计稿压缩包或者一个几百兆的工程文件事情就变得棘手了。微信小程序的环境限制、网络的不稳定性让传统的单次上传变得异常脆弱。想象一下这个场景用户花了半小时终于选好了一个800M的视频点击上传进度条缓慢爬升到90%突然网络抖动了一下或者小程序退到了后台上传直接失败。用户只能从头再来这种体验无疑是灾难性的。这正是“分片上传”和“断点续传”技术要解决的核心痛点。分片就是把一个大文件像切蛋糕一样切成一个个大小均等的小块例如每片1MB断点续传就是记录下哪些“蛋糕块”已经成功送到了服务器下次续传时只传剩下的部分。在uniapp开发微信小程序的语境下实现这套方案更有其特殊性。它不是一个简单的前端或后端任务而是一个需要兼顾小程序平台规范、uniapp跨端特性、前后端协同设计的系统工程。本文将从一个踩过无数坑的实践者角度手把手拆解如何在uniapp微信小程序中稳健地实现大文件分片上传与断点续传涵盖从核心思路、前端实现细节、后端接口设计到实际开发中那些官方文档不会告诉你的“坑”和优化技巧。2. 核心思路与方案选型为什么是“分片服务端记录”在动手写代码之前我们必须把核心思路理清楚。一个健壮的大文件上传方案关键在于将“大任务”分解为可管理、可重试、可追踪的“小任务”。2.1 分片上传的核心价值分片的首要目的不是为了“分”而“分”而是为了解决以下几个关键问题绕过大小限制虽然微信小程序基础库后期放开了单个文件上传的大小限制从早期的10M到后来很大但一些特殊场景如通过某些插件或旧版本仍有约束。分片可以确保每片都在安全大小内。提升上传成功率网络传输中数据包越大传输时间越长中间遭遇网络波动的概率就越高。将大文件分片后单次请求失败只影响当前这一个分片重试成本极低。实现并发上传这是大幅提升上传速度的关键。我们可以同时发起多个上传请求每个请求负责一个分片充分利用用户的带宽。当然小程序端对并发请求数也有限制需要合理控制。支持暂停与续传这是断点续传的基础。因为文件被分片了我们可以精确地知道哪些片传完了哪些没传。暂停时只需停止未完成的请求续传时从第一个未完成的分片开始即可。2.2 断点续传的实现基石断点续传听起来高级其核心逻辑就是“状态持久化”。关键在于这个“状态”由谁来记录、存在哪里。主要有两种思路前端记录将文件分片信息如文件唯一标识、总分片数、已上传分片索引列表保存在小程序的本地存储uni.setStorageSync中。优点是实现简单不依赖后端额外逻辑。缺点是用户清除小程序缓存或更换设备后记录丢失无法续传。服务端记录前端在上传前先向服务端申请一个本次上传任务的唯一标识如uploadId。每成功上传一个分片后端就将该分片索引与uploadId关联存储存在数据库或Redis中。当需要续传时前端带上uploadId询问服务端哪些分片已上传然后只传缺失的。这是生产环境推荐的做法它保证了状态的中心化和持久化。我们的方案将采用“服务端记录”模式。因为它更健壮能实现跨会话、跨设备的续传更符合实际业务场景。2.3 整体流程设计一次完整的分片断点续传通常包含以下几个阶段文件准备与分片前端选择文件计算文件唯一标识MD5按预设大小分片。初始化上传任务前端将文件标识、文件名、大小等信息发送给服务端服务端生成并返回本次上传任务的uploadId。查询上传进度前端根据uploadId向服务端查询已成功上传的分片列表。分片上传前端根据进度信息并发上传未完成的分片。每个分片上传请求需携带uploadId、分片索引(chunkIndex)、总分片数(chunks)等信息。分片合并所有分片上传完毕后前端通知服务端进行合并。服务端根据uploadId找到所有分片文件按索引顺序拼接成完整文件。清理与回调合并成功后服务端清理临时分片文件更新文件存储记录并回调通知前端最终结果。3. 前端核心实现细节与避坑指南前端是小程序用户体验的直接承担者代码的健壮性和交互细节至关重要。3.1 文件选择与分片计算在uniapp中我们使用uni.chooseFile注意H5端是uni.chooseImage/uni.chooseVideo但为统一小程序端推荐用uni.chooseFile来让用户选择文件。// 选择文件 async chooseFile() { try { const [fileRes] await uni.chooseFile({ count: 1, type: all, // 可选择所有类型 extension: [.mp4, .mov, .zip, .rar, .pdf, .ppt, .docx] // 根据业务限制 }); const file fileRes; // fileRes 是一个 File 对象 this.file file; this.calculateFileInfo(file); } catch (err) { console.error(选择文件失败:, err); } }接下来是计算文件唯一标识和分片。计算MD5是为了生成一个几乎不会重复的文件指纹用于服务端识别是否是同一个文件。这里可以使用spark-md5这个库它专门为前端计算大文件MD5而优化支持增量计算。// 安装npm install spark-md5 import SparkMD5 from spark-md5; async calculateFileInfo(file) { this.fileName file.name; this.fileSize file.size; // 1. 计算文件MD5 (这是一个异步过程对于大文件可能耗时) this.fileHash await this.calculateFileMD5(file); // 2. 计算分片 const CHUNK_SIZE 1 * 1024 * 1024; // 每片1MB可根据网络情况调整 this.chunks Math.ceil(this.fileSize / CHUNK_SIZE); this.chunkList []; // 用于存储每个分片的信息 for (let i 0; i this.chunks; i) { const start i * CHUNK_SIZE; const end Math.min(this.fileSize, start CHUNK_SIZE); const chunkBlob file.slice(start, end); // 使用slice方法切割文件 this.chunkList.push({ index: i, start, end, blob: chunkBlob, hash: ${this.fileHash}-${i}, // 分片哈希可用于秒传验证 uploaded: false // 上传状态 }); } console.log(文件【${this.fileName}】准备就绪大小${(this.fileSize/1024/1024).toFixed(2)}MB 分片数${this.chunks}); } // 计算文件MD5 calculateFileMD5(file) { return new Promise((resolve, reject) { const chunkSize 2 * 1024 * 1024; // 分块读取每块2MB const chunks Math.ceil(file.size / chunkSize); const spark new SparkMD5.ArrayBuffer(); const fileReader new FileReader(); let currentChunk 0; fileReader.onload function(e) { spark.append(e.target.result); currentChunk; if (currentChunk chunks) { loadNext(); } else { resolve(spark.end()); // 计算完成返回最终hash } }; fileReader.onerror function() { reject(new Error(文件读取失败无法计算MD5)); }; function loadNext() { const start currentChunk * chunkSize; const end start chunkSize file.size ? file.size : start chunkSize; fileReader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); }); }实操心得1MD5计算的性能与体验平衡计算大文件的MD5非常耗时一个500MB的文件可能需要十几秒。这会阻塞UI导致用户以为卡死了。两种优化思路使用Web Worker将MD5计算丢到Worker线程中避免阻塞主线程。这是最理想的方案。降级方案如果项目复杂度不支持Worker可以采用“文件大小最后修改时间文件名”拼接成一个标识符。虽然理论上可能重复但实际业务中概率极低且计算速度极快。可以与产品经理协商作为体验和准确性的权衡。3.2 初始化上传与进度查询拿到文件信息后第一步是通知服务端“我有一个新文件要传了”。服务端会创建一条上传记录并返回一个uploadId。async initUploadTask() { const res await uni.request({ url: https://your-api.com/upload/init, method: POST, data: { fileName: this.fileName, fileSize: this.fileSize, fileHash: this.fileHash, chunks: this.chunks } }); if (res.data.code 0) { this.uploadId res.data.data.uploadId; console.log(上传任务初始化成功uploadId:, this.uploadId); // 初始化成功后立即查询已有进度 await this.queryUploadProgress(); } else { throw new Error(res.data.msg || 初始化上传任务失败); } }查询进度接口服务端返回已经成功上传的分片索引列表。async queryUploadProgress() { const res await uni.request({ url: https://your-api.com/upload/progress, method: GET, data: { uploadId: this.uploadId } }); if (res.data.code 0) { const uploadedChunkIndexes res.data.data.uploadedChunks || []; // 例如 [0, 1, 2] // 更新前端分片状态 uploadedChunkIndexes.forEach(index { const chunk this.chunkList.find(c c.index index); if (chunk) chunk.uploaded true; }); console.log(已有 ${uploadedChunkIndexes.length} 个分片上传完成); } }3.3 分片上传并发控制与错误重试这是最核心的环节。我们不能一次性发起几百个并发请求会超过小程序限制并压垮网络。需要实现一个可控的并发上传队列。async startUpload() { if (!this.uploadId) { await this.initUploadTask(); } const MAX_CONCURRENT 3; // 最大并发数建议2-5之间根据网络环境调整 const retryLimit 3; // 单个分片失败重试次数 // 筛选出未上传的分片 const pendingChunks this.chunkList.filter(chunk !chunk.uploaded); const total pendingChunks.length; let completed 0; let currentConcurrent 0; // 创建一个控制并发的函数 const uploadNext async () { // 如果所有分片都已处理完或者并发数已达上限则返回 if (completed total || currentConcurrent MAX_CONCURRENT) { return; } const chunk pendingChunks[completed]; currentConcurrent; completed; let retryCount 0; const doUpload async () { try { const formData new FormData(); formData.append(file, chunk.blob); formData.append(uploadId, this.uploadId); formData.append(chunkIndex, chunk.index); formData.append(chunks, this.chunks); formData.append(chunkHash, chunk.hash); const uploadRes await uni.uploadFile({ url: https://your-api.com/upload/chunk, filePath: chunk.blob, // 注意在小程序中这里需要是临时文件路径。但我们的chunk.blob是Blob对象需要转换。 name: file, formData: { uploadId: this.uploadId, chunkIndex: chunk.index, chunks: this.chunks, chunkHash: chunk.hash }, // 关键使用自定义的请求头如果后端需要的话 header: { custom-header: value } }); const resData JSON.parse(uploadRes.data); if (resData.code 0) { console.log(分片 ${chunk.index} 上传成功); chunk.uploaded true; // 更新UI进度 this.updateProgress(); currentConcurrent--; // 递归调用继续上传下一个 uploadNext(); } else { throw new Error(resData.msg); } } catch (error) { retryCount; console.error(分片 ${chunk.index} 上传失败第${retryCount}次重试, error); if (retryCount retryLimit) { // 等待片刻后重试 setTimeout(doUpload, 1000 * retryCount); } else { console.error(分片 ${chunk.index} 重试${retryLimit}次后仍失败任务中止); // 这里可以触发全局错误处理如通知用户网络不稳定 this.uploadFailed true; currentConcurrent--; } } }; doUpload(); }; // 启动初始的并发任务 for (let i 0; i Math.min(MAX_CONCURRENT, total); i) { uploadNext(); } } // 更新上传进度UI updateProgress() { const uploadedCount this.chunkList.filter(c c.uploaded).length; const percent ((uploadedCount / this.chunks) * 100).toFixed(2); // 这里可以更新Vue data中的进度变量触发视图更新 this.uploadPercent percent; console.log(总进度: ${percent}%); }实操心得2小程序中Blob与临时文件路径的坑上面的示例代码中有一个关键问题uni.uploadFile的filePath参数在小程序端要求是一个临时文件路径如wx.chooseFile返回的tempFilePath而不是一个Blob对象。但在我们的分片逻辑中通过file.slice()得到的是Blob。直接传递Blob对象在小程序端是行不通的。解决方案方案A推荐分片时直接使用临时文件路径。如果文件来自uni.chooseFile它返回的File对象在小程序端其实包含了path属性临时路径。我们可以用uni.getFileSystemManager().readFile分段读取这个路径对应的文件内容但这种方式对二进制文件如视频处理起来比较麻烦。方案B通用将Blob写入临时文件。这是一个更可行的方法。我们可以使用小程序的FileSystemManager.writeFileAPI将每个分片的Blob数据写入一个小程序的临时文件获得临时路径再用这个路径去上传。虽然多了I/O操作但能保证兼容性。下面是方案B的修正代码片段// 在分片循环中将Blob转为临时文件路径 for (let i 0; i this.chunks; i) { const start i * CHUNK_SIZE; const end Math.min(this.fileSize, start CHUNK_SIZE); // 注意在小程序环境file.slice()可能返回一个ArrayBuffer需要处理 const chunkArrayBuffer await this.readFileSlice(file, start, end); // 将ArrayBuffer写入临时文件 const tempFilePath await this.writeArrayBufferToTempFile(chunkArrayBuffer, i); this.chunkList.push({ index: i, tempFilePath: tempFilePath, // 存储临时路径 uploaded: false }); } // 读取文件指定范围的ArrayBuffer readFileSlice(file, start, end) { return new Promise((resolve, reject) { // 这里需要根据uniapp提供的API或微信原生API来读取文件片段 // 一种方法是使用 uni.getFileSystemManager().readFile const fs uni.getFileSystemManager(); fs.readFile({ filePath: file.path, // 原始文件的临时路径 position: start, length: end - start, success: (res) resolve(res.data), fail: reject }); }); } // 将ArrayBuffer写入临时文件返回路径 writeArrayBufferToTempFile(arrayBuffer, index) { return new Promise((resolve, reject) { const fs uni.getFileSystemManager(); const tempFilePath ${wx.env.USER_DATA_PATH}/upload_chunk_${Date.now()}_${index}.tmp; fs.writeFile({ filePath: tempFilePath, data: arrayBuffer, encoding: binary, success: () resolve(tempFilePath), fail: reject }); }); }在上传时filePath参数就使用chunk.tempFilePath。3.4 通知合并与状态清理所有分片上传完成后前端需要主动通知服务端进行合并操作。async mergeChunks() { // 检查是否所有分片都已上传 const allUploaded this.chunkList.every(chunk chunk.uploaded); if (!allUploaded) { console.warn(尚有分片未上传完成无法合并); return; } const res await uni.request({ url: https://your-api.com/upload/merge, method: POST, data: { uploadId: this.uploadId, fileName: this.fileName, fileHash: this.fileHash, chunks: this.chunks } }); if (res.data.code 0) { console.log(文件合并成功最终文件路径:, res.data.data.fileUrl); // 上传成功清理前端临时状态和数据 this.resetUploadState(); uni.showToast({ title: 上传成功, icon: success }); } else { throw new Error(文件合并失败 res.data.msg); } } resetUploadState() { // 清理临时文件重要避免占用用户存储空间 this.chunkList.forEach(chunk { if (chunk.tempFilePath) { uni.getFileSystemManager().unlink({ filePath: chunk.tempFilePath, fail: (err) console.error(删除临时文件失败:, err) }); } }); this.file null; this.chunkList []; this.uploadId ; this.uploadPercent 0; }4. 服务端接口设计与关键逻辑前端逻辑再完善也离不开服务端的紧密配合。服务端需要提供三个核心接口初始化、上传分片、合并分片。这里以Node.js (Koa框架) 为例说明关键逻辑。4.1 初始化接口 (/upload/init)这个接口负责创建上传任务上下文。const UPLOAD_BASE_DIR path.join(__dirname, uploads/temp); // 临时分片存储目录 const FINAL_BASE_DIR path.join(__dirname, uploads/final); // 最终文件存储目录 router.post(/init, async (ctx) { const { fileName, fileSize, fileHash, chunks } ctx.request.body; // 1. 基础校验 if (!fileName || !fileHash) { ctx.body { code: 400, msg: 参数缺失 }; return; } // 2. 生成唯一上传ID const uploadId ${Date.now()}_${Math.random().toString(36).substr(2, 9)}; // 3. (可选) 秒传检查如果文件哈希已存在直接返回成功避免重复上传 const existingFile await findFileByHash(fileHash); // 假设的数据库查询方法 if (existingFile) { ctx.body { code: 0, data: { uploadId, skipped: true, fileUrl: existingFile.url } }; return; } // 4. 创建任务记录 (存入数据库或Redis) await createUploadTask(uploadId, { fileName, fileSize, fileHash, chunks }); // 5. 创建临时目录用于存放该任务的分片 const taskTempDir path.join(UPLOAD_BASE_DIR, uploadId); if (!fs.existsSync(taskTempDir)) { fs.mkdirSync(taskTempDir, { recursive: true }); } ctx.body { code: 0, data: { uploadId } }; });4.2 查询进度接口 (/upload/progress)这个接口返回指定uploadId下已上传的分片列表。router.get(/progress, async (ctx) { const { uploadId } ctx.query; if (!uploadId) { ctx.body { code: 400, msg: uploadId不能为空 }; return; } // 从数据库或Redis中获取该任务已上传的分片索引列表 const uploadedChunks await getUploadedChunks(uploadId); // 例如返回 [0, 1, 3] ctx.body { code: 0, data: { uploadedChunks } }; });4.3 分片上传接口 (/upload/chunk)这是压力最大的接口需要高效地接收和存储分片文件。// 注意这里使用 koa-body 中间件来处理 multipart/form-data 文件上传 router.post(/chunk, async (ctx) { const { uploadId, chunkIndex, chunks, chunkHash } ctx.request.body; const file ctx.request.files?.file; // koa-body 会将文件解析到 ctx.request.files if (!uploadId || chunkIndex undefined || !file) { ctx.body { code: 400, msg: 参数或文件缺失 }; return; } // 1. 验证任务是否存在 const taskExists await checkUploadTaskExists(uploadId); if (!taskExists) { ctx.body { code: 404, msg: 上传任务不存在或已过期 }; return; } // 2. 验证分片索引有效性 const chunkIdx parseInt(chunkIndex); const totalChunks parseInt(chunks); if (chunkIdx 0 || chunkIdx totalChunks) { ctx.body { code: 400, msg: 分片索引无效 }; return; } // 3. (可选) 分片哈希校验确保数据传输完整性 if (chunkHash) { const calculatedHash calculateFileHash(file.path); // 计算接收到的文件的hash if (calculatedHash ! chunkHash) { ctx.body { code: 400, msg: 分片数据校验失败可能已损坏 }; return; } } // 4. 存储分片文件 const chunkFileName ${chunkIndex}.part; const chunkFilePath path.join(UPLOAD_BASE_DIR, uploadId, chunkFileName); // 将上传的临时文件移动到指定位置 try { fs.renameSync(file.path, chunkFilePath); } catch (error) { // 如果移动失败可能跨设备则使用流式拷贝 const readStream fs.createReadStream(file.path); const writeStream fs.createWriteStream(chunkFilePath); await pipeline(readStream, writeStream); fs.unlinkSync(file.path); // 删除原临时文件 } // 5. 记录该分片已上传成功 (更新数据库或Redis) await markChunkAsUploaded(uploadId, chunkIdx); ctx.body { code: 0, data: { chunkIndex: chunkIdx } }; });4.4 合并分片接口 (/upload/merge)当所有分片上传完毕前端调用此接口服务端将所有分片按顺序拼接成完整文件。router.post(/merge, async (ctx) { const { uploadId, fileName, fileHash } ctx.request.body; // 1. 验证任务和分片完整性 const taskInfo await getUploadTaskInfo(uploadId); if (!taskInfo) { ctx.body { code: 404, msg: 上传任务不存在 }; return; } const uploadedChunks await getUploadedChunks(uploadId); const totalChunks taskInfo.chunks; // 检查是否所有分片都已上传 if (uploadedChunks.length ! totalChunks) { ctx.body { code: 400, msg: 分片不完整已上传${uploadedChunks.length}/${totalChunks} }; return; } // 2. 创建最终文件 const finalFileName ${fileHash}_${Date.now()}${path.extname(fileName)}; // 用哈希和时间戳命名避免冲突 const finalFilePath path.join(FINAL_BASE_DIR, finalFileName); const writeStream fs.createWriteStream(finalFilePath); const tempDir path.join(UPLOAD_BASE_DIR, uploadId); // 3. 按索引顺序合并分片 for (let i 0; i totalChunks; i) { const chunkPath path.join(tempDir, ${i}.part); if (!fs.existsSync(chunkPath)) { // 理论上不会进入这里因为前面检查过完整性 ctx.body { code: 500, msg: 分片${i}文件丢失 }; return; } const chunkStream fs.createReadStream(chunkPath); await pipeline(chunkStream, writeStream, { end: false }); // end:false 表示不关闭最终流 } writeStream.end(); // 关闭写入流 // 4. (可选) 合并后校验整个文件的哈希 const finalFileHash await calculateFileHash(finalFilePath); if (finalFileHash ! fileHash) { fs.unlinkSync(finalFilePath); // 删除错误的合并文件 ctx.body { code: 500, msg: 文件合并后校验失败 }; return; } // 5. 更新数据库将文件信息存入持久化存储 const fileUrl /uploads/final/${finalFileName}; // 最终访问URL await saveFileRecord({ name: fileName, hash: fileHash, size: taskInfo.fileSize, path: finalFilePath, url: fileUrl, uploadId: uploadId }); // 6. 清理临时分片文件和目录 fs.rmSync(tempDir, { recursive: true, force: true }); await deleteUploadTask(uploadId); // 清理任务记录 ctx.body { code: 0, data: { fileUrl } }; });实操心得3服务端存储与性能考量临时存储分片文件建议存储在服务器的临时目录如/tmp或专门的uploads/temp并定期如每天凌晨清理过期如超过24小时的任务目录防止磁盘被占满。使用流Stream进行合并合并大文件时绝对不要用fs.readFileSync和fs.appendFileSync这会一次性将整个分片读入内存导致内存溢出OOM。一定要使用fs.createReadStream和fs.createWriteStream通过流的方式边读边写内存占用恒定且小。数据库选型上传任务记录uploadId,fileHash,uploadedChunks非常适合存储在Redis中因为读写频繁且有过期需求。文件元信息最终fileUrl,size等则存入MySQL/PostgreSQL等关系型数据库。分布式环境如果服务端是集群部署分片文件必须存储在共享存储中如NFS、云存储OSS/S3确保每个服务节点都能访问到同一个分片文件否则合并会失败。更好的做法是直接使用云服务商提供的分片上传SDK如阿里云OSS、腾讯云COS它们已经完美实现了服务端的分片和合并逻辑你只需要调用API即可。5. 小程序端特殊处理与优化技巧微信小程序平台有其独特的限制和特性需要特别注意。5.1 网络请求与并发限制并发连接数限制微信小程序对wx.request和wx.uploadFile的并发连接数有限制早期是10个现在可能有调整但不宜过高。这就是为什么我们在前端要控制MAX_CONCURRENT建议2-5。过多的并发不仅可能被限制还会导致手机网络拥塞反而降低整体速度。超时时间uni.uploadFile默认有超时时间。对于大分片如果网络慢可能超时。可以通过timeout参数适当延长但也要设置合理的重试机制。uni.uploadFile({ url: ..., filePath: ..., name: file, formData: { ... }, timeout: 60000, // 设置为60秒 success() {}, fail() {} });5.2 前后台切换与任务保活小程序切换到后台时网络请求可能会被暂停或终止。监听生命周期在uniapp的页面或全局App.vue中监听onHide和onShow事件。// pages/upload.vue onHide() { // 页面隐藏小程序切后台时暂停上传 this.isPaused true; this.pauseUpload(); }, onShow() { // 页面再次显示时如果之前是暂停状态可以提示用户是否继续 if (this.isPaused this.uploadId) { uni.showModal({ title: 提示, content: 上传被中断是否继续, success: (res) { if (res.confirm) { this.resumeUpload(); } } }); } }实现暂停/继续暂停不是取消请求而是中止尚未发出的请求队列并abort掉正在进行的请求uni.uploadFile返回的UploadTask对象可以调用.abort()方法。继续时重新调用queryUploadProgress获取进度然后从断点开始。5.3 用户体验优化进度反馈除了整体百分比可以显示当前上传速度、剩余时间、当前正在上传第几个分片等让用户感知更清晰。断网/弱网处理监听网络状态变化uni.onNetworkStatusChange当网络断开时自动暂停网络恢复后提示用户是否继续。任务持久化将重要的上传任务信息uploadId,fileHash,fileName存入uni.setStorageSync。即使用户关闭小程序再打开也能在列表页看到未完成的任务点击后能继续上传。这结合服务端记录实现了真正的“断点续传”。6. 常见问题排查与解决方案实录在实际开发中你一定会遇到下面这些问题。6.1 分片上传后服务端合并文件损坏现象合并后的文件无法打开或视频/图片显示异常。排查分片顺序错乱这是最常见的原因。确保前端上传时chunkIndex是从0开始连续递增的并且服务端合并时严格按照0, 1, 2, ...的顺序读取${i}.part文件。分片大小不一致最后一个分片的大小可能小于预设的CHUNK_SIZE。前端在slice文件和使用fs.readFile读取时必须正确处理start和end指针确保不读多也不读少。服务端在存储和合并时直接保存和读取二进制流即可不要试图去“修正”分片大小。文本文件编码问题如果上传的是文本文件如代码合并时流操作可能会引入BOM头等问题。对于非二进制的文本文件需要特别注意编码一致性。6.2 小程序端上传速度慢或不稳定排查分片大小不合适CHUNK_SIZE不是越大或越小越好。太小如100KB会导致请求次数过多握手开销大太大如5MB则单次请求失败成本高且在小程序内存中处理大Blob可能有问题。建议从512KB或1MB开始测试。并发数过高如前所述将MAX_CONCURRENT调低如改为2试试。有时并发太多TCP连接竞争反而导致整体吞吐量下降。手机网络问题在Wi-Fi和4G/5G环境下测试对比。可以尝试在uni.uploadFile的success回调中计算每个分片的实际上传耗时用于诊断。6.3 服务端存储空间被快速占满现象服务器磁盘空间报警发现uploads/temp目录巨大。解决方案定期清理任务写一个定时任务cron job每天扫描临时目录删除创建时间超过24小时或自定义过期时间的目录。合并后立即清理在/merge接口成功合并后必须立即删除对应的临时分片目录代码中已有体现。提供管理接口开发一个简单的管理后台可以手动查看和清理异常的上传任务。6.4 秒传功能失效现象同一个文件第二次上传没有触发秒传还是重新上传了。排查文件哈希计算不一致前端计算MD5的方式必须和后端校验MD5的方式完全一致。确保前后端都是对文件的二进制内容进行计算而不是对文件名或其他元信息。使用标准的SparkMD5或crypto库。哈希库版本差异不同版本的spark-md5库计算结果可能不同。锁定版本号。后端查询逻辑错误检查/init接口中查询数据库findFileByHash(fileHash)的SQL或NoSQL语句是否正确确保fileHash字段建立了唯一索引。6.5 真机调试与开发者工具差异现象在微信开发者工具里上传一切正常到了真机上就失败。排查域名校验确保真机访问的服务器域名已在微信小程序后台的“开发设置”-“服务器域名”中正确配置包括uploadFile合法域名。SSL证书真机环境对HTTPS证书要求更严格确保服务端使用的是有效的、受信任的证书。用户权限在真机上首次使用uni.chooseFile时会弹窗请求用户授权如果用户拒绝后续会失败。需要做好授权失败的引导处理。系统差异iOS和Android在文件系统、后台运行策略上有所不同特别是文件路径处理。确保writeFile和readFile的路径使用的是小程序提供的沙箱路径wx.env.USER_DATA_PATH。实现uniapp微信小程序的大文件分片断点续传是一个对前后端都有要求的综合性功能。它没有想象中那么复杂但每一个环节都需要仔细考量。从文件分片、哈希计算、并发控制到服务端的任务管理、流式合并再到小程序端的生命周期适配、体验优化每一步都藏着细节。这套方案不仅适用于微信小程序其核心思想同样可以迁移到H5、APP等其他uni-app支持的平台只是在平台特定的API调用上有所差异。当你成功跑通整个流程后你会发现它带来的用户体验提升是巨大的对于涉及用户生成内容UGC的应用来说这几乎是必备的基础能力。
返回列表