解決 Semi Design Upload 組件實現(xiàn)自定義壓縮,上傳文件后無法觸發(fā) onChange
背景
我們團隊主要在做 C 端產(chǎn)品,對于 C 端應用,圖片資源使用 CDN 十分重要,因此我們曾建立了一個文件上傳平臺:上傳文件后,可以復制圖片的 CDN URL 在前端項目中使用。
目前服務端不會對圖片做壓縮,使用前得先借助其他工具手動壓縮再上傳,體驗很差。調(diào)研后發(fā)現(xiàn),純前端就能完成壓縮且完全滿足需求,于是決定給 Upload 組件加上「自動壓縮」能力。
實現(xiàn)思路
整體流程:
- 用戶多選文件 → 文件列表展示文件名、狀態(tài)、預覽圖等
- 異步批量壓縮圖片,實時更新狀態(tài):
壓縮中 → 已壓縮,等待上傳 / 壓縮失敗,使用原文件 - 用戶點擊「開始上傳」→ 狀態(tài)變?yōu)?
上傳中 → 上傳成功 / 失敗
Semi Design 的 Upload 組件有兩處可切入壓縮邏輯:
| 方案 | 入口 | 說明 |
|---|---|---|
| ① | transformFile |
選中后、上傳前觸發(fā),返回新 FileItem |
| ② | onChange |
文件狀態(tài)變化回調(diào),可完全自定義流程 |
需要展示「壓縮中」「壓縮失敗」等中間態(tài),因此選用更靈活的 方案②。
關鍵類型
Upload 組件的 FileItem 類型包含了一些信息,因此我通過更新 FileItem 對象的屬性可以很容易實現(xiàn)文件列表的狀態(tài)更新。
interface FileItem {
event? : event, // xhr event
fileInstance?: File, // original File Object which extends Blob, 瀏覽器實際獲取到的文件對象(https://developer.mozilla.org/zh-CN/docs/Web/API/File)
name: string,
percent? : number, // 上傳進度百分比
preview: boolean, // 是否根據(jù)url進行預覽
response?: any, // xhr的response, 請求成功時為respoonse body,請求失敗時為對應 error
shouldUpload?: boolean; // 是否應該繼續(xù)上傳
showReplace?: boolean, // 單獨控制該file是否展示替換按鈕
showRetry?: boolean, // 單獨控制該file是否展示重試按鈕
size: string, // 文件大小,單位kb
status: string, // 'success' | 'uploadFail' | 'validateFail' | 'validating' | 'uploading' | 'wait';
uid: string, // 文件唯一標識符,如果當前文件是通過upload選中添加的,會自動生成uid。如果是defaultFileList, 需要自行保證不會重復
url: string,
validateMessage?: ReactNode | string,
}
代碼實現(xiàn)
<Upload
// other props
customRequest={customRequest}
fileList={storeFileList}
onChange={onChange}
/>
傳入 fileList 表示組件處在受控模式。
const onChange = async ({ fileList }: OnChangeProps) => {
const newFileList = uniqBy(fileList, (file) => file.name);
actions.batchUpdate({ fileList: newFileList });
const filesToCompress = getFilesToCompress(newFileList);
if (filesToCompress.length === 0) {
return;
}
for (const file of filesToCompress) {
updateFileList(file.name, {
status: "validating",
validateMessage: "壓縮中...",
});
}
const compressionPromises = files.map(async (file) => {
try {
const compressedFile = await compressImage(file, 80);
updateFileList(file.name, {
fileInstance: compressedFile,
status: "wait",
validateMessage: "已壓縮,等待上傳",
});
} catch {
updateFileList(file.name, {
status: "wait",
validateMessage: "壓縮失敗,使用原文件",
});
}
});
await Promise.allSettled(compressionPromises);
};

為了演示效果,
compressImage里故意 sleep 了 2s。
踩坑:onSuccess 后 onChange 未觸發(fā)
文件上傳成功后,我們在 customRequest 里調(diào)用 onSuccess 期望組件自動把狀態(tài)改為 success,但 onChange 居然沒執(zhí)行!
const customRequest = ({ file, onProgress, onError, onSuccess }) => {
batchUpload(file.fileInstance)
.then(res => {
if (res.data.code === '0') onSuccess(res, file.event!);
// 其他邏輯
})
.catch(onError);
};
排查過程

經(jīng)過幾輪友好 AI 對話,它總往錯誤方向跑,即使我告訴它去源碼中尋找答案。看來只能動動小手了。
semi 有兩個概念:
- Foundation:Foundation 包含最能代表 Semi Design 組件交互的業(yè)務邏輯,包括 UI 行為觸發(fā)后的各種計算、分支判斷等邏輯,它并不直接操作或者引用 DOM,任意需要 DOM 操作,驅(qū)動組件渲染更新的部分會委派給 Adapter 執(zhí)行。
- Adapter:Adapter 是一個接口,具有 Foundation 實現(xiàn) Semi Design 業(yè)務邏輯所需的所有方法,并負責 1. 組件 DOM 結構聲明 2.負責所有跟 DOM 操作/更新相關的邏輯,通常會使用框架 API 進行 setState、getState、addEventListener、removeListener 等操作。適配器可以有許多實現(xiàn),允許與不同框架的互操作性。
所以我們可以直接去 upload/foundation.ts 中查看實現(xiàn)邏輯。

在 handleSuccess 中有調(diào)用 notifyChange, notifyChange 就是 props.onChange。

我們還看到在handleSuccess 中有一個判斷, 如果未找到文件就 return, 也就不會調(diào)用 onChange 了。我們繼續(xù)查看判斷中用到的 _getFileIndex 函數(shù)

它是通過對比 File 對象的 uid 屬性實現(xiàn)的,瀏覽器原生 File 對象是沒有 uid 屬性的,那么這個 uid 是哪里來的呢?也是 upload/foundation.ts 中添加的。

到此,我們可以大膽猜測,我們在上傳文件后,調(diào)用 onSuccess,走到 handleSuccess 邏輯被提前 return 了,原因就是上傳的文件對象中沒有 uid 或者 uid 不一致。我們在代碼中斷點看一下:

的確,F(xiàn)ile 對象中并沒有 uid 字段。 我們把壓縮邏輯注釋掉,繼續(xù)斷點并拿 fileInstance 對比下:

對比得知,壓縮后的 File 對象中沒有 uid 字段,這證實了我們之前的猜想。排查過程總結如下:
- 翻 Semi 源碼(upload/foundation.ts)
handleSuccess→notifyChange(props.onChange),但前面有一行:“找不到文件就提前 return”。 _getFileIndex通過uid匹配文件。原生File對象沒有uid,是Semi 在添加文件時動態(tài)掛上去的。- 我們壓縮后把
fileInstance整個替換成了新File,導致 uid 丟失,于是_getFileIndex返回 -1 → 直接 return →onChange永遠不會觸發(fā)。
解法:把 uid 還回去
解決這個問題非常簡單, 由于 uid 是本來就有的,只是我們對文件壓縮后給丟了, 所以文件壓縮后再還回去就可以了。



浙公網(wǎng)安備 33010602011771號