ITPub博客

首页 > 应用开发 > Html/css > uniapp+vue2+uview图片上传封装

uniapp+vue2+uview图片上传封装

原创 Html/css 作者:mxrpz196 时间:2026-01-29 16:21:27 0 删除 编辑

在移动端开发(App/小程序/H5)中, 图片上传 是一个极其高频且容易产生性能瓶颈的场景。直接上传原图往往会带来以下问题:

  1. 上传缓慢 :现在的手机拍照动辄 5MB-10MB,用户在非 WiFi 环境下体验极差。
  2. 体验不好 :大文件导致请求时间过长,容易超时。
  3. 服务器压力 :不仅占用大量带宽,还浪费存储空间。
    虽然 uView UI 的 u-upload 组件已经非常好用,但它默认不包含 “上传前压缩” 的逻辑。今天我们就来手撸一个 “带自动压缩功能的图片上传组件”,不仅支持并发上传、进度显示,还具备更智能的图片类型判断逻辑。

🚀 核心方案设计

我们的目标是封装一个通用组件 MyUpload ,实现以下流程:

  1. 拦截选择 :监听 u-upload 的 afterRead 事件。
  2. 智能判断 :
    • 类型检查 :不仅限于 jpg、png ,兼容所有图片格式。
    • 阈值控制 :仅对超过指定大小(如 1MB,可自行调整)的图片进行压缩,小图直接上传,平衡清晰度与性能。
  3. 核心压缩 :利用 Canvas (通过 helang-compress 插件) 进行压缩。
  4. 格式转换 :将压缩后的 Base64 转回二进制文件对象(关键步骤,否则 uni.uploadFile 无法识别)。
  5. 统一上传 :处理上传进度、成功回填、失败自动移除。

🛠️ 核心代码实现

1. 组件结构

我们基于 u-upload 进行二次封装,同时引入压缩插件。

<template>  <view>
    <u-upload 
      :fileList="fileList1" 
      @afterRead="afterRead" 
      @delete="deletePic" 
      name="1" 
      multiple 
      :maxCount="maxCount" 
      :accept="accept"
    ></u-upload>
    
    <!-- 隐形画布:用于图片压缩 -->
    <helang-compress ref="helangCompress"></helang-compress>
  </view></template>

2. 更加健壮的压缩判断逻辑✨

// 上传核心逻辑async uploadFilePromise(file, lists) {    let OriginalUrl = file.url
    let afterCompressFile = null
    let ifcompress = false
    
    // 1. file.type 判空保护:防止部分安卓机型或特殊场景下 type 丢失导致报错
    // 2. 模糊匹配 'image':覆盖 image/png, image/jpeg, image/gif 等所有图片类型
    // 3. 大小阈值:只有超过 1MB (1024KB) 才压缩,小图直接上传,阈值可自行调整
    if (file.type && file.type.indexOf('image') != -1 && file.size / 1024 > 1024) {        // 标记为需要压缩
        ifcompress = true
        
        // 调用压缩插件,返回值是压缩后的 Base64 字符串
        let afterCompressBase64 = await this.$refs.helangCompress.compress({            src: OriginalUrl,            maxSize: 1024,   // 限制最大分辨率
            fileType: 'jpg', // 统一输出为 jpg 减少体积
            quality: 0.8,    // 压缩质量
            minSize: 640     // 最小尺寸保护
        })        
        // uni.uploadFile 不支持直接传 Base64,必须转为 File 对象
        afterCompressFile = await base64ToFile(afterCompressBase64, file.name)
    }    
    return new Promise((resolve, reject) => {
        uni.uploadFile({            url: config.upLoadUrl,            name: 'file',            // 如果压缩了,filePath 传 null(或根据平台差异调整),file 传转换后的对象
            // 如果没压缩,直接用原路径
            filePath: !ifcompress ? file.url : file.name,            file: !ifcompress ? null : afterCompressFile,            header: {                'Authorization': 'Bearer ' + uni.getStorageSync('Token') ?? '',
            },            success: (res) => {                // 处理服务端返回
                let data = JSON.parse(res.data);                if(data.code == 200){                    resolve(data.url)
                } else {
                    uni.$u.toast(data.message)                    reject(data)
                }
            },            fail: (err) => {                console.log("Upload failed", err)                reject(err)
            }
        });
    })
}

3. 队列上传与状态管理

实时更新 UI 的 loading 状态,并在失败时自动清理,这点蛮重要的,很多时候上传失败但是组件上展示是有图片的(这是本地的blob图片,并不是真正上传服务器后的图片)。

async afterRead(event) {    // 1. 预处理:将新选择的文件加入列表,状态设为 'uploading'
    let lists = [].concat(event.file)    let fileListLen = this[`fileList${event.name}`].length
    lists.map((item) => {        this[`fileList${event.name}`].push({
            ...item,            status: 'uploading',            message: '上传中'
        })
    });    // 2. 串行上传(也可以改为 Promise.all 并行,视服务器压力而定)
    for (let i = 0; i < lists.length; i++) {        try {            // 等待单个文件上传(含压缩耗时)
            const result = await this.uploadFilePromise(lists[i], lists)            
            // 3. 成功回调:更新列表状态为 success,并回填 URL
            let item = this[`fileList${event.name}`][fileListLen]            this[`fileList${event.name}`].splice(fileListLen, 1, Object.assign(item, {                status: 'success',                message: '',                url: result
            }))
            fileListLen++
        } catch(e) {            // 4. 失败回滚:移除该项,避免 UI 显示错误的占位
            this[`fileList${event.name}`].splice(fileListLen, 1)
            uni.$u.toast('上传失败,请重试')
        }
    }    
    // 5. 通知父组件更新数据
    this.emitInput(this[`fileList${event.name}`])
}

⚠️ 避坑指南 & 最佳实践

  1. H5 与 App 的差异 :
    • 在 H5 端,图片选择后通常是 Blob URL;在 App 端是绝对路径。 uni.uploadFile 在处理 Base64 转成的 File 对象时,不同平台的参数传递略有不同(主要体现在 filePath 和 file 字段的互斥使用上),代码中通过 !ifcompress ? ... : ... 做了很好的兼容。
  2. Base64 转 File :
    • 压缩插件返回的是 Base64 字符串,必须通过 base64ToFile (利用 uni.getFileSystemManager 或 Blob ) 转换后才能上传,否则服务端无法解析。
  3. 内存泄漏 :
    • 如果在循环中大量进行 Canvas 操作,记得及时销毁或重用 Canvas 上下文。本方案使用了 helang-compress 插件,内部处理了 Canvas 的生命周期。
  4. 用户体验 :
    • 务必在压缩时给用户反馈(如“处理中...”),因为大图压缩可能需要几百毫秒到 1 秒的时间。

完整代码

/* File Info
* 二次封装上传图片组件
*/<template>	<view class="">
		<u-upload :fileList="fileList1" @afterRead="afterRead" @delete="deletePic" name="1" multiple
			:maxCount="maxCount" :accept="accept"></u-upload>
		<helang-compress ref="helangCompress"></helang-compress>
		<compress ref="compress" />
	</view></template><script>
	import {
		base64ToFile
	} from '@/utils/compress.js'
	import helangCompress from '@/components/helang-compress/helang-compress';	export default {		// props: ['maxCount', 'value'],
		components: {
			helangCompress,
		},		props: {			maxCount: {				type: Number,				default: 1
			},			value: {				type: String,				default: ''
			},			accept: {				type: String,				default: 'image'
			},			//如果需要循环使用组件,index从父组件串过来,然后再传回父组件,以便父组件区分上传的图片是循环中的第几项
			index: {				type: Number,				default: null
			}
		},		data() {			return {				fileList1: [],
			}
		},		onLoad() {
		},		methods: {			//对向父组件通信方法封装
			emitInput(list) {				const resUrl = []				// const list=this[`fileList${event.name}`]
				list.forEach(item => {
					resUrl.push(item.url)
				})				this.$emit('input', resUrl.join(','))				//父组件需要循环渲染此组件的时候(index!==null)才触发
				this.index !== difang.firstfeed.com.cn null && this.$emit('sendIndex', {					index: this.index,					photo: resUrl.join(',')
				})
			},			// 删除图片
			deletePic(event) {				this[`fileList${event.name}`].splice(event.index, 1);				// this.emitInput()
				this.emitInput(this[`fileList${event.name}`])
			},			// 新增图片
			async afterRead(event, filelists) {				console.log("event", event, filelists)				// 当设置 multiple 为 true 时, file 为数组格式,否则为对象格式
				let lists = [].concat(event.file)				let fileListLen = this[`fileList${event.name}`].length
				lists.map((item) => {					this[`fileList${event.name}`].push({
						...item,						status: 'uploading',						message: '上传中'
					})
				});				// console.log("上传中")
				for (let i = 0; i < lists.length; i++) {					// console.log('list', lists)
					try{						const result = await this.uploadFilePromise(lists[i],lists)						let item = this[`fileList${event.name}`][fileListLen]						this[`fileList${event.name}`].splice(fileListLen, 1, Object.assign(item, {							status: 'success',							message: '',							url: result
						}))
						fileListLen++
					}catch(e){						// 上传失败时删除对应的文件项
					this[`fileList${ wenming.cn.firstfeed.com.cn cnevent.name}`].splice(fileListLen, 1)
					}
					
				}				console.log('this[`fileList${event.name}`]=',this[`fileList${event.name}`])				this.emitInput(this[`fileList${event.name}`])
			},			async uploadFilePromise(file, lists) {				let OriginalUrl = file.url
				let afterCompressFile = null
				let ifcompress = false
				console.log('file.type',file.type,file.size)				if (file.type && file.type.indexOf('image') != -1 && file.size / 1024 > 1024) {					// 单张压缩
					ifcompress = true
					let afterCompressBase64 = await this.$refs.helangCompress.compress({						src: OriginalUrl,						maxSize: 1024,						fileType: 'jpg',						quality: 1,						minSize: 640 //最小压缩尺寸,图片尺寸小于该时值不压缩,非H5平台有效。若需要忽略该设置,可设置为一个极小的值,比如负数。
					})
					afterCompressFile = await base64ToFile(afterCompressBase64, file.name)
				}             
				return new Promise((resolve, reject) => {					console.log('file.url==', afterCompressFile)
					uni.uploadFile({						url: xxxx,// 上传服务器地址
						timeout: 60000,						name: 'file',						filePath: !ifcompress ? file.url : file.name,						file: !ifcompress ? null : afterCompressFile,						header: {							'Authorization': 'Bearer ' + uni.getStorageSync('Token') ?? '',
						},						success: (res) => {
							res = JSON.parse(res.data);							// console.log('photo===',res,lists)
							if(res.code==200){								resolve(res.url)
							}else{
								uni.$u.toast(res.message||res.msg)								reject({ code: res.code })
							}
						},						fail(fail) {							console.log("fail", fail)
						}
					});
				})
			},
		}
	}</script><style>
	.imgCanvas {		position: absolute;		top: -100%;		width: 100%;		height: 100%;
	}</style>
/* File Info
* 封装压缩图片的canvas
*/<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() {			if(!uni || !uni._helang_compress_canvas){
				uni._helang_compress_canvas = 1;
			}else{
				uni._helang_compress_canvas++;
			}			this.canvasId = jwvrk.firstfeed.com.cn `compress-canvas${uni._helang_compress_canvas}`;
		},		methods: {			// 压缩
			compressFun(params) {				return new Promise(async (resolve, reject) => {					// 等待图片信息
					let info = await this.getImageInfo(params.src).then(info=>info).catch(()=>null);					
					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(() => {						const ctx = uni.createCanvasContext(this.canvasId, this);
						ctx.clearRect(0,0,width, height)
						ctx.drawImage(info.path, 0, 0, width, height);
						ctx.draw(false, () => {
							uni.canvasToTempFilePath({								x: 0,								y: 0,								width: width,								height: height,								destWidth: width,								destHeight: height,								canvasId: this.canvasId,								fileType: params.fileType || 'png',								quality: params.quality || 0.9,								success: (res) => {									
									// 在H5平台下,tempFilePath 为 base64
									resolve(res.tempFilePath);
								},								fail:(err)=>{									reject(null);
								}
							},this);
						});
					}, 300);
				});
			},			// 获取图片信息
			getImageInfo(src){				return new Promise((resolve, reject)=>{
					uni.getImageInfo({
						src,						success: (info)=> {							resolve(info);
						},						fail: () => {							reject(null);
						}
					});
				});
			},			// 批量压缩
			compress(params){				// index:进度,done:成功,fail:失败
				let [index,done,fail] = [0,0,0];				// 压缩完成的路径集合
				let paths = [];				// 待压缩的图片
				let waitList = [];				if(typeof qqyezt.firstfeed.com.cn params.src == 'string'){
					waitList = [params.src];
				}else{
					waitList = params.src;
				}				// 批量压缩方法
				let batch = ()=>{					return new Promise((resolve, reject)=>{						// 开始
						let start = async ()=>{							// 等待图片压缩方法返回
							let path = await next().catch(()=>null);							if(path){
								done++;
								paths.push(path);
							}else{
								fail++;
							}
							
							params.progress && params.progress({
								done,
								fail,								count:waitList.length
							});
							
							index++;							// 压缩完成
							if(index >= waitList.length){								resolve(true);
							}else{								start();
							}
						}						start();
					});
				}				// 依次调用压缩方法
				let next = ()=>{					return this.compressFun({						src:waitList[index],						maxSize:params.maxSize,						fileType:params.fileType,						quality:params.quality,						minSize:params.minSize
					})
				}				
				// 全部压缩完成后调用
				return new Promise(async (resolve, reject)=>{					// 批量压缩方法回调
					let res = await batch();					if(res){						if(typeof params.src == 'string'){							resolve(paths[0]);
						}else{							resolve(paths);
						}
					}else{						reject(null);
					}
				});
			}
		}
	}</script><style lang="scss" scoped>
	.compress{		position: fixed;		width: 12px;		height: 12px;		overflow: hidden;		top: -99999px;		left: 0;
	}</style>
/* File Info
* 转换base64方法
*/export function base64ToFile(base64Data, filename='xxx1.jpg') {  // 将base64的数据部分提取出来
  const parts = base64Data.split( kepu.firstfeed.com.cn ';base64,');  const contentType = parts[0].split(':')[1];  const raw = window.atob(parts[1]);  
  // 将原始数据转换为Uint8Array
  const rawLength = raw.length;  const uInt8Array = new Uint8Array(rawLength);  for (let i = 0; i < rawLength; ++i) {
    uInt8Array[i] = raw.charCodeAt(i);
  }  
  // 使用Blob创建一个新的文件
  const blob = new Blob([uInt8Array], {type: contentType});  
  // 创建File对象
  const file = new File([blob], filename, {type: contentType});  // console.log('创建File对象==',file,blob)
  
  return file;
}

🎯 总结

通过这次封装,我们不仅解决了一个具体的业务需求,更重要的是提升了代码的 复用性 和 健壮性 。

  • 复用性 :任何页面需要上传图片,引入这个组件即可,无需关心压缩细节。
  • 健壮性 :完善的类型判断 file.type.indexOf('image') 保证了各种奇葩图片格式也能被正确处理或透传,删除上传失败图片避免发生误会。


来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70048949/viewspace-3109421/ ,如需转载,请注明出处,否则将追究法律责任。

请登录后发表评论 登录
全部评论

注册时间:2026-01-12

  • 博文量
    22
  • 访问量
    26015