🔥前端文件上传常见场景 -- 使用vue自定义指令实现拖拽上传

264 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第25天,点击查看活动详情

前端开发中,常见的文件上传场景有:

  • 单个文件上传
  • 多个文件上传
  • 目录上传
  • 拖拽上传
  • 剪贴板上传
  • 大文件分块上传

本篇我们来讲一下拖拽上传文件的场景

往期回顾:

使用拖拽上传的话,主要是前端部分要改成拖拽的方式上传文件,后端实际上不需要作出修改,那么前端需要作出什么修改呢?

我们需要先了解一下拖拽相关的api,看看如何实现拖拽,不过暂时不急,我们先把基础的页面搭建好先

搭建基础页面

首先搭建一个可拖拽的文件上传页面出来

<template>
  <div
    flex-col
    flex-center
    w-xl
    h-sm
    border-1
    border-dashed
    rounded-3
    border-gray-6
    cursor-pointer
    hover:border-cyan
    transition-200
  >
    <div i-carbon:cloud-upload text-16></div>
    <span>Drop file here or <span text-cyan>click to upload</span></span>
  </div>
</template>

效果如下:

image.png

抽离文件上传逻辑为hooks

首先添加上点击上传文件的功能,这个可以直接复用之前单文件多文件上传时的组件中的逻辑,所以我们可以先将之前的上传逻辑抽离成hooks

src/hooks/useFileUploader.ts

import { multipleUploadFile, singleUploadFile, uploadDirectory } from '~/api'

interface Options {
  // 是否进行目录上传
  directory?: boolean
  // formData 的 fieldName
  fieldName?: string
}

export default function useFileUploader(options?: Options) {
  const directory = options?.directory ?? false
  const fieldName = options?.fieldName ?? 'file'

  return (files: FileList | undefined | null) => {
    // 没选择文件则不调用接口
    if (!files || files.length === 0) return

    // 开启目录上传的话则只调用目录上传接口
    if (directory) {
      uploadDirectory(fieldName, files)
      return
    }

    if (files.length > 1) {
      // 超过 1 个文件则用多文件上传
      multipleUploadFile(fieldName, files)
    } else {
      // 获取选择的第一个文件并调用接口上传
      if (files.item(0) !== null) {
        singleUploadFile('file', files.item(0) as File)
      }
    }
  }
}

然后替换掉前面我们的文件上传组件的相应逻辑代码

// 文件上传表单元素
const uploadFileRef = ref<HTMLInputElement>()
const uploadFile = useFileUploader({ directory: props.webkitdirectory })

const handleInputFileConfirm = () => {
  if (uploadFileRef.value?.files) {
    uploadFile(uploadFileRef.value?.files)
  }
}

这样上传文件的逻辑就可以直接在新的拖拽上传文件组件中复用了,是不是特别方便呢!

将上传文件逻辑复用到新组件中

由于我们已经抽离了上传文件的逻辑了,现在直接复用即可

<script setup lang="ts">
import { useFileUploader } from '~/hooks'

interface Props {
  // 是否多文件上传
  multiple?: boolean
  // 上传文件的 MIME type
  mime?: string
  // 是否要开启目录上传 -- 非标准特性 有兼容性问题
  webkitdirectory?: boolean
}

// withDefaults 可以解决类型声明 props 类型的时候无法声明默认值的问题
const props = withDefaults(defineProps<Props>(), {
  // 默认单文件上传
  multiple: false,
  // 默认只支持上传图片
  mime: 'image/*',
  // 默认不开启目录上传
  webkitdirectory: false,
})

const uploadFileRef = ref<HTMLInputElement>()
const uploadFile = useFileUploader({ directory: props.webkitdirectory })
</script>

<template>
  <input
    display-none
    :multiple="multiple"
    :accept="mime"
    :webkitdirectory="webkitdirectory"
    type="file"
    ref="uploadFileRef"
    @input="uploadFile(uploadFileRef?.files)"
  />
  <div
    flex-col
    flex-center
    w-xl
    h-sm
    border-1
    border-dashed
    rounded-3
    border-gray-6
    cursor-pointer
    hover:border-cyan
    transition-200
    @click="uploadFileRef?.click()"
  >
    <div i-carbon:cloud-upload text-16></div>
    <span>Drop file here or <span text-cyan>click to upload</span></span>
  </div>
</template>

这样一来点击上传文件的逻辑就和之前一样了,接下来我们可以开始实现拖拽上传的逻辑了

使用自定义指令让元素变成可放置

我们现在要让虚线区域的元素变成拖拽元素的可放置区域,需要阻止几个拖拽事件的默认行为,才能让虚线区域的元素变成可放置元素

可以使用vue的事件修饰符.stop.prevent去完成,但是由于有四个事件dragenterdragoverdragleavedrop都要设置,也就意味着通过声明式的写法的话,我们得写八个这样的事件修饰符,显得有些冗余

这时候就可以让自定义指令发挥用处了,可以自定义一个v-droppable指令,将应用到的元素的这四个事件的默认行为阻止掉,并阻止它们冒泡

src/directives/vDroppable.ts

import type { Directive } from 'vue'

/**
 * @description 让元素变得可放置
 */
export const vDroppable: Directive<HTMLElement> = (el, binding) => {
  const preventDefaultAndStopPropagation = (e: Event) => {
    e.preventDefault()
    e.stopPropagation()
  }

  ;(
    [
      'dragenter',
      'dragover',
      'dragleave',
      'drop',
    ] as (keyof HTMLElementEventMap)[]
  ).forEach(eventName => {
    el.addEventListener(eventName, preventDefaultAndStopPropagation, false)
  })
}

为方便看到效果,我们先弄一个draggable的元素在页面上,然后尝试将它拖拽到上传区域中

<div w-30px h-30px bg-cyan draggable></div>

拖拽元素.gif

可以看到,并不能将元素拖拽进去,那么接下来将我们的v-droppable自定义指令套用上去后呢?

  <div
    ...
+   v-droppable
  >
    <div i-carbon:cloud-upload text-16></div>
    <span>Drop file here or <span text-cyan>click to upload</span></span>
  </div>

拖拽元素.gif

可以看到,上传区域现在变成了可放置元素了,接下来我们再实现以下元素拖进来时,让上传区域高亮的效果

检测到元素被拖拽进入时让上传区域高亮

这个功能我们只需要在dragenterdragover的时候给上传区域元素添加一个highlight类名,并为其编写样式即可让其高亮,而dragleavedrop的时候将highlight类名移除即可

这完全可以作为v-droppable自定义指令的特性,我们可以通过指令修饰符的方式,添加一个highlight修饰符来实现,而为了提高自定义能力,我们可以允许用户自定义高亮时添加的类名,这个可以通过自定义指令的value实现,代码如下

import type { Directive } from 'vue'

type EventNameList = (keyof HTMLElementEventMap)[]

const preventDefaultAndStopPropagation = (e: Event) => {
  e.preventDefault()
  e.stopPropagation()
}

/**
 * @description 批量注册事件监听器
 * @param eventNameList 事件名列表
 * @param handler 事件处理函数
 */
const batchAddEventListener = (
  el: HTMLElement,
  eventNameList: EventNameList,
  handler: (...args: any[]) => any,
  useCapture?: boolean,
) => {
  useCapture = useCapture ?? false
  eventNameList.forEach(eventName =>
    el.addEventListener(eventName, handler, useCapture),
  )
}

// 让元素变得可放置
const makeElDroppable = (el: HTMLElement) => {
  batchAddEventListener(
    el,
    ['dragenter', 'dragover', 'dragleave', 'drop'],
    preventDefaultAndStopPropagation,
  )
}

/**
 * @description 有可拖拽元素进入时让可放置元素高亮
 * @param el 元素
 * @param highlightClassName 高亮时添加的类名
 */
const makeElHighlight = (el: HTMLElement, highlightClassName: string) => {
  // 进入和移动时添加 highlightClassName
  batchAddEventListener(el, ['dragenter', 'dragover'], () => {
    el.classList.add(highlightClassName)
  })

  // 离开和放下时移除 highlightClassName
  batchAddEventListener(el, ['dragleave', 'drop'], () => {
    el.classList.remove(highlightClassName)
  })
}

/**
 * @description 让元素变得可放置
 */
export const vDroppable: Directive<
  HTMLElement,
  { highlightClassName?: string } | undefined
> = (el, binding) => {
  // 默认不高亮
  const highlight = binding.modifiers?.highlight ?? false
  // 高亮时添加的类名 -- 交给外界自定义类名及其样式 提高通用性
  const highlightClassName = binding.value?.highlightClassName ?? 'highlight'

  makeElDroppable(el)

  console.log(highlight)
  if (highlight) {
    makeElHighlight(el, highlightClassName)
  }
}

接下来我们在使用自定义指令的地方加上v-droppable.highlight修饰符即可启用高亮功能

  <div
    ...
    v-droppable.highlight
  >
    <div i-carbon:cloud-upload text-16></div>
    <span>Drop file here or <span text-cyan>click to upload</span></span>
  </div>

如果希望自定义高亮时添加的类名,就给它赋值

  <div
    ...
    v-droppable.highlight="{ highlightClassName: 'custom-highlight' }"
  >
    <div i-carbon:cloud-upload text-16></div>
    <span>Drop file here or <span text-cyan>click to upload</span></span>
  </div>

然后为.custom-hightlight类名定义样式,我们模仿element-plus的拖拽上传组件样式

.custom-highlight {
  background: rgba(64, 158, 255, 0.2);
  border-color: rgb(64, 158, 255);
}

效果如下:

拖拽元素.gif

接下来只要给拖拽区域元素的drop事件触发时执行uploadFile即可

  <div
    ...
    @drop="e => uploadFile(e.dataTransfer?.files)"
  >

现在我们来拖拽两张图片进来看看

拖拽元素.gif

image.png

可以看到成功上传,至此拖拽上传功能就实现啦!