uniapp+vue2+uview图片上传封装
🔥 打造基于 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 项目!如果你觉得有用,点个赞再走吧~ 👍

浙公网安备 33010602011771号