在 Vue3 + Element Plus 项目中,文件上传是常见的业务需求。然而原生的 ElUpload 组件虽然功能强大,但在实际项目中往往需要大量的重复配置和样式调整。本文将分享如何基于 Element Plus 的 Upload 组件进行深度封装,打造一个开箱即用、高可配置的上传组件。
一、封装背景与痛点
在实际项目开发中,我们经常遇到以下痛点:
- 重复配置:每个上传场景都需要重复配置 headers、action 等基础参数
- 样式统一困难:不同页面的上传样式难以保持一致
- 文件类型校验复杂:需要手动编写各种文件类型和大小的校验逻辑
- 用户体验不佳:缺少统一的加载、错误提示和预览功能
二、组件设计思路
2.1 核心设计原则
- 高度可配置:通过 props 暴露常用配置项
- 样式统一:内置多种预设样式,支持自定义
- 业务解耦:将上传逻辑封装在组件内部
- 类型安全:充分利用 TypeScript 的类型推断
2.2 功能特性
我们的封装组件 LeUpload 具备以下特性:
✅ 文件类型校验:支持图片、指定扩展名、所有文件类型
✅ 大小限制:可配置单个文件大小限制
✅ 数量限制:支持最大上传数量限制
✅ 自定义图标:根据文件类型自动显示对应图标
✅ 预览功能:支持图片预览和文件下载
✅ 尺寸:支持 small、default、large 三种尺寸
三、技术实现详解
3.1 组件 Props 设计
const props = defineProps({
value: {
type: Array as PropType<UploadFile[]>
},
accept: {
type: String,
description: '接受的文件类型,支持 MIME 类型和扩展名'
},
fileType: {
type: String as PropType<'all' | 'image' | 'fileExt'>,
default: 'all',
description: '文件类型:all-所有、image-图片、fileExt-指定扩展名'
},
fileLimit: {
type: Number,
default: 10,
description: '文件上传大小限制(MB)'
},
limit: {
type: Number,
description: '最大上传数量'
},
text: {
type: String,
default: '上传'
},
uploadUrl: {
type: String,
default: () => `${import.meta.env.VITE_APP_BASE_API}/file/upload`
},
tips: {
type: String,
description: '上传提示文字'
},
multiple: {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
},
removeConfirm: {
type: Boolean,
default: false,
description: '删除时是否显示确认框'
},
size: {
type: String as PropType<'small' | 'default' | 'large'>,
default: 'large'
}
})
3.2 智能文件类型识别
组件内置了智能的文件类型识别功能,根据文件扩展名自动显示对应的图标:
const bindProps = computed(() => {
const bind: any = Object.assign({ name: 'file' }, props, attrs)
// 自动设置图片模式的 accept
if (bind.fileType === 'image' && !bind.listType) {
bind.listType = 'picture-card'
}
if (bind.listType === 'picture-card' && !bind.accept) bind.accept = 'image/*'
// 自定义图标渲染
if (!bind.iconRender) {
bind.iconRender = ({ file }) => {
const isLoading = file.status === 'uploading'
const ext = getFileExt(file.url)
let type = 'ep:document'
if (isLoading) type = 'eos-icons:bubble-loading'
else if (isImageByExt(file.url)) return <img class="local_icon-image" src={file.url} />
else if (['.pdf', '.ppt', '.pptx'].includes(ext)) type = 'vscode-icons:file-type-powerpoint2'
else if (['.doc', '.docx'].includes(ext)) type = 'vscode-icons:file-type-word'
else if (['.xls', '.xlsx'].includes(ext)) type = 'vscode-icons:file-type-excel'
return <LeIcon icon={type} />
}
}
return bind
})
3.3 文件上传前置校验
在上传前进行全面的文件校验,包括类型、大小等:
function defaultBeforeUpload(file: UploadRawFile) {
let bool = true
const fileType = props.fileType
// 图片类型校验
if (bindProps.value.accept === 'image/*') {
if (!file.type!.includes('image')) {
ElMessage.warning(t('le.el.upload.acceptImage'))
bool = false
}
}
// 扩展名校验
else if (fileType === 'fileExt') {
const fileSuffix = getFileExt(file.name)
if (props.accept) {
const accepts = getAccepts(props.accept)
if (!accepts.includes(fileSuffix)) {
ElMessage.warning(t('le.el.upload.acceptUpload', [accepts.join(',')]))
bool = false
}
}
}
// 文件大小校验
if (bool && props.fileLimit && file.size / 1024 / 1024 > props.fileLimit) {
ElMessage.warning(t('le.el.upload.maxSize', [props.fileLimit]))
bool = false
}
return bool
}
3.4 统一的事件处理
组件统一处理了上传过程中的各种状态变化:
function handleChange(file: UploadFile, fileList: UploadFile[]) {
const status = file.status
if (status === 'ready') emit('fileChange', file, 'uploading')
if (['success', 'fail'].includes(status as string)) {
const idx = getFileIdx(file, fileList)
if (idx === -1) return
if (file.status === 'success') {
// 接口上传返回结果
const { code, data, msg } = file.response
let emitStatus: EmitStatus = 'success'
if (code !== 200) {
emitStatus = 'fail'
ElMessage.error(msg || t('le.el.upload.uploadErrorTip'))
fileList.splice(idx, 1)
} else {
fileList[idx].url = data
}
emit('fileChange', file, emitStatus)
} else {
ElMessage.error(t('le.el.upload.uploadErrorTip', [file.name]))
fileList.splice(idx, 1)
emit('fileChange', file, 'fail')
}
}
emitValue(fileList)
}
3.5 默认文件上传超出限制
const handleExceed: UploadProps['onExceed'] = (files: File[], uploadFiles: UploadUserFile[]) => {
// 选中的文件数
const selectedNum = files.length
// 可以新增的最大文件数
const addNum = props.limit - selectedNum
if (addNum <= 0) {
// 超过最大上传数量 清空已上传文件
uploadRef.value.clearFiles()
// 保留 后面选中的数据
files.splice(0, Math.abs(addNum))
} else {
// 可以保留的 已上传文件
const leftNum = uploadFiles.length - addNum
if (leftNum > 0) uploadFiles.splice(0, leftNum)
}
files.forEach(_file => {
const file = _file as UploadRawFile
file.uid = getUid()
uploadRef.value!.handleStart(file)
})
// 如果有文件 则提交上传
if (files.length) uploadRef.value!.submit()
}
3.6 文件预览与下载
集成了图片预览和文件下载功能:
// 文件预览
function handlePreview(file) {
// 如果是图片类型
if (file.type?.indexOf('image') >= 0 || isImageByExt(file.url)) {
createImgPreview({ imageList: [file.url], maskClosable: true })
} else {
window.open(file.url)
}
}
// 文件下载
function handleDownload(file: UploadFile) {
commonDownload(file.url as string, file.name)
}
四、使用示例
4.1 基础用法
<template>
<LeUpload v-model:value="fileList" />
</template>
<script setup>
import { ref } from 'vue'
const fileList = ref([])
</script>
4.2 图片上传模式
<LeUpload
v-model:value="fileList"
fileType="image"
:fileLimit="2"
tips="只能上传jpg/png格式图片,且不超过2MB"
/>
4.3 指定文件类型
<LeUpload
v-model:value="fileList"
fileType="fileExt"
accept=".pdf,.doc,.docx"
:fileLimit="10"
tips="请上传PDF、Word文档"
/>
4.4 自定义尺寸和样式
<LeUpload
v-model:value="fileList"
size="small"
listType="picture-card"
/>
五、完整代码
<script lang="tsx" setup>
import type { UploadFile, UploadRawFile, UploadInstance, UploadProps } from 'element-plus'
import { ElIcon, ElMessageBox, ElProgress, ElUpload, UploadUserFile } from 'element-plus'
import { computed, ref, useAttrs } from 'vue'
import type { EmitStatus } from './index'
import { commonDownload, t } from '@/utils'
import LeIcon from '@/components/Icon.vue'
import { ElMessage } from 'element-plus'
import { createImgPreview } from '@/components/Preview/index'
import { getHeaders } from '@/utils/request'
import { getFileExt, getUid, isImageByExt } from '@/utils/file'
import { useNamespace } from '@/hooks/useNameSpace'
defineOptions({ name: 'LeUpload' })
const props = defineProps({
value: {
type: Array as PropType<UploadFile[]>
},
// 接受的文件类型
accept: {
/**
* 通过,拼接的 文件类型
* eg:
* 1. 以.开头的合法文件名扩展名 如: .jpg、.pdf 或 .doc
* 2. MIME 类型字符串 如: image/png、audio/webm (https://developer.mozilla.org/zh-CN/docs/Web/HTTP/MIME_types)
* 3. 字符串 audio/*,表示“任何音频文件”
* 4. 字符串 video/*,表示“任何视频文件”
* 5. 字符串 video/*,表示“任何视频文件”
* 6.字符串 image/*,表示“任何图片文件”
* 7. 通配符 * 匹配所有类型
*
*/
type: String
},
fileType: {
/**
* image: 默认所有图片
* fileExt: 传递具体.xxx(.文件扩展名) 校验需要配置 `accept` 配合使用 如: .jpg,.pdf 或 .doc
* all: 所有
*/
type: String as PropType<'all' | 'image' | 'fileExt'>,
default: 'all'
},
// 文件上传大小限制(MB)
fileLimit: {
type: Number,
default: 10
},
text: {
type: String,
// default: '上传'
default: t('le.el.upload.upload')
},
uploadUrl: {
type: String,
default: `${import.meta.env.VITE_APP_BASE_API}/file/upload`
},
// 提示
tips: {
type: String
},
// 最大上传数量
limit: {
type: Number
},
multiple: {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
},
// 删除时是否显示确认框
removeConfirm: {
type: Boolean,
default: false
},
size: {
type: String as PropType<'small' | 'default' | 'large'>,
default: 'large'
}
})
const emit = defineEmits<{
change: [value: UploadFile[]]
'update:value': [value: UploadFile[]]
fileChange: [file: UploadFile, status: EmitStatus]
}>()
const { prefixCls } = useNamespace('upload')
console.error(prefixCls, 'prefixCls')
const attrs = useAttrs()
const headers = getHeaders()
// refs
const uploadRef = ref<UploadInstance>()
// 是否达到了最大上传数量
const isLimit = computed(() => props.limit > 0 && props.value?.length >= props.limit)
// 合并 props 和 attrs
const bindProps = computed(() => {
const bind: any = Object.assign({ name: 'file' }, props, attrs)
bind.data = { biz: 'temp', ...bind.data }
if (!bind.listType) {
if (bind.fileType === 'image') {
// picture text picture-card
bind.listType = 'picture-card'
} else {
bind.listType = 'text'
}
}
if (bind.listType === 'picture-card' && !bind.accept) bind.accept = 'image/*'
// 用于 渲染自定义图标
if (!bind.iconRender) {
bind.iconRender = ({ file }) => {
const isLoading = file.status === 'uploading'
const ext = getFileExt(file.url)
let type = 'ep:document'
if (isLoading) type = 'eos-icons:bubble-loading'
else if (isImageByExt(file.url)) return <img class="local_icon-image" src={file.url} />
else if (['.pdf', '.ppt', '.pptx'].includes(ext)) type = 'vscode-icons:file-type-powerpoint2'
else if (['.doc', '.docx'].includes(ext)) type = 'vscode-icons:file-type-word'
else if (['.xls', '.xlsx'].includes(ext)) type = 'vscode-icons:file-type-excel'
return <LeIcon icon={type} />
}
}
return bind
})
// 当前是否是上传图片模式
const isPictureCard = computed(() => bindProps.value.listType === 'picture-card')
function getAccepts(accept: string) {
return accept.replace(/\s/g, '').split(',')
}
// 默认 文件上传之前的操作 若有 其他判断 建议自行传beforeUpload 自定义
function defaultBeforeUpload(file: UploadRawFile) {
let bool = true
// console.error('defaultBeforeUpload', file, JSON.stringify(fileList))
const fileType = props.fileType
if (bindProps.value.accept === 'image/*') {
// 当前是否是上传图片模式
if (!file.type!.includes('image')) {
ElMessage.warning(t('le.el.upload.acceptImage'))
bool = false
}
} else if (fileType === 'fileExt') {
const fileSuffix = getFileExt(file.name)
if (props.accept) {
const accepts = getAccepts(props.accept)
if (!accepts.includes(fileSuffix)) {
// 只能上传 {.ppt,.pptx,.doc,.docx,.xls,.xlsx,.pdf,.txt,.jpg,.jpeg,.png,.gif,.bmp}格式文件
ElMessage.warning(t('le.el.upload.acceptUpload', [accepts.join(',')]))
bool = false
}
}
}
if (bool && props.fileLimit && file.size / 1024 / 1024 > props.fileLimit) {
// 单个文件大小不超过{0}MB
ElMessage.warning(t('le.el.upload.maxSize', [props.fileLimit]))
bool = false
}
return bool
}
// 用于 file slot 中的删除
async function handleRemove(file: UploadFile) {
const before = await beforeRemove() // fileList
if (before) {
const fileList = props.value || []
const idx = fileList.findIndex(f => f.uid === file.uid)
idx >= 0 && fileList.splice(idx, 1)
}
}
// 删除处理事件
function beforeRemove(/* file: UploadFile, uploadFiles: UploadFile[] */) {
if (props.removeConfirm) {
return ElMessageBox.confirm(t('le.el.upload.delConfirm'), t('le.el.upload.del'), {
type: 'warning'
})
.then(() => {
return true
})
.catch(() => {
return false
})
}
return true
}
function getFileIdx(file: UploadFile, fileList: UploadFile[]): number {
const uid = file.uid
return fileList.findIndex(f => f.uid === uid)
}
// upload组件change事件
function handleChange(file: UploadFile, fileList: UploadFile[]) {
const status = file.status
// 文件刚上传时 emit('uploading')
if (status === 'ready') emit('fileChange', file, 'uploading')
if (['success', 'fail'].includes(status as string)) {
const idx = getFileIdx(file, fileList)
if (idx === -1) return
// 成功处理
if (status === 'success') {
const { code, data, msg } = file.response
let emitStatus: EmitStatus = 'success'
// 错误处理 code !== 200: 有错误
if (code !== 200) {
ElMessage.error(msg || t('le.el.upload.uploadErrorTip'))
emitStatus = 'fail'
fileList.splice(idx, 1)
} else {
fileList[idx].url = data
}
emit('fileChange', file, emitStatus)
} else {
// 失败
ElMessage.error(t('le.el.upload.uploadErrorTip', [file.name]))
emit('fileChange', file, 'fail')
fileList.splice(idx, 1)
}
}
emitValue(fileList)
}
// 超出限制时 钩子函数
const handleExceed: UploadProps['onExceed'] = (files: File[], uploadFiles: UploadUserFile[]) => {
// 选中的文件数
const selectedNum = files.length
// 可以新增的最大文件数
const addNum = props.limit - selectedNum
if (addNum <= 0) {
// 超过最大上传数量 清空已上传文件
uploadRef.value.clearFiles()
// 保留 后面选中的数据
files.splice(0, Math.abs(addNum))
} else {
// 可以保留的 已上传文件
const leftNum = uploadFiles.length - addNum
if (leftNum > 0) uploadFiles.splice(0, leftNum)
}
files.forEach(_file => {
const file = _file as UploadRawFile
file.uid = getUid()
uploadRef.value!.handleStart(file)
})
// 如果有文件 则提交上传
if (files.length) uploadRef.value!.submit()
}
// 预览文件、图片
function handlePreview(file) {
if (file.type?.indexOf('image') >= 0 || isImageByExt(file.url)) {
createImgPreview({ urlList: [file.url] })
} else {
window.open(file.url)
}
}
function handleDownload(file: UploadFile) {
commonDownload(file.url as string, file.name)
}
function emitValue(value: UploadFile[]) {
emit('change', value)
emit('update:value', value)
}
defineExpose({
uploadRef
})
</script>
<template>
<div :class="`${prefixCls}-container ${prefixCls}-container--${isLimit ? 'limit' : 'normal'} ${prefixCls}-container--${size}`">
<slot name="tips">
<div v-if="tips" class="tip my-7px text-12px text-#8a8886">{{ tips }}</div>
</slot>
<ElUpload
ref="uploadRef"
:headers="headers"
:action="uploadUrl"
v-bind="bindProps"
:before-upload="bindProps.beforeUpload || defaultBeforeUpload"
:file-list="value"
:before-remove="beforeRemove"
:on-exceed="handleExceed"
@change="handleChange"
>
<!-- @preview="handlePreview" -->
<slot>
<template v-if="isPictureCard">
<div class="text-center">
<LeIcon size="18" icon="ant-design:plus-outlined" />
<div class="ant-upload-text">
{{ text }}
</div>
</div>
</template>
<el-button v-else :disabled="isLimit || disabled">
<LeIcon size="18" icon="ant-design:upload-outlined" />
<span>{{ text }}</span>
</el-button>
</slot>
<!-- 自定义file 渲染: 目前仅针对 picture-card 做自定义渲染 -->
<template #file="{ file, index }">
<slot name="file" :file="file" :index="index">
<div v-if="bindProps.listType === 'picture' || bindProps.listType === 'picture-card'" class="el-upload-list__item-thumbnail-wrap">
<component :is="bindProps.iconRender({ file })" />
</div>
<!-- 常规文件渲染 -->
<div v-if="file.status === 'uploading' || bindProps.listType !== 'picture-card'" class="el-upload-list__item-info">
<a class="el-upload-list__item-name" @click.prevent="handlePreview(file)">
<div v-if="bindProps.listType === 'text'" class="el-upload-upload-text-icon">
<component :is="bindProps.iconRender({ file })" />
</div>
<span class="el-upload-list__item-file-name" :title="file.name">
{{ file.name }}
</span>
</a>
<el-progress v-if="file.status === 'uploading'" type="line" :stroke-width="2" :percentage="+file.percentage" />
</div>
<!-- 文件-状态 -->
<label class="el-upload-list__item-status-label">
<el-icon v-if="bindProps.listType === 'text'" class="el-icon--upload-success el-icon--circle-check">
<LeIcon icon="ep:circle-check" />
</el-icon>
<el-icon v-else-if="['picture-card', 'picture'].includes(bindProps.listType)" class="el-icon--upload-success el-icon--check">
<LeIcon icon="ep:check" />
</el-icon>
</label>
<div class="el-upload-list__item-actions">
<!-- action.下载 -->
<LeIcon v-if="file.status === 'success'" class="action action--download" icon="ep:download" @click="handleDownload(file)" />
<!-- action.删除 -->
<LeIcon v-if="!disabled" class="action action--close" icon="ep:close" @click="handleRemove(file)" />
</div>
<i v-if="!disabled" class="el-icon--close-tip">按 delete 键可删除</i>
<!-- picture-card actions -->
<div v-if="bindProps.listType === 'picture-card'" class="el-upload-list__item-actions actions--picture-card">
<!-- 查看 -->
<LeIcon class="action action--view" icon="ep:view" @click="handlePreview(file)" />
<!-- 下载 -->
<LeIcon v-if="file.status === 'success'" class="action action--download" icon="ep:download" @click="handleDownload(file)" />
<!-- 删除 -->
<LeIcon v-if="!disabled" class="action action--delete" icon="ep:delete" @click="handleRemove(file)" />
</div>
</slot>
</template>
</ElUpload>
</div>
</template>
<style lang="scss">
$prefix-cls: '#{$prefix}upload';
.#{$prefix-cls}-container {
position: relative;
.el-upload-list {
// listType: text
&--text {
// 文件 自定义小图标
.el-upload-upload-text-icon {
color: var(--el-text-color-secondary);
margin-right: 6px;
.local_icon-image {
display: block;
width: 1.2em;
height: 1.2em;
overflow: hidden;
object-fit: contain;
}
}
.el-upload-list__item {
//background-color: var(--el-fill-color-lighter);
background-color: var(--el-fill-color-extra-light);
&:hover {
background-color: var(--el-fill-color-light);
}
&-info {
flex: 1;
width: 0;
margin-left: 0;
}
&:hover {
.el-upload-list__item-actions {
opacity: 1;
}
}
.el-upload-list__item-actions {
opacity: 0;
//padding-right: 16px;
padding-right: 2px;
}
}
}
// listType: picture
&--picture {
.el-upload-list__item {
&-thumbnail {
&-wrap,
&-wrap img {
width: 70px;
height: 70px;
//position: static;
//display: block;
//width: 100%;
//height: 1;
}
&-wrap {
// 自定义小图标
.le-icon {
font-size: 70px;
}
}
}
&-info {
flex: 1;
width: 0;
margin-left: 0;
}
.el-progress {
bottom: unset;
}
}
// 文件 自定义小图标
.el-upload-upload-text-icon {
color: var(--el-text-color-secondary);
margin-right: 6px;
.local_icon-image {
display: block;
//width: 1.2em;
//height: 1.2em;
width: 70px;
height: 70px;
overflow: hidden;
object-fit: contain;
}
}
}
// listType: picture-card
&--picture-card {
gap: 6px;
.el-upload-list__item {
margin: 0;
flex-direction: column;
&-status-label {
.le-icon {
font-size: 12px;
//margin-top: 11px;
//transform: rotate(-45deg);
}
}
&-actions {
span + span {
margin-left: 2px;
}
}
.el-progress {
//width: 126px;
//top: 86%;
top: unset;
//bottom: 0;
bottom: 2px;
width: 100%;
.el-progress__text {
top: -13px;
}
}
}
// 上传按钮 禁用 隐藏
.el-upload.el-upload--picture-card {
&.is-disabled {
display: none;
}
}
}
&__item {
display: flex;
align-items: center;
&-thumbnail {
&-wrap,
&-wrap img {
position: static;
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
&-wrap {
display: flex;
align-items: center;
justify-content: center;
// 自定义小图标
.le-icon {
font-size: 60px;
}
}
}
.el-progress {
top: unset;
bottom: -1px;
}
}
&__item-actions {
display: inline-flex;
.action {
width: 16px;
font-size: 16px;
margin: 0 2px;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: var(--el-color-primary);
opacity: 1;
}
}
.el-icon {
width: 16px;
//margin: 0 4px;
font-size: 16px;
//padding: 0 6px;
margin: 0 2px;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: var(--el-color-primary);
opacity: 1;
}
}
}
}
&--default {
.el-upload-list {
--el-upload-list-picture-card-size: 80px;
&--picture-card {
}
.el-upload--picture-card {
--el-upload-picture-card-size: 80px;
}
&--picture {
.el-upload-list__item {
padding: 6px;
margin-top: 6px;
&-thumbnail {
&-wrap,
&-wrap img {
width: 50px;
height: 50px;
}
&-wrap {
// 自定义小图标
.le-icon {
font-size: 50px;
}
}
}
}
}
}
}
&--small {
.el-upload-list {
--el-upload-list-picture-card-size: 52px;
&--picture-card {
gap: 4px;
.el-upload-list__item {
//margin: 0 4px 4px 0;
//padding: 0;
/*overflow: hidden;
&-name {
padding: 0 2px !important;
bottom: 2px !important;
}*/
&-thumbnail {
&-wrap {
.le-icon {
font-size: 30px;
}
}
//line-height: 52px !important;
//.anticon {}
}
}
.el-upload--picture-card {
--el-upload-picture-card-size: 52px;
}
}
&--picture {
.el-upload-list__item {
padding: 2px;
margin-top: 4px;
margin-bottom: 2px;
&-thumbnail {
&-wrap,
&-wrap img {
width: 36px;
height: 36px;
}
&-wrap {
// 自定义小图标
.le-icon {
font-size: 36px;
}
}
}
}
}
}
}
// 超出limit 限制
&--limit {
.el-upload-list {
// listType: picture-card
&--picture-card {
// 上传按钮 禁用 隐藏
.el-upload.el-upload--picture-card {
display: none;
}
}
}
}
}
</style>
六、预览/源码
预览
lancejiang.github.io/Lance-Eleme…
源码 github.com/LanceJiang/…