事情的经过
两年前,我用GPT3.5手搓了个类似朋友圈的项目,用户可以发图片。测试的时候一切正常,上线后一直是过了一段时间服务器就会重启和崩溃。
一开始以为是服务器问题,排查了半天发现服务器正常。后来发现是前端的问题:用户看的图片越多,浏览器占用内存越多,最后直接崩溃。
问题定位
用Chrome DevTools监控了一下,发现了这个恐怖的现象:
- 看了100张图片:内存占用 500MB
- 看了500张图片:内存占用 2.5GB
- 看了1000张图片:浏览器直接崩溃
罪魁祸首
由于这个项目是两年前用gpt3.5手搓的,一直很忙没时间看代码,服务重启了我就手动启动。这不Claude+Trae越来越好用,越来越强。。。这次我直接把项目丢进去,结果AI还真给我找到了问题代码:
// 💀 这段代码会造成内存泄漏
useEffect(() => {
const fetchImages = async () => {
const imageUrls = await Promise.all(
post.imageS.map(async (imageUrl) => {
const response = await fetch(imageUrl);
const blob = await response.blob();
return URL.createObjectURL(blob); // 创建了blob URL
})
);
setImages(imageUrls); // 保存到state
};
fetchImages();
}, [post.imageS]);
问题在哪?
URL.createObjectURL() 会在内存中创建一个blob URL,但这个URL不会被垃圾回收器自动清理。每次用户看新图片,就会创建新的blob URL,旧的永远不会被释放。也就是说每次用户滑动看新图片,就会重复下载并在内存中保存,旧的图片数据永远不会被清理!
用户看1000张图片 = 内存中永久保存1000个blob对象 = 内存爆炸
URL字符串 vs 实际内存占用
虽然我们保存的"看起来"是URL字符串,但实际情况是这样的:
const blob = await response.blob(); // 这一步,图片数据已经在内存里了
const url = URL.createObjectURL(blob); // 这一步只是给内存中的数据分配了一个地址
console.log(url); // "blob:http://localhost:3000/12345678-abcd-..."
真相:
- URL字符串本身只占几十个字节
- 但这个URL是内存中真实图片数据的"门牌号"
- 真正吃内存的是blob对象里的图片数据(可能几MB)
- 浏览器会一直保留这些图片数据,直到你主动调用
revokeObjectURL()
就像你在仓库里存了1000箱货物,虽然你只记录了1000个货物编号,但仓库里确实堆着1000箱实物。
揣测为啥GPT3.5大哥为啥这么写?
这么做有三个优点,1是性能优先,不需要base64转换,不需要网络请求。2.灵活性:可以用在任何需要URL的地方(img、a、video等)3. 内存可控:提供了 revokeObjectURL() 让开发者手动管理
想实现的效果可能是:
- 提前下载图片到本地
- 用户滑动时瞬间显示,不用等网络
历史上的做法
方法1:转base64
// 把图片转成很长的文本字符串
const reader = new FileReader();
reader.onload = () => {
img.src = reader.result;
// 结果类似:data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEA...(超级长)
};
问题:
- base64比原图片大33%
- 转换过程阻塞页面
- 占用更多内存
方法2:上传到自己服务器
// 先把图片存到自己服务器,再用自己的URL
<img src="https://my-server.com/cached-photo1.jpg" />
问题:
- 需要额外的服务器存储
- 需要额外的网络请求
blob URL的优势
// 直接在浏览器内存中创建"虚拟URL"
const url = URL.createObjectURL(blob);
img.src = url; // blob:http://localhost:3000/12345678-...
优点:
- 性能好:不需要base64转换,不需要网络请求
- 灵活:可以当普通URL使用
- 本地化:图片数据在浏览器内存中,访问很快
为什么不自动回收?
其实浏览器很难知道你什么时候"用完"了这个URL:
const url = URL.createObjectURL(blob);
img1.src = url; // 第一个img在用
img2.src = url; // 第二个img也在用
someArray.push(url); // 可能还存在别的地方
// 浏览器怎么知道你什么时候真的"不用"了?
所以设计者选择了手动管理的方式,就像C语言的 malloc/free 一样。
解决方案
1. 添加清理逻辑
useEffect(() => {
const fetchImages = async () => {
const imageUrls = await Promise.all(
post.imageS.map(async (imageUrl) => {
const response = await fetch(imageUrl);
const blob = await response.blob();
return URL.createObjectURL(blob);
})
);
setImages(imageUrls);
};
fetchImages();
// 🔑 关键:组件卸载时清理内存
return () => {
images.forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url); // 释放blob URL
}
});
};
}, [post.imageS]);
2. 防止组件卸载后的内存泄漏
useEffect(() => {
let isMounted = true; // 防止组件卸载后继续操作
const fetchImages = async () => {
// ... 异步操作
if (isMounted) { // 只有组件还在时才更新状态
setImages(imageUrls);
}
};
fetchImages();
return () => {
isMounted = false; // 标记组件已卸载
images.forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
};
}, [post.imageS]);
3. 现代解决方案
其实现在有更好的选择:
// 方案1:直接用HTTP URL,让浏览器自己缓存
<img src={imageUrl} alt="..." />
// 方案2:用 object-fit 配合懒加载
const [imageSrc, setImageSrc] = useState('');
useEffect(() => {
const img = new Image();
img.onload = () => setImageSrc(imageUrl);
img.src = imageUrl;
}, [imageUrl]);
// 方案3:用现代图片格式 + 响应式
<img
src={imageUrl}
loading="lazy"
decoding="async"
alt="..."
/>
总结:
createObjectURL的设计初衷是好的,解决了真实问题- 但它把内存管理的责任推给了开发者
- 很多人不知道要配对使用
revokeObjectURL() - 现在有更好的替代方案,除非有特殊需求,否则直接用HTTP URL就行
经验总结
URL.createObjectURL()必须配对URL.revokeObjectURL()- 异步操作要防止组件卸载后的状态更新
- 大量图片场景要考虑懒加载
- 定期用DevTools监控内存使用
一句话总结
别让 URL.createObjectURL() 裸奔,记得给它穿上 URL.revokeObjectURL() 的保猃套。