文件分片上传demo(ant design vue 的a-upload)

发布于:2025-03-29 ⋅ 阅读:(24) ⋅ 点赞:(0)

1.问题:

使用a-upload组件上传文件时,超过大小200m的文件无法上传

2.解决办法:

文件分片上传

3.代码部分

//spark-md5 是一个用于计算 MD5 值的库,支持对字符串和数组缓冲区(ArrayBuffer)进行 MD5 计算。
import SparkMD5 from 'spark-md5' 
const DEFAULT_SIZE = 20 * 1024 * 1024 //设置每次读取文件时的块大小。20 * 1024 * 1024 表示 20MB
// fileMd5用于计算文件的 MD5 值。它接受两个参数:
//   file:要计算 MD5 值的文件对象。
//   chunkSize:每个分块的大小,默认值为 DEFAULT_SIZE(20MB)。
const fileMd5 = (file, chunkSize = DEFAULT_SIZE) => {
	return new Promise((resolve, reject) => {
		const startMs = new Date().getTime(); //记录开始计算 MD5 的时间
		//blobSlice获取文件对象的 slice 方法。不同浏览器可能有不同的实现方式,这里做了兼容性处理,确保在所有浏览器中都能正确分块文件。
		let blobSlice =												
			File.prototype.slice ||
			File.prototype.mozSlice ||
			File.prototype.webkitSlice;
		//计算文件总共需要分成多少块
		let chunks = Math.ceil(file.size / chunkSize);
		//初始化当前正在处理的块编号,从 0 开始
		let currentChunk = 0;
		let spark = new SparkMD5.ArrayBuffer(); //追加数组缓冲区。
		let fileReader = new FileReader(); //读取文件
		//onload函数在文件块读取完成时触发
		fileReader.onload = function (e) {
			//将当前块的数组缓冲区内容追加到 SparkMD5 实例中,用于计算 MD5 值。
			spark.append(e.target.result);
			currentChunk++;
			if (currentChunk < chunks) { 
				//文件块未处理完成继续函数
				loadNext();
			} else {
				const md5 = spark.end(); //完成md5的计算,返回十六进制结果。
				console.log('文件md5计算结束,总耗时:', (new Date().getTime() - startMs) / 1000, 's')
				//调用 resolve 将计算结果返回给调用者
				resolve(md5);
			}
		};
		//当读取文件块时发生错误时,调用 reject 将错误传递给调用者
		fileReader.onerror = function (e) {
			reject(e);
		};
	//继续处理函数
		function loadNext() {
			console.log('当前part number:', currentChunk, '总块数:', chunks);
			let start = currentChunk * chunkSize;
			let end = start + chunkSize;
			(end > file.size) && (end = file.size);
			fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
		}
		loadNext();
	});
}
// 解释:定义一个函数 md5,用于计算字符串的 MD5 值。它接受两个参数:
// fileName:文件名。
// fileSize:文件大小。
const md5 = (fileName, fileSize) => {
	let md5Hash = '';
	const combinedString = fileName + '' + fileSize;

	// 使用 SparkMD5 计算字符串的 MD5 值
	const spark = new SparkMD5();
	spark.append(combinedString);
	md5Hash = spark.end();
	return md5Hash;
}


export default md5

这段代码提供了两种计算 MD5 值的方法:

  1. 文件的 MD5 值计算:通过分块读取文件并逐块计算 MD5,适用于大文件,避免一次性加载整个文件到内存中。
  2. 字符串的 MD5 值计算:直接对文件名和文件大小的组合字符串进行 MD5 计算,适用于快速生成文件的唯一标识。
import { defHttp } from '@/utils/http/axios';

const basicApi = '/file/minio/chuncked';
//定义一个枚举 Api,包含所有文件分片上传相关的接口路径。
// TASK_INFO:初始化任务或获取任务信息的接口。
// SELECT_TASK:获取任务列表的接口。
// INIT_TASK:初始化分片上传任务的接口。
// MERGE:合并分片的接口。
enum Api {
  TASK_INFO = basicApi + '/init',
  SELECT_TASK = basicApi + '/minioList',
  INIT_TASK = basicApi,
  MERGE = basicApi + '/merge',
}

export interface UploadPageIM {
  identifier: string;
  fileName: string;
  totalSize: any;
  chunkSize: number;
  isPublic: string;
  groupId: string;
}

export interface PreSignUrlIM {
  identifier: string;
  partNumber: string;
}

/**
 * 根据文件的md5获取未上传完的任务
 * @param identifier 文件md5
 * @returns {Promise<AxiosResponse<any>>}
 */
export const taskInfo = (identifier) =>
  defHttp.get<any>({ url: Api.INIT_TASK + '/' + identifier });


/**
 * 获取所有的url地址信息用于展示
 */
export const selectTask = (groupId) =>
  defHttp.get<any>({ url: Api.SELECT_TASK + '/' + groupId });

/**
 * 初始化一个分片上传任务
 * @param identifier 文件md5
 * @param fileName 文件名称
 * @param totalSize 文件大小
 * @param chunkSize 分块大小
 * @returns {Promise<AxiosResponse<any>>}
 */
export const initTask = (params: UploadPageIM) =>
  defHttp.post({ url: Api.TASK_INFO, params });

/**
 * 获取预签名分片上传地址
 * @param fileId 文件md5
 * @param partNumber 分片编号
 * @returns {Promise<AxiosResponse<any>>}
 */
export const preSignUrl = (fileId, partNumber) =>
  defHttp.get<any>({ url: Api.INIT_TASK + '/' + fileId + '/' + partNumber });

/**
 * 合并分片
 * @param fileId
 * @returns {Promise<AxiosResponse<any>>}
 */
export const merge = (fileId) =>
  defHttp.post({ url: Api.MERGE + '/' + fileId });逐行解释
<template>
  <div class="upload-page-style w-full h-full">
    <a-upload //ant design vue 上传组件
      v-model:file-list="fileList" //绑定文件列表,用于显示上传的文件状态
      name="file" //上传文件的字段名。
      :headers="headers" //上传时携带的 HTTP 头。
      :progress="progress"//自定义上传进度条的样式
      @change="handleChange" 
      :custom-request="handleHttpRequest" //自定义上传事件
      :on-remove="handleRemoveFile" //文件被移除时的回调函数
      drag
    >
      <upload-outlined></upload-outlined>
      <span class="pl-4 cursor-pointer">请点击此处上传</span>
    </a-upload>
  </div>
</template>
<script lang="ts" setup>
import axios from 'axios';
import { Card, message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { ref, onMounted } from 'vue';
import Queue from 'promise-queue-plus';
import type { UploadChangeParam, UploadProps } from 'ant-design-vue';
import md5 from '../../api/uploadpage/md5';
import { taskInfo, initTask, preSignUrl, merge, selectTask } from '../../api/uploadpage/uploadpage.api';
import { buildShortUUID } from '@/utils/uuid';

const fileUploadChunkQueue = ref({}).value //存储每个文件的上传队列
const fileList = ref([]);
const minioList = ref([]);
const progress: UploadProps['progress'] = {
  strokeColor: {
    '0%': '#108ee9',
    '100%': '#87d068',
  },
  strokeWidth: 3,
  format: percent => `${parseFloat(percent)}%`,
  class: 'test',
};
const headers = { authorization: 'authorization-text' };

// 更新文件列表中的上传进度
const updateFileListProgress = (uid: string, percent: number) => {
  const file = fileList.value.find((item) => item.uid === uid);
  if (file) {
    file.percent = percent;
    if(file.percent == 100) {
      file.status ='done';
    }
  }
};

/**
 * 自定义上传方法入口
 */
 const handleHttpRequest = async (options) => {
    const file = options.file; //当前上传的文件对象
    try {
      // 获取或初始化上传任务
      const task = await getTaskInfo(file); //调用 getTaskInfo 函数,获取或初始化上传任务
      console.log('task',task);
      
      if (!task) {
        throw new Error('任务初始化失败');
      }
      const { finished, taskRecord, path } = task; //解构
      // 如果任务已完成,直接返回成功
      if (finished) {
        options.onSuccess(path, file); //调用 options.onSuccess 回调函数
        return;
      }

      // // 执行分片上传
      await handleUpload(file, taskRecord, options);
     
      // // 合并分片
      await merge(taskRecord.fileId).then(() => {
        message.success(`${file.name} 文件上传成功`);
        options.onSuccess('上传成功', file);
        file.status = 'done';
      }).catch(() => {
        message.error(`${file.name} 文件上传失败`);
        options.onError(new Error('文件上传失败'));
      });
    } catch (error) {
      message.error(`${file.name} 上传失败: ${error.message}`);
      options.onError(error);
    }
  };

  /**
   * 获取一个上传任务,没有则初始化一个
   */
   const getTaskInfo = async (file) => {
    const identifier = await md5(file.name, file.size); //计算文件的 MD5 值
    //const identifier = (file.name + '' + file.size).substring(1,32);
    const msg = await taskInfo(identifier);
    if(msg) {
      return msg; 
    }

      // 初始化新任务
      const initTaskData = {
        md5: identifier,
        fileName: file.name,
        totalSize: file.size,
        chunkSize: 10 * 1024 * 1024, // 每个分片大小
        isPublic: '0',
        // groupId: buildShortUUID(),
        groupId: '_66771548521732506784121',
      };
      try{
        const initResult = await initTask(initTaskData);
        console.log('initResult',initResult);
        return initResult;
      }catch(error) {
        message.error('文件上传错误');
      }
    throw new Error('文件上传错误');
  };

   /**
   * 上传逻辑处理,如果文件已经上传完成(完成分块合并操作),则不会进入到此方法中
   */
  const handleUpload = async (file, taskRecord, options) => {
    const { chunkSize, chunkNum, fileId, exitPartList = [] } = taskRecord;

    let lastUploadedSize = 0; // 上次断点续传时上传的总大小
    let uploadedSize = 0; // 已上传的大小
    const totalSize = file.size || 0; // 文件总大小
    const startMs = new Date().getTime(); // 上传开始时间

    // 获取从开始上传到现在的平均速度(byte/s)
    const getSpeed = () => {
      // 已上传的总大小 - 上次上传的总大小(断点续传)= 本次上传的总大小(byte)
      const intervalSize = uploadedSize - lastUploadedSize;
      const nowMs = new Date().getTime();
      // 时间间隔(s)
      const intervalTime = (nowMs - startMs) / 1000;
      return intervalSize / intervalTime; // 返回速度 (byte/s)
    };

    const uploadNext = async (partNumber) => {
      //TODO 不知道为什么chunkSize类型是字符型
      const start = chunkSize * (partNumber - 1);
      const end = (start - 0) + (chunkSize - 0);
      const blob = file.slice(start, end); //获取当前分片的内容
      console.log('start:'+start+' end:'+end)
      const signalData = await preSignUrl(fileId,partNumber);//调用 preSignUrl 函数,获取预签名的上传地址
			//获取预签名的上传地址
      let signalUrl = signalData?.url;
			//如果获取到了上传地址
			if (signalUrl) {
        await axios.request({
          url: signalUrl,
          method: 'PUT',
          data: blob,
          headers: {'Content-Type': 'application/octet-stream'}
        })
        return Promise.resolve({ partNumber: partNumber, uploadedSize: blob.size })
      } else {
        return Promise.reject(`分片${partNumber}, 获取上传地址失败`)
      }
    };

    /**
     * 更新上传进度
     * @param increment 为已上传的进度增加的字节量,increment:当前分片的大小
     */
     const updateProcess = (increment) => {
        const { onProgress } = options
        let factor = 1000; // 每次增加1000 byte
        let from = 0;
        // 通过循环一点一点的增加进度
        while (from <= increment) {
            from += factor
            uploadedSize += factor
            const percent = Math.round(uploadedSize / totalSize * 100).toFixed(2);
            onProgress({percent: percent})
            updateFileListProgress(file.uid, percent)
        }

        const speed = getSpeed();
        const remainingTime = speed != 0 ? Math.ceil((totalSize - uploadedSize) / speed) + 's' : '未知'
        console.log('全部大小', totalSize)
        console.log('已上传大小', uploadedSize)
        console.log('剩余大小:', (totalSize - uploadedSize) / 1024 / 1024, 'mb');
        console.log('当前速度:', (speed / 1024 / 1024).toFixed(2), 'mbps');
        console.log('预计完成:', remainingTime);
    }

    return new Promise(resolve => {
        const failArr = []; //存储失败的分片
        const queue = Queue(5, { //初始化一个上传队列,最大并发数为 5
            "retry": 3,               //每个分片的最大重试次数
            "retryIsJump": false,     //是否立即重试
            "workReject": function(reason,queue){ //处理失败的分片
                failArr.push(reason) 
            },
            "queueEnd": function(queue){ //队列结束时的回调函数
                resolve(failArr); 
            }
        })
			//将队列存储到 fileUploadChunkQueue
        fileUploadChunkQueue[file.uid] = queue
        for (let partNumber = 1; partNumber <= chunkNum; partNumber++) {
            const exitPart = (exitPartList || []).find(exitPart => exitPart.partNumber == partNumber)
            if (exitPart) {
                // 分片已上传完成,累计到上传完成的总额中,同时记录一下上次断点上传的大小,用于计算上传速度
                lastUploadedSize += exitPart.size 
                updateProcess(exitPart.size)
            } else {
                queue.push(() => uploadNext(partNumber).then(res => {
                  // 单片文件上传完成再更新上传进度
                  updateProcess(res.uploadedSize)
                }))
            }
        }
        if (queue.getLength() == 0) {
            // 所有分片都上传完,但未合并,直接return出去,进行合并操作
            resolve(failArr);
            return;
        }
        queue.start()
    })
  };

  /**
   * 移除文件列表中的文件
   * 如果文件存在上传队列任务对象,则停止该队列的任务
   */
   const handleRemoveFile = (file) => {
    // 如果有对应上传队列,停止其上传
    if (fileUploadChunkQueue[file.uid]) {
      fileUploadChunkQueue[file.uid].stop();
      delete fileUploadChunkQueue[file.uid];
    }
    message.info(`已移除文件 ${file.name}`);
  };

</script>

这段代码实现了一个完整的文件分片上传功能,包括:

  1. 文件列表管理:通过 fileList 管理上传的文件列表。
  2. 自定义上传逻辑:通过 handleHttpRequest 自定义上传逻辑。
  3. 任务初始化:通过 getTaskInfo 获取或初始化上传任务。
  4. 分片上传:通过 handleUpload 处理分片上传。
  5. 进度更新:通过 updateFileListProgress 更新文件列表中的上传进度。
  6. 文件移除:通过 handleRemoveFile 移除文件列表中的文件。

总结:

涵盖了文件分片的基本做法,具体接口要看前后端商议,这里的后端接口的要求基本也在注释标注,个人理解,文件分片是切割+合并,md5方法可以拿过去直接用,文件唯一性标识也可以根据项目而定。