手写Upload组件带进度条,不重复造轮子 但得会造轮子
前言
组件库这么多为什么还要自己写,我觉得如下图一样,能用但和需求匹配得不完美,但更多还是驱动自己去学习吧,还是那句话 “不重复造轮子 但得会造轮子”,不做API程序员。

- 至少有个基本的组件样式
- 文件选择 接受的文件格式 多选 文件大小、数量上限等
- 上传状态 进度条
- 删除
实现的大致步骤
- 组件交互样式(此处只以图片为例)
- input 选择文件
- 文件上传 onUploadProgress参数(axios)实现上传进度
- URL.createObjectURL(file) 展示图片
效果预览
实现打开文件选择
使用 <input type="file" /> 打开文件选择,但咱们只使用不挂载它 同时可以设置文件接受的格式accept, multiple 多选单选。
input.oninput的回调中使用 input.files就能拿到包含一系列 File
对象的 FileList
对象
然后就可以开始上传做逻辑
/**
* 打开本地文件
* @param { String } accept 文件格式
* @param { Boolean } false 是否多选
* @return { Promise } files 文件列表
*/
const openLocalFolder = (accept = '*', multiple = false) => new Promise((resolve, reject) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.multiple = multiple
try {
input.oninput = () => {
resolve(input.files)
}
} catch (error) {
reject(error)
}
input.click()
})
文件数量、大小、类型检查
- 数量检查,这个就是用openLocalFolder拿到的files.length加已经上传的文件数量之和判断一下就行
// 来源于 props
const { limit } = this
// 文件数量检查
if ((this.files.length + newFiles.length) > limit) {
this.$notify.error(`最多上传${limit}个文件`)
return false
}
- 文件类型检查 从file.name拿到后缀名,再与指定的accept做比较就行。 我这是用逗号隔开多个文件格式 例: accept=".jpg,.png,.jpeg,.gif"
// 检查文件类型
checkFileType (file) {
// 来源于 props
let { accept } = this
if (!accept || accept === '*') {
return true
}
accept = accept.toLowerCase().replace(/\s/gu, '') // 转小写 去除空格
const acceptTypeList = accept.split(',')
const fileType = file.name.match(/\.\w*$/u)[0]
return acceptTypeList.includes(fileType.toLowerCase())
},
- 文件大小检查 大小检查就拿每个file.size与设置的limitSize做比较, 注意这里file.size 单位是字节,所以咱们设置的limitSize也需是字节单位
file.size <= this.limitSize
文件加工
openLocalFolder拿到文件列表并完成检查后 使用for of开始循环处理上传
步骤:
- 将file包进FormData
- copy一个文件副本并混入一些额外数据
- fileKey 供vue for in 做 :key
- status文件状态
- temporaryPath 将file生成一个存在于内存中的文件路径,给img展示
- total 文件总大小
- loaded 已执行大小,根据这两个属性计算上传进度
- lengthComputable表示文件是否可计算,以上三个数据均来源于 axios onUploadProgress方法的回调
- 然后将处理好的copyFile放入文件列表,formData 给接口上传
// 上传点击
handleUpload () {
// 这些是来着props的配置
const { accept, reqParams = {}, multiple } = this
openLocalFolder(accept, multiple).then(files => {
if (!this.filesCheck(files)) {
return
}
// 循环上传
for (const file of files) {
const fileData = new FormData()
// 添加额外参数
Object.keys(reqParams).forEach(key => fileData.append(key, reqParams[key]))
fileData.append('file', file)
const fileKey = getUniqueKey(18)
const copyFile = {
status: 'uploading',
message: '上传中...',
fileKey,
total: 0,
loaded: 0,
lengthComputable: false,
temporaryPath: URL.createObjectURL(file),
...file
}
this.files.unshift(copyFile)
this.asyncFileUpload(fileData, fileKey)
}
})
},
为什么要URL.createObjectURL(file)创建本地文件引用
不使用的步骤
文件上传->上传后服务端返回URl->浏览器使用URL发起资源请求-> 请求成功后再展示
弊端如下
- 上传中没有图片展示
- 没必要的资源请求
- 回显会存在图片闪动
使用后 效果一目了然
注意!!! URL.createObjectURL(file) 创建的文件引用是和
document
生命周期绑定,不需要的引用需要手动 调URL.revokeObjectURL() 静态方法用来释放哦
接口上传
代码中 reqApiFunction第二个参数就是axios的onUploadProgress上传进度回调方法 然后不断更新咱们文件列表中对应文件的 total, loaded两个字段,计算百分百就得到进度了,
上传接口的then中更新 status,改变界面交互状态,设置服务器后返回url 文件名等等数据
catch做上传失败逻辑,同样改变status,我这里上传失败就直接移除了对应文件,同学们可以做重新上传等一些逻辑
/**
* 文件上传
* @param {FormData} formData FormData
* @param {String} fileKey 文件Key
*/
asyncFileUpload (formData, fileKey) {
// props传入的文件上传接口
const { action } = this
// 请求方法
const reqApiFunction = action || uploadFile // 兜底上传方法
//
reqApiFunction(formData, ({ total, loaded, lengthComputable }) => {
const originFile = this.files.find(file => file.fileKey === fileKey)
originFile.lengthComputable = total
if (lengthComputable) {
originFile.total = total
originFile.loaded = loaded
}
}).then(res => {
const originFile = this.files.find(file => file.fileKey === fileKey)
originFile.url = this.getFileUrl(res)
originFile.status = 'done'
originFile.name = res.name
originFile.message = '完成'
this.$emit('uploadSuccess', res)
this.updateFileList(this.files)
}).catch(() => {
const originFile = this.files.find(file => file.fileKey === fileKey)
URL.revokeObjectURL(originFile.temporaryPath)
this.$notify.error('文件上传失败')
originFile.status = 'error'
originFile.message = '上传失败'
this.files = this.files.filter(file => file.url !== originFile.url)
})
},
// 更新 v-model
updateFileList (newList) {
this.$emit('input', JSON.parse(JSON.stringify(newList)))
},
完整代码 复制可用
这里只写了图片的展示样式,若需增加其他如文件展示 同学们可自行扩展
<template>
<div class="upload-container">
<div v-for="file in files" :key="file.url" class="card-item-box">
<img :src="file.temporaryPath || file.url" preview fit="contain" class="card-img">
<div v-show="hasDelete && !disabled" title="删除" class="delete-box" @click="handleDeleteImg(file)">
<i class="el-icon-close" />
</div>
<div v-show="file.status === 'uploading'" class="uploading-box">
<el-progress v-if="file.lengthComputable" type="circle" :width="50" :percentage="uploadPercentage(file)" />
<template v-else>
<i class="el-icon-loading" />
{{ file.message }}
</template>
</div>
</div>
<div
v-if="files.length < limit && !disabled"
title="点击上传"
class="card-action-box"
@click="handleUpload"
>
<i class="icon el-icon-upload" />
</div>
</div>
</template>
<script>
/**
* 获取区间随机数
* @param {Number} start
* @param {Number} end
* @returns
*/
const getRandomNumber = (start, end) => Number.parseInt((Math.random() * (end - start)) + start)
/**
* 生成随机key
* @param {Number} length key长度
* @returns
*/
const getUniqueKey = (length = 10) => {
const num = '0123456789'
const lowercase = 'abcdefghijklmnopqrstuvwxyz'
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const chars = `${num}${lowercase}${uppercase}`
const end = chars.length
let result = ''
for (let i = 0; i < length; i++) {
result += chars[getRandomNumber(0, end)]
}
return result
}
/**
* 打开本地文件
* @param { String } accept 文件格式
* @param { Boolean } false 是否多选
* @return { Promise } files 文件列表
*/
const openLocalFolder = (accept = '*', multiple = false) => new Promise((resolve, reject) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.multiple = multiple
try {
input.oninput = () => {
resolve(input.files)
}
} catch (error) {
reject(error)
}
input.click()
})
export default {
props: {
// 上传方法
uploadFun: {
type: Function,
default: null,
},
// 文件类型 例: '.jpg,png' | 'audio/*'
// 参照 https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#unique_file_type_specifiers
accept: {
type: String,
default: '*',
},
// 是否多选
multiple: {
type: Boolean,
default: false,
},
// 最大文件数量
limit: {
type: Number,
default: 1,
},
// 域,上传文件name
field: {
type: String,
default: 'file',
},
// 上传时的其他参数对象
reqParams: {
type: Object,
default: () => ({}),
},
// 文件列表或文件url
value: {
type: [Array, String],
default: () => [],
},
// 文件大小限制
limitSize: {
type: Number,
default: 50 * 1024 * 1024,
},
// 是否可删除
hasDelete: {
type: Boolean,
default: true,
},
// 是否禁用
disabled: {
type: Boolean,
default: false,
},
// 展示类型 card
actionType: {
type: String,
default: 'card',
},
},
data () {
return {
dialogVisible: false,
dialogImageUrl: '',
loading: false,
files: null,
}
},
watch: {
value: {
immediate: true,
handler () {
const { value } = this
if (!value || !value.length) {
this.files = []
return
}
if (Array.isArray(value)) {
this.files = value.concat([])
return
}
this.files = [value]
},
}
},
beforeDestroy () {
this.files?.length && this.handleRevokeObjectURL(this.files)
},
methods: {
// 移除URL.createObjectURL 在内存中的地址
handleRevokeObjectURL (list) {
list.forEach(({ temporaryPath }) => {
temporaryPath && URL.revokeObjectURL(temporaryPath)
})
},
uploadPercentage ({ total, loaded }) {
return parseInt(loaded / (total / 100))
},
// 图片上传
handleUpload () {
const { accept, reqParams, field, multiple } = this
openLocalFolder(accept, multiple).then(files => {
if (!this.filesCheck(files)) {
return
}
// 循环上传
for (const file of files) {
const fileData = new FormData()
// 添加额外参数
Object.keys(reqParams).forEach(key => fileData.append(key, reqParams[key]))
fileData.append(field, file)
const fileKey = getUniqueKey(18)
const copyFile = {
status: 'uploading',
message: '上传中...',
fileKey,
total: 0,
loaded: 0,
lengthComputable: false,
temporaryPath: URL.createObjectURL(file),
...file
}
this.files.unshift(copyFile)
this.asyncFileUpload(fileData, fileKey)
}
})
},
/**
* 文件上传
* @param {FormData} formData FormData
* @param {String} fileKey 文件Key
*/
asyncFileUpload (formData, fileKey) {
const { uploadFun } = this
// 请求方法
uploadFun(formData, ({ total, loaded, lengthComputable }) => {
const originFile = this.files.find(file => file.fileKey === fileKey)
originFile.lengthComputable = total
if (lengthComputable) {
originFile.total = total
originFile.loaded = loaded
}
}).then(res => {
const originFile = this.files.find(file => file.fileKey === fileKey)
originFile.url = this.getFileUrl(res)
originFile.status = 'done'
originFile.name = res.name
originFile.message = '完成'
this.$emit('uploadSuccess', res)
this.updateFileList(this.files)
}).catch(({ data: { isShowMsg, code, msg } = {} }) => {
const originFile = this.files.find(file => file.fileKey === fileKey)
URL.revokeObjectURL(originFile.temporaryPath)
isShowMsg || this.$notify.error(`文件上传失败 ${code || ''}`)
console.log('文件上传失败', msg)
originFile.status = 'error'
originFile.message = '上传失败'
this.files = this.files.filter(file => file.url !== originFile.url)
})
},
// 获取文件路径
getFileUrl (res) {
let url = res
if (typeof res === 'object') {
// eslint-disable-next-line
url = res.url
}
return url
},
// 图片删除
handleDeleteImg (file) {
this.files = this.files.filter(item => item.url !== file.url)
this.updateFileList(this.files)
this.$emit('remove', file)
},
// 文件列表检查
filesCheck (files) {
const { limitSize, limit, accept } = this
// 文件数量检查
if ((this.files.length + files.length) > limit) {
this.$notify.error(`最多上传${limit}个文件`)
return false
}
for (const file of files) {
if (!this.checkFileType(file)) {
this.$notify.error(`请上传${accept}等格式`)
return false
}
if (!this.checkSize(file)) {
this.$notify.error(`文件大小不能超过 ${limitSize / 1024 / 1024}MB!`)
return false
}
}
return true
},
// 更新 value
updateFileList (newList) {
this.$emit('input', JSON.parse(JSON.stringify(newList)))
},
// 检查文件大小
checkSize ({ size }) {
return size <= this.limitSize
},
// 检查文件类型
checkFileType (file) {
let { accept } = this
if (!accept || accept === '*') {
return true
}
accept = accept.toLowerCase().replace(/\s/gu, '') // 转小写 去除空格
const acceptTypeList = accept.split(',')
const fileType = file.name.match(/\.\w*$/u)[0]
return acceptTypeList.includes(fileType.toLowerCase())
},
},
}
</script>
<style lang="scss" scoped>
.upload-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-items: flex-start;
.card-item-box {
position: relative;
width: 100px;
height: 100px;
border: 1px dotted #dcdfe6;
border-radius: 2px;
overflow: hidden;
margin: 0 10px 10px 0;
.card-img {
object-fit: cover;
height: 100%;
width: 100%;
}
.delete-box {
padding: 2px 5px;
position: absolute;
right: 0;
top: 0;
line-height: normal;
background-color: #000;
color: #fff;
border-radius: 50%;
transform: translate(40%, -40%);
.el-icon-close {
font-size: 12px;
transform: translate(-30%, 20%);
}
}
}
.card-action-box {
display: flex;
flex-flow: row nowrap;
justify-content: center;
align-items: center;
width: 100px;
height: 100px;
border: 1px dotted #dcdfe6;
border-radius: 2px;
overflow: hidden;
margin-bottom: 10px;
cursor: pointer;
.icon {
font-size: 20px;
}
&:hover {
background-color: #f5f7fa;
}
}
.uploading-box {
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
color: #fff;
background-color: rgba(255, 255, 255, 0.7);
}
.progress-box {
height: 100%;
position: relative;
.el-progress {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
}
}
}
</style>
转载自:https://juejin.cn/post/7240996011547492389