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

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

1. 项目概述:为什么小程序大文件上传必须用分片和断点续传?

做微信小程序开发的朋友,尤其是用uniapp框架的,肯定都遇到过文件上传的需求。如果只是传个用户头像、几张产品图,那直接用uni.uploadFileAPI,几行代码就搞定了,轻松愉快。但业务场景稍微复杂一点,比如用户要上传一段自己录制的长视频、一份包含大量高清图片的设计稿压缩包,或者一个几百兆的工程文件,事情就变得棘手了。微信小程序的环境限制、网络的不稳定性,让传统的单次上传变得异常脆弱。

想象一下这个场景:用户花了半小时终于选好了一个800M的视频,点击上传,进度条缓慢爬升到90%,突然网络抖动了一下,或者小程序退到了后台,上传直接失败。用户只能从头再来,这种体验无疑是灾难性的。这正是“分片上传”和“断点续传”技术要解决的核心痛点。分片,就是把一个大文件像切蛋糕一样,切成一个个大小均等的小块(例如每片1MB);断点续传,就是记录下哪些“蛋糕块”已经成功送到了服务器,下次续传时只传剩下的部分。

在uniapp开发微信小程序的语境下,实现这套方案更有其特殊性。它不是一个简单的前端或后端任务,而是一个需要兼顾小程序平台规范、uniapp跨端特性、前后端协同设计的系统工程。本文将从一个踩过无数坑的实践者角度,手把手拆解如何在uniapp微信小程序中,稳健地实现大文件分片上传与断点续传,涵盖从核心思路、前端实现细节、后端接口设计,到实际开发中那些官方文档不会告诉你的“坑”和优化技巧。

2. 核心思路与方案选型:为什么是“分片+服务端记录”?

在动手写代码之前,我们必须把核心思路理清楚。一个健壮的大文件上传方案,关键在于将“大任务”分解为可管理、可重试、可追踪的“小任务”。

2.1 分片上传的核心价值

分片的首要目的不是为了“分”而“分”,而是为了解决以下几个关键问题:

  1. 绕过大小限制:虽然微信小程序基础库后期放开了单个文件上传的大小限制(从早期的10M到后来很大),但一些特殊场景(如通过某些插件或旧版本)仍有约束。分片可以确保每片都在安全大小内。
  2. 提升上传成功率:网络传输中,数据包越大,传输时间越长,中间遭遇网络波动的概率就越高。将大文件分片后,单次请求失败只影响当前这一个分片,重试成本极低。
  3. 实现并发上传:这是大幅提升上传速度的关键。我们可以同时发起多个上传请求,每个请求负责一个分片,充分利用用户的带宽。当然,小程序端对并发请求数也有限制,需要合理控制。
  4. 支持暂停与续传:这是断点续传的基础。因为文件被分片了,我们可以精确地知道哪些片传完了,哪些没传。暂停时,只需停止未完成的请求;续传时,从第一个未完成的分片开始即可。

2.2 断点续传的实现基石

断点续传听起来高级,其核心逻辑就是“状态持久化”。关键在于这个“状态”由谁来记录、存在哪里。主要有两种思路:

  1. 前端记录:将文件分片信息(如文件唯一标识、总分片数、已上传分片索引列表)保存在小程序的本地存储(uni.setStorageSync)中。优点是实现简单,不依赖后端额外逻辑。缺点是用户清除小程序缓存或更换设备后,记录丢失,无法续传。
  2. 服务端记录:前端在上传前,先向服务端申请一个本次上传任务的唯一标识(如uploadId)。每成功上传一个分片,后端就将该分片索引与uploadId关联存储(存在数据库或Redis中)。当需要续传时,前端带上uploadId询问服务端哪些分片已上传,然后只传缺失的。这是生产环境推荐的做法,它保证了状态的中心化和持久化。

我们的方案将采用“服务端记录”模式。因为它更健壮,能实现跨会话、跨设备的续传,更符合实际业务场景。

2.3 整体流程设计

一次完整的分片断点续传,通常包含以下几个阶段:

  1. 文件准备与分片:前端选择文件,计算文件唯一标识(MD5),按预设大小分片。
  2. 初始化上传任务:前端将文件标识、文件名、大小等信息发送给服务端,服务端生成并返回本次上传任务的uploadId
  3. 查询上传进度:前端根据uploadId向服务端查询已成功上传的分片列表。
  4. 分片上传:前端根据进度信息,并发上传未完成的分片。每个分片上传请求需携带uploadId、分片索引(chunkIndex)、总分片数(chunks)等信息。
  5. 分片合并:所有分片上传完毕后,前端通知服务端进行合并。服务端根据uploadId找到所有分片文件,按索引顺序拼接成完整文件。
  6. 清理与回调:合并成功后,服务端清理临时分片文件,更新文件存储记录,并回调通知前端最终结果。

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(); }); }

实操心得1:MD5计算的性能与体验平衡计算大文件的MD5非常耗时,一个500MB的文件可能需要十几秒。这会阻塞UI,导致用户以为卡死了。两种优化思路:

  1. 使用Web Worker:将MD5计算丢到Worker线程中,避免阻塞主线程。这是最理想的方案。
  2. 降级方案:如果项目复杂度不支持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.uploadFilefilePath参数在小程序端要求是一个临时文件路径(如wx.chooseFile返回的tempFilePath),而不是一个Blob对象。但在我们的分片逻辑中,通过file.slice()得到的是Blob。直接传递Blob对象在小程序端是行不通的

解决方案

  1. 方案A(推荐):分片时直接使用临时文件路径。如果文件来自uni.chooseFile,它返回的File对象在小程序端其实包含了path属性(临时路径)。我们可以用uni.getFileSystemManager().readFile分段读取这个路径对应的文件内容,但这种方式对二进制文件(如视频)处理起来比较麻烦。
  2. 方案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:服务端存储与性能考量

  1. 临时存储:分片文件建议存储在服务器的临时目录(如/tmp或专门的uploads/temp),并定期(如每天凌晨)清理过期(如超过24小时)的任务目录,防止磁盘被占满。
  2. 使用流(Stream)进行合并:合并大文件时,绝对不要fs.readFileSyncfs.appendFileSync!这会一次性将整个分片读入内存,导致内存溢出(OOM)。一定要使用fs.createReadStreamfs.createWriteStream通过流的方式边读边写,内存占用恒定且小。
  3. 数据库选型:上传任务记录(uploadId,fileHash,uploadedChunks)非常适合存储在Redis中,因为读写频繁,且有过期需求。文件元信息(最终fileUrl,size等)则存入MySQL/PostgreSQL等关系型数据库。
  4. 分布式环境:如果服务端是集群部署,分片文件必须存储在共享存储中(如NFS、云存储OSS/S3),确保每个服务节点都能访问到同一个分片文件,否则合并会失败。更好的做法是直接使用云服务商提供的分片上传SDK(如阿里云OSS、腾讯云COS),它们已经完美实现了服务端的分片和合并逻辑,你只需要调用API即可。

5. 小程序端特殊处理与优化技巧

微信小程序平台有其独特的限制和特性,需要特别注意。

5.1 网络请求与并发限制

  • 并发连接数限制:微信小程序对wx.requestwx.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中,监听onHideonShow事件。
    // 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 用户体验优化

  1. 进度反馈:除了整体百分比,可以显示当前上传速度、剩余时间、当前正在上传第几个分片等,让用户感知更清晰。
  2. 断网/弱网处理:监听网络状态变化(uni.onNetworkStatusChange),当网络断开时自动暂停,网络恢复后提示用户是否继续。
  3. 任务持久化:将重要的上传任务信息(uploadId,fileHash,fileName)存入uni.setStorageSync。即使用户关闭小程序再打开,也能在列表页看到未完成的任务,点击后能继续上传。这结合服务端记录,实现了真正的“断点续传”。

6. 常见问题排查与解决方案实录

在实际开发中,你一定会遇到下面这些问题。

6.1 分片上传后,服务端合并文件损坏

  • 现象:合并后的文件无法打开,或视频/图片显示异常。
  • 排查
    1. 分片顺序错乱:这是最常见的原因。确保前端上传时chunkIndex是从0开始连续递增的,并且服务端合并时严格按照0, 1, 2, ...的顺序读取${i}.part文件。
    2. 分片大小不一致:最后一个分片的大小可能小于预设的CHUNK_SIZE。前端在slice文件和使用fs.readFile读取时,必须正确处理startend指针,确保不读多也不读少。服务端在存储和合并时,直接保存和读取二进制流即可,不要试图去“修正”分片大小。
    3. 文本文件编码问题:如果上传的是文本文件(如代码),合并时流操作可能会引入BOM头等问题。对于非二进制的文本文件,需要特别注意编码一致性。

6.2 小程序端上传速度慢或不稳定

  • 排查
    1. 分片大小不合适CHUNK_SIZE不是越大或越小越好。太小(如100KB)会导致请求次数过多,握手开销大;太大(如5MB)则单次请求失败成本高,且在小程序内存中处理大Blob可能有问题。建议从512KB或1MB开始测试
    2. 并发数过高:如前所述,将MAX_CONCURRENT调低(如改为2)试试。有时并发太多,TCP连接竞争反而导致整体吞吐量下降。
    3. 手机网络问题:在Wi-Fi和4G/5G环境下测试对比。可以尝试在uni.uploadFilesuccess回调中计算每个分片的实际上传耗时,用于诊断。

6.3 服务端存储空间被快速占满

  • 现象:服务器磁盘空间报警,发现uploads/temp目录巨大。
  • 解决方案
    1. 定期清理任务:写一个定时任务(cron job),每天扫描临时目录,删除创建时间超过24小时(或自定义过期时间)的目录。
    2. 合并后立即清理:在/merge接口成功合并后,必须立即删除对应的临时分片目录,代码中已有体现。
    3. 提供管理接口:开发一个简单的管理后台,可以手动查看和清理异常的上传任务。

6.4 秒传功能失效

  • 现象:同一个文件第二次上传,没有触发秒传,还是重新上传了。
  • 排查
    1. 文件哈希计算不一致:前端计算MD5的方式必须和后端校验MD5的方式完全一致。确保前后端都是对文件的二进制内容进行计算,而不是对文件名或其他元信息。使用标准的SparkMD5或crypto库。
    2. 哈希库版本差异:不同版本的spark-md5库计算结果可能不同。锁定版本号。
    3. 后端查询逻辑错误:检查/init接口中查询数据库findFileByHash(fileHash)的SQL或NoSQL语句是否正确,确保fileHash字段建立了唯一索引。

6.5 真机调试与开发者工具差异

  • 现象:在微信开发者工具里上传一切正常,到了真机上就失败。
  • 排查
    1. 域名校验:确保真机访问的服务器域名已在微信小程序后台的“开发设置”-“服务器域名”中正确配置(包括uploadFile合法域名)。
    2. SSL证书:真机环境对HTTPS证书要求更严格,确保服务端使用的是有效的、受信任的证书。
    3. 用户权限:在真机上,首次使用uni.chooseFile时会弹窗请求用户授权,如果用户拒绝,后续会失败。需要做好授权失败的引导处理。
    4. 系统差异:iOS和Android在文件系统、后台运行策略上有所不同,特别是文件路径处理。确保writeFilereadFile的路径使用的是小程序提供的沙箱路径(wx.env.USER_DATA_PATH)。

实现uniapp微信小程序的大文件分片断点续传,是一个对前后端都有要求的综合性功能。它没有想象中那么复杂,但每一个环节都需要仔细考量。从文件分片、哈希计算、并发控制,到服务端的任务管理、流式合并,再到小程序端的生命周期适配、体验优化,每一步都藏着细节。这套方案不仅适用于微信小程序,其核心思想同样可以迁移到H5、APP等其他uni-app支持的平台,只是在平台特定的API调用上有所差异。当你成功跑通整个流程后,你会发现它带来的用户体验提升是巨大的,对于涉及用户生成内容(UGC)的应用来说,这几乎是必备的基础能力。