仿写el-upload组件,彻底搞懂文件上传

用了那么久的Upload组件 ,你知道是怎么实现的么,今天就来仿写一个饿了么el-upload vue组件,彻底搞懂前端的文件上传相关知识!

要实现的props

参数 说明
action 必选参数,上传的地址
headers 设置上传的请求头部
multiple 是否支持多选文件
data 上传时附带的额外参数
name 上传的文件字段名
with-credentials 支持发送 cookie 凭证信息
show-file-list 是否显示已上传文件列表
drag 是否启用拖拽上传
accept 接受上传的文件类型
on-preview 点击文件列表中已上传的文件时的钩子
on-remove 文件列表移除文件时的钩子
on-success 文件上传成功时的钩子
on-error 文件上传失败时的钩子
on-progress 文件上传时的钩子
on-change 添加文件时被调用
before-upload 上传文件之前的钩子,参数为上传的文件,若返回 false 或者返回 Promise 且被 reject,则停止上传。
before-remove 删除文件之前的钩子,参数为上传的文件和文件列表,若返回 false 或者返回 Promise 且被 reject,则停止删除。
list-type 文件列表的类型
auto-upload 是否在选取文件后立即进行上传
file-list 上传的文件列表, 例如: [{name: 'food.jpg', url: 'xxx.cdn.com/xxx.jpg'}]
limit 最大允许上传个数
on-exceed 文件超出个数限制时的钩子

参考:element.eleme.cn/#/zh-CN/com...

这里面有几个重要的点:

  1. input file 的美化
  2. 多选
  3. 拖拽

一个个实现

创建upload组件文件

src/components/upload/index.vue

xml 复制代码
<template></template>
<script setup>
  // 属性太多,把props单独放一个文件引入进来
  import property from './props'
  const props = defineProps(property)
</script>
<style></style>

./props.js

javascript 复制代码
export default {
  action: {
    type: String
  },
  headers: {
    type: Object,
    default: {}
  },
  multiple: {
    type: Boolean,
    default: false
  },
  data: {
    type: Object,
    default: {}
  },
  name: {
    type: String,
    default: 'file'
  },
  'with-credentials': {
    type: Boolean,
    default: false
  },
  'show-file-list': {
    type: Boolean,
    default: true,
  },
  drag: {
    type: Boolean,
    default: false
  },
  accept: {
    type: String,
    default: ''
  },
  'list-type': {
    type: String,
    default: 'text' // text、picture、picture-card
  },
  'auto-upload': {
    type: Boolean,
    default: true
  },
  'file-list': {
    type: Array,
    default: []
  },
  disabled: {
    type: Boolean,
    default: false
  },
  limit: {
    type: Number,
    default: Infinity
  },
  'before-upload': {
    type: Function,
    default: () => {
      return true
    }
  },
  'before-remove': {
    type: Function,
    default: () => {
      return true
    }
  }

具体的编写upload组件代码

1. 文件上传按钮的样式

我们都知道,<input type="file">的默认样式是这样的: 很丑,并且无法改变其样式。

解决办法:可以把input隐藏,重新写个按钮点击来触发input的文件选择。

xml 复制代码
<template>
  <input 
     type="file" 
     id="file" 
     @change="handleChange"
  >
  <button 
     class="upload-btn" 
     @click="choose"
  >
    点击上传
  </button>
</template>
<script setup>
  // 触发选择文件
  const choose = () => {
    document.querySelector('#file').click()
  }
  // input选择文件回调
  const handleChange = (event) => {
    files = Array.from(event.target.files)
    console.log('[ files ] >', files)
  }
</script>
<style scoped>
  #file {
    display: none;
  }
  .upload-btn {
    border: none;
    background-color: #07c160;
    color: #fff;
    padding: 6px 10px;
    cursor: pointer;
  }
</style>

效果:

这样也是可以调起文件选择框,并触发input的onchange事件。

2. 多选

直接在input上加一个Booelan属性multiple,根据props中的值动态设置

顺便把accept属性也加上

ini 复制代码
<template>
  <input 
     type="file" 
     id="file" 
     :multiple="multiple"
     :accept="accept"
     @change="handleChange"
  >
</template>

3. 拖拽

准备一个接收拖拽文件的区域,props传drag=true就用拖拽,否则就使用input上传。

ini 复制代码
<template>
  <input 
    type="file" 
    id="file" 
    :multiple="multiple"
    :accept="accept"
    @change="handleChange"
  >
  <button 
     class="upload-btn" 
     v-if="!drag" 
     @click="choose"
  >
    点击上传
  </button>
  <div 
    v-else 
    class="drag-box" 
    @dragover="handleDragOver"
    @dragleave="handleDragLeave"
    @drop="handleDrop"
    @click="choose"
    :class="{'dragging': isDragging}"
  >
    将文件拖到此处,或<span>点击上传</span>
  </div>
</template>

dragging用来拖拽鼠标进入时改变样式

ini 复制代码
<script setup>
  const isDragging = ref(false)
  // 拖放进入目标区域
  const handleDragOver = (event) => {
    event.preventDefault()
    isDragging.value = true
  }
  const handleDragLeave = (event) => {
    isDragging.value = false
  }
  let files = []
  // 拖拽放置
  const handleDrop = (event) => {
    event.preventDefault()
    isDragging.value = false
    files = Array.from(event.dataTransfer.files);
    console.log(files);
  }
</script>
css 复制代码
.drag-box {
    width: 240px;
    height: 150px;
    line-height: 150px;
    text-align: center;
    border: 1px dashed #ddd;
    cursor: pointer;
    border-radius: 8px;
  }
  .drag-box:hover {
    border-color: cornflowerblue;
  }
  .drag-box.dragging {
    background-color: rgb(131, 161, 216, .2);
    border-color: cornflowerblue;
  }
  .drag-box span {
    color: cornflowerblue;
  }

跟使用input上传效果一样

4. 上传到服务器

并实现on-xxx钩子函数

scss 复制代码
  const emit = defineEmits()
  const fileList = ref([])
  let files = []

  // 拖拽放置
  const handleDrop = (event) => {
    event.preventDefault()
    isDragging.value = false
    files = Array.from(event.dataTransfer.files);
    console.log('[ files ] >', files)
    handleBeforeUpload(files)
  }

  // input选择文件回调
  const handleChange = (event) => {
    files = Array.from(event.target.files)
    console.log('[ files ] >', files)
    handleBeforeUpload(files)
  }

  const handleBeforeUpload = (files) => {
    if (files.length > props.limit - fileList.value.length) {
      console.error(`当前限制选择 ${props.limit} 个文件,本次选择了 ${files.length} 个文件,共选择了 ${files.length + fileList.value.length} 个文件`)
      emit('on-exceed', files, toRaw(fileList.value))
      return
    }
    // 可以把锁哥文件放到一个formData中一起上传,
    // 遍历文件一个个上传,这里一个个上传是为了实现钩子函数回调时返回对应的file对象。
    files.forEach(async file => {
      emit('on-change', file, files)
      if (!props.beforeUpload()) {
        return
      }
      if (props.autoUpload) {
        uploadRequest(file, files)
      }
    })
  }

  // 手动上传已选择的文件
  const submit = () => {
    files.forEach(async file => {
      uploadRequest(file, files)
    })
  }
  
  // 保存xhr对象,用于后面取消上传
  let xhrs = []
  const uploadRequest = async (file, files) => {
    let xhr = new XMLHttpRequest();
    // 调用open函数,指定请求类型与url地址。请求类型必须为POST
    xhr.open('POST', props.action);
    // 设置自定义请求头
    Object.keys(props.headers).forEach(k => {
      xhr.setRequestHeader(k, props.headers[k])
    })
    // 额外参数
    const formData = new FormData()
    formData.append('file', file);
    Object.keys(props.data).forEach(k => {
      formData.append(k, props.data[k]);
    })
    // 携带cookie
    xhr.withCredentials = props.withCredentials
    xhr.upload.onprogress = (e) => {
      emit('on-progress', e, file, files)
    }
    // 监听状态
    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4) {
        const res = JSON.parse(xhr.response)
        const fileObj = {
          name: file.name,
          percentage: 100,
          raw: file,
          response: res,
          status: 'success',
          size: file.size,
          uid: file.uid,
        }
        fileList.value.push(fileObj)
        if (xhr.status === 200 || xhr.status === 201) {
          emit('on-success', res, fileObj, toRaw(fileList.value))
        } else {
          emit('on-error', res, fileObj, toRaw(fileList.value))
        }
      }
    }
    // 发起请求
    xhr.send(formData);
    xhrs.push({
      xhr,
      file
    })
  }

  const preview = (file) => {
    emit('on-preview', file)
  }

  const remove = (file, index) => {
    if (!props.beforeRemove()) {
      return
    }
    fileList.value.splice(index, 1)
    emit('on-remove', file, fileList.value)
  }

  // 取消上传
  const abort = (file) => {
    // 通过file对象找到对应的xhr对象,然后调用abort
    // xhr.abort()
  }

  defineExpose({
    abort,
    submit
  })

全部代码

xml 复制代码
<template>
  <input 
    type="file" 
    id="file" 
    :multiple="multiple"
    :accept="accept"
    @change="handleChange"
  >
  <button class="upload-btn" v-if="!drag" @click="choose">
    点击上传
  </button>
  <div 
    v-else 
    class="drag-box" 
    @dragover="handleDragOver"
    @dragleave="handleDragLeave"
    @drop="handleDrop"
    @click="choose"
    :class="{'dragging': isDragging}"
  >
    将文件拖到此处,或<span>点击上传</span>
  </div>
  <template v-if="showFileList">
    <template v-if="listType === 'text'">
      <p class="file-item" v-for="(file, index) in fileList" :key="index" @click="preview(file)">
        <span>{{file.name}}</span>
        <span class="remove" @click.stop="remove(file, index)">×</span>
      </p>
    </template>
  </template>
</template>

<script setup>
  import { ref, toRaw, onMounted } from 'vue'
  import property from './props'
  const props = defineProps(property)
  const emit = defineEmits()

  const fileList = ref([])
  const isDragging = ref(false)

  // 触发选择文件
  const choose = () => {
    document.querySelector('#file').click()
  }

  // 拖放进入目标区域
  const handleDragOver = (event) => {
    event.preventDefault()
    isDragging.value = true
  }

  const handleDragLeave = (event) => {
    isDragging.value = false
  }

  let files = []

  // 拖拽放置
  const handleDrop = (event) => {
    event.preventDefault()
    isDragging.value = false
    files = Array.from(event.dataTransfer.files);
    console.log('[ files ] >', files)
    handleBeforeUpload(files)
  }

  // input选择文件回调
  const handleChange = (event) => {
    files = Array.from(event.target.files)
    console.log('[ files ] >', files)
    handleBeforeUpload(files)
  }

  const handleBeforeUpload = (files) => {
    if (files.length > props.limit - fileList.value.length) {
      console.error(`当前限制选择 ${props.limit} 个文件,本次选择了 ${files.length} 个文件,共选择了 ${files.length + fileList.value.length} 个文件`)
      emit('on-exceed', files, toRaw(fileList.value))
      return
    }
    files.forEach(async file => {
      emit('on-change', file, files)
      if (!props.beforeUpload()) {
        return
      }
      if (props.autoUpload) {
        uploadRequest(file, files)
      }
    })
  }

  // 手动上传已选择的文件
  const submit = () => {
    files.forEach(async file => {
      uploadRequest(file, files)
    })
  }

  let xhrs = []
  const uploadRequest = async (file, files) => {
    let xhr = new XMLHttpRequest();
    // 调用open函数,指定请求类型与url地址。请求类型必须为POST
    xhr.open('POST', props.action);
    // 设置自定义请求头
    Object.keys(props.headers).forEach(k => {
      xhr.setRequestHeader(k, props.headers[k])
    })
    // 额外参数
    const formData = new FormData()
    formData.append('file', file);
    Object.keys(props.data).forEach(k => {
      formData.append(k, props.data[k]);
    })
    // 携带cookie
    xhr.withCredentials = props.withCredentials
    xhr.upload.onprogress = (e) => {
      emit('on-progress', e, file, files)
    }
    // 监听状态
    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4) {
        const res = JSON.parse(xhr.response)
        const fileObj = {
          name: file.name,
          percentage: 100,
          raw: file,
          response: res,
          status: 'success',
          size: file.size,
          uid: file.uid,
        }
        fileList.value.push(fileObj)
        if (xhr.status === 200 || xhr.status === 201) {
          emit('on-success', res, fileObj, toRaw(fileList.value))
        } else {
          emit('on-error', res, fileObj, toRaw(fileList.value))
        }
      }
    }
    // 发起请求
    xhr.send(formData);
    xhrs.push({
      xhr,
      file
    })
  }

  const preview = (file) => {
    emit('on-preview', file)
  }

  const remove = (file, index) => {
    if (!props.beforeRemove()) {
      return
    }
    fileList.value.splice(index, 1)
    emit('on-remove', file, fileList.value)
  }

  // 取消上传
  const abort = (file) => {
    // 通过file对象找到对应的xhr对象,然后调用abort
    // xhr.abort()
  }

  defineExpose({
    abort,
    submit
  })
</script>

<style scoped>
  #file {
    display: none;
  }
  .upload-btn {
    border: none;
    background-color: #07c160;
    color: #fff;
    padding: 6px 10px;
    cursor: pointer;
  }
  .drag-box {
    width: 240px;
    height: 150px;
    line-height: 150px;
    text-align: center;
    border: 1px dashed #ddd;
    cursor: pointer;
    border-radius: 8px;
  }
  .drag-box:hover {
    border-color: cornflowerblue;
  }
  .drag-box.dragging {
    background-color: rgb(131, 161, 216, .2);
    border-color: cornflowerblue;
  }
  .drag-box span {
    color: cornflowerblue;
  }
  .file-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 12px;
    padding: 0 8px;
    border-radius: 4px;
    cursor: pointer;
  }
  .file-item:hover {
    background-color: #f5f5f5;
    color: cornflowerblue;
  }
  .file-item .remove {
    font-size: 20px;
  }
</style>
相关推荐
有梦想的刺儿16 分钟前
webWorker基本用法
前端·javascript·vue.js
cy玩具36 分钟前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
qq_390161771 小时前
防抖函数--应用场景及示例
前端·javascript
John.liu_Test2 小时前
js下载excel示例demo
前端·javascript·excel
Yaml42 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事2 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
哟哟耶耶2 小时前
js-将JavaScript对象或值转换为JSON字符串 JSON.stringify(this.SelectDataListCourse)
前端·javascript·json
getaxiosluo2 小时前
react jsx基本语法,脚手架,父子传参,refs等详解
前端·vue.js·react.js·前端框架·hook·jsx
理想不理想v2 小时前
vue种ref跟reactive的区别?
前端·javascript·vue.js·webpack·前端框架·node.js·ecmascript
知孤云出岫2 小时前
web 渗透学习指南——初学者防入狱篇
前端·网络安全·渗透·web