开启辅助访问 切换到宽版

精易论坛

 找回密码
 注册

QQ登录

只需一步,快速开始

用微信号发送消息登录论坛

新人指南 邀请好友注册 - 我关注人的新帖 教你赚取精币 - 每日签到


求职/招聘- 论坛接单- 开发者大厅

论坛版规 总版规 - 建议/投诉 - 应聘版主 - 精华帖总集 积分说明 - 禁言标准 - 有奖举报

查看: 3307|回复: 1
收起左侧

[技术分享] js实现大文件分片上传和断点续传

[复制链接]
发表于 2025-10-28 15:52:54 | 显示全部楼层 |阅读模式   四川省成都市
### 1. 文件MD5加密
使用SparkMD5库计算文件的MD5值,作为文件的唯一标识,用于后端校验文件完整性和实现秒传功能。

### 2. 文件分片
使用 Blob.slice() 方法将大文件分割成固定大小的小块(5M或10M),分别上传。

### 3. 断点续传
通过localStorage或IndexedDB记录已上传的分片信息,上传前检查哪些分片已上传,只上传未完成的部分。

### 4. 分片上传管理
使用Promise.all或async/await控制分片上传,支持并发上传和错误重试。
/**
* 大文件分片上传工具类
*/
class FileUploader {
  constructor(options = {}) {
    // 配置项
    this.chunkSize = options.chunkSize || 5 * 1024 * 1024; // 默认5MB一片
    this.concurrency = options.concurrency || 3; // 并发上传数量
    this.uploadUrl = options.uploadUrl || '/api/upload/chunk'; // 分片上传接口
    this.mergeUrl = options.mergeUrl || '/api/upload/merge'; // 合并文件接口
    this.checkUrl = options.checkUrl || '/api/upload/check'; // 检查文件接口
  }

  /**
   * 计算文件MD5值
   * @param {File} file - 要上传的文件
   * @returns {Promise<string>} - 返回文件的MD5值
   */
  async calculateFileMd5(file) {
    return new Promise((resolve, reject) => {
      // 引入SparkMD5库
      const SparkMD5 = require('spark-md5');
      const spark = new SparkMD5.ArrayBuffer();
      const fileReader = new FileReader();
      const chunkSize = 2 * 1024 * 1024; // 计算MD5的块大小
      let currentChunk = 0;

      // 读取文件块
      const loadNextChunk = () => {
        const start = currentChunk * chunkSize;
        const end = Math.min(start + chunkSize, file.size);

        fileReader.readAsArrayBuffer(file.slice(start, end));
      };

      // 处理读取完成的块
      fileReader.onload = (e) => {
        spark.append(e.target.result);
        currentChunk++;

        if (currentChunk < Math.ceil(file.size / chunkSize)) {
          // 继续读取下一块
          loadNextChunk();
          // 可以在这里触发进度回调
        } else {
          // 计算完成,返回MD5值
          const md5 = spark.end();
          resolve(md5);
        }
      };

      // 处理错误
      fileReader.onerror = (error) => {
        reject(new Error('文件MD5计算失败'));
      };

      // 开始读取第一块
      loadNextChunk();
    });
  }

  /**
   * 获取文件分片信息
   * @param {File} file - 要分片的文件
   * @param {string} fileMd5 - 文件的MD5值
   * @returns {Array} - 返回分片信息数组
   */
  getFileChunks(file, fileMd5) {
    const chunks = [];
    const totalChunks = Math.ceil(file.size / this.chunkSize);

    for (let i = 0; i < totalChunks; i++) {
      const start = i * this.chunkSize;
      const end = Math.min(start + this.chunkSize, file.size);

      chunks.push({
        chunk: file.slice(start, end), // 分片数据
        index: i, // 分片索引
        size: end - start, // 分片大小
        fileMd5, // 文件MD5
        fileName: file.name, // 文件名
        totalChunks // 总分片数
      });
    }

    return chunks;
  }

  /**
   * 保存上传状态到localStorage
   * @param {string} fileMd5 - 文件MD5
   * @param {Array} uploadedChunks - 已上传的分片索引数组
   */
  saveUploadState(fileMd5, uploadedChunks) {
    try {
      localStorage.setItem(`upload_${fileMd5}`, JSON.stringify({
        uploadedChunks,
        lastUpdate: Date.now()
      }));
    } catch (error) {
      console.warn('保存上传状态失败:', error);
    }
  }

  /**
   * 从localStorage获取上传状态
   * @param {string} fileMd5 - 文件MD5
   * @returns {Array|null} - 返回已上传的分片索引数组或null
   */
  getUploadState(fileMd5) {
    try {
      const data = localStorage.getItem(`upload_${fileMd5}`);
      if (data) {
        const { uploadedChunks } = JSON.parse(data);
        return uploadedChunks;
      }
    } catch (error) {
      console.warn('获取上传状态失败:', error);
    }
    return null;
  }

  /**
   * 上传单个分片
   * @param {Object} chunkInfo - 分片信息
   * @returns {Promise} - 上传结果
   */
  async uploadChunk(chunkInfo) {
    const { chunk, index, fileMd5, fileName, totalChunks } = chunkInfo;

    // 创建FormData
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('index', index);
    formData.append('fileMd5', fileMd5);
    formData.append('fileName', fileName);
    formData.append('totalChunks', totalChunks);

    try {
      const response = await axios.post(this.uploadUrl, formData, {
        headers: {
          'Content-Type': 'multipart/form-data'
        },
        onUploadProgress: (progressEvent) => {
          // 可以在这里处理单个分片的上传进度
          if (progressEvent.total) {
            const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
            console.log(`分片 ${index} 上传进度: ${percentCompleted}%`);
          }
        }
      });

      return response.data;
    } catch (error) {
      console.error(`分片 ${index} 上传失败:`, error);
      throw error;
    }
  }

  /**
   * 并发上传分片
   * @param {Array} chunks - 分片信息数组
   * @param {Array} uploadedChunks - 已上传的分片索引
   * @param {Function} onProgress - 进度回调函数
   */
  async uploadChunks(chunks, uploadedChunks = [], onProgress) {
    // 过滤出未上传的分片
    const unUploadedChunks = chunks.filter(chunk => !uploadedChunks.includes(chunk.index));

    // 用于保存已上传的分片索引
    const currentUploadedChunks = [...uploadedChunks];

    // 分片总数
    const totalChunks = chunks.length;

    // 并发上传控制
    const queue = [...unUploadedChunks];
    const results = [];

    // 处理队列中的任务
    const processQueue = async () => {
      if (queue.length === 0) return;

      const chunk = queue.shift();
      try {
        const result = await this.uploadChunk(chunk);
        results.push(result);

        // 记录已上传的分片
        currentUploadedChunks.push(chunk.index);
        this.saveUploadState(chunk.fileMd5, currentUploadedChunks);

        // 更新进度
        if (onProgress) {
          onProgress(currentUploadedChunks.length / totalChunks);
        }

        console.log(`分片 ${chunk.index} 上传成功`);
      } catch (error) {
        console.error(`分片 ${chunk.index} 上传失败,将重试`, error);
        // 将失败的分片重新加入队列
        queue.unshift(chunk);
      }

      // 继续处理队列
      return processQueue();
    };

    // 创建并发任务
    const concurrentTasks = [];
    for (let i = 0; i < Math.min(this.concurrency, unUploadedChunks.length); i++) {
      concurrentTasks.push(processQueue());
    }

    // 等待所有任务完成
    await Promise.all(concurrentTasks);

    // 检查是否所有分片都已上传
    const allUploaded = currentUploadedChunks.length === totalChunks;

    return {
      allUploaded,
      uploadedChunks: currentUploadedChunks
    };
  }

  /**
   * 检查文件是否已存在或部分上传
   * @param {string} fileMd5 - 文件MD5
   * @param {string} fileName - 文件名
   * @returns {Promise<Array>} - 返回已上传的分片索引数组
   */
  async checkFile(fileMd5, fileName) {
    try {
      const response = await axios.post(this.checkUrl, {
        fileMd5,
        fileName
      });

      // 如果文件已存在且完整,直接返回秒传成功
      if (response.data.fileExists) {
        return { fileExists: true, uploadedChunks: [] };
      }

      // 返回已上传的分片索引
      return {
        fileExists: false,
        uploadedChunks: response.data.uploadedChunks || []
      };
    } catch (error) {
      console.warn('检查文件失败,假设文件不存在或未上传', error);
      return { fileExists: false, uploadedChunks: [] };
    }
  }

  /**
   * 合并分片文件
   * @param {string} fileMd5 - 文件MD5
   * @param {string} fileName - 文件名
   * @param {number} totalChunks - 总分片数
   * @returns {Promise} - 合并结果
   */
  async mergeChunks(fileMd5, fileName, totalChunks) {
    try {
      const response = await axios.post(this.mergeUrl, {
        fileMd5,
        fileName,
        totalChunks
      });

      // 合并成功后,清除本地缓存的上传状态
      this.clearUploadState(fileMd5);

      return response.data;
    } catch (error) {
      console.error('合并文件失败:', error);
      throw error;
    }
  }

  /**
   * 清除上传状态
   * @param {string} fileMd5 - 文件MD5
   */
  clearUploadState(fileMd5) {
    try {
      localStorage.removeItem(`upload_${fileMd5}`);
    } catch (error) {
      console.warn('清除上传状态失败:', error);
    }
  }

  /**
   * 上传文件(主方法)
   * @param {File} file - 要上传的文件
   * @param {Function} onProgress - 进度回调函数
   * @returns {Promise} - 上传结果
   */
  async uploadFile(file, onProgress = () => {}) {
    try {
      // 1. 计算文件MD5
      console.log('开始计算文件MD5...');
      const fileMd5 = await this.calculateFileMd5(file);
      console.log('文件MD5计算完成:', fileMd5);

      // 2. 检查文件是否已存在或部分上传
      console.log('检查文件状态...');
      const checkResult = await this.checkFile(fileMd5, file.name);

      if (checkResult.fileExists) {
        console.log('文件已存在,秒传成功');
        onProgress(1); // 设置进度为100%
        return { success: true, message: '秒传成功', fileMd5 };
      }

      // 3. 获取本地缓存的上传状态
      let uploadedChunks = checkResult.uploadedChunks;
      const localState = this.getUploadState(fileMd5);

      // 合并服务端和本地的已上传分片信息
      if (localState && localState.length > uploadedChunks.length) {
        uploadedChunks = localState;
      }

      // 4. 生成分片信息
      console.log('准备分片...');
      const chunks = this.getFileChunks(file, fileMd5);

      // 5. 上传分片
      console.log(`开始上传分片,已上传 ${uploadedChunks.length}/${chunks.length} 个分片`);
      const uploadResult = await this.uploadChunks(chunks, uploadedChunks, onProgress);

      if (!uploadResult.allUploaded) {
        console.log('部分分片上传失败,请重试');
        return {
          success: false,
          message: '部分分片上传失败,请重试',
          uploadedChunks: uploadResult.uploadedChunks
        };
      }

      // 6. 合并分片
      console.log('所有分片上传完成,开始合并文件...');
      await this.mergeChunks(fileMd5, file.name, chunks.length);
      console.log('文件合并完成');

      // 更新进度为100%
      onProgress(1);

      return { success: true, message: '文件上传成功', fileMd5 };
    } catch (error) {
      console.error('文件上传失败:', error);
      return { success: false, message: '文件上传失败', error: error.message };
    }
  }
}

module.exports = FileUploader;

签到天数: 2 天

发表于 昨天 12:47 | 显示全部楼层   广西壮族自治区南宁市
谢谢分享
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则 致发广告者

发布主题 收藏帖子 返回列表

sitemap| 易语言源码| 易语言教程| 易语言论坛| 易语言模块| 手机版| 广告投放| 精易论坛
拒绝任何人以任何形式在本论坛发表与中华人民共和国法律相抵触的言论,本站内容均为会员发表,并不代表精易立场!
论坛帖子内容仅用于技术交流学习和研究的目的,严禁用于非法目的,否则造成一切后果自负!如帖子内容侵害到你的权益,请联系我们!
防范网络诈骗,远离网络犯罪 违法和不良信息举报QQ: 793400750,邮箱:wp@125.la
网站简介:精易论坛成立于2009年,是一个程序设计学习交流技术论坛,隶属于揭阳市揭东区精易科技有限公司所有。
Powered by Discuz! X3.4 揭阳市揭东区精易科技有限公司 ( 粤ICP备2025452707号) 粤公网安备 44522102000125 增值电信业务经营许可证 粤B2-20192173

快速回复 返回顶部 返回列表