如何将uniapp Vue2 uview图片上传功能为?

摘要:🔥 打造基于 uView+uniapp+vue 的高性能图片上传组件(自动压缩 + 更加健壮的类型判断) 前言 在移动端开发(App小程序H5)中,
🔥 打造基于 uView+uniapp+vue 的高性能图片上传组件(自动压缩 + 更加健壮的类型判断) 前言 在移动端开发(App/小程序/H5)中, 图片上传 是一个极其高频且容易产生性能瓶颈的场景。直接上传原图往往会带来以下问题: 上传缓慢 :现在的手机拍照动辄 5MB-10MB,用户在非 WiFi 环境下体验极差。 体验不好 :大文件导致请求时间过长,容易超时。 服务器压力 :不仅占用大量带宽,还浪费存储空间。 虽然 uView UI 的 u-upload 组件已经非常好用,但它默认不包含“上传前压缩” 的逻辑。今天我们就来手撸一个 “带自动压缩功能的图片上传组件”,不仅支持并发上传、进度显示,还具备更智能的图片类型判断逻辑。 🚀 核心方案设计 我们的目标是封装一个通用组件 MyUpload ,实现以下流程: 拦截选择 :监听 u-upload 的 afterRead 事件。 智能判断 : 类型检查 :不仅限于 jpg、png ,兼容所有图片格式。 阈值控制 :仅对超过指定大小(如 1MB,可自行调整)的图片进行压缩,小图直接上传,平衡清晰度与性能。 核心压缩 :利用 Canvas (通过 helang-compress 插件) 进行压缩。 格式转换 :将压缩后的 Base64 转回二进制文件对象(关键步骤,否则 uni.uploadFile 无法识别)。 统一上传 :处理上传进度、成功回填、失败自动移除。 🛠️ 核心代码实现 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}`]) } ⚠️ 避坑指南 & 最佳实践 H5 与 App 的差异 : 在 H5 端,图片选择后通常是 Blob URL;在 App 端是绝对路径。 uni.uploadFile 在处理 Base64 转成的 File 对象时,不同平台的参数传递略有不同(主要体现在 filePath 和 file 字段的互斥使用上),代码中通过 !ifcompress ? ... : ... 做了很好的兼容。 Base64 转 File : 压缩插件返回的是 Base64 字符串,必须通过 base64ToFile (利用 uni.getFileSystemManager 或 Blob ) 转换后才能上传,否则服务端无法解析。 内存泄漏 : 如果在循环中大量进行 Canvas 操作,记得及时销毁或重用 Canvas 上下文。本方案使用了 helang-compress 插件,内部处理了 Canvas 的生命周期。 用户体验 : 务必在压缩时给用户反馈(如“处理中...”),因为大图压缩可能需要几百毫秒到 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 !== 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${event.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 = `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 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(';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') 保证了各种奇葩图片格式也能被正确处理或透传,删除上传失败图片避免发生误会。 希望这篇文章能帮你优化你的 Uni-app 项目!如果你觉得有用,点个赞再走吧~ 👍