用户往图片编辑器里上传了十几张图片,每一张都通过 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 组件里,"错误的时机"很容易撞上:
tsx
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 ------ 创建与回收,都替你处理好
tsx
import { useObjectUrl } from '@reactuses/core';
function FilePreview({ file }: { file?: File }) {
const url = useObjectUrl(file);
return url ? <img src={url} /> : null;
}
整个 hook 就这么多。签名是:
ts
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 判断,也不会有服务端崩溃需要排查。
文件处理三件套
useObjectUrl 是一小组 hook 里的一环,这组 hook 覆盖了文件从选取、拖放到预览的完整生命周期:
| Hook | 角色 |
|---|---|
useFileDialog |
打开原生文件选择器,返回选中的 FileList |
useDropZone |
把任意元素变成文件的拖放目标 |
useObjectUrl |
把得到的 File/Blob 转成安全、自动回收的预览 URL |
把 useFileDialog 或 useDropZone 的结果直接接入 useObjectUrl,预览这一步就完全不需要写清理代码。
要点总结
- 核心问题是一个配对 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 调用排查组件了。