2026年7月10日

React useObjectUrl Hook:預覽檔案與 Blob,不留記憶體洩漏(2026)

使用者往圖片編輯器裡上傳了十幾張圖片,每一張都透過 URL.createObjectURL() 產生即時預覽。工作階段持續了二十分鐘,預覽隨著檔案的增減不斷切換,分頁的記憶體佔用則一路攀升——因為每一次 createObjectURL() 呼叫都必須配對一次 revokeObjectURL() 呼叫,而這個配對關係要在重新渲染、props 變化、元件卸載之間始終成立。大多數元件都沒做對,而這個 bug 要等到有人把分頁開得夠久、注意到風扇轉起來了才會被發現。

useObjectUrl 就是那個一行程式碼的修復:把 FileBlobMediaSource 丟給它,拿回一個 URL 字串,再也不用自己呼叫 revokeObjectURL。以下都是 @reactuses/core 的真實 API,TypeScript 優先。

手寫版本,以及它在哪裡洩漏

URL.createObjectURL() 是瀏覽器裡支撐一切客戶端檔案預覽的 API:傳給它一個 BlobFile 就是其中一種),它會回傳一個 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() 的清理函式。它和手寫版本的差別不在行為,而在於這套配對關係只存在於一個經過稽核的地方,而不是每個元件各自重新推導一遍。

來源變化時,以及元件卸載時

回收會在兩個真正重要的時機觸發:

  • 來源變化。file prop 換成另一個 File,hook 會在鑄造新 URL 之前先回收舊的——使用者逐一點開附件清單時不會產生堆積。
  • 元件卸載。 關閉預覽視窗、跳轉到別的頁面、從清單裡移除某一項——清理函式都會執行,URL 會被釋放。不會留下什麼等著一個根本不知道要去回收它的垃圾回收器。

第二種情況恰恰是手寫程式碼最常漏掉的一種,因為它只會在真實使用中表現為緩慢的記憶體爬升,快速的手動測試根本發現不了。

真實使用情境

  • 檔案輸入預覽。 在使用者提交上傳之前,把剛選中的圖片、影片或 PDF 展示出來——這是最典型的情境,也是下面示範裡用到的那種。
  • Blob API 回應。 一個回傳 Blob 的介面(產生的匯出檔案、帶簽章的資源、fetch().blob() 的結果)可以直接變成可下載的 <a href>,或者內嵌的 <img>/<video>,不需要先寫到磁碟上。
  • Canvas 匯出。 canvas.toBlob() 會產出一個 Blob;把它直接餵給 useObjectUrl,就能預覽或提供下載使用者剛畫好、裁切好的內容。
  • MediaSource 用於自訂影片播放器。 MediaSourceMedia 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

useFileDialoguseDropZone 的結果直接接入 useObjectUrl,預覽這一步就完全不需要寫清理程式碼。包含多檔案圖庫範例(把三個 hook 組合在一起)的完整說明在 React File Handling 裡。

重點整理

  • 核心問題是一個配對 bug。 每一次 URL.createObjectURL() 都需要一次相符的 URL.revokeObjectURL(),而在每一個展示檔案預覽的元件裡重新推導這個配對關係,正是記憶體洩漏悄悄出現的地方。
  • useObjectUrl(object) 接受一個 Blob(或 File)或 MediaSource,回傳 URL 字串,在來源存在之前則是 undefined
  • 回收是自動的,在兩個真正重要的時機都會觸發:來源變化,以及元件卸載。
  • 涵蓋的不只是圖片預覽:Blob API 回應、canvas.toBlob() 匯出,以及用於自訂影片播放器的 MediaSource
  • SSR 安全——在伺服器端回傳 undefined,不存取 URL API,不需要寫任何守衛程式碼。
  • 搭配 useFileDialoguseDropZone,選取、拖放、預覽三步都不需要手動管理生命週期。

@reactuses/core 裡拿走它,別再為漏掉的 revokeObjectURL 呼叫排查元件了。