Vue实现分片上传

659 阅读2分钟

一、需求

自定义上传组件,实现当附件大小超过10M时分片上传功能,有进度条,有删除功能,可以通过拖拽粘贴点击上传,附件icon根据附件类型进行展示

二、实现过程

  1. 安装 spark-md5

spark-md5主要用作生成唯一标识使用,可根据项目自行调整

npm i spark-md5 --save
  1. 效果图如下

image.png

  1. 完整代码如下

GeFile.vue

<template>
  <div class="ge-file-contanier">
    <div class="upload-box">
      <!-- 上传部分 -->
      <input
        ref="fileInput"
        id="id"
        type="file"
        class="upload-file"
        name="ufile"
        @change="handleChange($event)"
        :disabled="getConfig.disabled"
        :multiple="getConfig.multiple"
        :accept="getConfig.accept"
        :webkitdirectory="getConfig.directory"
      />
      <div class="upload-btns">
        <div
          for="ufile"
          :class="[
            'ge-file-large-box',
            { 'disabled-file': getConfig.disabled },
          ]"
          @drop.prevent="onDrop"
          @paste="handlePaste"
          @dragover.prevent="dragOver = true"
          @dragleave.prevent="dragOver = false"
          @click="handleClickBtn"
        >
          <i class="geanicon ge-icon-shangchuan upload-icon"></i>
          <p>将文件拖到此处,或<span class="uploadBtn">点击上传</span></p>
        </div>
      </div>
      <!-- 上传的回显 -->
      <div class="file-list-box">
        <div
          class="file-list"
          v-for="(item, index) in fileList"
          :key="`file_${index}`"
        >
          <div class="file-left">
            <img :src="fileIconBase64(item)" class="file-icon" />
            <span class="file-name" :title="item.name">{{ item.name }}</span>
          </div>
          <div class="file-tools">
            <a-icon type="check-circle" class="success-icon" />
            <a-icon
              type="close"
              class="close-icon"
              v-if="!getConfig.disabled"
              @click="handleRemove(index)"
            />
          </div>
        </div>
        <!-- 进度条 -->
        <div class="progress-box" v-if="progressData.showProgress">
          <div class="progress-file-list">
            <div class="file-left">
              <img :src="fileIconBase64(progressData.file)" class="file-icon" />
              <span class="file-name" :title="progressData.file.name">{{
                progressData.file.name
              }}</span>
            </div>
          </div>
          <div class="progress">
            <div
              class="progress-line"
              :style="{ width: progressData.startValue + '%' }"
            ></div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { getFileType, uploadByPieces } from "@/utils/tools.js";
export default {
  name: "GeFile",
  props: {
    value: {
      //接收自定义v-model传过来的值
      type: [String, Array],
      default: null,
    },
    /* 配置项 */
    config: {
      type: Object,
      default: () => ({}),
    },
  },
  data() {
    return {
      dragOver: false,
      progressData: {
        file: null, //当前正在上传的附件
        startValue: 0,
        showProgress: false, //是否展示进度条
      },
      privateData: {
        disabled: false, //是否可编辑
        maxLength: null, //最多可选择几个
        minLength: null, //最少可选择几个
        multiple: false, //是否支持多选
        acquiescence: [], //默认值
        accept: null /* 接收上传的类型 */,
        directory: false /* 是否支持文件夹上传,默认false不可上传 */,
        json: false /* 是否是json类型 */,
      },
      defaultImg: 'this.src="' + require("@/assets/image/no-pic.png") + '"',
    };
  },
  created() {
    if (this.value) {
      return;
    }
    if (this.getConfig.acquiescence.length) {
      this.fileList = this.getConfig.acquiescence;
    }
  },
  computed: {
    fileList: {
      get() {
        if (this.getConfig.json && this.value) {
          return this.value.reduce((prev, cur) => {
            prev.push(JSON.parse(cur));
            return prev;
          }, []);
        }
        if (!this.value) {
          this.$emit("input", []);
        }
        return this.value || [];
      },
      set(e) {
        let list = e;
        if (this.getConfig.json && e) {
          list = e.reduce((prev, cur) => {
            prev.push(JSON.stringify(cur));
            return prev;
          }, []);
        }
        this.$emit("input", list);
      },
    },
    // 配置项
    getConfig() {
      return Object.assign(this.privateData, this.config);
    },
  },
  methods: {
    // 手动将svg图片转成base64,防止打包后在项目中使用不展示的问题
    fileIconBase64(val) {
      let n = val.name.lastIndexOf(".");
      let name = val.name.slice(n + 1);
      let t = getFileType(name);
      return require(`@/assets/image/${t}.png`);
    },
    // 删除已上传附件
    handleRemove(index) {
      // 为了触发一下计算属性,做了重新赋值
      let list = this.fileList;
      list.splice(index, 1);
      this.fileList = list;
      let { minLength } = this.getConfig;
      if (this.fileList.length < minLength) {
        this.$message.info(`您最少要上传${minLength}个附件`);
      }
    },
    handleChange(e) {
      const files = e.target.files;
      if (!files) {
        return;
      }
      this.uploadFiles(files);
      this.clearInputFile();
    },
    // 清空上传
    clearInputFile() {
      this.$refs.fileInput.value = null;
    },
    // 点击上传按钮
    handleClickBtn() {
      this.$refs.fileInput.click();
    },
    // 拖拽松开事件
    onDrop(e) {
      this.dragOver = false;
      this.uploadFiles(e.dataTransfer.files);
    },
    // 粘贴事件
    handlePaste(e) {
      this.uploadFiles(e.clipboardData.files);
    },
    // 多文件上传
    uploadFiles(files) {
      let postFiles = Array.prototype.slice.call(files);
      if (postFiles.length === 0) return;
      let { maxLength } = this.getConfig;
      let uploadFiles = postFiles;
      if (maxLength && this.fileList.length + postFiles.length > maxLength) {
        this.$message.info(
          `您已超过最大上传附件数,最多可上传${maxLength}个!`
        );
        uploadFiles = postFiles.slice(0, maxLength - this.fileList.length);
      }

      uploadFiles.forEach((file) => {
        this.upload(file);
      });
    },
    // 上传附件
    upload(file) {
      let { minLength } = this.getConfig;
      if (minLength && this.fileList.length < minLength - 1) {
        this.$message.info(`您最少要上传${minLength}个附件`);
      }
      this.progressData.file = file;
      this.uploadLocal(file);
    },
    // 通过本地服务器上传文件
    uploadLocal(file) {
      // 分片上传
      uploadByPieces({
        file, // 文件实体
        pieceSize: 10, // 分片大小
        success: async (response) => {
          this.progressData.showProgress = false;
          let list = [...this.fileList];
          list.push({
            name: response.name,
            url: response.url,
            size: response.size,
            useOss: !this.privateData.upload, // true 代表阿里云 false本地
          });
          this.fileList = list;
          this.$emit("success", list);
        },
        // 上传进度
        uploading: (res) => {
          this.progressData = {
            file: res.file,
            startValue: res.percentage * 100,
            showProgress: true,
          };
        },
        error: (e) => {
          console.log(e);
          this.progressData.showProgress = false;
        },
      });
    },
  },
};
</script>

<style lang="less" scoped>
.ge-file-contanier {
  width: 100%;
  .file-icon {
    width: 16px;
    height: 16px;
  }
  .ge-file-large-box {
    width: 300px;
    height: 150px;
    background-color: rgba(189, 93, 93, 0.02);
    border-radius: 2px;
    border: 1px dashed rgba(0, 0, 0, 0.15);
    .flex-align-justify-center;
    flex-direction: column;
    cursor: pointer;
    .upload-icon {
      font-size: 30px;
      line-height: 2;
    }
  }
  .disabled-file {
    cursor: no-drop;
    background-color: #f5f5f5;
    color: rgba(0, 0, 0, 0.25);
    .upload-icon,
    .uploadBtn {
      color: rgba(0, 0, 0, 0.25);
    }
  }
  .uploadBtn {
    color: #0086fb;
  }
  .upload-icon {
    color: #0086fb;
  }
  .upload-file {
    position: absolute;
    top: -10000px;
  }
  .file-list-box {
    width: 300px;
    max-height: 250px;
    overflow-y: auto;
    .file-list {
      .flex-align-justify-center;
      margin-top: 5px;
      height: 30px;
      line-height: normal;
      padding: 0 10px;
      &:hover {
        background-color: #f9fafc;
        .file-tools {
          .close-icon {
            display: inline-block;
            &:hover {
              color: #ef3e31;
            }
          }
          .success-icon {
            display: none;
          }
        }
        .file-left {
          color: #0086fb;
        }
      }
      .file-left {
        width: calc(~"100% - 16px");
        cursor: pointer;
        height: 100%;
        display: flex;
        align-items: center;
      }
      .file-name {
        display: inline-block;
        padding-left: 5px;
        width: calc(~"100% - 20px");
        .text-ellipsis;
      }
      .file-tools {
        cursor: pointer;
        .success-icon {
          color: #00c668;
          display: inline-block;
          font-size: 14px;
        }
        .close-icon {
          display: none;
          font-size: 12px;
        }
      }
    }
  }
  .progress-box {
    .progress-file-list {
      .flex-justify-between;
      margin-bottom: 10px;
      height: 22px;
      padding: 0 10px;
      .file-left {
        width: calc(~"100% - 16px");
      }
      img {
        margin-top: -21px;
      }
      .file-name {
        display: inline-block;
        padding-left: 5px;
        width: calc(~"100% - 20px");
        .text-ellipsis;
      }
      .file-tools {
        cursor: pointer;
        .close-icon {
          font-size: 12px;
          &:hover {
            color: #ef3e31;
          }
        }
      }
    }
    .progress {
      height: 2px;
      background-color: rgba(0, 0, 0, 0.06);
      border-radius: 1px;
      width: calc(~"100% - 26px");
      margin-left: 26px;
      .progress-line {
        background-color: #0086fb;
        border-radius: 1px;
        height: 2px;
      }
    }
  }
}
</style>

tools.js

注:文中使用Vue.prototype.$req.uploadAction是因为我的组件是独立出去封装成单独的组件库的用法,如果复制使用时可以根据自己需求替换成自己的请求方式,包括需要的传递的参数,参数名等都可自行修改

import SparkMD5 from 'spark-md5';

// 文件后缀名获取
export const getFileType = (fileName) => {
    let suffix = fileName.toLocaleLowerCase();
    let result;
    // 图片格式
    const imglist = ["png", "jpg", "jpeg", "bmp", "gif", "svg"];
    // 进行图片匹配
    result = imglist.find((item) => item === suffix);
    if (result) {
        return "png";
    }
    // 匹配txt
    const txtlist = ["txt"];
    result = txtlist.find((item) => item === suffix);
    if (result) {
        return "txt";
    }
    // 匹配 excel
    const excelist = ["xls", "xlsx"];
    result = excelist.find((item) => item === suffix);
    if (result) {
        return "Excel";
    }
    // 匹配 word
    const wordlist = ["doc", "docx"];
    result = wordlist.find((item) => item === suffix);
    if (result) {
        return "word";
    }
    // 匹配 pdf
    const pdflist = ["pdf"];
    result = pdflist.find((item) => item === suffix);
    if (result) {
        return "pdf";
    }
    // 匹配 ppt
    const pptlist = ["ppt", "pptx"];
    result = pptlist.find((item) => item === suffix);
    if (result) {
        return "ppt";
    }
    // 匹配 视频
    const videolist = ["mp4", "m2v", "mkv", "rmvb", "wmv", "avi", "flv", "mov", "m4v"];
    result = videolist.find((item) => item === suffix);
    if (result) {
        return "video";
    }
    // 匹配 音频
    const radiolist = ["mp3", "wav", "wmv"];
    result = radiolist.find((item) => item === suffix);
    if (result) {
        return "radio";
    }

    const ziplist = ["zip"];
    result = ziplist.find((item) => item === suffix);
    if (result) {
        return "zip";
    }
    // 其他 文件类型
    return "other";
};

/**
 * 分片上传处理
 * @param options 配置参
 * file:上传文件
 * pieceSize:每片的大小
 * baseURL:分片上传的接口地址
 * mergeURL:合并上传接口
 * selectedSize:用于抽取文件首尾各多少字节作为md5值
 * success:成功后回调
 * uploading:分片传输中回调
 * error:报错回调
 * @return Boolean json格式判断结果,true为JSON格式
 * */
export function uploadByPieces({
    file,
    pieceSize = 1,
    baseURL = '/client/file/webUploader',
    mergeURL = '/client/file/webMerge',
    selectedSize = 1024,
    success,
    uploading,
    error,
}) {
    // 上传过程中用到的变量
    const randomNumber = Date.now() + String(Math.round(Math.random() * 100000)) // 生成随机数
    const fileSize = file.size //附件的大小
    const chunkSize = pieceSize * 1024 * 1024; // pieceSize:10 MB一片
    const chunkCount = Math.ceil(fileSize / chunkSize); // 总片数
    let identifier = ''
    // 计算md5的值
    const countMd5 = async () => {
        return new Promise(function async (resolve) {
            const fileReader = new FileReader()
            if (fileSize > selectedSize) {
                // 文件字节大于分割字节
                const md5 = new SparkMD5();
                let index = 0;
                const loadFile = (start, end) => {
                    const slice = file.slice(start, end);
                    fileReader.readAsBinaryString(slice);
                }
                loadFile(0, selectedSize);
                fileReader.onload = e => {
                    md5.appendBinary(e.target.result);
                    if (index === 0) {
                        index += selectedSize;
                        loadFile(fileSize - selectedSize, fileSize);
                    } else {
                        resolve(md5.end())
                    }
                };
            } else {
                fileReader.readAsBinaryString(file);
                fileReader.onload = e => {
                    resolve(SparkMD5.hashBinary(e.target.result))
                }
            }
        })
    }
    // 获取file分片
    const getChunkInfo = (file, currentChunk, chunkSize) => {
        let start = (currentChunk - 1) * chunkSize;
        let end = Math.min(fileSize, start + chunkSize);
        let chunk = file.slice(start, end);
        return {
            start,
            end,
            chunk
        };
    };
    // 调用接口上传文件分片
    const uploadChunk = chunkInfo => {
        let fetchForm = new FormData();
        fetchForm.append("chunk", chunkInfo.currentChunk - 1); //第几分片
        fetchForm.append("chunks", chunkInfo.chunkCount); //分片总数
        fetchForm.append("guid", identifier); // 文件唯一标识 md5
        fetchForm.append("size", fileSize); // 文件大小
        fetchForm.append("name", file.name); // 文件名称
        fetchForm.append("upload", chunkInfo.chunk, file.name); // 分块文件传输对象
        Vue.prototype.$req.uploadAction(baseURL, fetchForm).then(() => {
            if (chunkInfo.currentChunk < chunkInfo.chunkCount) {
                const {
                    chunk
                } = getChunkInfo(
                    file,
                    chunkInfo.currentChunk + 1,
                    chunkSize
                );
                uploadChunk({
                    chunk,
                    currentChunk: chunkInfo.currentChunk + 1,
                    chunkCount: chunkInfo.chunkCount
                });
                uploading && uploading({
                    file,
                    percentage: chunkInfo.currentChunk / chunkInfo.chunkCount
                });
            } else {
                // 当总数大于等于分片个数的时候
                if (chunkInfo.currentChunk >= chunkInfo.chunkCount - 1) {
                    let fileInfo = {
                        chunks: chunkInfo.chunkCount,
                        guid: identifier,
                        name: file.name,
                    }
                    // 调用合并接口
                    Vue.prototype.$req.formdataAction(mergeURL, fileInfo).then((res) => {
                        uploading && uploading({
                            file,
                            percentage: 1
                        });
                        success && success(res.file);
                    }).catch(e => {
                        uploading && uploading({
                            file,
                            percentage: 1
                        });
                        error && error(e);
                    })
                }
            }
        }).catch(e => {
            uploading && uploading({
                file,
                percentage: 1
            });
            error && error(e);
        })
    };
    // 针对每个文件进行chunk处理
    const readChunk = () => {
        // 针对单个文件进行chunk上传
        const {
            chunk
        } = getChunkInfo(file, 1, chunkSize);
        uploadChunk({
            chunk,
            currentChunk: 1,
            chunkCount
        });
    };
    countMd5().then(function (result) {
        // 生成的identifier拼接一个随机数,为了防止同文件无法重复上传的问题
        identifier = result + "_" + randomNumber
        // 附件小于10M时进行直接上传,否则分片上传
        if (fileSize < chunkSize) {
            uploadFile()
        } else {
            readChunk(); // 开始执行代码
        }
    })

    // 整片上传
    const uploadFile = () => {
        let fetchForm = new FormData();
        fetchForm.append("guid", identifier); // 文件唯一标识 md5
        fetchForm.append("size", fileSize); // 文件大小
        fetchForm.append("name", file.name); // 文件名称
        fetchForm.append("upload", file, file.name); // 分块文件传输对象
        Vue.prototype.$req.uploadAction(baseURL, fetchForm).then((res) => {
            uploading && uploading({
                file,
                percentage: 1
            });
            success && success(res.file);
        }).catch(e => {
            uploading && uploading({
                file,
                percentage: 1
            });
            error && error(e);
        })
    }
}

三、可做优化部分(实现断点续传功能)

1、思路:在分片上传时每一片都有传一个利用 spark-md5 库,根据内容生成的唯一标识identifier,前端在重新上传时只需向服务端请求已经上传的切分。 2、难点:

  • 在计算identifier时,如果上传的附件过大,计算量太大,可能出现ui阻塞问题,这时可以使用web-worker开启线程计算
  • 这里补充一个文件秒传的概念,所谓文件秒传就是服务端根据前端传的identifier去查询库中是否已经存在,如果已经存在立即返回上传成功状态,避免重复上传的工作量。
  • 暂停上传:可以利用axios中axios.CancelToken.source的方法生成取消令牌token,设置cancelToken来暂停上传
  • 续传:在点击继续上传时,前端需要调用验证接口,后端根据前端传过来的fileNameidentifier来判断是否已经上传过,或者是否已经上传过部分,如果上传部分,需要返回前端已经完成上传的分片

参考文章# 字节跳动面试官:请你实现一个大文件上传和断点续传