前端知库

Three.js 页面退出后为什么还占内存?怎样管理模型和渲染器的生命周期?

从产品预览反复打开的场景出发,区分场景移除、GPU 资源释放和 JavaScript 引用,给出独占资源的挂载与清理示例,并解释共享纹理和异步加载的边界。

参考答案

简答

  • scene.remove 只把对象移出场景,不会释放 geometry、material 和 texture。
  • 独占资源由页面在卸载时 dispose,共享资源由资源管理层在最后一个使用者释放后处理。
  • 异步加载的结果可能在页面离开后才返回,完成时要先检查是否仍被需要。
  • 验证时重复挂载和卸载,比较多轮之后的 JS 堆和 renderer.info,不要求所有数字归零。

产品详情页打开三维预览,关闭后删掉 canvas,再打开另一个商品。几次切换后内存越来越高:DOM 已移除,但动画循环仍在调用 render,几何体和纹理的 GPU 资源也没有释放。浏览器垃圾回收与 Three.js 资源生命周期不是一回事。

本文按 Three.js r180 官方资源释放说明编写。示例适用于安装对应版本 three 的浏览器模块项目,需要可用的 WebGL2;不是把题库网站改成三维应用,也不承诺所有设备都支持。

移除、释放、断开引用分别解决什么

scene.remove(mesh) 让对象不再参与该场景的遍历,但不会自动 dispose 它的 geometry、material 和 texture。因为这些对象可能被其他 mesh 共用,渲染器无法替业务决定是否还能使用。

geometry.dispose() 释放关联的 GPU 缓冲;material.dispose() 通知释放材质相关资源,但不会递归释放纹理。texture.dispose() 要单独调用。若纹理由 ImageBitmap 提供,还需在确定没有其他消费者时调用 bitmap.close() 释放相应 CPU 侧资源。

最后,JavaScript 数组、缓存、组件状态和闭包中的引用也应移除。dispose 并不意味着对象变量变成 null,更不意味着动画回调、ResizeObserver 或 DOM 监听器自动停止。

一个独占资源的完整挂载例子

假设容器由页面提供明确高度,例如 style="height: 320px"。把函数放入浏览器模块,调用 const cleanup = mountPreview(container),路由卸载时调用 cleanup。这个场景只有一个立方体,没有共享模型或外部资源,因而所有资源都由这次挂载独占。

JavaScript
import * as THREE from 'three';

export function mountPreview(container) {
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.domElement.style.display = 'block';
  container.append(renderer.domElement);

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
  camera.position.z = 4;
  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const material = new THREE.MeshNormalMaterial();
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);

  let disposed = false;
  function resize() {
    if (disposed) return;
    const width = Math.max(1, container.clientWidth);
    const height = Math.max(1, container.clientHeight);
    renderer.setSize(width, height);
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
  }
  const observer = new ResizeObserver(resize);
  observer.observe(container);
  resize();

  renderer.setAnimationLoop((time) => {
    if (disposed) return;
    cube.rotation.y = time * 0.0005;
    renderer.render(scene, camera);
  });

  return function cleanup() {
    if (disposed) return;
    disposed = true;
    renderer.setAnimationLoop(null);
    observer.disconnect();
    scene.remove(cube);
    geometry.dispose();
    material.dispose();
    renderer.dispose();
    renderer.domElement.remove();
  };
}

顺序先停止生产新帧和回调,再释放被回调使用的资源。disposed 使重复清理安全,也保护已经排队的尺寸回调。宿主调用 cleanup 后还应删除保存它的引用,否则闭包仍保留相关 JavaScript 对象。

创建 WebGLRenderer 可能失败,宿主需捕获异常并显示静态产品图或说明。示例聚焦正常挂载后的资源生命周期,没有实现初始化中途异常的逐项回滚。实际组件若还创建控制器、后处理目标或加载器,应让每一步成功创建的资源都登记到清理范围。

为什么不能遍历场景后无条件 dispose 所有东西

两个商品可能共享同一张环境贴图,多个 mesh 可能共享一个材质。关闭 A 时直接 dispose 共享资源,会让仍在显示的 B 重新上传或编译资源,产生卡顿。更合理的方式是明确所有者:页面独占资源由页面清理,共享资源由资源管理层在最后一个消费者释放后处理。

同一个对象在遍历中出现多次,可以用 Set 去重;但 Set 只解决重复调用,不证明所有权。scene.environment、后处理 render target、控制器监听器和动画 mixer 也不一定包含在 mesh.material 的遍历路径里,要按所用类的文档分别管理。

异步加载结束时页面可能已经离开

用户切走后 glTF 请求仍可能完成。仅取消页面 render 不会让返回的模型自动释放。加载流程应记录所属页面或请求代次,完成时先检查是否仍被需要;过期结果不要加入场景,而应交给资源所有者释放。如果加载器支持取消就调用取消,但仍保留迟到结果检查。

不要声称所有 Three.js loader 都接受同一个 AbortSignal。文件、纹理与解码 Worker 的取消能力需逐一确认。对用户模型限制文件大小、纹理尺寸和解码资源,并控制外部资源来源;跨源加载与 CORS 也要由资源服务器配合。

怎样判断修复有效

固定同一个模型,重复挂载、渲染、卸载,观察 JS 堆快照及 renderer.info 中的几何体、纹理和程序统计。比较多轮操作后的稳定基线,不要求所有数字归零:Three.js 官方说明部分内部缓存会复用,并可能在场景清理后仍显示。

renderer.info 不是显存字节精确计量器。纹理尺寸、像素比和后处理目标都会增加实际资源成本;devicePixelRatio 翻倍可能使画布像素数约为四倍。真实 GPU、移动设备和上下文丢失行为需要另外验证,单元测试只能验证清理调用和生命周期协议。

静态预览可考虑按需渲染而不是永久动画循环;大量页面共享一个 renderer 时,页面卸载只释放页面拥有的资源,不能把宿主 renderer 一并销毁。选择策略应从资源所有权和实际交互出发。

补充参考