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 调用排查组件了。