2026年7月10日
React useObjectUrl Hook:預覽檔案與 Blob,不留記憶體洩漏(2026)
使用者往圖片編輯器裡上傳了十幾張圖片,每一張都透過 URL.createObjectURL() 產生即時預覽。工作階段持續了二十分鐘,預覽隨著檔案的增減不斷切換,分頁的記憶體佔用則一路攀升——因為每一次 createObjectURL() 呼叫都必須配對一次 revokeObjectURL() 呼叫,而這個配對關係要在重新渲染、props 變化、元件卸載之間始終成立。大多數元件都沒做對,而這個 bug 要等到有人把分頁開得夠久、注意到風扇轉起來了才會被發現。
useObjectUrl 就是那個一行程式碼的修復:把 File、Blob 或 MediaSource 丟給它,拿回一個 URL 字串,再也不用自己呼叫 revokeObjectURL。以下都是 @reactuses/core 的真實 API,TypeScript 優先。
手寫版本,以及它在哪裡洩漏
URL.createObjectURL() 是瀏覽器裡支撐一切客戶端檔案預覽的 API:傳給它一個 Blob(File 就是其中一種),它會回傳一個 blob: URL,可以直接塞進 <img src> 或 <video src>。問題出在另一端——它鑄造出的每一個 URL 都會持續存活、持有底層資料的參照,直到你明確呼叫 URL.revokeObjectURL()。忘記呼叫,或者在錯誤的時機呼叫,這個 URL——以及它背後的記憶體——就永遠不會被釋放。
在 React 元件裡,「錯誤的時機」很容易撞上:
function FilePreview({ file }: { file?: File }) {
const [url, setUrl] = useState<string>();
useEffect(() => {
if (!file) return;
const next = URL.createObjectURL(file);
setUrl(next);
return () => URL.revokeObjectURL(next);
}, [file]);
return url ? <img src={url} /> : null;
}
這一版其實是對的——清理函式閉包捕捉的是 next,而不是 state,所以它總能精確回收自己建立的那個 URL。但它「對」的方式,正是手寫 effect 常見的那種脆弱:只要一次不小心的重構就會出問題。把 URL.createObjectURL 呼叫搬到 effect 外面、錯誤地做了 memo、或者從 state 裡讀 url 而不是本地變數 next,配對關係就會悄無聲息地斷裂。把這套模式複製到每一個需要預覽檔案的元件裡——大頭貼上傳、聊天附件、圖庫縮圖——你就是在五個不同的地方維護著同一套脆弱的生命週期邏輯。
useObjectUrl —— 建立與回收,都替你處理好
import { useObjectUrl } from '@reactuses/core';
function FilePreview({ file }: { file?: File }) {
const url = useObjectUrl(file);
return url ? <img src={url} /> : null;
}
整個 hook 就這麼多。簽名是:
function useObjectUrl(object: Blob | MediaSource): string | undefined;
傳入一個 Blob(或者它的子型別 File)或 MediaSource,拿回對應的 object URL——在來源存在之前則是 undefined。它內部就是一個 useEffect:來源存在時呼叫 URL.createObjectURL(),並回傳一個呼叫 URL.revokeObjectURL() 的清理函式。它和手寫版本的差別不在行為,而在於這套配對關係只存在於一個經過稽核的地方,而不是每個元件各自重新推導一遍。
來源變化時,以及元件卸載時
回收會在兩個真正重要的時機觸發:
- 來源變化。 把
fileprop 換成另一個File,hook 會在鑄造新 URL 之前先回收舊的——使用者逐一點開附件清單時不會產生堆積。 - 元件卸載。 關閉預覽視窗、跳轉到別的頁面、從清單裡移除某一項——清理函式都會執行,URL 會被釋放。不會留下什麼等著一個根本不知道要去回收它的垃圾回收器。
第二種情況恰恰是手寫程式碼最常漏掉的一種,因為它只會在真實使用中表現為緩慢的記憶體爬升,快速的手動測試根本發現不了。
真實使用情境
- 檔案輸入預覽。 在使用者提交上傳之前,把剛選中的圖片、影片或 PDF 展示出來——這是最典型的情境,也是下面示範裡用到的那種。
- Blob API 回應。 一個回傳
Blob的介面(產生的匯出檔案、帶簽章的資源、fetch().blob()的結果)可以直接變成可下載的<a href>,或者內嵌的<img>/<video>,不需要先寫到磁碟上。 - Canvas 匯出。
canvas.toBlob()會產出一個Blob;把它直接餵給useObjectUrl,就能預覽或提供下載使用者剛畫好、裁切好的內容。 MediaSource用於自訂影片播放器。MediaSource是 Media Source Extensions 背後的物件,支撐著自適應串流和自訂緩衝的影片——你以程式化方式建構這個串流,仍然需要一個 URL 交給<video src>。useObjectUrl可以直接接受它,和接受Blob一樣。
天生 SSR 安全
URL.createObjectURL 在伺服器端根本不存在——沒有 DOM,也就沒有 Blob URL 可鑄造。useObjectUrl 在伺服器端渲染期間完全不會碰 URL API;它會一直回傳 undefined,直到 effect 在客戶端執行為止,所以不需要記得寫 typeof window 判斷,也不會有伺服器端崩潰需要排查。如果你在排查其他手寫瀏覽器 API 程式碼裡的類似缺口,SSR-Safe React Hooks 講了這個通用模式。
檔案處理三件套
useObjectUrl 是一小組 hook 裡的一環,這組 hook 涵蓋了檔案從選取、拖放到預覽的完整生命週期:
| Hook | 角色 |
|---|---|
useFileDialog | 開啟原生檔案選擇器,回傳選中的 FileList |
useDropZone | 把任意元素變成檔案的拖放目標 |
useObjectUrl | 把得到的 File/Blob 轉成安全、自動回收的預覽 URL |
把 useFileDialog 或 useDropZone 的結果直接接入 useObjectUrl,預覽這一步就完全不需要寫清理程式碼。包含多檔案圖庫範例(把三個 hook 組合在一起)的完整說明在 React File Handling 裡。
重點整理
- 核心問題是一個配對 bug。 每一次
URL.createObjectURL()都需要一次相符的URL.revokeObjectURL(),而在每一個展示檔案預覽的元件裡重新推導這個配對關係,正是記憶體洩漏悄悄出現的地方。 useObjectUrl(object)接受一個Blob(或File)或MediaSource,回傳 URL 字串,在來源存在之前則是undefined。- 回收是自動的,在兩個真正重要的時機都會觸發:來源變化,以及元件卸載。
- 涵蓋的不只是圖片預覽:Blob API 回應、
canvas.toBlob()匯出,以及用於自訂影片播放器的MediaSource。 - SSR 安全——在伺服器端回傳
undefined,不存取URLAPI,不需要寫任何守衛程式碼。 - 搭配
useFileDialog和useDropZone,選取、拖放、預覽三步都不需要手動管理生命週期。
從 @reactuses/core 裡拿走它,別再為漏掉的 revokeObjectURL 呼叫排查元件了。