小程序开发中的大文件上传:分片上传与断点续传实战 分类:公司动态 发布时间:2026-05-28
分片上传与断点续传技术的组合,将大文件"化整为零",并通过进度持久化实现"断点续传",不仅能将上传失败率降至5%以下,还能支持暂停/继续、后台上传等高级功能,同时大幅降低服务器的内存和IO压力。本文将基于微信小程序开发原生API,从原理到实战,完整讲解大文件上传系统的设计与实现。
一、核心原理深度解析
1. 传统上传的技术瓶颈
传统的 wx.uploadFile() 一次性上传方案,本质是将整个文件作为单个HTTP请求体发送。其局限性主要体现在:
(1)超时限制:微信小程序开发默认请求超时时间为60秒,大文件上传极易超时
(2)内存溢出:小程序进程内存限制约为200MB,读取大文件会导致内存占用飙升甚至崩溃
(3)不可恢复性:网络中断或用户退出小程序后,所有已上传数据全部丢失
(4)服务器压力:服务器需一次性接收并处理整个文件,高并发下极易出现内存泄漏和IO阻塞
2. 分片上传的核心思想
分片上传(Chunked Upload)遵循"分而治之"的设计理念:
(1)文件分割:客户端将大文件按固定大小(通常2-5MB)切割为多个数据块(Chunk)
(2)独立上传:每个分片作为独立的HTTP请求发送,支持串行或并行上传
(3)服务端合并:所有分片上传完成后,服务器按顺序将分片拼接为完整文件
其核心优势在于:单个分片体积小,上传时间短,不易超时;单个分片失败只需重传该分片,无需重传整个文件;可灵活控制上传并发数,平衡速度与服务器负载。
3. 断点续传的实现基础
断点续传是在分片上传基础上的增强功能,其核心是上传状态的持久化与校验:
(1)文件唯一标识:通过MD5/SHA1等哈希算法生成文件指纹,确保同一文件的唯一识别
(2)进度持久化:客户端本地存储已上传分片的索引,服务端记录已接收的分片列表
(3)续传校验:重新上传时,客户端先向服务端查询已上传分片,仅上传未完成的部分
二、技术选型与前置准备
1. 技术栈选择
| 端侧 | 技术选型 | 说明 |
|---|---|---|
| 小程序端 | 微信小程序基础库 2.10.0+ |
支持FileSystemManager文件系统 API |
| SparkMD5 | 高性能分片 MD5 计算库,避免阻塞主线程 | |
| 原生 Promise/async-await | 处理异步流程,避免回调地狱 | |
| 服务端 | Node.js + Express | 轻量高效,适合快速开发 |
| multer | 处理 multipart/form-data 文件上传 | |
| fs-extra | 增强版文件系统 API,支持目录操作 | |
| node-schedule | 定时清理过期临时文件 |
2. 核心API说明
(1)小程序端关键API
1) wx.chooseMedia() :选择图片/视频文件,支持获取文件临时路径和大小
2) wx.getFileSystemManager() :文件系统管理器,支持分片读取文件内容
3) wx.uploadFile() :上传单个分片,支持设置请求头和表单数据
4) wx.setStorageSync() / wx.getStorageSync() :本地持久化存储上传进度
(2) 服务端接口设计
1) POST /api/upload/init :初始化上传任务,返回文件唯一标识和已上传分片
2) POST /api/upload/chunk :接收单个分片,保存到临时目录
3) POST /api/upload/merge :合并所有分片,生成完整文件并返回访问地址
4) GET /api/upload/progress :查询指定文件的上传进度(可选)
三、完整实战实现
1. 客户端核心实现
(1)文件选择与信息预处理
首先实现文件选择功能,并获取文件的基本信息。注意微信小程序开发中 wx.chooseMedia() 返回的文件路径是临时路径,有效期为本次小程序启动期间。
// 选择要上传的文件
async function chooseAndUpload() {
try {
const res = await wx.chooseMedia({
count: 1,
mediaType: ['video', 'image', 'file'], // 支持所有文件类型
sourceType: ['album', 'camera'],
maxDuration: 300 // 视频最长5分钟
});
const file = res.tempFiles[0];
const fileInfo = {
name: file.tempFilePath.split('/').pop(),
size: file.size,
tempPath: file.tempFilePath,
type: file.fileType || 'file'
};
// 开始上传流程
await startUpload(fileInfo);
} catch (err) {
console.error('文件选择失败:', err);
wx.showToast({ title: '文件选择失败', icon: 'error' });
}
}
(2)文件MD5计算(唯一标识生成)
文件MD5是实现断点续传的关键,必须确保同一文件生成相同的MD5。对于大文件,必须使用分片计算方式,避免阻塞主线程导致小程序卡顿。
import SparkMD5 from './spark-md5.min.js';
/**
* 分片计算文件MD5
* @param {string} filePath 文件临时路径
* @param {number} fileSize 文件总大小
* @returns {string} 文件MD5值
*/
async function calculateFileMD5(filePath, fileSize) {
return new Promise((resolve, reject) => {
const chunkSize = 2 * 1024 * 1024; // 2MB一个计算分片
const totalChunks = Math.ceil(fileSize / chunkSize);
const spark = new SparkMD5.ArrayBuffer();
const fs = wx.getFileSystemManager();
let currentChunk = 0;
function loadNextChunk() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, fileSize);
fs.readFile({
filePath,
position: start,
length: end - start,
success: (res) => {
spark.append(res.data);
currentChunk++;
if (currentChunk < totalChunks) {
// 异步计算,避免阻塞主线程
setTimeout(loadNextChunk, 0);
} else {
const md5 = spark.end();
resolve(md5);
}
},
fail: reject
});
}
loadNextChunk();
});
}
(3)上传任务初始化
在开始上传前,客户端先向服务端发送初始化请求,告知文件信息。服务端会根据MD5检查文件是否已存在或部分上传,并返回已上传的分片列表。
/**
* 初始化上传任务
* @param {Object} fileInfo 文件信息
* @param {string} fileMD5 文件MD5值
* @returns {Object} 上传任务信息
*/
async function initUploadTask(fileInfo, fileMD5) {
const res = await wx.request({
url: 'shturl.cc/P8EQi3zyk0Mw99QFSpzt9w1hAmlpE',
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: {
fileName: fileInfo.name,
fileSize: fileInfo.size,
fileMD5: fileMD5,
fileType: fileInfo.type
}
});
if (res.data.code !== 0) {
throw new Error(res.data.message || '初始化上传失败');
}
return res.data.data;
}
(4)分片上传核心逻辑
这是整个上传系统的核心部分。我们将文件按2MB大小分割,逐个上传每个分片,并实时更新上传进度。同时实现了分片级别的重试机制,提高上传成功率。
const CHUNK_SIZE = 2 * 1024 * 1024; // 分片大小:2MB
const MAX_RETRY = 3; // 单个分片最大重试次数
/**
* 上传所有分片
* @param {Object} fileInfo 文件信息
* @param {string} fileMD5 文件MD5值
* @param {string} uploadId 上传任务ID
* @param {Array} uploadedChunks 已上传分片索引
*/
async function uploadAllChunks(fileInfo, fileMD5, uploadId, uploadedChunks) {
const totalChunks = Math.ceil(fileInfo.size / CHUNK_SIZE);
const fs = wx.getFileSystemManager();
let uploadedCount = uploadedChunks.length;
// 更新初始进度
updateProgress(Math.floor((uploadedCount / totalChunks) * 100));
for (let i = 0; i < totalChunks; i++) {
// 跳过已上传的分片
if (uploadedChunks.includes(i)) continue;
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, fileInfo.size);
let retryCount = 0;
while (retryCount < MAX_RETRY) {
try {
// 读取分片数据
const chunkData = await new Promise((resolve, reject) => {
fs.readFile({
filePath: fileInfo.tempPath,
position: start,
length: end - start,
success: (res) => resolve(res.data),
fail: reject
});
});
// 上传单个分片
await uploadSingleChunk(uploadId, i, chunkData);
// 上传成功,更新进度
uploadedCount++;
const progress = Math.floor((uploadedCount / totalChunks) * 100);
updateProgress(progress);
// 保存进度到本地存储
saveProgressToLocal(fileMD5, {
uploadId,
uploadedChunks: [...uploadedChunks, i],
progress
});
break; // 跳出重试循环
} catch (err) {
retryCount++;
console.error(`分片${i}上传失败,重试${retryCount}次:`, err);
if (retryCount === MAX_RETRY) {
throw new Error(`分片${i}上传失败,已重试${MAX_RETRY}次`);
}
// 指数退避重试
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
}
}
}
// 所有分片上传完成
return { uploadId, fileMD5 };
}
/**
* 上传单个分片
* @param {string} uploadId 上传任务ID
* @param {number} chunkIndex 分片索引
* @param {ArrayBuffer} chunkData 分片数据
*/
async function uploadSingleChunk(uploadId, chunkIndex, chunkData) {
// 将ArrayBuffer保存为临时文件(wx.uploadFile需要文件路径)
const tempChunkPath = `${wx.env.USER_DATA_PATH}/chunk_${uploadId}_${chunkIndex}`;
const fs = wx.getFileSystemManager();
fs.writeFileSync(tempChunkPath, chunkData, 'binary');
try {
const res = await wx.uploadFile({
url: 'shturl.cc/zYzw7QNlK86lDhenyD5AH5Xeh2A2je',
filePath: tempChunkPath,
name: 'chunk',
formData: {
uploadId,
chunkIndex: chunkIndex.toString()
}
});
if (res.statusCode !== 200) {
throw new Error(`HTTP错误: ${res.statusCode}`);
}
const data = JSON.parse(res.data);
if (data.code !== 0) {
throw new Error(data.message || '分片上传失败');
}
} finally {
// 删除临时分片文件,释放空间
fs.unlinkSync(tempChunkPath);
}
}
(5)分片合并与进度持久化
所有分片上传完成后,客户端通知服务端进行分片合并。同时实现上传进度的本地持久化,支持断点续传。
/**
* 合并所有分片
* @param {string} uploadId 上传任务ID
* @param {string} fileMD5 文件MD5值
* @returns {string} 完整文件访问地址
*/
async function mergeChunks(uploadId, fileMD5) {
const res = await wx.request({
url: 'shturl.cc/O6XiH3UWWGtMKlT3OAzyAvDaEaGFh4',
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: { uploadId, fileMD5 }
});
if (res.data.code !== 0) {
throw new Error(res.data.message || '合并分片失败');
}
// 清除本地存储的进度
clearProgressFromLocal(fileMD5);
return res.data.data.fileUrl;
}
/**
* 保存上传进度到本地存储
*/
function saveProgressToLocal(fileMD5, progress) {
try {
wx.setStorageSync(`upload_${fileMD5}`, {
...progress,
timestamp: Date.now()
});
} catch (err) {
console.error('保存进度失败:', err);
}
}
/**
* 从本地存储获取上传进度
*/
function getProgressFromLocal(fileMD5) {
try {
const progress = wx.getStorageSync(`upload_${fileMD5}`);
// 进度有效期24小时
if (progress && Date.now() - progress.timestamp < 24 * 60 * 60 * 1000) {
return progress;
}
return null;
} catch (err) {
console.error('获取进度失败:', err);
return null;
}
}
(6)完整上传流程整合
将上述步骤整合为一个完整的上传流程,并添加异常处理和用户提示。
/**
* 完整上传流程
* @param {Object} fileInfo 文件信息
*/
async function startUpload(fileInfo) {
wx.showLoading({ title: '准备上传...', mask: true });
try {
// 1. 计算文件MD5
wx.showLoading({ title: '校验文件中...', mask: true });
const fileMD5 = await calculateFileMD5(fileInfo.tempPath, fileInfo.size);
// 2. 检查本地是否有未完成的上传进度
const localProgress = getProgressFromLocal(fileMD5);
// 3. 初始化上传任务
wx.showLoading({ title: '连接服务器...', mask: true });
const initResult = await initUploadTask(fileInfo, fileMD5);
if (initResult.isComplete) {
wx.hideLoading();
wx.showToast({ title: '文件已存在', icon: 'success' });
return initResult.fileUrl;
}
// 合并本地和服务端的已上传分片
const uploadedChunks = [...new Set([
...initResult.uploadedChunks,
...(localProgress?.uploadedChunks || [])
])];
// 4. 上传所有分片
wx.showLoading({ title: '上传中...', mask: true });
const uploadResult = await uploadAllChunks(
fileInfo,
fileMD5,
initResult.uploadId,
uploadedChunks
);
// 5. 合并分片
wx.showLoading({ title: '合并文件中...', mask: true });
const fileUrl = await mergeChunks(uploadResult.uploadId, uploadResult.fileMD5);
wx.hideLoading();
wx.showToast({ title: '上传成功', icon: 'success' });
return fileUrl;
} catch (err) {
wx.hideLoading();
console.error('上传失败:', err);
wx.showModal({
title: '上传失败',
content: err.message || '请检查网络后重试',
showCancel: false
});
throw err;
}
}
2. 服务端核心实现
(1)项目初始化与配置
首先创建Node.js项目并安装依赖:
mkdir upload-server && cd upload-server
npm init -y
npm install express multer fs-extra cors node-schedule
创建主文件 app.js 并进行基础配置:
const express = require('express');
const multer = require('multer');
const fs = require('fs-extra');
const path = require('path');
const cors = require('cors');
const schedule = require('node-schedule');
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// 目录配置
const UPLOAD_ROOT = path.join(__dirname, 'uploads');
const TEMP_ROOT = path.join(__dirname, 'temp');
fs.ensureDirSync(UPLOAD_ROOT);
fs.ensureDirSync(TEMP_ROOT);
// 上传任务存储(生产环境建议使用Redis)
const uploadTasks = new Map();
// 静态文件服务
app.use('/uploads', express.static(UPLOAD_ROOT));
(2)初始化上传接口
该接口负责接收客户端的文件信息,检查文件是否已存在,并返回已上传的分片列表。
app.post('/api/upload/init', (req, res) => {
try {
const { fileName, fileSize, fileMD5, fileType } = req.body;
// 生成最终文件路径
const fileExt = path.extname(fileName);
const finalPath = path.join(UPLOAD_ROOT, `${fileMD5}${fileExt}`);
// 检查文件是否已存在
if (fs.existsSync(finalPath)) {
return res.json({
code: 0,
message: '文件已存在',
data: {
uploadId: fileMD5,
isComplete: true,
fileUrl: `/uploads/${fileMD5}${fileExt}`
}
});
}
// 检查是否有未完成的上传任务
const taskDir = path.join(TEMP_ROOT, fileMD5);
let uploadedChunks = [];
if (fs.existsSync(taskDir)) {
// 读取已上传的分片文件
uploadedChunks = fs.readdirSync(taskDir)
.filter(file => /^\d+$/.test(file))
.map(file => parseInt(file))
.sort((a, b) => a - b);
} else {
// 创建任务临时目录
fs.ensureDirSync(taskDir);
}
// 保存上传任务信息
uploadTasks.set(fileMD5, {
fileName,
fileSize,
fileType,
uploadedChunks,
createdAt: Date.now()
});
res.json({
code: 0,
message: '初始化成功',
data: {
uploadId: fileMD5,
uploadedChunks,
isComplete: false
}
});
} catch (err) {
console.error('初始化上传失败:', err);
res.status(500).json({ code: -1, message: '服务器内部错误' });
}
});
(3)分片上传接口
使用multer中间件处理分片上传,将每个分片保存到对应的临时目录中。
// 配置multer存储
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const { uploadId } = req.body;
const taskDir = path.join(TEMP_ROOT, uploadId);
cb(null, taskDir);
},
filename: (req, file, cb) => {
const { chunkIndex } = req.body;
cb(null, chunkIndex); // 用分片索引作为文件名
}
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 } // 单个分片最大5MB
});
app.post('/api/upload/chunk', upload.single('chunk'), (req, res) => {
try {
const { uploadId, chunkIndex } = req.body;
const index = parseInt(chunkIndex);
// 更新任务的已上传分片列表
const task = uploadTasks.get(uploadId);
if (task && !task.uploadedChunks.includes(index)) {
task.uploadedChunks.push(index);
}
res.json({ code: 0, message: '分片上传成功' });
} catch (err) {
console.error('分片上传失败:', err);
res.status(500).json({ code: -1, message: '服务器内部错误' });
}
});
(4)分片合并接口
所有分片上传完成后,该接口负责按顺序将分片拼接为完整文件,并清理临时资源。
app.post('/api/upload/merge', async (req, res) => {
try {
const { uploadId, fileMD5 } = req.body;
const task = uploadTasks.get(uploadId);
if (!task) {
return res.status(404).json({ code: -1, message: '上传任务不存在' });
}
const { fileName, fileSize } = task;
const taskDir = path.join(TEMP_ROOT, uploadId);
const fileExt = path.extname(fileName);
const finalPath = path.join(UPLOAD_ROOT, `${fileMD5}${fileExt}`);
const totalChunks = Math.ceil(fileSize / (2 * 1024 * 1024));
// 验证所有分片是否都已上传
if (task.uploadedChunks.length !== totalChunks) {
return res.status(400).json({
code: -1,
message: `分片不完整,已上传${task.uploadedChunks.length}/${totalChunks}`
});
}
// 流式合并分片(避免内存溢出)
const writeStream = fs.createWriteStream(finalPath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(taskDir, i.toString());
const readStream = fs.createReadStream(chunkPath);
await new Promise((resolve, reject) => {
readStream.pipe(writeStream, { end: false });
readStream.on('end', resolve);
readStream.on('error', reject);
});
}
writeStream.end();
// 等待写入完成
await new Promise((resolve) => {
writeStream.on('finish', resolve);
});
// 验证文件大小
const stats = fs.statSync(finalPath);
if (stats.size !== fileSize) {
fs.unlinkSync(finalPath);
throw new Error('文件大小不匹配,合并失败');
}
// 清理临时资源
await fs.remove(taskDir);
uploadTasks.delete(uploadId);
res.json({
code: 0,
message: '合并成功',
data: {
fileUrl: `/uploads/${fileMD5}${fileExt}`
}
});
} catch (err) {
console.error('合并分片失败:', err);
res.status(500).json({ code: -1, message: '合并文件失败' });
}
});
(5)过期任务清理
添加定时任务,定期清理超过24小时未完成的上传任务,避免磁盘空间被占用。
// 每天凌晨2点清理过期临时文件
schedule.scheduleJob('0 0 2 * * *', () => {
console.log('开始清理过期上传任务...');
const now = Date.now();
const expireTime = 24 * 60 * 60 * 1000; // 24小时
// 清理临时目录
fs.readdirSync(TEMP_ROOT).forEach(dir => {
const dirPath = path.join(TEMP_ROOT, dir);
const stats = fs.statSync(dirPath);
if (stats.isDirectory() && now - stats.mtimeMs > expireTime) {
fs.removeSync(dirPath);
uploadTasks.delete(dir);
console.log(`已删除过期任务: ${dir}`);
}
});
console.log('过期任务清理完成');
});
// 启动服务器
const PORT = 3000;
app.listen(PORT, () => {
console.log(`上传服务器运行在 http://localhost:${PORT}`);
});
四、性能优化与最佳实践
1. 分片大小的动态调整
分片大小是影响上传性能的关键因素:
(1)移动网络:建议使用2MB分片,减少单个分片的上传时间
(2)WiFi环境:可使用4-5MB分片,减少HTTP请求次数
(3)动态调整:可根据前几个分片的上传速度自动调整后续分片的大小
2. 并发上传优化
为了提高上传速度,可以实现并发上传多个分片:
(1)推荐并发数:3-5个(过多会导致网络拥塞)
(2)实现方式:使用 Promise.all() 结合并发控制库(如 p-limit )
(3)注意事项:需要确保分片按顺序合并,并发上传不影响最终结果
3. 内存与性能优化
(1)分片读取:避免一次性读取整个文件,始终使用分片读取
(2)及时清理:上传完成后立即删除临时分片文件
(3)MD5计算优化:可在用户选择文件后后台异步计算MD5
(4)避免主线程阻塞:使用 setTimeout 将计算任务分片执行
4. 安全与可靠性
(1)文件类型校验:客户端和服务端都要验证文件类型和扩展名
(2)文件大小限制:设置单个文件最大大小限制(如2GB)
(3)MD5校验:合并完成后校验文件MD5,确保文件完整性
(4)权限控制:只有登录用户才能上传文件
(5)频率限制:限制单个用户的上传频率,防止恶意攻击
五、常见问题与解决方案
1. 小程序开发文件系统限制
问题:小程序临时文件目录有大小限制,且重启后会被清空
解决方案:上传过程中及时删除临时分片文件;对于超大文件,建议使用云存储SDK
2. 大文件MD5计算慢
问题:GB级文件的MD5计算可能需要几十秒
解决方案:使用Web Worker进行后台计算;或使用"文件名+大小+修改时间"作为临时标识,上传完成后再校验MD5
3. 服务端合并慢
问题:GB级文件的合并过程会占用大量服务器IO
解决方案:使用云存储服务(如阿里云OSS、腾讯云COS),它们提供原生的分片上传和合并功能,无需自己实现
4. 后台上传支持
问题:用户切到后台后,小程序可能会被系统回收
解决方案:使用微信小程序的 wx.setKeepScreenOn() 保持屏幕常亮;或使用后台音频播放技巧延长后台运行时间
分片上传与断点续传是小程序开发大文件上传的标准解决方案,它通过"化整为零"和"进度持久化"的思想,完美解决了传统上传方式的诸多问题。本文提供的实现方案,涵盖了从客户端到服务端的完整流程,包括文件选择、MD5计算、分片上传、合并、进度持久化和异常处理等核心功能。
- 上一篇:无
- 下一篇:数据可视化网站设计:复杂信息的图形化呈现与用户理解效率提升
京公网安备 11010502052960号