[Vue]手撸多文件上传组件

1,748 阅读6分钟

前几天公司做一个临时招聘页,为了方便直接使用了Vue CDN 链接,组件也直接写进了 JS 文件,也没有做模块化分割。不过页面需要上传个人简历照片等信息,于是便手写了一个多文件上传组件,下面附上源码和一些注释

主要功能

  • 支持拖拽上传
  • 支持多文件选择
  • 支持复制粘贴上传(由于浏览器限制,粘贴功能的有使用范围,可以用其他软件截图,比如QQ,然后在上传域粘贴即可)
  • 支持上传预览
  • 页面效果图:

  • mutipleUpload.vue
<template>
  <div :class="cls.container">
    <!-- 这儿绑定了拖拽事件和粘贴事件 -->
    <div :class="cls.area"
         @drop.prevent="onDrop"
         @dragover.prevent="dragOver = true"
         @dragleave.prevent="dragOver = false"
         @paste.prevent="onPaste">
         
      <!-- 这儿是中间的提示信息,可以传入一个子标题 subtitle,如:最多可上传 XX 个 jpg 文件 -->
      <div v-if="!images.length" :class="cls.tips">
        <div>点击 “+” 号或拖拽文件到此处</div>
        <div :class="cls.subtitle">{{subtitle}}</div>
      </div>
      
      <!-- 这儿是选中文件的预览列表 -->
      <div v-for="(item, index) of images" :title="item.name" :key="index"
           :class="cls.item" :data-title="item.name">
        <img :src="item.src">
        <span :class="cls.close">&times;</span>
      </div>
      
      <!-- 这儿是点击选择上传文件按钮 -->
      <label :for="nextId" :class="cls.img">
        <span :class="cls.choice">+</span>
      </label>
    </div>
    
    <!-- 这儿是上传域脚提示信息部分 -->
    <div :class="cls.foot">
      <span>已选择 {{images.length}} 张图片,共 {{getSize}}</span>
      <div class="float-right">
        <!-- 整个就这一个普通按钮,就不贴按钮的样式了,谁的页面没有按钮的基本样式不是 -->
        <button class="im-btn" @click="getData">开始上传</button>
      </div>
      <input :id="nextId" @change.prevent="onChoice" multiple
             style="display: none !important;" type="file">
    </div>
  </div>
</template>
<script>
// 为什么这么写?母鸡啊,这不是重点
var namespace = 'im'

function toClass (name) {
	if (namespace) {
		if (namespace[-1] === '-') {
    		return namespace + name
		} else {
			return namespace + '-' + name
		}
	} else {
		return name
	}
}

toClass.useSubNS = function (ns) {
	var prefix = toClass(ns).trim()

	prefix = prefix ? (prefix[-1] === '-' ? prefix : prefix + '-') : prefix
	return function (name) {
		return name ? prefix + name : prefix.slice(0, -1)
	}
}

const creator = toClass.useSubNS('upload')

const classes = {
  container: creator('container'),
  area: creator('area'),
  item: creator('item'),
  img: creator('add'),
  choice: creator('sign'),
  foot: creator('foot'),
  close: creator('close'),
  tips: creator('tips'),
  subtitle: creator('subtitle')
}

const megaByte = 1024 * 1024

export default {
  props: {
    // 上传地址,就像 form 标签的 action
    action: String,
    count: {
      type: Number,
      default: 99
    },
    subtitle: String
  },
  computed: {
    // 每个组件都有自己的唯一 ID
    nextId: function () {
		return (Math.random() + '').substring(2)
	},
    // 组件内 class 列表
    cls: function () {
      return classes
    },
    // 计算当前选中的文件总大小
    // 小于 1MB 按 KB 显示
    // 小于 1GM 按 MB 显示(不过不建议上传这么大的文件,目前也不支持大文件上传)
    getSize: function () {
      let size = this.size / 1024
      if (size < 1024) {
        return size.toFixed(2) + ' KB'
      } else if ((size = size / 1024) < 1024) {
        return size.toFixed(2) + ' MB'
      } else {
        return (size / 1024).toFixed(2) + ' GB'
      }
    }
  },
  data () {
    return {
      size: 0,
      images: [],
      dragOver: false, // 
      visible: false
    }
  },
  methods: {
    // 很抱歉,这儿把上传的 ajax 部分给阉割了,但构建好了 FormData,直接调就可以
    getData: function () {
      let data = new FormData()
      for (let item of this.images) {
        data.append(item.name, item.file)
      }
      console.log(data)
      console.log(megaByte)
    },
    // 处理拖拽图片事件
    onDrop: function (event) {
      let data = event.dataTransfer
      if (data) {
        this.readFiles(data.files)
      }
    },
    // 处理粘贴图片事件
    onPaste: function (event) {
      let data = event.clipboardData
      if (data) {
        this.readItems(data.items)
      }
    },
    // 处理选择图片事件
    onChoice: function (event) {
      this.readFiles(event.target.files)
    },
    readItems: function (items, file) {
      for (let item of items) {
        if (item.kind === 'file' && (file = item.getAsFile())) {
          this.readImage(file.getAsFile ? file.getAsFile() : file)
        }
      }
    },
    readFiles: function (files) {
      for (let file of files) {
        this.readImage(file.getAsFile ? file.getAsFile() : file)
      }
    },
    // 主要通过 FileReader 实现图片预览功能,也没有采用 iframe 来兼容低版本浏览器
    // 不得不说 H5 真的为很多操作都提供了很大的便利
    readImage: function (file) {
      if (file && file.size && file.type) {
        let that = this
        let reader = new FileReader()
        that.size += file.size
        reader.onload = function () {
          that.images.push({
            checked: false,
            src: reader.result,
            file: file,
            name: file.name,
            uploaded: false
          })
        }
        reader.readAsDataURL(file)
      }
    }
  }
}
</script>

下面是样式

        /* 容器 */
        .im-upload-container {
            border: 1px solid #d2d2d2;
            border-radius: 0.3em;
        }
        
        .im-upload-container .im-upload-foot {
            display: block;
            height: 46px;
            line-height: 46px;
            padding: 0 15px;
            border-top: 1px solid #d2d2d2;
        }

        /* 中间的提示 */
        .im-upload-tips {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            user-select: none;
        }

        .im-upload-area {
            position: relative;
            display: block;
            padding: 15px 0 0 15px;
        }

        .im-upload-area:hover {
            box-shadow: 0 0 0 1px #d2d2d2 inset;
        }

        .im-upload-area > .im-upload-tips {
            color: #ddd;
            font-size: 22px;
            cursor: default;
            text-align: center;
        }

        .im-upload-area > .im-upload-tips > .im-upload-subtitle {
            font-size: 18px;
        }

        .im-upload-area > .im-upload-item {
            position: relative;
            display: inline-block;
            width: 100px;
            height: 100px;
            margin-bottom: 15px;
            margin-right: 15px;
            overflow: hidden;
            box-shadow: 0 0 0 0 #d2d2d2;
            transition-duration: 0.2s;
            border-radius: 0.3em;
        }

        .im-upload-area > .im-upload-item.im-uploaded {
            box-shadow: 0 0 0 3px #008B45;
        }

        .im-upload-area > .im-upload-item.im-uploaded:hover {
            box-shadow: 0 0 5px 5px #006231;
        }

        .im-upload-area > .im-upload-item::before {
            content: attr(data-title);
            display: block;
            width: 100%;
            height: 0;
            position: absolute;
            background: rgba(35, 35, 35, 0.6);
            line-height: 25px;
            text-align: center;
            color: #fefefe;
            overflow: hidden;
            transition-duration: 0.2s;
        }

        .im-upload-area > .im-upload-item:hover {
            box-shadow: 0 0 5px 5px #bebebe;
        }

        .im-upload-area > .im-upload-item:hover::before {
            height: 25px;
        }

        .im-upload-area > .im-upload-item > .im-upload-close {
            position: absolute;
            display: block;
            top: 3px;
            right: 3px;
            z-index: 1;
            background: #000;
            color: #ffffff;
            width: 18px;
            height: 18px;
            text-align: center;
            border-radius: 50%;
        }

        .im-upload-area > .im-upload-item > img {
            vertical-align: middle;
            height: 100%;
        }

        .im-upload-add {
            margin-bottom: 15px;
            position: relative;
            display: inline-block;
            width: 100px;
            height: 100px;
            border: 3px dashed #d2d2d2;
            transition-duration: 0.2s;
            vertical-align: top;
            -webkit-box-sizing: border-box;
            -moz-box-sizing: border-box;
            box-sizing: border-box;
            border-radius: 0.3em;
            cursor: pointer;
        }

        .im-upload-add > .im-upload-sign {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-size: 140px;
            color: #d2d2d2;
            transition-duration: 0.2s;
        }

        .im-upload-add > .im-upload-sign:hover {
            color: #bebebe;
        }

        .im-upload-add:hover {
            border-color: #bebebe;
        }

        .float-right {
            float: right;
        }

       看了很多上传组件,包括一些组件库,实现都很完善,也很强大,这儿主要介绍一下这个组件的一些关键点:

  1. 粘贴上传:暂时没发现哪些组件实现的这个功能,实现方式主要是对整个上传域监听一个 paste 事件,其实这个事件还可以做更多事,不过这需要结合一定的场景,可以天马行空发挥一下想象力;
  2. 拖拽上传:和 mousedown、mousemove、mouseup 写拖拽控件相似,注意一下频繁触发事件即可,这儿用了一个变量 dragOver 来屏蔽多余的触发;
  3. 选择文件按钮:控件在列表末尾放了一个大大的“+”号,这也是很多地方比较通用的方式,这个“+”号实际是一个 label 标签,利用的 label 和 input 标签的传递特性(通过 input 的 id 绑定,这也是为什么每个组件都有自己的唯一 id 的原因),此时就可以完全隐藏 input 标签,然后对 label 标签任意发挥样式的书写。
  4. 其他:图片预览用到了 FileReader ,这是 h5 的特性,它的 readAsDataURL 方法可以直接读取图片文件为一个 base64 编码的 url,可以直接用 img 标签显示。

也可以放进 background 里面,但是用背景渲染出来的样式很卡,也做不了更多事,不建议这么用。

  1. 如果出现“+”号位置对不齐的情况加上(是因为字体的原因):
html {
    font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei","微软雅黑", Arial, sans-serif;
}
  1. 注意:没有针对不同文件类型的处理