前端音视频大文件分片上传与断点续传方案设计

背景

文件上传是前端开发中一个常见的功能需求,最近给在对接券商的一个后台管理系统需求的时候,需要新增音视频素材库,用于上传相应的素材在业务中使用。由于音视频文件体积一般都较大,直接上传会遇到很多问题,比如:文件体积大,上传时间长、网络不稳定上传失败、服务器压力大和用户体验差等。

为了解决这些问题,我们需要设计一个可靠的大文件上传方案:

整体设计

我们采用"分片上传 + 断点续传"的方案,主要包含以下核心功能:

  1. 文件分片:将大文件分割成小块进行上传
  2. 断点续传:支持断点续传,提高上传可靠性
  3. 秒传功能:通过文件指纹避免重复上传
  4. 并发控制:控制上传并发数,避免服务器压力过大
  5. 进度显示:实时显示上传进度,提升用户体验

1. 整体上传流程


选择文件
   ↓
文件校验(类型、大小)
   ↓
计算文件 MD5
   ↓
检查文件是否已存在(秒传)
   ↓
生成文件切片
   ↓
检查已上传切片
   ↓
上传未完成的切片(断点续传)
   ↓
合并切片(后端)
   ↓
上传完成

2. 核心功能实现

2.1 文件预处理

在文件上传前,我们需要进行必要的预处理校验工作:


// 允许上传的文件类型
const FILE_TYPE_LIMIT = ["video/mp4"];
// 文件大小限制,单位为字节,这里是 1GB
const FILE_SIZE_LIMIT = 1024 * 1024 * 1024;
const handleFileChange = async (file) => {
  try {
    // 1. 文件校验
    if (!FILE_TYPE_LIMIT.includes(file.raw.type)) {
      console.error("不支持的文件类型");
      return;
    }
    if (file.raw.size > FILE_SIZE_LIMIT) {
      console.error("文件大小超出限制");
      return;
    }
    // 2. 添加到文件列表
    fileList.push({
      originFile: file.raw,
      fileName: file.raw.name,
      fileSize: file.raw.size,
      fileStatus: 'wating',
      progress: 0,
    });
    // 3. 开始上传流程
    await handleUploadLargeFile(file.raw, fileList[0]);
  } catch (error) {
    console.error("文件处理错误:", error);
  }
};

2.2 MD5 计算

为了支持秒传和断点续传功能,我们需要计算文件的 MD5 值作为唯一标识。由于大文件计算 MD5 可能会比较耗时,我们采用分片计算的方式:


import SparkMD5 from "spark-md5";
// 计算文件 MD5
const calculateFileMD5 = (file) => {
  return new Promise((resolve, reject) => {
    const chunkSize = 2 * 1024 * 1024; // 2MB
    const chunks = Math.ceil(file.size / chunkSize);
    let currentChunk = 0;
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    // 读取文件切片
    const loadNext = () => {
      const start = currentChunk * chunkSize;
      const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
      fileReader.readAsArrayBuffer(file.slice(start, end));
    };
    loadNext(); // 开始读取第一个切片
    // 文件读取完成后的处理
    fileReader.onload = (e) => {
      spark.append(e.target.result);
      currentChunk++;
      if (currentChunk < chunks) {
        // 继续读取下一个切片
        loadNext();
      } else {
        // 所有切片读取完成,计算最终的 MD5 值
        const md5 = spark.end();
        resolve(md5);
      }
    };
    // 错误处理
    fileReader.onerror = (error) => {
      reject(error);
    };
  });
};

2.3 秒传功能

秒传功能可以避免重复上传相同的文件,提高上传效率。实现原理是通过文件的 MD5 值判断文件是否已存在于服务器:


// 在文件上传流程中使用秒传功能
const handleUploadLargeFile = async (file, index) => {
  try {
    // 1. 计算文件 MD5
    const fileMd5 = await calculateFileMD5(file);
    // 2. 检查文件是否已存在(秒传)
    const isFileExists = await checkFileExists(fileMd5, file.name);
    if (isFileExists) {
      // 秒传成功
      return;
    }
    // 3. 继续后续的分片上传流程...
  } catch (error) { }
};

// 检查文件在服务端是否已存在(秒传)
const checkFileExists = async (fileMd5, fileName) => {
  try {
    const { data } = await axios({
      method: "post",
      url: "/api/check-file",
      data: {
        md5: fileMd5,
        fileName: fileName,
      }
    });
    return data.state === 1;
  } catch (error) {
    console.error("检查文件是否存在失败:", error);
    return false;
  }
};

2.4 文件分片

为了提高上传效率和可靠性,我们将文件分割成小块:


const generateFileChunks = (file) => {
  const chunkSize = 2 * 1024 * 1024; // 2MB
  const chunks = [];
  let cur = 0;
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + chunkSize));
    cur += chunkSize;
  }
  return chunks;
};

2.5 切片上传队列与状态管理

切片上传队列和状态管理是整个上传功能的核心,它负责管理所有切片的上传状态、重试机制和进度更新:


// 切片上传状态管理
const chunkStatus = new Map();
// 上传队列管理
const uploadQueue = [];
const activeUploads = 0;
// 初始化切片状态
/*
* chunks: 需要上传的切片
* uploadedChunks: 已经上传过的切片
*/
const initChunkStatus = (chunks, uploadedChunks) => {
  chunkStatus = new Map();
  chunks.forEach((_, index) => {
    const isUploaded = uploadedChunks.includes(index);
    chunkStatus.set(index, {
      status: isUploaded ? 'success' : 'pending',
      retryCount: 0,
      progress: isUploaded ? 100 : 0
    });
  });
};
// 创建上传任务
const createUploadTasks = (chunks, fileMd5, fileName) => {
  return chunks
    .map((chunk, index) => ({
      chunk,
      index,
      fileMd5,
      fileName
    }))
    .filter((_, index) => chunkStatus.get(index).status !== 'success'); // 断点续传未成功的
};
// 处理上传队列,控制并发数量不超过3
const processUploadQueue = async () => {
  while (uploadQueue.length > 0 && activeUploads < 3) {
    const task = uploadQueue.shift();
    activeUploads++;
    await uploadChunk(task);
  }
};
// 上传单个切片
const uploadChunk = async (task) => {
  const { chunk, index, fileMd5, fileName } = task;
  const chunkInfo = chunkStatus.get(index);
  try {
    // 更新切片状态为上传中
    chunkStatus.set(index, {
      ...chunkInfo,
      status: 'uploding',
      progress: 0
    });
    const formData = new FormData();
    formData.append('file', chunk);
    formData.append('chunkIndex', index);
    formData.append('md5', fileMd5);
    formData.append('fileName', fileName);

    const { data } = await axios({
      method: 'post',
      url: '/api/upload-chunk',
      data: formData,
      headers: { 'Content-Type': 'multipart/form-data' },
      onUploadProgress: (progressEvent) => {
        // 更新切片上传进度
        const progress = Math.floor((progressEvent.loaded / progressEvent.total) * 100);
        chunkStatus.set(index, {
          ...chunkInfo,
          progress
        });
        // 更新文件总进度
        updateFileProgress(fileIndex);
      }
    });
    // 上传成功,更新状态
    chunkStatus.set(index, {
      ...chunkInfo,
      status: 'success',
      progress: 100
    });
    // 检查是否所有切片都上传完成
    checkUploadComplete(fileIndex);
  } catch (error) {
    console.error(`切片 ${index} 上传失败:`, error);
    handleUploadError(task, chunkInfo);
  } finally {
    activeUploads--;
    // 继续执行切片上传任务
    processUploadQueue();
  }
};
// 处理上传错误
const handleUploadError = (task, chunkInfo) => {
  const { index, fileIndex } = task;
  if (chunkInfo.retryCount < 3) {
    // 重试上传
    chunkStatus.set(index, {
      ...chunkInfo,
      status: 'pending',
      retryCount: chunkInfo.retryCount + 1
    });
    
    setTimeout(() => {
      uploadQueue.push(task);
      processUploadQueue();
    }, 1000);
  } else {
    // 重试次数用完,标记为失败
    chunkStatus.set(index, {
      ...chunkInfo,
      status: 'failed'
    });
    fileList[0].fileStatus = FileStatus.Failed;
    console.error(`切片 ${index + 1} 上传失败`);
  }
};
// 检查上传是否完成
const checkUploadComplete = (fileIndex) => {
  const allChunks = Array.from(chunkStatuss());
  const isComplete = allChunks.every(chunk => chunk.status === 'success');
  if (isComplete) {
    // 所有切片上传完成,请求合并
    mergeChunks(fileIndex);
  }
};
// 更新文件总进度
const updateFileProgress = (fileIndex) => {
  const chunks = Array.from(chunkStatuss());
  const totalProgress = chunks.reduce((acc, chunk) => acc + chunk.progress, 0) / chunks.length;
  fileList[0].progress = Math.floor(totalProgress);
};

// 请求合并切片
const mergeChunks = async () => {
  try {
    const file = fileList[0];
    const { data } = await axios({
      method: 'post',
      url: '/api/merge-chunks',
      data: {
        md5: file.fileMd5,
        fileName: file.fileName
      }
    });

    if (data.state === 1) {
      // 文件上传成功...
    } else {
      throw new Error('文件合并失败');
    }
  } catch (error) 
    // 文件合并失败...
  }
};

2.6 断点续传

断点续传是提高上传可靠性的关键功能:若当前文件存在上次未完成切片,则继续上传


const checkUploadedChunks = async (fileMd5, fileName) => {
  try {
    const { data } = await axios({
      method: "post",
      url: "/api/check-chunks",
      data: {
        md5: fileMd5,
        fileName: fileName,
      }
    });
    return data.uploadedChunks || [];
  } catch (error) {
    console.error("检查已上传切片失败:", error);
    return [];
  }
};

2.7 并发控制

为了避免服务器压力过大,我们实现了并发控制:


const uploadQueue = [];
const activeUploads = 0;
const processUploadQueue = async () => {
  while (uploadQueue.length > 0 && activeUploads < 3) {
    const task = uploadQueue.shift();
    activeUploads++;
    await uploadChunk(task);
  }
};

2.8 进度管理

为了提供良好的用户体验,我们实现了实时进度显示:


const updateChunkProgress = (fileIndex, chunkIndex, progressEvent) => {
  const totalChunks = chunkStatus.size;
  const chunkProgress = progressEvent.loaded / progressEvent.total;
  const completedChunks = Array.from(chunkStatuss())
    .filter(status => status.status === "success").length;
  const totalProgress = Math.floor(
    ((completedChunks + chunkProgress) / totalChunks) * 100
  );
  fileList[0].progress = totalProgress;
};

总结

通过上述方案,我们成功解决了大文件上传的问题。该方案分片上传优化了服务器压力,提升了用户体验;断点续传提高了上传的可靠性,还实现了秒传功能。在实际应用中,可以根据具体需求进行相应的调整和优化,以达到最佳效果。

posted @ 2025-06-11 14:30  Justus-  阅读(171)  评论(0)    收藏  举报