Three.js 简介

Vue3 实战封装

理清 Three.js 和 Vue 的分工原则,把 3D 场景封装成可复用组件,并学会正确释放 WebGL 资源避免内存泄漏。

🎯 引言

前面的代码都写在一个组件里,换一个页面要用就得复制一遍。更隐蔽的问题是:Three.js 占用的 WebGL 资源不会随组件卸载自动释放,不管不顾会造成内存泄漏。学完这篇文章,你能理清 Three.js 和 Vue 的分工,封装出可复用的 3D 组件,并把资源清理得干干净净。


🧱 分工原则:Vue 管容器,Three 管 canvas

Three.js 和 Vue 都想操作 DOM,分工原则和 D3 课程讲的一样,一句话:

Vue 只负责渲染空容器,容器内部的 canvas 全部交给 Three.js。

三条规矩:

  1. 模板里只写一个带 ref 的空 <div>
  2. <canvas> 由渲染器创建(renderer.domElement),我们手动挂进容器。
  3. 初始化代码放在 onMounted 里执行。

🪤 卸载时必须手动清理

这是 Three.js 和 D3 明显不同的地方。Vue 卸载组件时会移除 DOM,但以下三样东西还留在内存里

  • 渲染循环requestAnimationFrame 还在一帧帧空跑。
  • WebGL 资源:几何体顶点、材质、贴图存在 GPU 内存里,Vue 不知道它们的存在。
  • 事件监听:OrbitControls 在 canvas 上绑定的鼠标事件。

页面里反复进出几次 3D 页面,内存就一路上涨。清理套路是固定的,在 onBeforeUnmount 里逐个释放:

onBeforeUnmount(() => {
    cancelAnimationFrame(frameId); // 1. 停掉渲染循环
    controls.dispose(); // 2. 移除控制器的事件监听
    geometry.dispose(); // 3. 释放几何体
    material.dispose(); // 4. 释放材质
    renderer.dispose(); // 5. 释放渲染器(WebGL 上下文)
});

注意要把这些变量声明在 onMounted 外面,清理函数才访问得到。这是和前面示例代码唯一的结构差异。


🛠 封装 SpinCube 组件

按分工原则和清理规范,封装一个带交互的旋转立方体组件:

components/SpinCube.vue
<template>
    <div ref="containerRef" class="container"></div>
</template>

<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const props = defineProps({
    color: {
        type: Number,
        default: 0x44aa88,
    },
});

const containerRef = ref(null);

let renderer = null;
let controls = null;
let geometry = null;
let material = null;
let frameId = 0;

onMounted(() => {
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, 320 / 240, 0.1, 1000);
    camera.position.set(2, 2, 4);
    camera.lookAt(0, 0, 0);

    geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
    material = new THREE.MeshStandardMaterial({ color: props.color });
    const cube = new THREE.Mesh(geometry, material);
    scene.add(cube);

    scene.add(new THREE.AmbientLight(0xffffff, 0.5));
    const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
    directionalLight.position.set(3, 5, 4);
    scene.add(directionalLight);

    renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(320, 240);
    renderer.setPixelRatio(window.devicePixelRatio);
    containerRef.value.appendChild(renderer.domElement);

    controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;

    const animate = () => {
        frameId = requestAnimationFrame(animate);
        controls.update();
        renderer.render(scene, camera);
    };
    animate();
});

onBeforeUnmount(() => {
    cancelAnimationFrame(frameId);
    controls.dispose();
    geometry.dispose();
    material.dispose();
    renderer.dispose();
});
</script>

<style scoped>
.container {
    width: 320px;
    height: 240px;
}
</style>

使用时就一行,想要别的颜色传个 prop:

views/Product.vue
<template>
    <SpinCube :color="0x6699ff" />
</template>

<script setup>
import SpinCube from '@/components/SpinCube.vue';
</script>

💡 Vue 中使用 Three.js 的常见坑

原因对策
页面空白onMounted 前初始化初始化代码放进 onMounted
物体不动只用 renderer.render() 拍了一张照片requestAnimationFrame 开渲染循环
反复进出页面后越来越卡渲染循环、WebGL 资源没清理onBeforeUnmount 里逐个 dispose
阻尼无效循环里没调 controls.update()每帧调用 controls.update()
高分屏画面模糊没设置像素比renderer.setPixelRatio(window.devicePixelRatio)

🧾 小节总结

  • 分工原则:Vue 渲染空容器,canvas 由渲染器创建并挂进容器。
  • 组件卸载不会自动释放 WebGL 资源,必须手动清理。
  • 清理五件套:cancelAnimationFramecontrols.dispose()geometry.dispose()material.dispose()renderer.dispose()
  • 要释放的对象声明在 onMounted 外层,清理函数才能访问。
  • setPixelRatio 解决高分屏模糊;controls.update() 是阻尼生效的前提。

❓ 知识问答

Q1:每个几何体、材质都要单独 dispose 吗?

是的。场景里每 new 一个几何体或材质,卸载时就对应一个 dispose()。封装组件时把它们收集起来统一释放,就不会漏。

Q2:路由跳走组件就销毁了,为什么还要取消动画帧?

requestAnimationFrame 的回调挂在浏览器上,不归 Vue 管。不取消的话,组件没了它还在一帧帧执行,访问已经销毁的渲染器还可能报错。

Q3:项目里有多个不同的 3D 场景,每个都封装一个组件吗?

通用部分(初始化、循环、清理)是重复的,可以抽成一个组合式函数或基座组件,各场景只写自己的场景内容。思路和 D3 课程的组件封装一脉相承。

Q4:SSR(如 Nuxt)项目里要注意什么?

Three.js 依赖浏览器环境,初始化代码必须在 onMounted(只在客户端执行)里运行。本课程的写法天然满足这一点,直接可用。


🧪 小练习

封装一个 FloatSphere.vue 组件:

  1. 接收 colorspeed 两个 prop,控制球体颜色和浮动速度。
  2. 球体边自转边上下浮动,支持 OrbitControls 交互。
  3. 卸载时完整清理五件套,然后在页面上放两个不同颜色的组件,验证互不影响。
components/FloatSphere.vue
<template>
    <div ref="containerRef" class="container"></div>
</template>

<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

// 请在这里编写代码
</script>

<style scoped>
.container {
    width: 320px;
    height: 240px;
}
</style>

🎉 恭喜你已经完整掌握 Three.js 的核心技能啦!从三大件、几何体材质、灯光阴影,到相机动画和鼠标交互,再到 Vue3 工程化封装,你已经具备在真实项目中落地 3D 场景的能力。Three.js 的世界很大,模型加载、粒子特效、着色器都在向你招手,快去探索吧!