欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

vue3 实现自定义文件上传复选框、图像预览、文件上传

最编程 2024-05-18 17:19:52
...

序言

最近在倒腾一个有关文件上传的小项目,使用到了 koa 搭建服务器, vue3 来完成页面开发。过程中磕磕绊绊,但最后总算是完成了功能实现,并且对 node 环境下的文件上传流程有了一定了解。故此,写下这篇笔记以记录摸索的过程。

本文是前端部分,后端部分已更新

后端篇

自定义文件选择框

在开始考虑做文件上传的功能的时候,就一直好奇原生的 input 那么丑(此处包括下文的 input 都是指 type='file' 的情况),并且,我们都知道 input 是个非常特殊的 html标签 ,它允许被修改的样式其实很有限,可我们见到的文件上传选择框各式各样,它是如何被做成五花八门的炫酷的样式的呢?

其实,对于 input 样式的自定义,我们需要放弃在 input 元素身上直接动刀子的想法,这个就是改不了!!!但我们可以转换思路,既然不能改 input 的样式,那么我们就把它藏起来,然后用个好看的组件来代替不久好了吗?

<template>
  <div>
    <input
      ref="fileInput"
      type="file"
      placeholder="选择你的文件"
      style="display:none;"
      multiple
    >
    <div class="file-input">
      <i class="select-file iconfont">&#xe668;</i>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.file-input {
  display: flex;
  flex-direction: row;
  padding: 12px;

  .select-file {
    width: 100px;
    height: 100px;
    color: #eee;
    font-size: 70px;
    line-height: 100px;
    text-align: center;
    background-color: white;
    border: 1px solid #ddd;
    border-radius: 8px;
  }
}
</style>

因此,我们给 input 设置上 display:none ,那么在页面上,这个 input 就不可见了。我们另外加了一个元素(这里使用 iconfont 来展示图标),给它设置上想要的样式,一样,页面上就只剩下我们期望的文件上传选择框:

cutom-select-1.PNG

但这样还没完。对于文件选择框,当我们点击后,会弹系统提供的文件选择的弹窗,但单机我们自定义的组件,并没有这个效果。

其实要实现这个效果,也很简单,我们点击默认样式的 input 的时候,其实就是触发了这个 input 元素的 click 事件。那么我们只需要在用户点击了我们自定义的文件选择框的组件的时候,手动地触发本我们隐藏了的 inputclick 的事件就可以了:

<script lang="ts" setup>
import { onMounted, ref } from "vue"

const fileInput = ref<HTMLInputElement>()

/** 点击模拟的文件选择触发按钮,触发真实的文件选择的响应事件 */
const clickFileIput = () => {
  fileInput.value?.click()
}
</script>

<template>
  <!-- ... -->
  <i class="select-file iconfont" @click="clickFileIput">&#xe668;</i>
  <!-- ... -->
</template>

<style lang="scss" scoped>
/* ... */
</style>

上面的代码中,我们给自定义的文件选择框添加了一个点击事件,然后在这个点击事件当中,我们获取了 ref 中的 input 的元素,然后执行 click 这个事件方法,此时,点击后便会弹出系统的文件选择弹窗了。

而用户选择并确认了文件后,会触发 input 上的 change 事件,然后,我们可以在 input 元素上拿到一个 files 的属性,这个属性中保存的便是用户这次选择的文件封装的对象。

这就便于我们手动地对用户选择文件这一行为进行管理。

比方说,用户每次选择文件并确认后,浏览器地行为是替换 input 上的 files 属性,这会导致用户每次选择的结果并没有关联,如果选择的文件很多,那么用户就必须一次性选中需要需要的文件。这会降低用户的体验。

我们期望的是,用户可以分多次选择所有文件的子集,而我们的代码帮用户完成将子集合并成全集的过程,所以我们来优化一下代码:

<script lang="ts" setup>
// ...

const fileMap = ref(new Map<string, File>())

/**
 * 当 input[type='file'] 变化,即用户选择了文件的使用,将其以文件名为 key ,暂时保存在 fileMap 中;
 * 注意, input 上的 files 属性,只保存用户打开了文件选择框,并且选择了文件后,这一时刻的文件
 * 也就是说,用户每次选择执行的都是 replace 的操作,因此,我们使用 map 来维护用户多次选择的所有文件,以及一些手动删除的操作
 */
const onFileChange = function(this: HTMLInputElement) {
  const files = this.files || [];
  for (const file of files) {
    fileMap.value.set(file.name, file)
  }
}

onMounted(() => {
  fileInput.value?.addEventListener("change", onFileChange)
})
// ...
</script>

在这里,我们先来定义一个 fileMap 来保存用户选择的文件,文件名作为 key ,将每个 File 对象作为 value。

然后,我们在页面渲染之后,为 input 的对象添加上 change 事件的监听器 onFileChange (当然,你也可以直接在 input 元素上写上 @change 事件),而在这个监听器中,我们拿到 input 元素上的 files ,然后根据名字保存到 fileMap 中。

这样一来,用户便能分多次选择自己想要上传的文件。

如果需要添加删除的功能,只需要设法移除 fileMap 中保存的文件即可。

实际上,在原生的 input 上,我们每次选择了文件后,会展示我们选择的文件的文件名清单,在当前的代码中,我们选择了文件之后,什么都不会发生。这是因为我想在下文中介绍一下图片选择预览的实现,因此没有单独实现。如果你期望有文件名的清单出现,那么只需要将下文的图片预览的逻辑替换为文字展示即可。

图片预览

既然已经做了自定义文件选择框,不妨顺便也实现一下图片预览好了。

我们来思考一下,实现图片预览大致需要哪几个步骤:

  • 知道用户选择了哪些图片
  • 获取图片源数据
  • 展示图片

这里概括的较为笼统,我们来一步一步详细介绍。

首先,我们如何知道用户选择了哪些文件?其实这一步,在上文中已经介绍到了,我们可以使用一个 fileMap 来保存用户选择的文件,其 键值 是文件名,其 属性值 是 File 对象,所以这一步我们已经完成了。

那么关于图片源数据呢?实际上,这个 File 对象,我们可以打印出来看一下:

file-obj.png

实际上,它已经包含了一个文件的所有基本描述信息,因此,如果它能被转换成一个 blob 对象,那么我们就可以操作它真实的数据了。但实际上,我们并不需要转换成 blob 对象,因为 File 这个类就是继承自 Blob 的,这个我们展开方才打印的对象的原型链便可见一斑:

file-obj-2.png

所以,第二步也迎刃而解了。

最后就到了如何展示图片,我们首先会想到的便是使用 img 标签。没错,这是正确的。但是,我们也知道, img 标签需要设置 src 的属性,才能指定需要展示的图片。

这样一来,问题就转换成了如何将我们选中的图片转换成 img 标签可用的 src 属性了。而 src 这个属性的属性值,是一个 URL 对象,因此,我们只需要想方法使用选中的 File 构建一个 URL 对象即可。

这点,浏览器其实已经为我们做好了工具。

我们可以在全局环境上拿到一个 URL 的类,而这个类上有一个 createObjectURL 方法,接受一个 Blob 实例作为参数,返回一个 URL 对象。这正好就是我们想要的。我们只需要将我们存在 fileMap 中的每个文件描述取到,然后构建成为一个 URL ,然后为对应的 imgsrc 属性设置上这个 URL 即可。

大致代码可以参考如下:

<script lang="ts" setup>
import { defineComponent, onMounted, ref } from "vue"

interface FileWithURL {
  file: File;
  url: string;
}

const fileInput = ref<HTMLInputElement>()
const fileMap = ref(new Map<string, FileWithURL>())

/**
 * 当 input[type='file'] 变化,即用户选择了文件的使用,将其以文件名为 key ,暂时保存在 fileMap 中;
 * 注意, input 上的 files 属性,只保存用户打开了文件选择框,并且选择了文件后,这一时刻的文件
 * 也就是说,用户每次选择执行的都是 replace 的操作,因此,我们使用 map 来维护用户多次选择的所有文件,以及一些手动删除的操作
 */
const onFileChange = function(this: HTMLInputElement) {
  const files = this.files || [];
  for (const file of files) {
    const url = URL.createObjectURL(file)
    fileMap.value.set(file.name, { file, url })
  }
}

/** 点击模拟的文件选择触发按钮,触发真实的文件选择的响应事件 */
const clickFileIput = () => {
  fileInput.value?.click()
}

onMounted(() => {
  fileInput.value?.addEventListener("change", onFileChange)
})
</script>

<template>
  <div>
    <input
      ref="fileInput"
      type="file"
      placeholder="选择你的文件"
      style="display:none;"
      multiple
    >
    <div class="file-input">
      <div
        v-for="item in fileMap.values()"
        :key="item.file.name"
        class="img-preview"
      >
        <img :src="item.url">
      </div>
      <i class="select-file iconfont" @click="clickFileIput">&#xe668;</i>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.file-input{
  display: flex;
  flex-direction: row;
  padding: 12px;

  .img-preview {
    position: relative;
    margin: 0 6px;

    img {
      width: 100px;
      height: 100px;
    }
  }

  .select-file {
    width: 100px;
    height: 100px;
    color: #eee;
    font-size: 70px;
    line-height: 100px;
    text-align: center;
    background-color: white;
    border: 1px solid #ddd;
    border-radius: 8px;
  }
}
</style>

文件上传

做完了一些花里胡哨的交互后,我们回归正题。

对于普通的前后端数据交互,我们一般都是使用 JSON ,因此我们会设置 httpcontent-typeapplication/json

但是,当我们需要上传图片或者其它文件的时候, JSON 是不适合的,我们需要使用到 二进制流 来替代。

而要想一个 http 的请求体表现为 二进制流 ,我们需要设置 content-typemultipart/form-data

但实际上,我们并不需要手动去设置这个请求头部,这个后面会介绍到。

对于我们需要上传的文件的处理,我们需要使用到 FormData 这个对象来构造我们的请求表单。

// ...
const onSubmit = () => {
  const formData = new FormData();
  for (const extendFile of fileMap.value.values()) {
    formData.append(extendFile.file.name, extendFile.file)
  }
  axios.post("your server address", formData)
}
// ...

这里我们定义来一个 onSubmit 的方法来管理我们的数据提交。

在这个方法中,我们从 fileMap 中遍历取出所有的文件对象,通过调用 formDataappend 这个方法把每个文件添加到我们的表单对象中去,然后使用 axios 来发送我们的请求。

实际上到此,前端需要处理的事情已经差不多了。如果你尝试选择一些文件然后发送这个请求,你会看到请求头部 content-type 很正确地设置为了 multipart (以及 boundary ,这个是流中用于分割不同部分数据的),而且我们可以查看控制台中请求的负载,发现文件正确地被编译成了 二进制流

upload-1.png

upload-2.png

首先先来说说数据的部分,在 MDN 中提到这么一句话:

如果送出时的编码类型被设为 "multipart/form-data",它会使用和表单一样的格式。

所以如果设置好 content-type ,那么我们就无需关系 FormData 到 二进制 的转换过程。

那么,又是谁帮我们完成了请求头部的设置呢?

由于这里我使用到了 axios ,那么要么是 axios 进行了头部设置,要么就是浏览器帮我们完成了头部设置。

因此,我从 axios 的源码下手,在 adapter/xhr.js 的文件中找到了这样几行代码:

if (utils.isFormData(requestData)) {
  delete requestHeaders['Content-Type']; // Let the browser set it
}

那么答案很明显了,这是浏览器的默认行为。

到此为止,我们基本完成了上传文件这一功能前端的部分,接下来就剩下后端如果接收我们发送的文件数据,然后保存到服务器的工作了。

总结

  • 自定义文件上传选择框是通过隐藏 input ,使用自己设计的组件作为替代展示,然后在合适的时机手动地触发 inputclick 事件
  • 图片预览需要将文件对象转换成为一个 URL 对象,然后设置给 img 标签的 src 属性
  • 文件上传需要使用 FormData 封装我们的文件数据,然后设置 content-typemultipart/form-data ,但是后者浏览器会帮我们完成

推荐阅读