小程序开发中如何处理大文件上传与断点续传 分类:公司动态 发布时间:2025-12-17

小程序开发中,大文件上传(如视频、高清图片、压缩包等)是常见需求,但受限于小程序运行环境、网络稳定性及服务器配置,直接上传易出现超时、失败、进度丢失等问题。断点续传作为核心优化手段,能有效提升上传成功率与用户体验。本文将从技术原理、实现步骤、优化方案三方面,详细讲解小程序中大文件上传与断点续传的完整解决方案。
 
一、大文件上传的核心痛点与技术选型
 
1. 大文件上传的核心痛点
小程序环境下,直接上传几十 MB 甚至 GB 级文件时,会面临以下关键问题:
(1)网络稳定性差:小程序多运行于移动网络(4G/5G/Wi-Fi 切换),单次长连接易因网络波动中断,导致上传前功尽弃;
(2)超时限制:小程序默认 HTTP 请求超时时间较短(通常为 60 秒),大文件上传耗时超限时会被强制中断;
(3)内存占用过高:直接读取大文件到内存会导致小程序内存溢出,引发闪退;
(4)服务器压力大:单次接收大文件会占用服务器大量 IO 资源,同时增加网络带宽消耗。
 
2. 技术选型:分片上传 + 断点续传
针对上述痛点,行业主流解决方案是 **“分片上传 + 断点续传”** 组合:
(1)分片上传:将大文件按固定大小(如 5MB/10MB)分割为多个 “分片”,逐个上传至服务器,最后由服务器合并所有分片为完整文件;
(2)断点续传:基于分片上传,记录已成功上传的分片信息,下次上传时仅需补充未完成的分片,避免重复上传。
该方案的优势在于:降低单次请求压力、支持断点恢复、便于进度监控,同时兼容小程序的 API 能力(如wx.chooseMessageFilewx.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 计算性能,以及服务器端的分片存储、合并逻辑与安全校验。
在线咨询
服务项目
获取报价
意见反馈
返回顶部