大家好,我是鱼樱!!!
关注公众号【鱼樱AI实验室】
持续每天分享更多前端和AI辅助前端编码新知识~~喜欢的就一起学反正开源至上,无所谓被诋毁被喷被质疑文章没有价值~
一个城市淘汰的自由职业-农村前端程序员(虽然不靠代码挣钱,写文章就是为爱发电),兼职远程上班目前!!!热心坚持分享~~~
以下是一个基于Vant封装的通用图片上传组件,支持单张和多张图片上传、预览和旋转,并具有良好的扩展性。
组件代码 (ImageUploader.vue)
html
<template>
<div class="v-image-uploader">
<!-- 主上传组件 -->
<van-uploader
v-model="innerFileList"
v-bind="$attrs"
:max-count="maxCount"
:max-size="maxSize"
:multiple="multiple"
:disabled="disabled"
:readonly="readonly"
:deletable="deletable"
:before-read="handleBeforeRead"
@oversize="$emit('oversize', $event)"
@delete="handleDelete"
@click-preview="handlePreview"
>
<!-- 传递上传区域默认插槽 -->
<template #default>
<slot>
<!-- 默认上传区域 -->
<div class="upload-area" v-if="showUploadArea">
<van-icon name="photograph" size="24" />
<div class="upload-text">{{ uploadText }}</div>
</div>
</slot>
</template>
<!-- 传递预览覆盖插槽 -->
<template #preview-cover="{ file, index }">
<slot name="preview-cover" :file="file" :index="index"></slot>
</template>
<!-- 传递其他所有插槽 -->
<template v-for="(_, name) in $slots" :key="name" #[name]="slotData" v-if="name !== 'default' && name !== 'preview-cover'">
<slot :name="name" v-bind="slotData"></slot>
</template>
</van-uploader>
<!-- 错误信息 -->
<div v-if="errorMsg" class="upload-error">{{ errorMsg }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, useAttrs } from 'vue';
import { showImagePreview, showToast } from 'vant';
import type { UploaderFileListItem, ImagePreviewOptions } from 'vant';
// 定义组件属性
interface Props {
/**
* 已上传的文件列表
* 支持字符串数组 ['url1', 'url2'] 或对象数组 [{url: 'url1'}, {url: 'url2'}]
*/
modelValue: (string | UploaderFileListItem)[];
/**
* 是否多选图片
* @default false
*/
multiple?: boolean;
/**
* 最大上传数量
* @default Infinity
*/
maxCount?: number;
/**
* 文件大小限制,单位为字节
* @default Infinity
*/
maxSize?: number;
/**
* 允许的文件格式扩展名数组,不带点,如 ['jpg', 'jpeg', 'png']
* @default ['jpg', 'jpeg', 'png', 'gif', 'webp']
*/
formats?: string[];
/**
* 是否显示删除按钮
* @default true
*/
deletable?: boolean;
/**
* 是否禁用
* @default false
*/
disabled?: boolean;
/**
* 是否只读状态
* @default false
*/
readonly?: boolean;
/**
* 是否开启图片预览
* @default true
*/
preview?: boolean;
/**
* 上传区域的文字
* @default "上传图片"
*/
uploadText?: string;
/**
* 图片预览配置
*/
previewOptions?: Partial<ImagePreviewOptions>;
/**
* 格式错误提示文本
* @default "文件格式不正确,请上传{formats}格式的图片"
*/
formatErrorMsg?: string;
}
// 定义默认属性值
const props = withDefaults(defineProps<Props>(), {
multiple: false,
maxCount: Infinity,
maxSize: Infinity,
formats: () => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
deletable: true,
disabled: false,
readonly: false,
preview: true,
uploadText: '上传图片',
previewOptions: () => ({}),
formatErrorMsg: ''
});
// 定义事件
const emit = defineEmits([
'update:modelValue',
'oversize',
'delete',
'error',
'success',
'preview'
]);
// 获取原生属性
const attrs = useAttrs();
// 内部状态
const innerFileList = ref<UploaderFileListItem[]>([]);
const errorMsg = ref('');
// 计算是否显示上传区域(当达到最大数量时隐藏)
const showUploadArea = computed(() => {
return innerFileList.value.length < props.maxCount;
});
// 格式化错误信息
const formattedFormatErrorMsg = computed(() => {
if (props.formatErrorMsg) return props.formatErrorMsg;
return `文件格式不正确,请上传${props.formats.join('/')}格式的图片`;
});
// 将字符串数组转换为文件列表对象数组
const convertToFileList = (value: (string | UploaderFileListItem)[]) => {
return value.map(item => {
if (typeof item === 'string') {
return { url: item };
}
return item;
});
};
// 监听modelValue变化
watch(
() => props.modelValue,
(newVal) => {
innerFileList.value = convertToFileList(newVal);
},
{ immediate: true, deep: true }
);
// 监听内部文件列表变化,同步到父组件
watch(
innerFileList,
(newVal) => {
emit('update:modelValue', newVal);
},
{ deep: true }
);
/**
* 在读取文件前验证格式
* @param file 文件对象
* @returns 是否通过验证
*/
const handleBeforeRead = (file: File | File[]) => {
errorMsg.value = '';
const files = Array.isArray(file) ? file : [file];
// 验证文件格式
for (const file of files) {
// 获取文件扩展名
const extension = file.name.split('.').pop()?.toLowerCase() || '';
if (!props.formats.includes(extension)) {
errorMsg.value = formattedFormatErrorMsg.value;
showToast(errorMsg.value);
emit('error', { file, message: errorMsg.value });
return false;
}
}
// 验证通过
emit('success', files.length === 1 ? files[0] : files);
return true;
};
/**
* 处理文件删除
* @param file 被删除的文件
* @param detail 删除的详细信息
*/
const handleDelete = (file: UploaderFileListItem, detail: { index: number }) => {
emit('delete', file, detail);
};
/**
* 处理图片预览
* @param file 被预览的文件
* @param detail 预览的详细信息
*/
const handlePreview = (file: UploaderFileListItem, detail: { index: number }) => {
if (!props.preview) return;
// 获取所有预览图片URL
const images = innerFileList.value.map(item => item.url || '');
// 合并默认预览选项和用户自定义选项
const options: ImagePreviewOptions = {
images,
startPosition: detail.index,
showIndex: true,
closeable: true,
showIndicators: images.length > 1,
// 允许图片旋转
swipeDuration: 300,
...props.previewOptions
};
// 调用Vant图片预览组件
showImagePreview(options);
// 触发预览事件
emit('preview', file, detail);
};
// 对外暴露方法
defineExpose({
// 清空上传列表
clear: () => {
innerFileList.value = [];
},
// 手动触发图片预览
preview: (index = 0) => {
if (innerFileList.value[index]) {
handlePreview(innerFileList.value[index], { index });
}
}
});
</script>
<style scoped>
.v-image-uploader {
width: 100%;
}
.upload-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 16px;
box-sizing: border-box;
}
.upload-text {
margin-top: 8px;
font-size: 12px;
color: #969799;
}
.upload-error {
margin-top: 8px;
font-size: 12px;
color: #ee0a24;
}
</style>
使用示例
html
<template>
<div class="upload-demo">
<h2>单张图片上传</h2>
<v-image-uploader
v-model="singleImage"
:max-count="1"
:max-size="2 * 1024 * 1024"
:formats="['jpg', 'jpeg', 'png']"
upload-text="上传头像"
@oversize="onOversize"
@error="onError"
@success="onSuccess"
/>
<h2>多张图片上传</h2>
<v-image-uploader
v-model="multipleImages"
multiple
:max-count="9"
:max-size="5 * 1024 * 1024"
upload-text="上传图片集"
:preview-options="previewOptions"
>
<!-- 自定义上传区域 -->
<div class="custom-upload">
<van-icon name="plus" size="22" />
<span>添加图片</span>
</div>
<!-- 自定义预览覆盖层 -->
<template #preview-cover="{ index }">
<div class="preview-cover">
<span class="index">{{ index + 1 }}</span>
</div>
</template>
</v-image-uploader>
<!-- 操作按钮 -->
<div class="actions">
<van-button type="primary" @click="clearAll">清空所有</van-button>
<van-button type="primary" @click="previewFirst">预览第一张</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { showToast } from 'vant';
import VImageUploader from '@/components/VImageUploader.vue';
import type { UploaderFileListItem } from 'vant';
// 单张图片数据
const singleImage = ref<UploaderFileListItem[]>([]);
// 多张图片数据
const multipleImages = ref<UploaderFileListItem[]>([
{ url: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg' },
{ url: 'https://fastly.jsdelivr.net/npm/@vant/assets/leaf.jpeg' }
]);
// 图片预览配置
const previewOptions = {
showIndex: true,
closeable: true,
closeIconPosition: 'top-right' as const,
beforeClose: () => {
return true; // 返回true允许关闭,返回false阻止关闭
}
};
// 组件引用,用于调用组件方法
const uploaderRef = ref();
// 文件超出大小限制回调
const onOversize = (file: File | File[]) => {
const files = Array.isArray(file) ? file : [file];
showToast(`文件 ${files.map(f => f.name).join(', ')} 大小超出限制`);
};
// 上传错误回调
const onError = ({ file, message }: { file: File, message: string }) => {
console.error('上传错误:', message, file);
};
// 上传成功回调
const onSuccess = (file: File | File[]) => {
showToast('上传成功');
console.log('上传成功:', file);
};
// 清空所有图片
const clearAll = () => {
singleImage.value = [];
multipleImages.value = [];
};
// 预览第一张图片
const previewFirst = () => {
if (multipleImages.value.length > 0) {
// 通过组件实例调用preview方法
uploaderRef.value?.preview(0);
} else {
showToast('没有可预览的图片');
}
};
</script>
<style scoped>
.upload-demo {
padding: 16px;
}
h2 {
margin: 16px 0;
font-size: 16px;
}
.custom-upload {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #969799;
}
.custom-upload span {
margin-top: 8px;
font-size: 12px;
}
.preview-cover {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.3);
}
.preview-cover .index {
color: #fff;
font-size: 12px;
background-color: rgba(0, 0, 0, 0.5);
padding: 2px 6px;
border-radius: 4px;
}
.actions {
margin-top: 20px;
display: flex;
gap: 10px;
}
</style>
注册全局组件 (main.ts)
typescript
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { Button, Icon, Uploader, Toast } from 'vant';
import VImageUploader from '@/components/VImageUploader.vue';
const app = createApp(App);
// 注册Vant组件
app.use(Button);
app.use(Icon);
app.use(Uploader);
app.use(Toast);
// 注册自定义上传组件
app.component('VImageUploader', VImageUploader);
app.mount('#app');
组件特点
-
强大的格式限制:
- 支持通过
formats
属性指定允许的图片格式 - 提供友好的格式错误提示
- 支持通过
-
灵活的单/多图片上传:
- 通过
multiple
属性控制是否允许多选 - 使用
maxCount
属性限制最大上传数量
- 通过
-
高性能图片预览和旋转:
- 利用Vant的
ImagePreview
组件实现预览和旋转功能 - 通过
previewOptions
属性自定义预览行为
- 利用Vant的
-
完整的事件支持:
- 支持上传成功、失败、超出大小限制等关键事件
- 继承Vant原有事件系统
-
灵活的插槽系统:
- 支持自定义上传区域
- 支持自定义预览覆盖层
- 传递所有Vant原生插槽
-
扩展性强:
- 继承了Vant Uploader的所有属性
- 提供了额外的扩展属性和方法
- 支持通过类型系统进行严格类型检查
-
简单易用:
- 使用
v-model
进行双向数据绑定 - 提供清晰的组件API和类型定义
- 详细的代码注释
- 使用
这个组件充分利用了Vue3的新特性和Vant组件库的能力,同时提供了更强大的功能和更好的开发体验。无论是简单的单图上传还是复杂的多图管理,都能轻松实现。