博客 / 詳情

返回

uniapp+vue2+uview圖片上傳封裝

🔥 打造基於 uView+uniapp+vue 的高性能圖片上傳組件(自動壓縮 + 更加健壯的類型判斷)

前言

在移動端開發(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 !== 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 項目!如果你覺得有用,點個贊再走吧~ 👍
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.