🔥 一个API让我的网站内存泄漏 3GB,最后靠AI救了命

1,114 阅读5分钟

事情的经过

两年前,我用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-...

优点

  1. 性能好:不需要base64转换,不需要网络请求
  2. 灵活:可以当普通URL使用
  3. 本地化:图片数据在浏览器内存中,访问很快

为什么不自动回收?

其实浏览器很难知道你什么时候"用完"了这个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就行

经验总结

  1. URL.createObjectURL() 必须配对 URL.revokeObjectURL()
  2. 异步操作要防止组件卸载后的状态更新
  3. 大量图片场景要考虑懒加载
  4. 定期用DevTools监控内存使用

一句话总结

别让 URL.createObjectURL() 裸奔,记得给它穿上 URL.revokeObjectURL() 的保猃套。