与前端框架集成
本教程共 40 篇 · 第 39 篇 · 更新于 2026-08-14 · 约 8 分钟阅读
本节目标:学会把 three.js 场景干净地嵌入 React、Vue 项目,了解 React Three Fiber、TypeScript 和 Vite 的配合方式。学完你能在自己的框架项目里跑起 three.js,并且不踩重复创建的坑。
为什么要在框架里用 three.js
真实项目很少是裸 HTML。组件化、状态管理、路由、工程化,这些需求让 React、Vue 成为主流。three.js 场景恰好有个特点:它只画在一个 <canvas> 元素里。一个 canvas 就是框架组件和 three.js 之间的全部接口,集成难度比想象中小得多。
难点在思维方式。React 和 Vue 是声明式的:你描述”界面应该是什么样”,框架负责更新。three.js 是命令式的:你一步步告诉它”创建渲染器、添加物体、渲染”。两种范式要在组件边界上做好隔离。
框架的响应式系统也管不到 three.js 内部。用户改了颜色,你要手动把新值写进 material.color;物体飞到了哪里,要展示到界面上,也得手动写回状态。这个”双向手动同步”是框架集成的日常,封装得好的组件会把同步逻辑收进 World 类,对外只暴露几个方法。
集成三原则
- three.js 代码封装成一个类或模块(比如一个
World类),框架组件只负责创建和销毁它 - 创建一次:renderer、scene、camera 在组件挂载时创建一次,不要随每次渲染重新创建
- 销毁干净:组件卸载时调用
renderer.dispose()、geometry.dispose()、material.dispose(),并取消动画循环
NoteReact 18+ 的 StrictMode 在开发模式下会让
useEffect执行两次,用来暴露副作用问题。如果直接在 effect 里new THREE.WebGLRenderer(),会出现两个重叠的 canvas。解决:用useRef保存实例,只在为空时创建。
import { useEffect, useRef } from 'react';
function ThreeContainer() {
const mountRef = useRef(null); // 挂载点
const worldRef = useRef(null); // 保存 three.js 实例
useEffect(() => {
if (!worldRef.current) {
worldRef.current = new World(mountRef.current); // 只创建一次
}
return () => {
worldRef.current?.dispose(); // 卸载时销毁
worldRef.current = null;
};
}, []);
return <div ref={mountRef} style={{ width: 400, height: 300 }} />;
}
World 类的职责:创建 scene、camera、renderer,把 renderer 的 canvas 塞进挂载点,启动动画循环;dispose() 里做全部清理。这套模式在 discoverthreejs 一书里叫”World 模式”,任何框架都适用。
状态同步的示例
两个方向的同步都要手动做。频率要克制:动画循环里每帧写回状态,会拖垮框架的响应式系统。只同步 UI 真正需要的值,其余一律不碰。
// 框架状态 -> three.js 对象(以 Vue 为例)
watch(() => props.color, value => {
material.color.set(value);
});
// three.js 对象 -> 框架状态(点击拾取后同步给 UI)
function onObjectSelected(mesh) {
uiState.selectedName = mesh.name;
}
React 生态:React Three Fiber
React 官方没有 three.js 绑定,社区方案是 React Three Fiber(R3F),由 pmndrs 组织维护。它不是给 three.js 包一层 API,而是一个真正的 React renderer:用 JSX 声明式地描述 three.js 场景树。
import { Canvas, useFrame } from '@react-three/fiber';
import { useRef } from 'react';
function Box() {
const ref = useRef();
// 每帧回调,参数里带时钟和状态
useFrame((state, delta) => {
ref.current.rotation.y += delta;
});
return (
<mesh ref={ref}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#00aaff" />
</mesh>
);
}
export default function Scene() {
return (
<Canvas camera={{ position: [2, 2, 2] }}>
<ambientLight intensity={0.5} />
<directionalLight position={[3, 3, 3]} />
<Box />
</Canvas>
);
}
要点:
<mesh>、<boxGeometry>、<meshStandardMaterial>对应 three.js 的类,标签名是类名的连字符形式<Canvas>自动创建 renderer、scene、camera,自动处理 resize 和渲染循环,卸载时自动清理useFrame取代手写requestAnimationFrame,组件卸载自动停止args对应构造参数:<boxGeometry args={[1, 1, 1]} />等于new BoxGeometry(1, 1, 1)- 配套工具库 Drei(
@react-three/drei)提供OrbitControls、Environment、Text等现成组件
R3F 版本紧跟 React:v8 配 React 18,v9 面向 React 19。升级 three 或 React 时,先看 R3F 的发布说明,版本不匹配会出各种怪问题。
Notethree.js 的渲染循环(
requestAnimationFrame)和 React 的渲染是两个独立时钟,别混在一起。不要在 React 组件的 render 里调用renderer.render(),也不要用setState驱动每帧动画。渲染循环自己转,React 只管 UI 状态。
Vue 集成
Vue 没有 R3F 那样的官方渲染器绑定(社区有 TresJS,风格类似 R3F,成熟度稍低)。更常见的做法是直接用生命周期钩子包一层,代码量也不大:
import { ref, onMounted, onUnmounted } from 'vue';
import * as THREE from 'three';
const mount = ref(null);
let renderer;
onMounted(() => {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 100);
camera.position.z = 3;
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(400, 300);
mount.value.appendChild(renderer.domElement);
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x00aaff })
);
scene.add(mesh);
scene.add(new THREE.AmbientLight(0xffffff, 1));
const animate = () => {
mesh.rotation.y += 0.01;
renderer.render(scene, camera);
requestAnimationFrame(animate);
};
animate();
});
onUnmounted(() => {
renderer.dispose();
});
模板里给挂载点一个 ref:
<div ref="mount"></div>
Note组件卸载时只调
renderer.dispose()还不够严谨:场景里几何体、材质多的话,最好遍历 scene 逐个 dispose。写一个工具函数,scene.traverse()遍历,对geometry和material调用dispose(),一劳永逸。
Angular、Svelte 也是同样的思路:Angular 用 ngOnInit/ngOnDestroy,Svelte 用 onMount/onDestroy。同一个 World 类,五个框架都能用,只换生命周期钩子,three.js 部分的代码一行都不用动。
TypeScript
three.js 本身是 JavaScript 写的,但 npm 包自带完整的类型声明(.d.ts),不需要再装 @types/three。装完 three 就有类型提示:
import * as THREE from 'three';
const geometry: THREE.BufferGeometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
官方 API 文档里每个类的方法签名都标了类型,和 .d.ts 一一对应。R3F 和 Drei 本身也是 TypeScript 写的,JSX 里写错属性名直接标红。
用 Vite 构建
Vite 是现在最主流的前端构建工具,用它搭 three.js 项目只需三步:
- 创建项目:
npm create vite@latest my-3d-app -- --template vanilla - 安装依赖:
npm install three - 在
main.js里直接 import,不需要 importmap,不需要 CDN:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
// ... 之后的代码和本教程前面章节完全一样
npm run dev 起开发服务器,npm run build 产出生产包。Vite 基于原生 ESM,开发体验和 importmap 时代一样快,但拿到了依赖管理、代码分割这些工程能力。
Tip项目里装了什么版本的 three,看
package.json的"three": "^0.185.0"。教程代码里的 CDN 地址换成 npm 包后,API 完全一致,只有引入方式不同。
集成避坑清单
- 不要重复创建 renderer/scene:StrictMode、热更新(HMR)、路由切换都可能重复执行模块代码。用 ref 或单例模式保护
- SSR 项目要小心:three.js 用到
window、document,Next.js 里要dynamic(() => import('...'), { ssr: false })或判断typeof window !== 'undefined' - resize 用 ResizeObserver:框架容器尺寸变化不一定触发
window.resize,用 ResizeObserver 观察挂载点最可靠 - 不要在渲染函数里 new 对象:React 的 render、Vue 的 setup 都可能执行多次,three.js 对象应该在生命周期钩子里创建
- 动画循环的清理:
requestAnimationFrame的 id 要存下来,卸载时cancelAnimationFrame(id) - 依赖版本要锁定:three、R3F、Drei 之间有版本匹配关系,装包时别随手
latest,package-lock.json记得提交进仓库
three.js 和框架的关系,说到底就是”一个 canvas、两个生命周期钩子”。边界划清楚,后面就顺了。组件封装好了,以后换框架也只是换个壳。