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

1)預覽圖顯示慢


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

點(dian)擊上傳(chuan)組(zu)件(jian)中的預(yu)覽按鈕,在大圖(tu)預(yu)覽時的可拖(tuo)動區域的小圖(tu),也(ye)會請求失敗圖(tu)裂

3)上傳時間長
上(shang)傳(chuan)時間長,頁面意外關閉需(xu)重(zhong)新(xin)上(shang)傳(chuan)。

接下來會(hui)記(ji)錄優化的(de)整個過程,其中不(bu)乏一(yi)(yi)些失敗的(de)優化手段,我都做了(le)一(yi)(yi)一(yi)(yi)記(ji)錄。
性能優化(hua)的核心是(shi)降低圖像(xiang)尺寸,分(fen)批次請求等(deng),體(ti)驗優化(hua)的核心是(shi)增(zeng)加反饋(kui)交互,續(xu)傳(chuan)文件等(deng)。
二、預覽小圖優化
首先讓運營(ying)做(zuo)了一個人工的前置優化。
原始圖先(xian)讓攝影師自(zi)行(xing)壓縮(suo)一遍,從 4.5M 壓縮(suo)到 1.5M,總(zong)容量從 2.19G 縮(suo)小到 466M。
1)質量變換
為預覽小圖增加(公司購買了(le)七牛云(yun)的(de)(de)服務),經過調(diao)試,選擇 3p 的(de)(de)圖(tu)片質(zhi)量。
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

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

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

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

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

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

上述優化,除了(le)質量(liang)參數達到了(le)優化的效果,其余沒有(you)達到預期,遂(sui)放棄。
三、自定義上傳列表項
1)懶加載
Upload 組件的默認行(xing)為,是(shi)會加載(zai)組件內的所(suo)有預覽圖(tu),所(suo)以需要(yao)自定義上傳(chuan)列表項(itemRender),做懶加載(zai)優(you)化。
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"> {/* 預覽按鈕(niu) */} <a onClick={actions.preview}> <EyeOutlined /> </a> {/* 刪除按(an)鈕(niu) */} <a onClick={actions.remove}> <DeleteOutlined /> </a> </div> </div> </div> ); }
自定義的列(lie)表項(xiang)與默認的列(lie)表項(xiang)在行為(wei)和樣(yang)式上保持了高(gao)度(du)的一致。
但是為 img 增(zeng)加了 loading 懶加載屬性(xing)。
- loading="lazy" 告訴瀏覽器,在用戶滾動至圖片附近時才開始加載圖片。
- loading="eager"(這是默認值)則指示瀏覽器在頁面加載時立即加載所有圖片。
經過調試發現,300個圖(tu)像請求,默認會先(xian)請求100個左右(you)。

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

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

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

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

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

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

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

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

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

將得(de)到(dao)的(de)查詢條件復(fu)制(zhi)到(dao)搜索框中,就能得(de)到(dao)還未上(shang)傳的(de)圖像。

3)存儲溢出
當(dang)上傳300多(duo)張圖像時(shi),頁面(mian)報錯了。意思就是存儲超(chao)過(guo)最大(da)閾值,localStorage 最大(da)是 5M~10M。
QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'upload_image' exceeded the quota.
嘗試(shi)采(cai)用數(shu)據壓縮(suo),但仍然會報錯,只能另辟蹊徑。
/** * 緩(huan)存上傳文件 */ export function setUploadImageCache(data:UploadFile[]) { // 壓縮數據 const compress = btoa(encodeURIComponent(JSON.stringify(data))); localStorage.setItem('upload_image', compress); } /** * 讀取上(shang)傳文件的緩(huan)存 */ export function getUploadImageCache() { const files = localStorage.getItem('upload_image'); if(!files) return []; // 解壓數(shu)據 const uncompress = decodeURIComponent(atob(files)); return JSON.parse(uncompress); }
4)IndexedDB
將數據緩存到 IndexedDB 中,理(li)論上(shang)它可以(yi)占用 50% 的磁(ci)盤(pan)空(kong)間,所以(yi)不存在(zai)溢出(chu)的問題。
/** * 存儲(chu)數(shu)據(ju)(支持大型數(shu)據(ju)) * @param key 鍵名 * @param value 要存儲(chu)的值(zhi)(可為(wei)任(ren)意類型) */ 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); }; }); } /** * 獲(huo)取(qu)存(cun)(cun)儲(chu)的數據 * @param key 鍵名 * @returns 存(cun)(cun)儲(chu)的值(zhi),若不(bu)存(cun)(cun)在則(ze)返回 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); }; }); }
為了(le)能自動清理(li) IndexedDB 的數據,在(zai)組(zu)件中上(shang)傳(chuan)文件時,觸發清理(li)的操作,下面查(cha)看占用空間的代碼。
const quota = await navigator.storage.estimate(); console.log(`已用空間: ${quota.usage} 字(zi)節(jie)`); console.log(`總配額: ${quota.quota} 字(zi)節(jie)`); const percentageUsed = (quota.usage / quota.quota) * 100; console.log(`已使用可用存(cun)儲的 ${percentageUsed}%`);
現(xian)在(zai)即使(shi)緩(huan)存了 300 多張(zhang)圖(tu)像(xiang),也能(neng)實(shi)現(xian)恢復。
七、上傳反饋
1)不友好的等待
當需要(yao)上(shang)傳幾百張圖時(shi),在選好圖像(xiang)后,組(zu)件就會有(you)比(bi)較長的時(shi)間(jian)沒有(you)狀態變化(hua)。

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

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

雖然為 window 注冊 focus 事(shi)件,能夠實現(xian)在點擊取消(xiao)時觸發事(shi)件。
window.addEventListener('focus', function() {
const fileInput = document.getElementById(name) as HTMLInputElement;
console.log(fileInput && fileInput.files)
});
但是無(wu)法準確(que)讀(du)取(qu) fileList 列表(biao),當文(wen)件(jian)少的時(shi)候(hou),fileList 會馬上(shang)更新。
但是(shi)當文件(jian)幾百(bai)個時,在觸(chu)發 focus 事件(jian)時,fileList 仍然是(shi)空的。
而我優化的目的就(jiu)是要在加載這些文件的過(guo)程中,出現 loading 過(guo)渡動畫,如此就(jiu)無(wu)法判斷了。
為 file 控件(jian)(jian)注冊 change 事件(jian)(jian)的確能馬上拿(na)到(dao) 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 = ''; //清空已選文(wen)件
});
但(dan)是(shi)在 Upload 組件中,再次上傳(chuan)時(shi),卻無法觸(chu)發 file 控件 change 事(shi)件。
即使在 Upload 組件內清空文件的值,也無法(fa)再次觸(chu)發事件,很奇怪。
后面發現原來(lai)上傳控(kong)件(input[type=file])在(zai)上傳完成后會(hui)被刪(shan)除,之前注冊的事件就沒有了。
<span class="ant-upload"> <input id="reviews" type="file" accept="image/*" multiple="" style="display: none;"> </span>
需要自定義 Upload 組件的上(shang)傳區域,暫時不修改(gai)。
先與業(ye)務方同步,告知他們開啟緩存的(de)(de)上傳(chuan)組件(jian)會有取消彈框無法自(zi)動移(yi)除 loading 的(de)(de)問題(ti)。
5)事件委托
既(ji)然不(bu)能指定控件綁定事(shi)件,那(nei)么可以借用委托,給容器元素注冊事(shi)件。
如果(guo)用 DOM 的方式(shi)查(cha)找容(rong)器(qi),像下面這樣,那么當頁面中有多個上傳組件(jian)時,就會給(gei)所(suo)有的容(rong)器(qi)都(dou)注冊(ce)事件(jian)。
document.getElementsByClassName('ant-upload-select')
而 Upload 提供了 ref,可以通(tong)過(guo)該屬(shu)性(xing)注冊特(te)定組件內的容器。
const fileContainer = uploadRef.current?.nativeElement; const onClick = (e:Event) => { const fileInput = e.target as HTMLInputElement; // 攔(lan)截(jie)非上傳控件(jian)的(de)點(dian)擊事件(jian) if(!fileInput || fileInput.id !== name) { return; } /** * 在上傳(chuan)區域開啟(qi)加載狀態 * 因為當(dang)上傳(chuan)幾百張圖(tu)時,整個界(jie)面就會卡住(zhu),不給用戶(hu)反饋(kui) */ setUploadAreaLoading(true); }; fileContainer?.addEventListener('click', onClick, false);
同樣用委托的方式為 file 控件增加 change 事(shi)件,當有(you)文件時,更新 loading 狀態(tai)。
const onChange = (e:Event) => { const fileInput = e.target as HTMLInputElement; // 攔截非上傳控件的(de)點擊事(shi)件 if(!fileInput || fileInput.id !== name) { return; } const files = fileInput.files; if(files && files.length > 0) { setUploadAreaLoading(true); } }; fileContainer?.addEventListener('change', onChange, false);
這樣就能實現在(zai)選(xuan)中文(wen)件(jian)后,組件(jian)顯示 loading 等待狀態,取消(xiao)選(xuan)擇(ze)框(kuang),也能關閉(bi)等待狀態。
posted on