在移动端开发(App/小程序/H5)中, 图片上传 是一个极其高频且容易产生性能瓶颈的场景。直接上传原图往往会带来以下问题:
我们的目标是封装一个通用组件 MyUpload ,实现以下流程:
我们基于 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>
// 上传核心逻辑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)
}
});
})
}
实时更新 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}`])
}
/* 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;
}
通过这次封装,我们不仅解决了一个具体的业务需求,更重要的是提升了代码的 复用性 和 健壮性 。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70048949/viewspace-3109421/ ,如需转载,请注明出处,否则将追究法律责任。