Vue3 实战封装
🎯 引言
前面的代码都写在一个组件里,换一个页面要用就得复制一遍。更隐蔽的问题是:Three.js 占用的 WebGL 资源不会随组件卸载自动释放,不管不顾会造成内存泄漏。学完这篇文章,你能理清 Three.js 和 Vue 的分工,封装出可复用的 3D 组件,并把资源清理得干干净净。
🧱 分工原则:Vue 管容器,Three 管 canvas
Three.js 和 Vue 都想操作 DOM,分工原则和 D3 课程讲的一样,一句话:
Vue 只负责渲染空容器,容器内部的 canvas 全部交给 Three.js。
三条规矩:
- 模板里只写一个带
ref的空<div>。 <canvas>由渲染器创建(renderer.domElement),我们手动挂进容器。- 初始化代码放在
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 组件
按分工原则和清理规范,封装一个带交互的旋转立方体组件:
<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:
<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 资源,必须手动清理。
- 清理五件套:
cancelAnimationFrame、controls.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 组件:
- 接收
color、speed两个 prop,控制球体颜色和浮动速度。 - 球体边自转边上下浮动,支持 OrbitControls 交互。
- 卸载时完整清理五件套,然后在页面上放两个不同颜色的组件,验证互不影响。
<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 的世界很大,模型加载、粒子特效、着色器都在向你招手,快去探索吧!
