小程序开发中如何处理大文件上传与断点续传 分类:公司动态 发布时间:2025-12-17
在小程序开发中,大文件上传(如视频、高清图片、压缩包等)是常见需求,但受限于小程序运行环境、网络稳定性及服务器配置,直接上传易出现超时、失败、进度丢失等问题。断点续传作为核心优化手段,能有效提升上传成功率与用户体验。本文将从技术原理、实现步骤、优化方案三方面,详细讲解小程序中大文件上传与断点续传的完整解决方案。
一、大文件上传的核心痛点与技术选型
1. 大文件上传的核心痛点
小程序环境下,直接上传几十 MB 甚至 GB 级文件时,会面临以下关键问题:
(1)网络稳定性差:小程序多运行于移动网络(4G/5G/Wi-Fi 切换),单次长连接易因网络波动中断,导致上传前功尽弃;
(2)超时限制:小程序默认 HTTP 请求超时时间较短(通常为 60 秒),大文件上传耗时超限时会被强制中断;
(3)内存占用过高:直接读取大文件到内存会导致小程序内存溢出,引发闪退;
(4)服务器压力大:单次接收大文件会占用服务器大量 IO 资源,同时增加网络带宽消耗。
2. 技术选型:分片上传 + 断点续传
针对上述痛点,行业主流解决方案是 **“分片上传 + 断点续传”** 组合:
(1)分片上传:将大文件按固定大小(如 5MB/10MB)分割为多个 “分片”,逐个上传至服务器,最后由服务器合并所有分片为完整文件;
(2)断点续传:基于分片上传,记录已成功上传的分片信息,下次上传时仅需补充未完成的分片,避免重复上传。
该方案的优势在于:降低单次请求压力、支持断点恢复、便于进度监控,同时兼容小程序的 API 能力(如wx.chooseMessageFile、wx.uploadFile)。
二、分片上传的实现步骤(基础版)
分片上传是断点续传的基础,需小程序端与服务器端协同实现,核心流程分为 “文件分片”“分片上传”“分片合并” 三步。
1. 小程序端:文件分片与预处理
首先通过小程序 API 选择文件,再对文件进行分片处理,关键是保证分片的唯一性与顺序性,避免合并时错乱。
(1)选择文件并获取基础信息
使用wx.chooseMessageFile(或wx.chooseImage,视文件类型而定)选择大文件,获取文件的临时路径、大小、名称等信息:
// 选择大文件(支持视频、文档等)
wx.chooseMessageFile({
count: 1,
type: 'all', // 允许所有类型文件
success: (res) => {
const file = res.tempFiles[0];
console.log('选中文件信息', {
name: file.name, // 文件名
size: file.size, // 文件大小(字节)
path: file.path, // 临时文件路径(关键)
type: file.type // 文件MIME类型
});
// 下一步:文件分片
splitFile(file);
},
fail: (err) => {
wx.showToast({ title: '文件选择失败', icon: 'none' });
}
});
(2)按固定大小分割文件(分片核心)
小程序端无法直接操作二进制文件,需借助wx.getFileSystemManager()的readFile方法读取文件二进制数据,再按固定分片大小(如 5MB)分割为数组。
首先定义分片配置:
// 分片配置(可根据业务调整)
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB/分片(建议5-20MB,避免分片过多)
const CHUNK_SUFFIX = '_chunk'; // 分片标识后缀
然后实现分片逻辑:
/**
* 分割文件为分片
* @param {Object} file - 选中的文件对象
* @returns {Array} 分片数组(含分片索引、大小、二进制数据等)
*/
function splitFile(file) {
const fileSize = file.size;
const chunkCount = Math.ceil(fileSize / CHUNK_SIZE); // 总分片数
const chunks = [];
// 获取文件系统管理器
const fs = wx.getFileSystemManager();
// 循环读取并分割文件
for (let i = 0; i i++) {
// 计算当前分片的起始位置与结束位置
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, fileSize);
try {
// 读取分片二进制数据(微信API支持按字节范围读取)
const chunkData = fs.readFileSync({
filePath: file.path,
position: start, // 起始字节
length: end - start // 读取长度
});
// 生成分片唯一标识(建议:文件名_文件大小_分片索引,避免重复)
const chunkKey = `${file.name}_${fileSize}_${i}`;
chunks.push({
chunkKey, // 分片唯一标识
index: i, // 分片索引(用于合并顺序)
total: chunkCount, // 总分片数
size: end - start, // 分片大小(字节)
data: chunkData, // 分片二进制数据
fileName: file.name, // 原文件名(用于服务器合并)
fileSize: fileSize // 原文件大小(校验用)
});
} catch (err) {
wx.showToast({ title: `分片失败:${err.message}`, icon: 'none' });
return [];
}
}
console.log('文件分片完成', { chunkCount, chunksLength: chunks.length });
// 下一步:上传所有分片
uploadAllChunks(chunks);
return chunks;
}
2. 服务器端:分片接收与合并
服务器端需提供两个核心接口:/upload/chunk(接收单个分片)和/upload/merge(合并所有分片),同时需处理分片存储、重复校验、合并逻辑。
(1)分片接收接口(/upload/chunk)
功能:接收小程序端上传的单个分片,存储到临时目录,并记录分片上传状态(避免重复上传)。
以 Node.js(Express 框架)为例:
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const app = express();
// 配置分片临时存储目录(需提前创建)
const TEMP_DIR = path.join(__dirname, 'temp_chunks');
if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
// 配置multer(处理文件上传)
const upload = multer({
storage: multer.diskStorage({
destination: (req, res, cb) => {
// 按原文件创建子目录,避免不同文件分片混淆
const fileDir = path.join(TEMP_DIR, req.body.fileName);
if (!fs.existsSync(fileDir)) fs.mkdirSync(fileDir, { recursive: true });
cb(null, fileDir);
},
filename: (req, file, cb) => {
// 分片文件名:分片索引_chunk(如0_chunk、1_chunk)
cb(null, `${req.body.index}_chunk`);
}
}),
limits: {
fileSize: 20 * 1024 * 1024 // 限制单个分片最大20MB(与小程序端CHUNK_SIZE对应)
}
});
// 分片接收接口
app.post('/upload/chunk', upload.single('chunkData'), (req, res) => {
try {
const { fileName, fileSize, index, total } = req.body;
console.log(`接收分片:${fileName} - 第${index+1}/${total}片`);
// 校验分片是否已存在(避免重复上传)
const chunkPath = path.join(TEMP_DIR, fileName, `${index}_chunk`);
if (fs.existsSync(chunkPath)) {
return res.json({
code: 200,
message: '分片已存在,无需重复上传',
data: { index, isExist: true }
});
}
// 分片接收成功
res.json({
code: 200,
message: '分片上传成功',
data: { index, isExist: false }
});
} catch (err) {
res.status(500).json({
code: 500,
message: `分片上传失败:${err.message}`
});
}
});
(2)分片合并接口(/upload/merge)
功能:当所有分片上传完成后,小程序端调用该接口,服务器按分片索引顺序合并为完整文件,并删除临时分片文件。
// 配置最终文件存储目录
const FINAL_DIR = path.join(__dirname, 'final_files');
if (!fs.existsSync(FINAL_DIR)) fs.mkdirSync(FINAL_DIR, { recursive: true });
// 分片合并接口
app.post('/upload/merge', express.json(), (req, res) => {
try {
const { fileName, fileSize, total } = req.body;
const fileDir = path.join(TEMP_DIR, fileName); // 分片存储目录
const finalPath = path.join(FINAL_DIR, fileName); // 最终文件路径
// 1. 校验分片是否完整(检查所有分片文件是否存在)
const chunkExists = [];
for (let i = 0; i < total; i++) {
const chunkPath = path.join(fileDir, `${i}_chunk`);
chunkExists.push(fs.existsSync(chunkPath));
}
if (chunkExists.includes(false)) {
return res.status(400).json({
code: 400,
message: '存在未上传的分片,无法合并'
});
}
// 2. 按索引顺序合并分片
const writeStream = fs.createWriteStream(finalPath); // 创建最终文件写入流
let mergedCount = 0; // 已合并的分片数
// 循环合并每个分片
function mergeNextChunk(index) {
if (index >= total) {
// 所有分片合并完成
writeStream.end();
// 删除临时分片目录(可选,节省存储空间)
fs.rmSync(fileDir, { recursive: true, force: true });
return res.json({
code: 200,
message: '文件合并成功',
data: {
filePath: finalPath,
fileUrl: `/files/${fileName}` // 前端访问文件的URL
}
});
}
// 读取当前分片并写入最终文件
const chunkPath = path.join(fileDir, `${index}_chunk`);
const readStream = fs.createReadStream(chunkPath);
readStream.pipe(writeStream, { end: false }); // 不自动关闭写入流
readStream.on('end', () => {
mergedCount++;
console.log(`合并分片:${fileName} - 第${mergedCount}/${total}片`);
mergeNextChunk(index + 1); // 合并下一个分片
});
}
// 开始合并(从第0个分片开始)
mergeNextChunk(0);
} catch (err) {
res.status(500).json({
code: 500,
message: `文件合并失败:${err.message}`
});
}
});
3. 小程序端:分片上传与合并触发
小程序端需逐个上传分片(建议串行上传,避免并发过多导致服务器压力),并在所有分片上传完成后,调用合并接口。
(1)单个分片上传函数
使用wx.uploadFile上传分片二进制数据,需携带分片索引、总分片数、原文件名等参数:
/**
* 上传单个分片
* @param {Object} chunk - 分片对象
* @returns {Promise} 上传结果Promise
*/
function uploadSingleChunk(chunk) {
return new Promise((resolve, reject) => {
wx.uploadFile({
url: 'https://your-server.com/upload/chunk', // 服务器分片接收接口
filePath: chunk.tempPath || chunk.path, // 分片临时路径(若用readFileSync,需先写入临时文件)
name: 'chunkData', // 与服务器端upload.single('chunkData')对应
formData: {
fileName: chunk.fileName,
fileSize: chunk.fileSize,
index: chunk.index,
total: chunk.total,
chunkKey: chunk.chunkKey
},
header: {
'content-type': 'multipart/form-data' // 上传文件需指定该类型
},
success: (res) => {
const result = JSON.parse(res.data);
if (result.code === 200) {
resolve({ index: chunk.index, success: true, isExist: result.data.isExist });
} else {
reject(new Error(`分片${chunk.index}上传失败:${result.message}`));
}
},
fail: (err) => {
reject(new Error(`分片${chunk.index}网络失败:${err.errMsg}`));
}
});
});
}
(2)批量上传所有分片并触发合并
通过循环串行上传所有分片,上传完成后调用合并接口:
/**
* 上传所有分片并触发合并
* @param {Array} chunks - 分片数组
*/
async function uploadAllChunks(chunks) {
const total = chunks.length;
let uploadedCount = 0; // 已成功上传的分片数
try {
// 串行上传每个分片(避免并发压力)
for (const chunk of chunks) {
const result = await uploadSingleChunk(chunk);
if (result.success) {
uploadedCount++;
// 更新上传进度(可选,提升用户体验)
const progress = Math.round((uploadedCount / total) * 100);
wx.setStorageSync('uploadProgress', progress); // 存储进度到本地
console.log(`上传进度:${progress}%(${uploadedCount}/${total})`);
}
}
// 所有分片上传完成,调用合并接口
wx.request({
url: 'https://your-server.com/upload/merge',
method: 'POST',
data: {
fileName: chunks[0].fileName,
fileSize: chunks[0].fileSize,
total: total
},
success: (res) => {
if (res.data.code === 200) {
wx.showToast({ title: '文件上传完成!' });
console.log('文件合并成功', res.data.data);
// 清空进度存储
wx.removeStorageSync('uploadProgress');
} else {
wx.showToast({ title: `合并失败:${res.data.message}`, icon: 'none' });
}
},
fail: (err) => {
wx.showToast({ title: `合并请求失败:${err.errMsg}`, icon: 'none' });
}
});
} catch (err) {
wx.showToast({ title: err.message, icon: 'none' });
// 此处可触发断点续传逻辑(见下文)
}
}
三、断点续传的实现(进阶版)
断点续传的核心是 “记录已上传分片”,下次上传时跳过已完成的分片。需结合 “本地存储”(记录客户端上传状态)和 “服务器校验”(确保分片真实性),避免两端状态不一致。
1. 断点续传的核心逻辑
(1)上传前记录文件标识:对每个文件生成唯一标识(如 MD5 哈希),作为断点记录的 key;
(2)本地存储上传状态:用wx.setStorageSync存储已上传的分片索引,避免小程序重启后丢失;
(3)服务器校验分片状态:上传前调用服务器接口,获取该文件已接收的分片列表,与本地状态对比,取交集作为 “已完成分片”;
(4)跳过已完成分片:仅上传未完成的分片,减少重复请求。
2. 关键步骤实现
(1)生成文件唯一标识(MD5 哈希)
为避免不同文件重名导致的断点错乱,需对文件内容生成唯一 MD5 值(注意:大文件 MD5 计算会消耗性能,建议在后台线程或服务器端辅助计算)。
小程序端可使用第三方库(如spark-md5)计算文件 MD5:
// 引入spark-md5(需先在小程序项目中安装,或使用CDN)
const SparkMD5 = require('spark-md5');
/**
* 计算文件MD5(唯一标识)
* @param {Object} file - 选中的文件对象
* @returns {Promise} 文件MD5值
*/
function calculateFileMD5(file) {
return new Promise((resolve, reject) => {
const fs = wx.getFileSystemManager();
const spark = new SparkMD5.ArrayBuffer();
const fileSize = file.size;
const chunkSize = 2 * 1024 * 1024; // 2MB/块(计算MD5的块大小,与上传分片大小无关)
let offset = 0;
// 分块读取文件并更新MD5
function readNext() {
const start = offset;
const end = Math.min(start + chunkSize, fileSize);
try {
const chunkData = fs.readFileSync({
filePath: file.path,
position: start,
length: end - start
});
spark.append(chunkData);
offset = end;
if (offset >= fileSize) {
// 计算完成,生成MD5
const md5 = spark.end();
resolve(md5);
} else {
// 继续读取下一块(避免阻塞主线程,用setTimeout拆分)
setTimeout(readNext, 0);
}
} catch (err) {
reject(new Error(`MD5计算失败:${err.message}`));
}
}
readNext();
});
}
(2)本地存储与服务器校验结合
在上传前,先获取本地已存储的分片状态,再调用服务器接口校验真实已接收的分片,最终确定需上传的分片列表:
/**
* 断点续传:获取需上传的分片列表
* @param {Object} file - 文件对象
* @param {String} fileMD5 - 文件唯一MD5标识
* @param {Array} chunks - 完整分片数组
* @returns {Promise} 需上传的分片列表
*/
async function getNeedUploadChunks(file, fileMD5, chunks) {
const total = chunks.length;
let uploadedIndexes = [];
// 1. 从本地存储获取已上传分片索引
const localUploadState = wx.getStorageSync(`upload_${fileMD5}`) || {};
if (localUploadState.uploadedIndexes) {
uploadedIndexes = localUploadState.uploadedIndexes;
console.log('本地已上传分片索引', uploadedIndexes);
}
// 2. 调用服务器接口,获取真实已接收的分片索引(避免本地与服务器不一致)
try {
const res = await new Promise((resolve, reject) => {
wx.request({
url: 'https://your-server.com/upload/check',
data: {
fileMD5: fileMD5,
fileName: file.name,
total: total
},
success: resolve,
fail: reject
});
});
if (res.data.code === 200) {
uploadedIndexes = res.data.data.uploadedIndexes; // 服务器返回的已完成分片
console.log('服务器已接收分片索引', uploadedIndexes);
}
} catch (err) {
wx.showToast({ title: '服务器校验失败,将重新上传', icon: 'none' });
uploadedIndexes = []; // 校验失败时,重新上传所有分片
}
// 3. 筛选出需上传的分片(排除已完成的索引)
const needUploadChunks = chunks.filter(chunk => !uploadedIndexes.includes(chunk.index));
console.log(`需上传分片数:${needUploadChunks.length}/${total}`);
// 4. 更新本地存储(记录当前已上传分片)
wx.setStorageSync(`upload_${fileMD5}`, {
fileName: file.name,
fileSize: file.size,
total: total,
uploadedIndexes: uploadedIndexes,
lastUpdateTime: Date.now()
});
return needUploadChunks;
}
(3)断点续传的完整流程整合
将 “MD5 计算”“断点校验”“分片上传” 整合为完整流程:
/**
* 大文件断点续传完整流程
* @param {Object} file - 选中的文件对象
*/
async function uploadWithBreakpoint(file) {
try {
// 1. 计算文件MD5(唯一标识)
wx.showLoading({ title: '计算文件标识中...' });
const fileMD5 = await calculateFileMD5(file);
wx.hideLoading();
// 2. 分割文件为分片
const chunks = splitFile(file);
if (chunks.length === 0) throw new Error('文件分片失败');
// 3. 断点校验:获取需上传的分片列表
wx.showLoading({ title: '校验上传进度中...' });
const needUploadChunks = await getNeedUploadChunks(file, fileMD5, chunks);
wx.hideLoading();
if (needUploadChunks.length === 0) {
// 所有分片已上传,直接触发合并
wx.showToast({ title: '文件已上传,正在合并...' });
triggerMerge(file, chunks);
return;
}
// 4. 上传需补充的分片
wx.showLoading({ title: `正在上传(${needUploadChunks.length}分片)` });
await uploadNeedChunks(needUploadChunks, fileMD5);
wx.hideLoading();
// 5. 所有需上传分片完成,触发合并
triggerMerge(file, chunks);
} catch (err) {
wx.hideLoading();
wx.showToast({ title: err.message, icon: 'none' });
}
}
/**
* 上传需补充的分片,并更新本地状态
*/
async function uploadNeedChunks(needUploadChunks, fileMD5) {
const totalNeedUpload = needUploadChunks.length;
let uploadedCount = 0;
const localState = wx.getStorageSync(`upload_${fileMD5}`) || {};
let uploadedIndexes = localState.uploadedIndexes || [];
for (const chunk of needUploadChunks) {
const result = await uploadSingleChunk(chunk);
if (result.success) {
uploadedCount++;
// 更新已上传分片索引
uploadedIndexes.push(chunk.index);
// 实时更新本地存储(避免上传中断后丢失进度)
wx.setStorageSync(`upload_${fileMD5}`, {
...localState,
uploadedIndexes: uploadedIndexes,
lastUpdateTime: Date.now()
});
// 更新进度
const progress = Math.round(((uploadedIndexes.length) / localState.total) * 100);
console.log(`断点续传进度:${progress}%`);
}
}
}
/**
* 触发文件合并(复用之前的合并逻辑)
*/
function triggerMerge(file, chunks) {
const total = chunks.length;
wx.request({
url: 'https://your-server.com/upload/merge',
method: 'POST',
data: {
fileName: file.name,
fileSize: file.size,
total: total,
fileMD5: file.fileMD5 // 可选,用于服务器校验
},
success: (res) => {
if (res.data.code === 200) {
wx.showToast({ title: '断点续传完成!' });
// 清空本地上传状态(上传完成后)
wx.removeStorageSync(`upload_${file.fileMD5}`);
}
}
});
}
四、优化方案与注意事项
1. 关键优化点
(1)分片大小动态调整:根据网络类型(Wi-Fi/4G)调整分片大小(Wi-Fi 用 20MB,4G 用 5MB),减少网络波动影响;
(2)分片上传重试机制:单个分片上传失败时,重试 3-5 次(每次间隔 1-2 秒),避免偶发网络错误导致整体失败;
(3)进度可视化:结合wx.setStorageSync存储的进度,在页面中显示进度条(如progress组件),提升用户体验;
(4)过期分片清理:服务器端定期清理超过 24 小时未合并的分片(避免存储空间浪费),小程序端清理超过 7 天的本地上传状态。
2. 注意事项
(1)临时路径有效期:小程序wx.chooseMessageFile返回的临时路径(tempFilePath)仅在当前会话有效,若需跨会话断点续传,需先将文件保存到本地(wx.saveFile),但本地存储有大小限制(通常为 100MB);
(2)MD5 计算性能:大文件(如 1GB 以上)在小程序端计算 MD5 会消耗较多时间和内存,建议优化为 “客户端计算部分块 MD5 + 服务器端校验完整 MD5”,或直接由服务器端生成文件 MD5;
(3)跨域问题:服务器端需配置 CORS(跨域资源共享),允许小程序域名的请求(如Access-Control-Allow-Origin: *,生产环境需指定具体域名);
(4)安全性校验:上传时需携带用户身份令牌(如 Token),服务器端校验用户权限;合并文件时校验文件 MD5,避免恶意文件上传。
小程序开发中大文件上传的核心是 “分片上传 + 断点续传”,通过将大文件分割为小分片,降低单次请求压力;通过记录已上传分片状态,实现断点恢复。实际开发中,需注意小程序端的临时路径有效期、MD5 计算性能,以及服务器端的分片存储、合并逻辑与安全校验。
- 上一篇:无
- 下一篇:网站设计中的首屏内容优化策略
京公网安备 11010502052960号