前言
【同事 A
】:你知道 Element UI
的 Upload
组件怎么实现 批量拖拽上传 吗?现有的二次封装的 Upload
组件只能做到 批量点击上传 。
【我
】:Show me your code !!!
【同事 A
】:代码地址是 https://git.i-have-no-idea.com/what-the-hell
【我
】:就冲这个地址,就算是天天 CRUD
的我,也高低给你整一篇水文 ~~
同事 A
接到的大致需求是这样的:
- 当用户同时选择单个文件时,该文件会经过识别后需要生成一个对应 待填写的信息项,并将识别的内容进行自动填充和供用户手动编辑
- 当用户同时选择多个文件时,这多个文件会被作为同一个 待填写的信息项 的内容进行显示
接口设计
的内容大致如下:
- 前端每调用一次该识别接口,就会返回一个新的 待填写的信息项 数据,其中会包含本次上传的 单个或多个 文件信息
- 意味着生成 待填写的信息项 应该只调用一次上传接口,多次调用则会得到多个 待填写的信息项
下面就基于 Element UI
的 Upload
组件进行二次封装,一边封装一边复现上述问题,然后在解决问题,主要内容包含 批量点击上传 和 批量拖拽上传 两种方式。
自定义上传方式
该项目涉及核心技术为:vue@2.6.10 + vue-property-decorator@8.3.0 + element-ui@2.15.1
,下面省略创建测试项目的过程 ~ ~
默认上传方式
熟悉 Element UI
的都知道 Upload
组件默认的上传方式是为每一个单独的文件发送单独的请求,大致如下:
这显然和上述的需求不一致,因此自定义上传方式是必然的。
自定义上传
Upload
组件提供给了对应的 auto-upload
选项便于使用者能够自定义上传时机,同时为了能够更好的控制上传逻辑,还得使用 http-request
选项来覆盖其默认的上传行为,这样便于我们自定义上传的实现。
当然除这些之外,在 vue
中一般基于二次封装的组件,可以直接通过 $attrs 的方式来接收外部传入的在 父组件 中不作为 prop
被识别的 attribute
绑定,除了 class
和 style
之外,也就是直接使用 属性继承。
于是就得到了一个最基本的二次封装后 <EasyUpload />
组件的大致内容:
<template> <div class="easy-upload"> <el-upload :ref="refName" :name="aliasName" v-bind="$attrs" :auto-upload="false" :http-request="httpRequest" :on-change="onChange"> <slot></slot> </el-upload> </div> </template> <script lang="ts"> import Vue from 'vue'; import { Component } from 'vue-property-decorator'; import { post } from '@utils/request'; import ElementUI from 'element-ui'; // 定义一个自增的 id ,避免在同一个组件中多次使用 EasyUpload 造成 ref 和 name 重复 let _uploadId_ = 0; @Component({}) export default class EasyUpload extends Vue { refName = '_upload_ref_'; aliasName = '_upload_name_'; created() { this.initConfig(); } // 初始化组件数据 initConfig() { if (this.$attrs.name) this.aliasName = this.$attrs.name; // 保证 refName 和 <input> 的 name 属性值在父组件中唯一 this.refName += _uploadId_; this.aliasName += _uploadId_; _uploadId_++; } formatParams(file) { const formData = new FormData(); // 文件相关参数 formData.append(this.$attrs.name || 'file', file); // 额外参数 const { data } = this.$attrs; if (data) { Object.keys(data).forEach((key) => { formData.append(key, data[key]); }); } return formData; } async httpRequest(options: any) { const formData = this.formatParams(options.file); const res = await post(this.$attrs.action, formData, true); // do something (this.$refs[this.refName] as ElementUI.Upload).clearFiles(); } onChange(file, fileList) { // 触发上传逻辑 (this.$refs[this.refName] as ElementUI.Upload).submit(); } } </script> <style scoped lang="less"> @import './index.less'; </style> 复制代码
批量点击上传
显然,以上的实现根本满足不了 批量点击上传,因为我们在 onChange
直接调用了 submit
来实现和直接使用 el-upload
差不多的上传方式,既然如此我们只要在 onChange
只调用一次 submit
方法即可,判断方法很简单:
- 首先要获取用户当前一共选中了多少文件数量,即 总数量
- 每次触发
onChange
时,把当前的file
对象保存到uploadedFiles
中,直到uploadedFiles
中的文件数量和总数量一致时,在手动触发submit
方法
怎么获取当前用户获取文件的总数量?
别着急,咱们先审查元素看看 el-upload
到底渲染的是个啥?相信你大概率已经猜到了,其实就是 type="file"
的 <input />
元素,它本身也支持多选(即设置multiple="multiple"
),具体如下:
更重要的是 input
元素的 onchange
中可以获取到对应的文件列表对象 files
,即通过 event.target.files
来获取,而这就是用户选择文件的总数量
通过 onChange 控制 submit
经过改写的 onChange
如下所示,没有什么太大的难点,直接上代码:
为什么不用
onChange
中的fileList
参数呢?因为多选的情况下每次onChange
被执行,其中的fileList
就只会增加一条数据,而不是所有的数据,因此没办法根据其值来进行判断.
<script lang="ts"> import Vue from 'vue'; import { Component } from 'vue-property-decorator'; import { post } from '@utils/request'; import ElementUI from 'element-ui'; // 定义一个自增的 id ,避免在同一个组件中多次使用 EasyUpload 造成 ref 和 name 重复 let _uploadId_ = 0; @Component({}) export default class EasyUpload extends Vue { inputFiles: File[] = []; uploadedFiles: File[] = []; ... created() { this.initConfig(); } // 初始化组件数据 initConfig() { ... } formatParams(file) { .... } async httpRequest(options: any) { const formData = this.formatParams(options.file); const res = await post(this.$attrs.action, formData, true); // do something (this.$refs[this.refName] as ElementUI.Upload).clearFiles(); } onChange(file, fileList) { // 将当前文件保存到 uploadedFiles 中 if (file.status == 'ready') { this.uploadedFiles.push(file.raw); } // 只赋值一次,因为 input 元素上的 files 就是本次用户选中的所有文件 if (this.inputFiles.length === 0) { this.inputFiles = Array.from((<HTMLInputElement>document.getElementsByName(this.aliasName)[0]).files || []); } console.log(' ================ onChange trigger ================ '); console.log('inputFiles.length = ', this.inputFiles.length, 'uploadedFiles.length = ', this.uploadedFiles.length); // 触发上传逻辑 if (this.inputFiles.length === this.uploadedFiles.length) { (this.$refs[this.refName] as ElementUI.Upload).submit(); } } } </script> 复制代码
来测试一下看看效果吧!
可以看到 onChange
事件中的判断没有问题,既获取到了总文件数量,也保存了当前的文件,最终的判断条件也是没问题的,但是为什么还是调用了多次的接口呢?
这个不扯别的,直接上源码,因为内容太简单了,文件路径:element-ui\packages\upload\src\upload.vue
原因超级简单,上面我们控制的是 submit
的执行次数,但是源码中是直接从已上传的文件列表中通过遍历的方式来依次调用 upload
方法,其中是否调用上传方法是根据 beforeUpload
的返回值来决定的(关于这一点在文档中也有说明):
通过 beforeUpload 控制上传
beforeUpload
是上传文件之前的钩子,参数为上传的文件,若返回 false
或者返回 Promise
且被 reject
会停止上传,于是可以有如下的实现:
<template> <div class="easy-upload"> <el-upload :ref="refName" :name="aliasName" v-bind="$attrs" :auto-upload="false" :before-upload="beforeUpload" :http-request="httpRequest" :on-change="onChange"> <slot></slot> </el-upload> </div> </template> <script lang="ts"> import Vue from 'vue'; import { Component } from 'vue-property-decorator'; import { post } from '@utils/request'; import ElementUI from 'element-ui'; import { lang } from 'moment'; let _uploadId_ = 0; @Component({}) export default class EasyUpload extends Vue { uploadedFiles: File[] = []; inputFiles: File[] = []; refName = '_upload_ref_'; aliasName = '_upload_name_'; created() { this.initConfig(); } // 初始化组件数据 initConfig() { if (this.$attrs.name) this.aliasName = this.$attrs.name; this.refName += _uploadId_; this.aliasName += _uploadId_; _uploadId_++; } formatParams() { const formData = new FormData(); // 文件相关参数 this.uploadedFiles.forEach((file) => { formData.append(this.$attrs.name || 'file', file); }); // 额外参数 const { data } = this.$attrs; if (data) { Object.keys(data).forEach((key) => { formData.append(key, data[key]); }); } return formData; } async httpRequest(options: any) { const formData = this.formatParams(); const res = await post(this.$attrs.action, formData, true); (this.$refs[this.refName] as ElementUI.Upload).clearFiles(); } beforeUpload() { // 是否需要调用上传接口 return this.uploadedFiles.length === this.inputFiles.length; } onChange(file, fileList) { if (file.status === 'ready') { this.uploadedFiles.push(file.raw); } // 只赋值一次,因为 input 元素上的 files 就是本次用户选中的所有文件 if (this.inputFiles.length === 0) { this.inputFiles = Array.from((<HTMLInputElement>document.getElementsByName(this.aliasName)[0]).files || []); } (this.$refs[this.refName] as ElementUI.Upload).submit(); } } </script> 复制代码
大致效果如下: