目录
本文详细解析一个基于 uni-app 框架的图片上传组件实现,该组件集成了文件选择、格式验证、大小限制、图片压缩和上传功能。
组件核心结构
<template>
<view class="image-uploader">
<uni-file-picker
multiple
:limit="1"
@select="onFileChange"
:file-extname="fileType"
@delete="removeImage"
:modelValue="uploadedFiles"
ref="FilePicker"
:auto-upload="false"
mode="grid"
>
<slot>
<view class="upload-btn">
<uni-icons type="camera-filled" color="#007aff" size="40"></uni-icons>
</view>
</slot>
</uni-file-picker>
<cmpay-compress ref="Compress"></cmpay-compress>
</view>
</template>
<script setup>
let baseUrl = import.meta.env.VITE_BASE_URL
import { computed, ref } from 'vue'
import cmpayCompress from '../cmpay-compress/cmpay-compress.vue'
const props = defineProps({
fileType: {
type: Array,
default: () => ['jpg', 'jpeg', 'png', 'gif']
},
// 最大
maxSize: {
type: Number,
default: 10 // 10
},
// 最小
minSize: {
type: Number,
default: 100 // 100kb
},
value: {
type: [String, Object, Array],
default: ''
},
//自己的上传地址
action: {
type: String,
default: '/openness-api/h5/checkH5Photo'
},
// 上传文件字段名
name: {
type: String,
default: 'filePath'
},
formData: {
type: Object,
default: () => {
return {}
}
},
headers: {
type: Object,
default: () => {
return {}
}
}
})
const emit = defineEmits(['update:uploaded', 'update:value'])
const FilePicker = ref()
const Compress = ref()
const uploadedFiles = computed({
get: () => {
let val = props.value
if (val) {
let temp = 1
// 首先将值转为数组
const list = Array.isArray(val) ? val : val.split(',')
// 然后将数组转为对象数组
return list.map((item) => {
if (typeof item === 'string') {
item = {
url: item
}
}
// uid
item.pic_md5 = item.pic_md5 || new Date().getTime() + temp++
return item
})
} else {
return []
}
},
set: (val) => {
let res = listToString(val)
emit('update:value', res)
}
})
const onFileChange = async (files) => {
const file = files.tempFiles[0]
// 检查文件格式
if (!props.fileType.includes(file.name.split('.').pop().toLowerCase())) {
uni.showModal({ title: '提示', content: '不支持的文件格式' })
FilePicker.value.clearFiles(0)
return
}
// 检查文件大小
const maxBytes = props.maxSize * 1024 * 1024
const minBytes = props.minSize * 1024
if (file.size > maxBytes) {
uni.showModal({
title: '提示',
content: `文件大小不能超过 ${props.maxSize} MB`
})
FilePicker.value.clearFiles(0)
return
}
if (file.size < minBytes) {
uni.showModal({
title: '提示',
content: `文件大小不能小于 ${props.minSize} KB`
})
FilePicker.value.clearFiles(0)
return
}
const fileSize = file.size
const quality = getQuality(fileSize)
uni.showLoading({ title: '上传中...', mask: true })
Compress.value
.compress({
src: file.path,
quality: quality,
progress: (res) => {
console.log('压缩进度', res)
}
})
.then(async (compressedPath) => {
// 压缩成功,开始上传
uni.uploadFile({
url: baseUrl + props.action,
filePath: compressedPath,
name: props.name,
headers: props.headers,
formData: { ...props.formData },
success: (uploadFileRes) => {
const data = JSON.parse(uploadFileRes.data)
emit('update:uploaded', { ...data.data, formData: props.formData }) // 上传结果回调
if (data && data.result_code === 'success') {
// 上传成功,更新文件列表
uploadedFiles.value = [
...uploadedFiles.value,
{ url: data.data.picUrl, pic_md5: data.data.pic_md5 }
]
} else {
// 上传失败,删除对应文件
FilePicker.value.clearFiles(0)
uni.showModal({
content: data.result_msg || '识别失败,请检查图片是否正确清晰',
showCancel: false
})
}
},
fail: (error) => {
uni.showModal({ title: '提示', content: '上传失败,网络异常' })
console.error(error)
FilePicker.value.clearFiles(0)
},
complete: () => {
uni.hideLoading()
}
})
})
.catch((err) => {
uni.hideLoading()
uni.showModal({ title: '提示', content: '图片压缩失败: ' + err })
FilePicker.value.clearFiles(0)
})
}
// 计算压缩质量
const getQuality = (fileSize) => {
const sizeMB = fileSize / (1024 * 1024)
if (sizeMB > 6) return 0.4
if (sizeMB > 4) return 0.6
return 0.8
}
// 对象转成指定字符串分隔
const listToString = (list, separator) => {
let strs = ''
separator = separator || ','
for (const i in list) {
strs += list[i].url + separator
}
return strs != '' ? strs.substring(0, strs.length - 1) : ''
}
// 删除图片
const removeImage = (file) => {
uploadedFiles.value = uploadedFiles.value.filter(
(_, index) => index != file.index
)
}
</script>
<style scoped>
.image-uploader {
padding: 5px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.upload-btn {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border: 2px dashed #ddd;
border-radius: 8px;
color: #bbb;
}
</style>
核心依赖说明
uni-file-picker - 文件选择器组件
功能:提供文件选择能力,支持多种选择模式
uni-icons - 图标组件
提供丰富的图标资源
自定义压缩组件 (cmpay-compress)
<template>
<view class="compress" v-if="canvasId">
<canvas
:canvas-id="canvasId"
:style="{ width: canvasSize.width, height: canvasSize.height }"
></canvas>
</view>
</template>
<script>
export default {
data() {
return {
pic: '',
canvasSize: {
width: 0,
height: 0
},
canvasId: ''
}
},
mounted() {
// 创建 canvasId
if (!uni || !uni._helang_compress_canvas) {
uni._helang_compress_canvas = 1
} else {
uni._helang_compress_canvas++
}
this.canvasId = `compress-canvas${uni._helang_compress_canvas}`
},
methods: {
// 压缩
compressFun(params) {
return new Promise((resolve, reject) => {
// 等待图片信息
this.getImageInfo(params.src)
.then((info) => {
if (!info) {
reject('获取图片信息异常')
return
}
// 设置最大 & 最小 尺寸
const maxSize = params.maxSize || 1080
const minSize = params.minSize || 640
// 当前图片尺寸
let { width, height } = info
// 非 H5 平台进行最小尺寸校验
// #ifndef H5
if (width <= minSize && height <= minSize) {
resolve(params.src)
return
}
// #endif
// 最大尺寸计算
//(图像的宽度和高度是否超过最大尺寸。如果其中任一维度超过最大尺寸,代码将对图像进行调整,以使其适应最大尺寸并保持其宽高比。)
// 这样可以确保图像在调整大小后仍保持原始比例,并且不会超过指定的最大尺寸
if (width > maxSize || height > maxSize) {
if (width > height) {
height = Math.floor(height / (width / maxSize))
width = maxSize
} else {
width = Math.floor(width / (height / maxSize))
height = maxSize
}
}
// 设置画布尺寸
this.$set(this, 'canvasSize', {
width: `${width}px`,
height: `${height}px`
})
// Vue.nextTick 回调在 App 有异常,则使用 setTimeout 等待DOM更新
setTimeout(() => {
// 创建 canvas 绘图上下文(指定 canvasId)。在自定义组件下,第二个参数传入组件实例this,以操作组件内 <canvas/> 组件
// Tip: 需要指定 canvasId,该绘图上下文只作用于对应的 <canvas/>
const ctx = uni.createCanvasContext(this.canvasId, this)
// 清除画布上在该矩形区域内的内容。(x,y,宽,高)
ctx.clearRect(0, 0, width, height)
// 绘制图像到画布。(所要绘制的图片资源,x,y,宽,高)
ctx.drawImage(info.path, 0, 0, width, height)
// 将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中。
// 本次绘制是否接着上一次绘制,即reserve参数为false,则在本次调用drawCanvas绘制之前native层应先清空画布再继续绘制;若reserver参数为true,则保留当前画布上的内容,本次调用drawCanvas绘制的内容覆盖在上面,默认 false
// 绘制完成后回调
ctx.draw(false, () => {
// 把当前画布指定区域的内容导出生成指定大小的图片,并返回文件路径。在自定义组件下,第二个参数传入自定义组件实例,以操作组件内 <canvas> 组件。
uni.canvasToTempFilePath(
{
x: 0, //画布x轴起点(默认0)
y: 0, //画布y轴起点(默认0)
width: width, //画布宽度(默认为canvas宽度-x)
height: height, //画布高度(默认为canvas高度-y
destWidth: width, //图片宽度(默认为 width * 屏幕像素密度)
destHeight: height, //输出图片高度(默认为 height * 屏幕像素密度)
canvasId: this.canvasId, //画布标识,传入 <canvas/> 的 canvas-id(支付宝小程序是id、其他平台是canvas-id)
fileType: params.fileType || 'png', //目标文件的类型,只支持 'jpg' 或 'png'。默认为 'png'
quality: params.quality || 0.9, //图片的质量,取值范围为 (0, 1],不在范围内时当作1.0处理
success: (res) => {
// 在H5平台下,tempFilePath 为 base64
resolve(res.tempFilePath)
},
fail: (err) => {
console.log(err)
reject(null)
}
},
this
)
})
}, 300)
})
.catch((err) => {
console.log(err)
reject('获取图片信息异常')
})
})
},
// 获取图片信息
getImageInfo(src) {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src,
success: (info) => {
resolve(info)
},
fail: (err) => {
console.log(err, 'err===获取图片信息')
reject(null)
}
})
})
},
// 批量压缩
async compress(params) {
// 初始化状态变量
let [index, done, fail] = [0, 0, 0]
let paths = []
// 处理待压缩图片列表
let waitList = Array.isArray(params.src) ? params.src : [params.src]
// 批量压缩方法
let batch = async () => {
while (index < waitList.length) {
try {
const path = await next()
done++
paths.push(path)
params.progress?.({ done, fail, count: waitList.length })
} catch (error) {
fail++
params.progress?.({ done, fail, count: waitList.length })
}
index++
}
}
// 单个图片压缩方法
let next = () => {
const currentSrc = waitList[index]
return this.compressFun({
src: currentSrc,
maxSize: params.maxSize,
fileType: params.fileType,
quality: params.quality,
minSize: params.minSize
})
}
// 返回Promise并处理结果
return new Promise((resolve, reject) => {
try {
batch()
.then(() => {
if (typeof params.src === 'string') {
resolve(paths[0])
} else {
resolve(paths)
}
})
.catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
})
}
}
}
</script>
<style lang="scss" scoped>
.compress {
position: fixed;
width: 12px;
height: 12px;
overflow: hidden;
top: -99999px;
left: 0;
}
</style>
组件参数详解
1. Props 配置
参数名 | 类型 | 默认值 | 说明 |
---|---|---|---|
fileType | Array | ['jpg','jpeg','png','gif'] |
允许上传的文件类型 |
maxSize | Number | 10 (MB) |
文件大小上限 |
minSize | Number | 100 (KB) |
文件大小下限 |
value | [String,Object,Array] | '' |
已上传文件数据(支持字符串/对象/数组格式) |
action | String | /openness-api/h5/checkH5Photo |
上传接口地址 |
name | String | 'filePath' |
上传文件的字段名 |
formData | Object | {} |
上传时附加的表单数据 |
headers | Object | {} |
上传请求头配置 |
2. Emits 事件
事件名 | 说明 |
---|---|
update:uploaded | 上传完成时触发,返回服务器响应数据 |
update:value | 文件列表变更时触发,更新绑定值 |
核心方法解析
1. onFileChange - 文件选择处理
const onFileChange = async (files) => {
// 1. 获取文件并验证格式
const file = files.tempFiles[0];
const ext = file.name.split('.').pop().toLowerCase();
if (!props.fileType.includes(ext)) {
uni.showModal({ title: '提示', content: '不支持的文件格式' });
return;
}
// 2. 验证文件大小
const maxBytes = props.maxSize * 1024 * 1024;
const minBytes = props.minSize * 1024;
if (file.size > maxBytes) {
uni.showModal({ title: '提示', content: `文件大小不能超过 ${props.maxSize} MB` });
return;
}
if (file.size < minBytes) {
uni.showModal({ title: '提示', content: `文件大小不能小于 ${props.minSize} KB` });
return;
}
// 3. 计算压缩质量
const quality = getQuality(file.size);
// 4. 执行压缩
uni.showLoading({ title: '上传中...', mask: true });
try {
const compressedPath = await Compress.value.compress({
src: file.path,
quality: quality
});
// 5. 上传文件
uni.uploadFile({
url: baseUrl + props.action,
filePath: compressedPath,
name: props.name,
headers: props.headers,
formData: { ...props.formData },
success: (res) => {
const data = JSON.parse(res.data);
// 处理上传结果...
},
fail: (error) => {
// 错误处理...
},
complete: () => uni.hideLoading()
});
} catch (err) {
// 压缩错误处理...
}
}
2. 动态压缩质量算法
const getQuality = (fileSize) => {
const sizeMB = fileSize / (1024 * 1024);
if (sizeMB > 6) return 0.4; // >6MB 使用40%质量
if (sizeMB > 4) return 0.6; // >4MB 使用60%质量
return 0.8; // 其他使用80%质量
}
3. 文件列表管理
// 计算属性:转换value为可用格式
const uploadedFiles = computed({
get: () => {
let val = props.value;
if (!val) return [];
const list = Array.isArray(val) ? val : val.split(',');
return list.map((item, index) => ({
url: typeof item === 'string' ? item : item.url,
pic_md5: item.pic_md5 || Date.now() + index
}));
},
set: (val) => {
const urls = val.map(item => item.url);
emit('update:value', urls.join(','));
}
});
// 删除文件处理
const removeImage = (file) => {
uploadedFiles.value = uploadedFiles.value.filter(
(_, index) => index !== file.index
);
}
uni-file-picker 关键参数
参数 | 说明 |
---|---|
multiple | 是否支持多选(实际被limit=1限制为单选) |
:limit="1" | 最大选择文件数量 |
@select | 文件选择事件 |
@delete | 文件删除事件 |
:file-extname | 允许的文件扩展名 |
:modelValue | 绑定的文件列表 |
:auto-upload="false" | 关闭自动上传(手动控制上传流程) |
mode="grid" | 网格显示模式 |
ref="FilePicker" | 组件引用,用于调用clearFiles等方法 |
样式设计要点
.image-uploader {
padding: 5px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); /* 添加微妙阴影 */
}
.upload-btn {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border: 2px dashed #ddd; /* 虚线边框 */
border-radius: 8px;
color: #bbb;
transition: all 0.3s; /* 添加过渡效果 */
}
.upload-btn:active {
background-color: #f9f9f9; /* 点击反馈 */
}
组件功能流程
用户交互:点击相机图标触发文件选择
文件验证:
格式验证(jpg/jpeg/png/gif)
大小验证(100KB-10MB)
图片处理:
根据文件大小动态计算压缩质量
调用压缩组件进行图片压缩
文件上传:
显示加载状态
携带自定义表单数据和请求头
处理上传结果
状态管理:
成功:更新文件列表
失败:显示错误信息
文件删除:
从文件列表中移除项目
更新绑定数据
总结
该图片上传组件提供了完整的文件处理解决方案:
通过
3
实现文件选择严格的格式和大小验证
智能的图片压缩策略
灵活的上传配置(地址/字段/请求头)
完善的状态管理和错误处理
组件设计考虑了移动端用户体验,包括:
友好的提示信息
加载状态反馈
直观的操作流程
美观的视觉设计
开发者可根据实际需求调整验证规则、压缩算法和UI样式,以适应不同项目需求。