likes
comments
collection
share

手写Upload组件带进度条,不重复造轮子 但得会造轮子

作者站长头像
站长
· 阅读数 50

前言

组件库这么多为什么还要自己写,我觉得如下图一样,能用但和需求匹配得不完美,但更多还是驱动自己去学习吧,还是那句话 “不重复造轮子 但得会造轮子”,不做API程序员。

手写Upload组件带进度条,不重复造轮子 但得会造轮子 一个Upload需要有哪些功能?
  • 至少有个基本的组件样式
  • 文件选择 接受的文件格式 多选 文件大小、数量上限等
  • 上传状态 进度条
  • 删除

实现的大致步骤

  1. 组件交互样式(此处只以图片为例)
  2. input 选择文件
  3. 文件上传 onUploadProgress参数(axios)实现上传进度
  4. URL.createObjectURL(file) 展示图片

效果预览

手写Upload组件带进度条,不重复造轮子 但得会造轮子

实现打开文件选择

使用 <input type="file" /> 打开文件选择,但咱们只使用不挂载它 同时可以设置文件接受的格式accept, multiple 多选单选。

input.oninput的回调中使用 input.files就能拿到包含一系列 File 对象的 FileList 对象 然后就可以开始上传做逻辑

/**
 * 打开本地文件
 * @param { String } accept 文件格式
 * @param { Boolean } false 是否多选
 * @return { Promise } files 文件列表
 */
const openLocalFolder = (accept = '*', multiple = false) => new Promise((resolve, reject) => {
  const input = document.createElement('input')
  input.type = 'file'
  input.accept = accept
  input.multiple = multiple
  try {
    input.oninput = () => {
      resolve(input.files)
    }
  } catch (error) {
    reject(error)
  }
  input.click()
})

文件数量、大小、类型检查

  1. 数量检查,这个就是用openLocalFolder拿到的files.length加已经上传的文件数量之和判断一下就行
// 来源于 props
 const { limit } = this
  // 文件数量检查
  if ((this.files.length + newFiles.length) > limit) {
    this.$notify.error(`最多上传${limit}个文件`)
    return false
  }
  1. 文件类型检查 从file.name拿到后缀名,再与指定的accept做比较就行。 我这是用逗号隔开多个文件格式 例: accept=".jpg,.png,.jpeg,.gif"
// 检查文件类型
    checkFileType (file) {
    // 来源于 props
      let { accept } = this
      if (!accept || accept === '*') {
        return true
      }
      accept = accept.toLowerCase().replace(/\s/gu, '') // 转小写 去除空格
      const acceptTypeList = accept.split(',')
      const fileType = file.name.match(/\.\w*$/u)[0]
      return acceptTypeList.includes(fileType.toLowerCase())
    },
  1. 文件大小检查 大小检查就拿每个file.size与设置的limitSize做比较, 注意这里file.size 单位是字节,所以咱们设置的limitSize也需是字节单位
file.size <= this.limitSize

文件加工

openLocalFolder拿到文件列表并完成检查后 使用for of开始循环处理上传

步骤:

  1. file包进FormData
  2. copy一个文件副本并混入一些额外数据
    • fileKeyvue for in:key
    • status文件状态
    • temporaryPathfile生成一个存在于内存中的文件路径,给img展示
    • total 文件总大小
    • loaded 已执行大小,根据这两个属性计算上传进度
    • lengthComputable表示文件是否可计算,以上三个数据均来源于 axios onUploadProgress方法的回调
  3. 然后将处理好的copyFile放入文件列表,formData 给接口上传
    // 上传点击
    handleUpload () {
    // 这些是来着props的配置
      const { accept, reqParams = {}, multiple } = this
      openLocalFolder(accept, multiple).then(files => {
        if (!this.filesCheck(files)) {
          return
        }
        // 循环上传
        for (const file of files) {
          const fileData = new FormData()
          // 添加额外参数
          Object.keys(reqParams).forEach(key => fileData.append(key, reqParams[key]))
          fileData.append('file', file)
          const fileKey = getUniqueKey(18)
          const copyFile = {
            status: 'uploading',
            message: '上传中...',
            fileKey,
            total: 0,
            loaded: 0,
            lengthComputable: false,
            temporaryPath: URL.createObjectURL(file),
            ...file
          }
          this.files.unshift(copyFile)
          this.asyncFileUpload(fileData, fileKey)
        }
      })
    },

为什么要URL.createObjectURL(file)创建本地文件引用

不使用的步骤

文件上传->上传后服务端返回URl->浏览器使用URL发起资源请求-> 请求成功后再展示

弊端如下

  1. 上传中没有图片展示
  2. 没必要的资源请求
  3. 回显会存在图片闪动

手写Upload组件带进度条,不重复造轮子 但得会造轮子

使用后 效果一目了然

手写Upload组件带进度条,不重复造轮子 但得会造轮子

注意!!! URL.createObjectURL(file) 创建的文件引用是和document 生命周期绑定,不需要的引用需要手动 调URL.revokeObjectURL()  静态方法用来释放哦

接口上传

代码中 reqApiFunction第二个参数就是axiosonUploadProgress上传进度回调方法 然后不断更新咱们文件列表中对应文件的 total, loaded两个字段,计算百分百就得到进度了,

上传接口的then中更新 status,改变界面交互状态,设置服务器后返回url 文件名等等数据

catch做上传失败逻辑,同样改变status,我这里上传失败就直接移除了对应文件,同学们可以做重新上传等一些逻辑

/**
 * 文件上传
 * @param {FormData} formData FormData
 * @param {String} fileKey 文件Key
 */
asyncFileUpload (formData, fileKey) {
    // props传入的文件上传接口
  const { action } = this
  // 请求方法
  const reqApiFunction = action || uploadFile // 兜底上传方法
  // 
  reqApiFunction(formData, ({ total, loaded, lengthComputable }) => {
    const originFile = this.files.find(file => file.fileKey === fileKey)
    originFile.lengthComputable = total
    if (lengthComputable) {
      originFile.total = total
      originFile.loaded = loaded
    }
  }).then(res => {
    const originFile = this.files.find(file => file.fileKey === fileKey)
    originFile.url = this.getFileUrl(res)
    originFile.status = 'done'
    originFile.name = res.name
    originFile.message = '完成'
    this.$emit('uploadSuccess', res)
    this.updateFileList(this.files)
  }).catch(() => {
    const originFile = this.files.find(file => file.fileKey === fileKey)
    URL.revokeObjectURL(originFile.temporaryPath)
     this.$notify.error('文件上传失败')
    originFile.status = 'error'
    originFile.message = '上传失败'
    this.files = this.files.filter(file => file.url !== originFile.url)
  })
},
// 更新 v-model
updateFileList (newList) {
  this.$emit('input', JSON.parse(JSON.stringify(newList)))
},

完整代码 复制可用

这里只写了图片的展示样式,若需增加其他如文件展示 同学们可自行扩展

<template>
  <div class="upload-container">
    <div v-for="file in files" :key="file.url" class="card-item-box">
      <img :src="file.temporaryPath || file.url" preview fit="contain" class="card-img">
      <div v-show="hasDelete && !disabled" title="删除" class="delete-box" @click="handleDeleteImg(file)">
        <i class="el-icon-close" />
      </div>
      <div v-show="file.status === 'uploading'" class="uploading-box">
        <el-progress v-if="file.lengthComputable" type="circle" :width="50" :percentage="uploadPercentage(file)" />
        <template v-else>
          <i class="el-icon-loading" />
          {{ file.message }}
        </template>
      </div>
    </div>
    <div
      v-if="files.length < limit && !disabled"
      title="点击上传"
      class="card-action-box"
      @click="handleUpload"
    >
      <i class="icon el-icon-upload" />
    </div>
  </div>
</template>
<script>
/**
* 获取区间随机数
* @param {Number} start
* @param {Number} end
* @returns
*/
const getRandomNumber = (start, end) => Number.parseInt((Math.random() * (end - start)) + start)

/**
* 生成随机key
* @param {Number} length key长度
* @returns
*/
const getUniqueKey = (length = 10) => {
  const num = '0123456789'
  const lowercase = 'abcdefghijklmnopqrstuvwxyz'
  const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
  const chars = `${num}${lowercase}${uppercase}`
  const end = chars.length
  let result = ''
  for (let i = 0; i < length; i++) {
    result += chars[getRandomNumber(0, end)]
  }
  return result
}
/**
* 打开本地文件
* @param { String } accept 文件格式
* @param { Boolean } false 是否多选
* @return { Promise } files 文件列表
*/
const openLocalFolder = (accept = '*', multiple = false) => new Promise((resolve, reject) => {
  const input = document.createElement('input')
  input.type = 'file'
  input.accept = accept
  input.multiple = multiple
  try {
    input.oninput = () => {
      resolve(input.files)
    }
  } catch (error) {
    reject(error)
  }
  input.click()
})

export default {
  props: {
    // 上传方法
    uploadFun: {
      type: Function,
      default: null,
    },
    // 文件类型 例: '.jpg,png' | 'audio/*'
    // 参照 https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#unique_file_type_specifiers
    accept: {
      type: String,
      default: '*',
    },
    // 是否多选
    multiple: {
      type: Boolean,
      default: false,
    },
    // 最大文件数量
    limit: {
      type: Number,
      default: 1,
    },
    // 域,上传文件name
    field: {
      type: String,
      default: 'file',
    },
    // 上传时的其他参数对象
    reqParams: {
      type: Object,
      default: () => ({}),
    },
    // 文件列表或文件url
    value: {
      type: [Array, String],
      default: () => [],
    },
    // 文件大小限制
    limitSize: {
      type: Number,
      default: 50 * 1024 * 1024,
    },
    // 是否可删除
    hasDelete: {
      type: Boolean,
      default: true,
    },
    // 是否禁用
    disabled: {
      type: Boolean,
      default: false,
    },
    // 展示类型 card
    actionType: {
      type: String,
      default: 'card',
    },
  },
  data () {
    return {
      dialogVisible: false,
      dialogImageUrl: '',
      loading: false,
      files: null,
    }
  },
  watch: {
    value: {
      immediate: true,
      handler () {
        const { value } = this
        if (!value || !value.length) {
          this.files = []
          return
        }
        if (Array.isArray(value)) {
          this.files = value.concat([])
          return
        }
        this.files = [value]
      },
    }
  },
  beforeDestroy () {
    this.files?.length && this.handleRevokeObjectURL(this.files)
  },
  methods: {
    // 移除URL.createObjectURL 在内存中的地址
    handleRevokeObjectURL (list) {
      list.forEach(({ temporaryPath }) => {
        temporaryPath && URL.revokeObjectURL(temporaryPath)
      })
    },
    uploadPercentage ({ total, loaded }) {
      return parseInt(loaded / (total / 100))
    },
    // 图片上传
    handleUpload () {
      const { accept, reqParams, field, multiple } = this
      openLocalFolder(accept, multiple).then(files => {
        if (!this.filesCheck(files)) {
          return
        }
        // 循环上传
        for (const file of files) {
          const fileData = new FormData()
          // 添加额外参数
          Object.keys(reqParams).forEach(key => fileData.append(key, reqParams[key]))
          fileData.append(field, file)
          const fileKey = getUniqueKey(18)
          const copyFile = {
            status: 'uploading',
            message: '上传中...',
            fileKey,
            total: 0,
            loaded: 0,
            lengthComputable: false,
            temporaryPath: URL.createObjectURL(file),
            ...file
          }
          this.files.unshift(copyFile)
          this.asyncFileUpload(fileData, fileKey)
        }
      })
    },
    /**
     * 文件上传
     * @param {FormData} formData FormData
     * @param {String} fileKey 文件Key
     */
    asyncFileUpload (formData, fileKey) {
      const { uploadFun } = this
      // 请求方法
      uploadFun(formData, ({ total, loaded, lengthComputable }) => {
        const originFile = this.files.find(file => file.fileKey === fileKey)
        originFile.lengthComputable = total
        if (lengthComputable) {
          originFile.total = total
          originFile.loaded = loaded
        }
      }).then(res => {
        const originFile = this.files.find(file => file.fileKey === fileKey)
        originFile.url = this.getFileUrl(res)
        originFile.status = 'done'
        originFile.name = res.name
        originFile.message = '完成'
        this.$emit('uploadSuccess', res)
        this.updateFileList(this.files)
      }).catch(({ data: { isShowMsg, code, msg } = {} }) => {
        const originFile = this.files.find(file => file.fileKey === fileKey)
        URL.revokeObjectURL(originFile.temporaryPath)
        isShowMsg || this.$notify.error(`文件上传失败 ${code || ''}`)
        console.log('文件上传失败', msg)
        originFile.status = 'error'
        originFile.message = '上传失败'
        this.files = this.files.filter(file => file.url !== originFile.url)
      })
    },
    // 获取文件路径
    getFileUrl (res) {
      let url = res
      if (typeof res === 'object') {
        // eslint-disable-next-line
        url = res.url
      }
      return url
    },
    // 图片删除
    handleDeleteImg (file) {
      this.files = this.files.filter(item => item.url !== file.url)
      this.updateFileList(this.files)
      this.$emit('remove', file)
    },
    // 文件列表检查
    filesCheck (files) {
      const { limitSize, limit, accept } = this
      // 文件数量检查
      if ((this.files.length + files.length) > limit) {
        this.$notify.error(`最多上传${limit}个文件`)
        return false
      }
      for (const file of files) {
        if (!this.checkFileType(file)) {
          this.$notify.error(`请上传${accept}等格式`)
          return false
        }
        if (!this.checkSize(file)) {
          this.$notify.error(`文件大小不能超过 ${limitSize / 1024 / 1024}MB!`)
          return false
        }
      }
      return true
    },
    // 更新 value
    updateFileList (newList) {
      this.$emit('input', JSON.parse(JSON.stringify(newList)))
    },
    // 检查文件大小
    checkSize ({ size }) {
      return size <= this.limitSize
    },
    // 检查文件类型
    checkFileType (file) {
      let { accept } = this
      if (!accept || accept === '*') {
        return true
      }
      accept = accept.toLowerCase().replace(/\s/gu, '') // 转小写 去除空格
      const acceptTypeList = accept.split(',')
      const fileType = file.name.match(/\.\w*$/u)[0]
      return acceptTypeList.includes(fileType.toLowerCase())
    },
  },
}
</script>
<style lang="scss" scoped>
.upload-container {
  display: flex;
  flex-flow: row wrap;
  justify-content: flex-start;
  align-items: flex-start;

  .card-item-box {
    position: relative;
    width: 100px;
    height: 100px;
    border: 1px dotted #dcdfe6;
    border-radius: 2px;
    overflow: hidden;
    margin: 0 10px 10px 0;
    .card-img {
      object-fit: cover;
      height: 100%;
      width: 100%;
    }

    .delete-box {
      padding: 2px 5px;
      position: absolute;
      right: 0;
      top: 0;
      line-height: normal;
      background-color: #000;
      color: #fff;
      border-radius: 50%;
      transform: translate(40%, -40%);

      .el-icon-close {
        font-size: 12px;
        transform: translate(-30%, 20%);
      }
    }
  }

  .card-action-box {
    display: flex;
    flex-flow: row nowrap;
    justify-content: center;
    align-items: center;
    width: 100px;
    height: 100px;
    border: 1px dotted #dcdfe6;
    border-radius: 2px;
    overflow: hidden;
    margin-bottom: 10px;
    cursor: pointer;

    .icon {
      font-size: 20px;
    }

    &:hover {
      background-color: #f5f7fa;
    }
  }

  .uploading-box {
    display: flex;
    flex-flow: column nowrap;
    justify-content: center;
    align-items: center;
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    color: #fff;
    background-color: rgba(255, 255, 255, 0.7);
  }

  .progress-box {
    height: 100%;
    position: relative;

    .el-progress {
      position: absolute;
      right: 10px;
      top: 50%;
      transform: translateY(-50%);
    }
  }
}
</style>

转载自:https://juejin.cn/post/7240996011547492389
评论
请登录