小程序开发中的大文件上传:分片上传与断点续传实战 分类:公司动态 发布时间: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计算、分片上传、合并、进度持久化和异常处理等核心功能。
在线咨询
服务项目
获取报价
意见反馈
返回顶部