管理后臺基于 Ant Design Pro 搭建,組件庫是 Ant Design 5.20,本文會對糟糕的性能和用戶體驗進行多輪優化。
一、存在的問題
核心就是上傳的圖像數量龐大,公司的網絡速度慢,被全國94%的網絡用戶超越。

1)預覽圖顯示慢


2)圖像請求失敗
上傳組件預覽圖請求失敗圖裂。

點擊上傳組件中的預覽按鈕,在大圖預覽時的可拖動區域的小圖,也會請求失敗圖裂

3)上傳時間長
上傳時間長,頁面意外關閉需重新上傳。

接下來會記錄優化的整個過程,其中不乏一些失敗的優化手段,我都做了一一記錄。
性能優化的核心是降低圖像尺寸,分批次請求等,體驗優化的核心是增加反饋交互,續傳文件等。
二、預覽小圖優化
首先讓運營做了一個人工的前置優化。
原始圖先讓攝影師自行壓縮一遍,從 4.5M 壓縮到 1.5M,總容量從 2.19G 縮小到 466M。
1)質量變換
為預覽小圖增加質量變換(公司購買了七牛云的服務),經過調試,選擇 3p 的圖片質量。
https://static.xxx.com/activity/review/1746525272362kzrjtd.jpg

https://static.xxx.com/activity/review/1746525272362kzrjtd.jpg?imageMogr2/thumbnail/!5p

https://static.xxx.com/activity/review/1746525272362kzrjtd.jpg?imageMogr2/thumbnail/!3p

三次請求的圖像尺寸縮小了 538 倍,請求效率提升了不少。

2)批量預請求
另一個優化舉措是在上傳之前先用腳本批量(10個一組)去請求壓縮裁剪后的圖。
在全部請求完成后才呈現上傳區域。
useEffect(() => { // 預加載圖像 const imageLoader = new BatchImageLoader({ batchSize: 10 }); const imageUrls = current.reviews ? current.reviews.map(url => scaleImg(url)) : []; // 批量加載 imageUrls.length > 0 && imageLoader.loadImagesBatch(imageUrls) .then((results: ImageLoadResult[]) => { setImgLoading(false); // 關閉加載中的狀態 }) .catch((error: Error) => { console.error('批量加載失敗:', error); }); return () => { imageLoader.clearPendingRequests(); //銷毀請求 setImgLoading(true); // 開啟加載中的狀態 } }, [current])

3)骨架屏
還給 Modal 組件賦值 loading 屬性(5.18.0新增),出現骨架屏效果。

不過,在骨架屏消失后,本來以為預請求了一次,不會再訪問出錯,結果還是會有一定概率出錯。
公司網絡比較慢,很容易復現此類問題。

4)3次重復請求
并且從上傳區域的預加載,到加載組件內的預覽圖,再到拖動區域的小圖,總共進行了 3 次請求,太冗余。

上述優化,除了質量參數達到了優化的效果,其余沒有達到預期,遂放棄。
三、自定義上傳列表項
1)懶加載
Upload 組件的默認行為,是會加載組件內的所有預覽圖,所以需要自定義上傳列表項(itemRender),做懶加載優化。
const itemRender = (originNode: React.ReactElement, file: UploadFile, fileList:UploadFile[], actions: { download: () => void; preview: () => void; remove: () => void; }) => { return ( <div className="ant-upload-list-item-container"> <div className="ant-upload-list-item ant-upload-list-item-done"> {file.status === 'uploading' ? ( <div style={{width: '100%', textAlign:'center'}}> 文件上傳中<Progress percent={file.percent} showInfo={false} size="small" strokeWidth={2}/> </div> ) : <span className="ant-upload-list-item-thumbnail"> <img src={file.thumbUrl} alt={file.name} className="ant-upload-list-item-image" loading='lazy' onError={e => onImgError(e, file.thumbUrl)} /> </span> } <div className="ant-upload-list-item-actions"> {/* 預覽按鈕 */} <a onClick={actions.preview}> <EyeOutlined /> </a> {/* 刪除按鈕 */} <a onClick={actions.remove}> <DeleteOutlined /> </a> </div> </div> </div> ); }
自定義的列表項與默認的列表項在行為和樣式上保持了高度的一致。
但是為 img 增加了 loading 懶加載屬性。
- loading="lazy" 告訴瀏覽器,在用戶滾動至圖片附近時才開始加載圖片。
- loading="eager"(這是默認值)則指示瀏覽器在頁面加載時立即加載所有圖片。
經過調試發現,300個圖像請求,默認會先請求100個左右。

盡管如此,還是會有小概率的情況出現圖裂。
2)重加載一次
為此,在圖像請求錯誤時,增加一次重新加載的機制,而可拖動區域的小圖也要加上此機制。
// 預覽圖像發生錯誤時的事件 data-retried const onImgError = (e:SyntheticEvent, src:string|undefined) => { const img = e.target as HTMLImageElement; // 只重新加載一次 if (!img.dataset.retried) { img.dataset.retried = 'true'; src && (img.src = src); console.error('重新加載', src); } }
四、可拖動區域

1)網絡錯誤
拖動區域的小圖裂掉主要是因為上傳區域的圖裂了導致,兩者的地址是相同的。
得到了圖裂時候的錯誤信息。
https://static.xxx.com/activity/review/1757929803424ukyomp.jpg?imageMogr2/thumbnail/!3p net::ERR_HTTP2_PROTOCOL_ERROR 200 (OK)
在客戶端(你的瀏覽器)和服務器之間的 HTTP/2 通信過程中,發生了某種違反協議規則的事情,導致連接被中止。
查看 Chrome Net Logs (網絡日志),它可以記錄所有網絡活動。
- 在 Chrome 地址欄輸入 chrome://net-export/。
- 點擊 "Start Logging to Disk" 并保存一個文件。
- 在瀏覽器中復現這個錯誤。
- 返回 chrome://net-export/ 點擊 "Stop Logging"。
- 將日志文件上傳到 https://netlog-viewer.appspot.com 進行分析。搜索 ERR_HTTP2_PROTOCOL_ERROR,可以看到更詳細的錯誤上下文。
上述操作完成后,并沒有得到具體的錯誤信息,可能是日志文件還沒有全部導出。
2)懶加載
但是在拖動區域,也增加了 loading 屬性,有時候即使組件內的預覽圖沒有顯示,此處也能正確顯示。
<img src={item.thumbUrl} loading='lazy' />

3)選中效果
還增加了一處體驗優化,即為當前預覽小圖增加選中效果,就是增加個 2px 的邊框。
寬高也設置了一下,還用到了CSS中的 object-fit 屬性。
- cover:用于被替換的內容在保持其寬高比的同時,填充元素的整個內容框。
- contain:用于被替換的內容將被縮放,以在填充元素的內容框時保持其寬高比。
選取關鍵字 cover,將圖像能夠等比縮放的填充滿整個內容區域。
{
width:'100%';
height: 45;
object-fit: 'cover';
border: 2px solid #a0d911;
}

五、預覽大圖
1)禁止打開預覽彈框
預覽彈框有一處體驗優化,就是當上傳組件還有文件在上傳中時,禁止打開預覽彈框。
避免在彈框的拖動區域,修改圖像順序時發生異常

在預覽彈框中,調節大圖尺寸,并且為其配置 objectFit 屬性,將圖像能夠等比縮放的填充到內容區域。
<img style={{ width: '100%', height: '60vh', marginTop: 5, objectFit: 'contain' }} src='https://static.allreaday.com/xxx.jpg' alt="預覽圖" />

2)loading切換
由于在切換預覽時的加載時間較長(公司網絡較差),于是增加了 loading 加載的過渡動畫。

后續,也為大圖增加了質量變換,值為 50p,確保肉眼看的時候能保持清晰度和色彩度。
https://static.xxx.com/xxx.jpg?imageMogr2/thumbnail/!50p
六、緩存上傳文件
1)localStorage
在上傳按鈕旁增加恢復最近數據的按鈕,每次上傳時會將文件列表緩存到 localStorage 中

在 Upload 組件的 onChange 事件中,增加緩存的配置,并且為上傳組件增加 isCache 屬性控制是否開啟緩存。
const onChange = (data: UploadChangeParam) => { const { fileList } = data; // ... isCache && setUploadImageCache(fileList); // ... } /** * 緩存上傳文件 */ export function setUploadImageCache(data:UploadFile[]) { localStorage.setItem('upload_image', JSON.stringify(data)); }
2)續傳失敗
本來將 uploading 也存儲到了緩存中,并且在更新狀態時,組件也顯示了上傳中的樣式,但是無法續傳。
可能是沒有 file 對象,即沒有文件信息導致無法上傳。
目前就暫時無法給上傳中的文件實現斷點續傳,所以只能先把文件名給列出來。

在警告框中提供了復制按鈕(用OR分隔的名稱),點擊復制后,找到對應的文件夾(macOS),選中搜索,

將得到的查詢條件復制到搜索框中,就能得到還未上傳的圖像。

3)存儲溢出
當上傳300多張圖像時,頁面報錯了。意思就是存儲超過最大閾值,localStorage 最大是 5M~10M。
QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'upload_image' exceeded the quota.
嘗試采用數據壓縮,但仍然會報錯,只能另辟蹊徑。
/** * 緩存上傳文件 */ export function setUploadImageCache(data:UploadFile[]) { // 壓縮數據 const compress = btoa(encodeURIComponent(JSON.stringify(data))); localStorage.setItem('upload_image', compress); } /** * 讀取上傳文件的緩存 */ export function getUploadImageCache() { const files = localStorage.getItem('upload_image'); if(!files) return []; // 解壓數據 const uncompress = decodeURIComponent(atob(files)); return JSON.parse(uncompress); }
4)IndexedDB
將數據緩存到 IndexedDB 中,理論上它可以占用 50% 的磁盤空間,所以不存在溢出的問題。
/** * 存儲數據(支持大型數據) * @param key 鍵名 * @param value 要存儲的值(可為任意類型) */ async setItem(key: string, value: any): Promise<void> { if (!this.db) await this.init(); return new Promise<void>((resolve, reject) => { const transaction = this.db!.transaction(['largeData'], 'readwrite'); const store = transaction.objectStore('largeData'); const item: StoredItem = { key, value, timestamp: Date.now() }; const request: IDBRequest = store.put(item); request.onsuccess = () => resolve(); request.onerror = () => { console.error('存儲數據失敗:', request.error); reject(request.error); }; }); } /** * 獲取存儲的數據 * @param key 鍵名 * @returns 存儲的值,若不存在則返回 null */ async getItem(key: string): Promise<any> { if (!this.db) await this.init(); return new Promise<any>((resolve, reject) => { const transaction = this.db!.transaction(['largeData'], 'readonly'); const store = transaction.objectStore('largeData'); const request: IDBRequest = store.get(key); request.onsuccess = () => { const result = request.result as StoredItem | undefined; resolve(result ? result.value : null); }; request.onerror = () => { console.error('獲取數據失敗:', request.error); reject(request.error); }; }); }
為了能自動清理 IndexedDB 的數據,在組件中上傳文件時,觸發清理的操作,下面查看占用空間的代碼。
const quota = await navigator.storage.estimate(); console.log(`已用空間: ${quota.usage} 字節`); console.log(`總配額: ${quota.quota} 字節`); const percentageUsed = (quota.usage / quota.quota) * 100; console.log(`已使用可用存儲的 ${percentageUsed}%`);
現在即使緩存了 300 多張圖像,也能實現恢復。
七、上傳反饋
1)不友好的等待
當需要上傳幾百張圖時,在選好圖像后,組件就會有比較長的時間沒有狀態變化。

不給用戶反饋,體驗很不友好。需要在合適的時機更新上傳區域的 loading 狀態。

2)自定義上傳按鈕
沒有找到合適的事件,就想自定義上傳按鈕,然后控制按鈕的點擊事件,以此來更新狀態。
Upload 組件只要在子元素位置增加按鈕元素就能替換默認的上傳按鈕。
<Upload {...props}> <Button icon={<UploadOutlined />}>Click to Upload</Button> </Upload>
但是在 Ant Design Pro 中的 ProFormUploadButton 組件內,并不能覆蓋上傳按鈕。
除非自定義 ProFormUploadButton 組件的邏輯,但開發成本較高。
3)點擊事件
首先想到的是在 ProFormUploadButton 外增加個容器元素,注冊點擊事件,通過冒泡的方式觸發。
<div onClick={}> <ProFormUploadButton /> </div>
但這樣的話,點擊范圍會比較大,并不局限于上傳按鈕,而是整個區域都會冒泡觸發。
然后想到的是直接給生成的 input[type=file] 控件注冊點擊事件。
document.getElementById(name)?.addEventListener('click', () => {
setUploadAreaLoading(true);
});
組件的屬性 name 會渲染成 input 按鈕的 id 屬性。
在 onChange 事件中調用 setUploadAreaLoading(false) 取消 loading 狀態。
4)取消文件選擇
但會有一個問題,就是在彈出的選擇文件框中,不選擇文件,點擊取消,那么就不能觸發 onChange 事件。
針對取消按鈕,瀏覽器也沒有提供專門的事件。

雖然為 window 注冊 focus 事件,能夠實現在點擊取消時觸發事件。
window.addEventListener('focus', function() {
const fileInput = document.getElementById(name) as HTMLInputElement;
console.log(fileInput && fileInput.files)
});
但是無法準確讀取 fileList 列表,當文件少的時候,fileList 會馬上更新。
但是當文件幾百個時,在觸發 focus 事件時,fileList 仍然是空的。
而我優化的目的就是要在加載這些文件的過程中,出現 loading 過渡動畫,如此就無法判斷了。
為 file 控件注冊 change 事件的確能馬上拿到 files 列表。
document.getElementById(name)?.addEventListener('change', (e) => {
const fileInput = e.target as HTMLInputElement;
if(!fileInput) return;
const files = fileInput.files;
console.log('change', files);
if(files && files.length > 0) {
setUploadAreaLoading(true);
}
// fileInput.value = ''; //清空已選文件
});
但是在 Upload 組件中,再次上傳時,卻無法觸發 file 控件 change 事件。
即使在 Upload 組件內清空文件的值,也無法再次觸發事件,很奇怪。
后面發現原來上傳控件(input[type=file])在上傳完成后會被刪除,之前注冊的事件就沒有了。
<span class="ant-upload"> <input id="reviews" type="file" accept="image/*" multiple="" style="display: none;"> </span>
需要自定義 Upload 組件的上傳區域,暫時不修改。
先與業務方同步,告知他們開啟緩存的上傳組件會有取消彈框無法自動移除 loading 的問題。
5)事件委托
既然不能指定控件綁定事件,那么可以借用委托,給容器元素注冊事件。
如果用 DOM 的方式查找容器,像下面這樣,那么當頁面中有多個上傳組件時,就會給所有的容器都注冊事件。
document.getElementsByClassName('ant-upload-select')
而 Upload 提供了 ref,可以通過該屬性注冊特定組件內的容器。
const fileContainer = uploadRef.current?.nativeElement; const onClick = (e:Event) => { const fileInput = e.target as HTMLInputElement; // 攔截非上傳控件的點擊事件 if(!fileInput || fileInput.id !== name) { return; } /** * 在上傳區域開啟加載狀態 * 因為當上傳幾百張圖時,整個界面就會卡住,不給用戶反饋 */ setUploadAreaLoading(true); }; fileContainer?.addEventListener('click', onClick, false);
同樣用委托的方式為 file 控件增加 change 事件,當有文件時,更新 loading 狀態。
const onChange = (e:Event) => { const fileInput = e.target as HTMLInputElement; // 攔截非上傳控件的點擊事件 if(!fileInput || fileInput.id !== name) { return; } const files = fileInput.files; if(files && files.length > 0) { setUploadAreaLoading(true); } }; fileContainer?.addEventListener('change', onChange, false);
這樣就能實現在選中文件后,組件顯示 loading 等待狀態,取消選擇框,也能關閉等待狀態。
posted on
浙公網安備 33010602011771號