在用 Three.js 开发 3D 模型渲染功能时,收到一条 Bug 报告:“在手机上看了几个模型后,页面会突然刷新。”
起初我以为只是网络问题或浏览器兼容性问题,但复现之后发现存在明确的规律。在 iPhone 的 Safari 上,连续加载大约 3~4 个不同的 3D 模型时,浏览器会反复强制刷新页面。
在移动端浏览器中,这种强制刷新最常见的原因是内存不足。iOS Safari 一旦内存超过某个阈值,就会毫不留情地杀掉页面。因此我决定重点排查内存泄漏的可能性。
问题
在 Chrome DevTools 的 Memory 面板中多次拍摄堆快照,跟踪内存使用量的变化。果不其然,每次加载 3D 模型时,JSArrayBufferData 都会按照对应 GLB 文件的大小不断累积。如果把一个 10MB 的模型加载 5 次,大约 50MB 会一直留在内存中。
虽然我在 useEffect 的 cleanup 函数里已经实现了细致的清理逻辑,全面调用了 geometry.dispose()
、material.dispose()
、texture.dispose()
,甚至显式赋值为 null
,但内存使用量仍不下降。
原因
问题出在我为性能优化而在项目初期全局设置的一行代码。
THREE.Cache.enabled = true;
该设置会让 GLTFLoader 在再次请求相同 URL 的文件时,从内存缓存中读取以节省网络开销。一般情况下这是很好的优化,但本项目的特殊情况使之出了问题。
出于安全原因,3D 模型文件使用的是预签名(pre-signed)URL,一旦下载完成,该 URL 会立刻失效,并被新的 URL 替换。也就是说,即便是同一个模型,每次访问的 URL 都完全不同。于是 Three.js 的缓存会把它们当作“不同的文件”,不断堆积且无法复用。
解决
找到原因后,解决方案就很明确了:在 useEffect 的 cleanup 函数中,在原有的 dispose 逻辑之外,再显式地把缓存项删除。
useEffect(() => {
return () => {
// ...
THREE.Cache.remove(fbxUrl);
THREE.Cache.remove(glbUrl);
};
}, []);
修改后再次进行内存分析,发现每次页面切换时 JSArrayBufferData 都能被干净地回收。在移动端,即使连续加载 10 个以上的模型,也能稳定运行而不会被强制刷新。
THREE.Cache.remove 与 dispose
THREE.Cache.remove 处理的是“文件缓存层面”的内存;而 dispose 则是在“GPU 内存层面”释放 Three.js 对象的资源。两者所处的层次不同。
Cache.remove
:Three.js 文件缓存层面(网络加载的原始文件数据)dispose
:GPU 内存层面(如 geometry、material、texture 等 WebGL 资源)
要实现彻底的内存清理,这两者都必不可少。
收尾
我意识到,为了性能而开启的缓存功能,在特殊场景下反而可能成为内存泄漏的根源。同时也得到一个教训:在初期设置时,像 THREE.Cache.enabled
这样的缓存功能是否真的有必要开启?结合项目特性可能带来哪些副作用?这些都应该事先加以考虑。
参考