vue + axios + element-plus + axios 实现文件分片上传

1,428 阅读2分钟

流程

  1. 获取文件MD5,请求后端文件是否已上传,返回已上传的分片列表;
  2. 根据分片参数(分片大小和片数)开始分片,并上传分片文件,更新上传进度;
  3. 上传完成,请求后端进行文件合并并返回文件URL。

文件分片上传.png

实现

需要用到的插件有 axios、 element-plus、spark-md5

以下代码有些是伪代码,后续会更新补全更新到 github

vue Template

<template>
    <el-upload
        class="upload-wrap"
        ref="uploadRef"
        :auto-upload="false"
        action="/"
        :on-change="onFileChange"
        :on-exceed="handleExceed"
        :limit="1">
        <el-button>
            <i class="iconfont icon-upload" />
            文件上传
        </el-button>
        <template #tip>
            <div class="tip">仅允许上传1个文件,大小不超100M,多个附件请以压缩包上传</div>
        </template>
        <template #file="{ file }">
            <div class="el-upload-list__item-info">
                <i class="el-icon el-icon-document"></i>
                <span>{{ file.name }}</span>
            </div>
            <i class="el-icon el-icon-close" @click="handleDelete"></i>
            <label class="el-icon el-upload-list__item-status-label">
                <i class="el-icon-upload-success el-icon-circle-check" v-if="currentFile.percentage >= 100"></i>
            </label>
            <el-progress :percentage="currentFile.percentage" :status="currentFile.status === 'fail' ? 'exception' : ''" />
        </template>
    </el-upload>
</template>

<style lang="less" scoped>
.upload-wrap {
    :deep .el-upload-list {
        width: 33%;
        max-height: 32px;
        overflow-y: hidden;
    }
}
</style>

选择文件并上传

// 选择文件
const onFileChange = (file: any) => {
    state.currentFile = file;
    state.currentFile.chunkList = [];
    if (file.size > maxSize) {
        window.$message.error('文件大小不得超过100M');
        state.uploadRef.clearFiles();
    } else {
        handleUpload();
    }
};

const handleExceed = (files: any[]) => {
    state.uploadRef.clearFiles();
    const file = files[0];
    state.uploadRef.handleStart(file);
};
// 分片上传文件
const handleUpload = async () => {
    // 1. 计算MD5
    const md5 = await getFileMd5(state.currentFile.raw) as string;
    if (!md5) return;
    // 2. 正在创建分片
    const fileChunks = createFileChunk(state.currentFile.raw, chunkSize);
    fileChunks.map((chunkItem, index) => {
        state.currentFile.chunkList.push({
            chunkNumber: index + 1,
            chunkSize: chunkSize,
            currentChunkSize: chunkItem.file.size,
            totalChunks: state.totalChunks,
            identifier: md5,
            filename: state.currentFile.name,
            totalSize: state.currentFile.size,
            file: chunkItem.file,
            progress: 0,
            status: {}
        });
    });
    // 3. 接口检查是否已上传,如已上传返回上传过的分片索引列表,若未上传过则返回null
    // 过滤已经上传的分片
    const uploadedResult = await checkFileUploadedByMd5({
        chunkNumber: 0,
        chunkSize: chunkSize,
        currentChunkSize: 0,
        totalChunks: state.totalChunks,
        identifier: md5,
        filename: state.currentFile.name,
        totalSize: state.currentFile.size
    });
    if (uploadedResult) {
        // 获取已上传分片列表
        state.currentFile.chunkUploadedList = uploadedResult.uploaded;
    }
    state.currentFile.chunkList = filterUploadChunkList(state.currentFile.chunkList);
    let uploadResult = true;
    if (state.currentFile.chunkList.length === 0) { // 所有分片全部已上传,立即完成(秒传)
        state.currentFile.status = FileStatusMap.success;
        state.currentFile.percentage = 100;
    } else {
        // 4. 上传分片
        state.currentFile.status = FileStatusMap.uploading;
        uploadResult = await uploadChunkList(state.currentFile.chunkList);
    }
    if (!uploadResult) {
        window.$message.error('文件上传失败');
        state.currentFile.status = FileStatusMap.error;
        return;
    }
    // 5. 接口获取文件路径
    await getAttachmentUrl({
        identifier: md5,
        filename: state.currentFile.name,
        totalChunks: state.totalChunks
    }).then((res: BINet.Resp) => {
        const mergeResult = res.code === 200;
        if (!mergeResult) {
            window.$message.error('获取文件路径失败');
            state.currentFile.status = FileStatusMap.error;
        } else {
            state.currentFile.status = FileStatusMap.success;
            state.attachedUrl = res.desc || '';
        }
    });
};

1. 获取文件MD5

npm install spark-md5 --save
import SparkMD5 from 'spark-md5';

/**
 * 分片读取文件 MD5
 */
const getFileMd5 = (file: Blob) => {
    const blobSlice = File.prototype.slice || (File.prototype as any).mozSlice || (File.prototype as any).webkitSlice;
    const fileReader = new FileReader();
    // 计算分片数
    state.totalChunks = Math.ceil(file.size / chunkSize);
    console.log('总分片数:' + state.totalChunks);
    let currentChunk = 0;
    const spark = new SparkMD5.ArrayBuffer();
    function loadNext () {
        const start = currentChunk * chunkSize;
        const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
        // 注意这里的 fileRaw
        fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
    }
    loadNext();
    return new Promise((resolve: Function) => {
        fileReader.onload = (e: ProgressEvent<FileReader>) => {
            try {
                spark.append((e.target as FileReader).result as ArrayBuffer);
            } catch (error) {
                console.log('获取Md5错误:' + currentChunk);
            }
            if (currentChunk < state.totalChunks) {
                currentChunk++;
                loadNext();
            } else {
                resolve(spark.end());
            }
        };
        fileReader.onerror = () => {
            window.$message.error('读取Md5失败,文件读取错误');
            resolve('');
        };
    });
};

2. 创建分片

const createFileChunk = (file: Blob, size = chunkSize) => {
    const fileChunkList = [];
    let count = 0;
    while (count < file.size) {
        fileChunkList.push({
            file: file.slice(count, count + size)
        });
        count += size;
    }
    return fileChunkList;
};

3. 过滤已上传分片

const filterUploadChunkList = (chunkList: any[]) => {
    const chunkUploadedList = state.currentFile.chunkUploadedList;
    if (chunkUploadedList === undefined || chunkUploadedList === null || chunkUploadedList.length === 0) {
        return chunkList;
    }
    for (let i = chunkList.length - 1; i >= 0; i--) {
        const chunkItem = chunkList[i];
        for (let j = 0; j < chunkUploadedList.length; j++) {
            if (chunkItem.chunkNumber === chunkUploadedList[j]) {
                chunkList.splice(i, 1);
                break;
            }
        }
    }
    return chunkList;
};

4. 上传分片并更新上传进度

const uploadChunkList = (chunkList: any[]): Promise<boolean> => {
    let successCount = 0;
    const retryArr = [] as number[]; // 数组存储每个文件hash请求的重试次数,做累加 比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次
    return new Promise((resolve: Function) => {
        const handler = () => {
            if (!chunkList.length) {
                // 删除并取消上传
                chunkList.length = 0;
                resolve(true);
            } else {
                const chunkItem = chunkList.shift();
                const formdata = new FormData();
                [
                    'chunkNumber', 'chunkSize', 'currentChunkSize', 'totalChunks', 'identifier', 'filename', 'totalSize', 'file'
                ].forEach(key => {
                    formdata.append(key, chunkItem[key]);
                });
                uploadAttachment(
                    url: 'uploadAttachment',
                    methods: 'post',
                    headers: {
                        'Content-Type': ''
                    },
                    data: formdata,
                    {
                        payload: {
                            onUploadProgress: (p: any) => {
                                chunkItem.progress = parseInt(String((p.loaded / p.total) * 100));
                                updateChunkUploadStatus(chunkItem);
                            }
                        }
                    }
                ).then((uploadResult: any) => {
                    if (uploadResult) {
                        // 上传成功,继续上传下一个分片
                        successCount++;
                        handler();
                    } else { // 上传失败
                        if (typeof retryArr[chunkItem.chunkNumber] !== 'number') {
                            retryArr[chunkItem.chunkNumber] = 0;
                        }
                        // 累加错误次数
                        retryArr[chunkItem.chunkNumber]++;
                        // 最多重试 maxRetryCount 次
                        if (retryArr[chunkItem.chunkNumber] >= maxRetryCount) {
                            // 多次重试失败,取消上传
                            chunkList.length = 0;
                            state.currentFile.status = FileStatusMap.error;
                            resolve(false);
                        }
                        // 重新添加到队列中
                        chunkList.push(chunkItem);
                        handler();
                    }
                });
            }
            if (successCount >= state.totalChunks) {
                resolve(true);
            }
        };
        // 并发
        for (let i = 0; i < simultaneousUploads; i++) {
            handler();
        }
    });
};

5. 更新进度和状态

const getCurrentFileProgress = () => {
    if (!state.currentFile || !state.currentFile.chunkList) return;
    const chunkList = state.currentFile.chunkList;
    const uploadedSize = chunkList.map((item: any) => item.file.size * item.progress).reduce((acc: any, cur: any) => acc + cur);
    // 计算方式:已上传大小 / 文件总大小
    const progress = parseInt((uploadedSize / state.currentFile.size).toFixed(2));
    state.currentFile.percentage = progress;
};
const updateChunkUploadStatus = (item: any) => {
    let status = FileStatusMap.uploading;
    let progressStatus = 'normal';
    if (item.progress >= 100) {
        status = FileStatusMap.success;
        progressStatus = 'success';
    }
    const chunkIndex = item.chunkNumber - 1;
    const currentChunk = state.currentFile.chunkList[chunkIndex];
    // 修改状态
    currentChunk.status = status;
    currentChunk.progressStatus = progressStatus;
    // 获取文件上传进度
    getCurrentFileProgress();
};

GIT

vue-element-upload

参考文档

蓝伟洪的博客