{T}

前端框架集成指南

Three.js 作为原生 JavaScript 库,可以与任何前端框架(React、Vue、Angular 等)集成。本指南涵盖主流框架的集成方案、最佳实践和常见问题。

系统架构

code
┌─────────────────────────────────────────────────────────────────────────┐
│                    前端框架 + Three.js 架构                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   ┌───────────────────────────────────────────────────────────────┐    │
│   │                     框架层 (UI/状态)                           │    │
│   │  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────────┐  │    │
│   │  │ 组件树   │  │ 状态管理 │  │ 路由     │  │ UI 交互事件  │  │    │
│   │  └────┬─────┘  └────┬─────┘  └────┬─────┘  └──────┬───────┘  │    │
│   │       │             │            │               │          │    │
│   └───────┼─────────────┼────────────┼───────────────┼──────────┘    │
│           │             │            │               │                │
│           ▼             ▼            ▼               ▼                │
│   ┌───────────────────────────────────────────────────────────────┐    │
│   │                    Three.js 层 (渲染)                          │    │
│   │                                                               │    │
│   │  ┌─────────┐  ┌─────────┐  ┌──────────┐  ┌──────────────┐   │    │
│   │  │ Scene   │  │ Camera  │  │ Renderer │  │ Controls     │   │    │
│   │  ├─────────┤  ├─────────┤  ├──────────┤  ├──────────────┤   │    │
│   │  │ Lights  │  │ Objects │  │ Animations│ │ Raycaster    │   │    │
│   │  │ Meshes  │  │ Materials│ │ PostFX   │ │ Loaders      │   │    │
│   │  └─────────┘  └─────────┘  └──────────┘  └──────────────┘   │    │
│   │                                                               │    │
│   └───────────────────────────────────────────────────────────────┘    │
│                                                                         │
│   数据流:                                                              │
│   框架状态 → Props/Store → Three.js 对象属性 → 渲染循环                  │
│   用户交互 → 事件 → 框架状态更新 → Three.js 同步                        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

核心设计原则:
1. Three.js 对象不应存储在框架的响应式系统中(避免性能问题)
2. 使用 ref/引用持有 Three.js 对象,避免不必要的重渲染
3. 渲染循环独立于框架的更新周期
4. 通过命令式 API 操作 Three.js,通过声明式 API 描述 UI

概述

为什么需要框架集成?

场景纯 Three.js框架 + Three.js
单页 Demo✅ 足够过度工程
复杂应用❌ 状态管理困难✅ 结构清晰
团队协作❌ 代码组织难✅ 组件化
与其他功能结合❌ 手动 DOM 操作✅ 无缝集成
路由/权限等❌ 需自己实现✅ 框架支持

三种集成方式

  1. 手动集成:直接在组件生命周期中管理 Three.js
  2. 声明式库:使用 @react-three/fiber、TresJS 等
  3. 混合模式:部分使用库,部分手动

通用集成原则

核心要点

javascript
// ⚠️ 不要这样做:
// 将 Three.js 对象放入响应式状态会导致严重性能问题

// React - 错误示例 ❌
const [scene, setScene] = useState(new THREE.Scene())
// 每次 setState 都会尝试比较整个场景图...

// Vue - 错误示例 ❌
const scene = ref(new THREE.Scene())
// Vue 的深度代理会遍历所有对象属性...

// ✅ 正确做法:用 ref 引用非响应式对象

正确的引用模式

javascript
// React 方式
import { useRef, useEffect } from 'react'
import * as THREE from 'three'

function CanvasComponent() {
  const containerRef = useRef(null)
  const sceneRef = useRef(null)        // 非 useState!
  const rendererRef = useRef(null)
  
  useEffect(() => {
    // 初始化(只执行一次)
    const scene = new THREE.Scene()
    sceneRef.current = scene
    
    const renderer = new THREE.WebGLRenderer({ antialias: true })
    renderer.setSize(containerRef.current.clientWidth, containerRef.current.clientHeight)
    containerRef.current.appendChild(renderer.domElement)
    rendererRef.current = renderer
    
    // 动画循环
    let animId
    function animate() {
      animId = requestAnimationFrame(animate)
      renderer.render(scene, camera)
    }
    animate()
    
    // 清理
    return () => {
      cancelAnimationFrame(animId)
      renderer.dispose()
      if (containerRef.current) {
        containerRef.current.removeChild(renderer.domElement)
      }
    }
  }, [])  // 空依赖数组 = 只执行一次
  
  return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />
}

尺寸自适应

javascript
function useResizeObserver(containerRef, callback) {
  useEffect(() => {
    const container = containerRef.current
    if (!container) return
    
    const observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        callback(entry.contentRect.width, entry.contentRect.height)
      }
    })
    
    observer.observe(container)
    return () => observer.disconnect()
  }, [])
}

React 集成

方式一:纯手写(推荐入门)

jsx
// components/ThreeCanvas.jsx
import { useRef, useEffect, useCallback } from 'react'
import * as THREE from 'three'

export default function ThreeCanvas({ onReady }) {
  const containerRef = useRef(null)
  const threeObjects = useRef({})
  
  const initScene = useCallback(() => {
    const container = containerRef.current
    const width = container.clientWidth
    const height = container.clientHeight
    
    // 场景
    const scene = new THREE.Scene()
    scene.background = new THREE.Color(0x1a1a2e)
    threeObjects.current.scene = scene
    
    // 相机
    const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
    camera.position.set(5, 5, 5)
    threeObjects.current.camera = camera
    
    // 渲染器
    const renderer = new THREE.WebGLRenderer({ antialias: true })
    renderer.setSize(width, height)
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    container.appendChild(renderer.domElement)
    threeObjects.current.renderer = renderer
    
    // 光照
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.4)
    scene.add(ambientLight)
    
    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
    directionalLight.position.set(10, 20, 10)
    scene.add(directionalLight)
    
    // 控制器
    import('three/examples/jsm/controls/OrbitControls.js').then(({ OrbitControls }) => {
      const controls = new OrbitControls(camera, renderer.domElement)
      controls.enableDamping = true
      threeObjects.current.controls = controls
      
      if (onReady) onReady(threeObjects.current)
    })
    
    // 动画循环
    let animId
    function animate() {
      animId = requestAnimationFrame(animate)
      if (threeObjects.current.controls) {
        threeObjects.current.controls.update()
      }
      renderer.render(scene, camera)
    }
    animate()
    
    // 自适应尺寸
    const resizeObserver = new ResizeObserver((entries) => {
      const { width: w, height: h } = entries[0].contentRect
      camera.aspect = w / h
      camera.updateProjectionMatrix()
      renderer.setSize(w, h)
    })
    resizeObserver.observe(container)
    
    return () => {
      cancelAnimationFrame(animId)
      resizeObserver.disconnect()
      renderer.dispose()
      container.removeChild(renderer.domElement)
    }
  }, [onReady])
  
  useEffect(() => {
    const cleanup = initScene()
    return cleanup
  }, [initScene])
  
  return (
    <div 
      ref={containerRef} 
      style={{ width: '100%', height: '100%', position: 'relative' }}
    />
  )
}
jsx
// App.jsx
import { useCallback, useRef } from 'react'
import ThreeCanvas from './components/ThreeCanvas'

export default function App() {
  const sceneRef = useRef(null)
  
  const handleSceneReady = useCallback((objects) => {
    sceneRef.current = objects
    
    // 添加一个测试立方体
    const geometry = new THREE.BoxGeometry(2, 2, 2)
    const material = new THREE.MeshStandardMaterial({
      color: 0x4488ff,
      metalness: 0.3,
      roughness: 0.4
    })
    const cube = new THREE.Mesh(geometry, material)
    objects.scene.add(cube)
    
    // 可选:添加动画
    objects.userData = objects.userData || {}
    objects.userData.animatedMeshes = [cube]
    
    // 启动自定义动画
    startAnimation(objects)
  }, [])
  
  function startAnimation(objects) {
    function update() {
      if (objects.userData?.animatedMeshes) {
        objects.userData.animatedMeshes.forEach(mesh => {
          mesh.rotation.x += 0.005
          mesh.rotation.y += 0.01
        })
      }
      requestAnimationFrame(update)
    }
    update()
  }
  
  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      {/* 左侧控制面板 */}
      <aside style={{ width: 280, padding: 20, background: '#16213e', color: '#fff' }}>
        <h2>控制面板</h2>
        {/* UI 控件 */}
      </aside>
      
      {/* 右侧 3D 画布 */}
      <main style={{ flex: 1 }}>
        <ThreeCanvas onReady={handleSceneReady} />
      </main>
    </div>
  )
}

方式二:@react-three/fiber(推荐生产)

bash
npm install three @types/three @react-three/fiber @react-three/drei
jsx
// components/Scene.jsx
import { Canvas } from '@react-three/fiber'
import { OrbitControls, Environment, ContactShadows } from '@react-three/drei'
import { Suspense } from 'react'
import AnimatedBox from './AnimatedBox'
import ParticleField from './ParticleField'

export default function Scene() {
  return (
    <Canvas
      shadows
      camera={{ position: [5, 5, 5], fov: 50 }}
      gl={{ antialias: true }}
      style={{ background: '#1a1a2e' }}
    >
      <Suspense fallback={null}>
        {/* 光照 */}
        <ambientLight intensity={0.4} />
        <directionalLight
          position={[10, 20, 10]}
          intensity={0.8}
          castShadow
          shadow-mapSize={[2048, 2048]}
        />
        
        {/* 场景内容 */}
        <AnimatedBox position={[0, 1, 0]} color="#4488ff" />
        <ParticleField count={2000} />
        
        {/* 环境 */}
        <Environment preset="city" />
        <ContactShadows
          position={[0, -0.5, 0]}
          opacity={0.6}
          scale={15}
          blur={2}
        />
        
        {/* 控制器 */}
        <OrbitControls enableDamping dampingFactor={0.05} />
      </Suspense>
    </Canvas>
  )
}
jsx
// components/AnimatedBox.jsx
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import { MeshDistortMaterial } from '@react-three/drei'
import * as THREE from 'three'

export default function AnimatedBox({ position = [0, 0, 0], color = '#ff4444' }) {
  const meshRef = useRef()
  
  useFrame((state, delta) => {
    if (!meshRef.current) return
    meshRef.current.rotation.x += delta * 0.5
    meshRef.current.rotation.y += delta * 0.8
    meshRef.current.position.y = position[1] + Math.sin(state.clock.elapsedTime) * 0.3
  })
  
  return (
    <mesh ref={meshRef} position={position} castShadow>
      <boxGeometry args={[1.5, 1.5, 1.5]} />
      <MeshDistortMaterial
        color={color}
        speed={2}
        distort={0.3}
        radius={1}
      />
    </mesh>
  )
}
jsx
// components/ParticleField.jsx
import { useRef, useMemo } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'

export default function ParticleField({ count = 1000, spread = 20 }) {
  const pointsRef = useRef()
  
  const positions = useMemo(() => {
    const pos = new Float32Array(count * 3)
    for (let i = 0; i < count; i++) {
      pos[i * 3] = (Math.random() - 0.5) * spread
      pos[i * 3 + 1] = (Math.random() - 0.5) * spread
      pos[i * 3 + 2] = (Math.random() - 0.5) * spread
    }
    return pos
  }, [count, spread])
  
  useFrame((state) => {
    if (!pointsRef.current) return
    pointsRef.current.rotation.y = state.clock.elapsedTime * 0.03
  })
  
  return (
    <points ref={pointsRef}>
      <bufferGeometry>
        <bufferAttribute
          attach="attributes-position"
          count={count}
          array={positions}
          itemSize={3}
        />
      </bufferGeometry>
      <pointsMaterial
        size={0.08}
        color="#88ccff"
        transparent
        opacity={0.7}
        sizeAttenuation
      />
    </points>
  )
}

React 状态与 Three.js 通信

jsx
import { create } from 'zustand'

// Zustand store
const useSceneStore = create((set, get) => ({
  selectedObject: null,
  objects: [],
  showGrid: true,
  wireframeMode: false,
  backgroundColor: '#1a1a2e',
  
  selectObject: (id) => set({ selectedObject: id }),
  addObject: (obj) => set(state => ({ objects: [...state.objects, obj] })),
  toggleGrid: () => set(state => ({ showGrid: !state.showGrid })),
  toggleWireframe: () => set(state => ({ wireframeMode: !state.wireframeMode })),
  setBackgroundColor: (color) => set({ backgroundColor: color }),
}))

// 在 R3F 组件中读取 store
function ReactiveScene() {
  const backgroundColor = useSceneStore(s => s.backgroundColor)
  const wireframeMode = useSceneStore(s => s.wireframeMode)
  
  return (
    <Canvas style={{ background: backgroundColor }}>
      <ReactiveContent wireframe={wireframeMode} />
    </Canvas>
  )
}

function ReactiveContent({ wireframe }) {
  const materialRef = useRef()
  
  useFrame(() => {
    if (materialRef.current) {
      materialRef.current.wireframe = wireframe
    }
  })
  
  return (
    <mesh>
      <boxGeometry />
      <meshStandardMaterial ref={materialRef} color="#4488ff" />
    </mesh>
  )
}

// 从 UI 控制 Three.js
function ControlPanel() {
  const toggleWireframe = useSceneStore(s => s.toggleWireframe)
  const setBackgroundColor = useSceneStore(s => s.setBackgroundColor)
  
  return (
    <div className="panel">
      <button onClick={toggleWireframe}>切换线框</button>
      <input
        type="color"
        onChange={(e) => setBackgroundColor(e.target.value)}
      />
    </div>
  )
}

Vue 集成

方式一:Composition API(Vue 3 推荐)

Vue SFC
<!-- components/ThreeCanvas.vue -->
<template>
  <div ref="containerRef" class="canvas-container"></div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue'
import * as THREE from 'three'

const props = defineProps({
  backgroundColor: { type: String, default: '#1a1a2e' },
  showAxes: { type: Boolean, default: false },
  enableControls: { type: Boolean, default: true }
})

const emit = defineEmits(['ready'])

const containerRef = ref(null)

// 用 shallowRef 避免 Vue 的深度代理
const scene = shallowRef(null)
const camera = shallowRef(null)
const renderer = shallowRef(null)
let controls = null
let animationId = null

onMounted(async () => {
  await initThree()
})

onBeforeUnmount(() => {
  cleanup()
})

async function initThree() {
  const container = containerRef.value
  const width = container.clientWidth
  const height = container.clientHeight
  
  // 场景
  const s = new THREE.Scene()
  s.background = new THREE.Color(props.backgroundColor)
  scene.value = s
  
  // 相机
  const c = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
  c.position.set(5, 5, 5)
  camera.value = c
  
  // 渲染器
  const r = new THREE.WebGLRenderer({ antialias: true })
  r.setSize(width, height)
  r.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  r.shadowMap.enabled = true
  r.shadowMap.type = THREE.PCFSoftShadowMap
  container.appendChild(r.domElement)
  renderer.value = r
  
  // 光照
  s.add(new THREE.AmbientLight(0xffffff, 0.4))
  
  const dirLight = new THREE.DirectionalLight(0xffffff, 0.8)
  dirLight.position.set(10, 20, 10)
  dirLight.castShadow = true
  s.add(dirLight)
  
  // 坐标轴辅助
  if (props.showAxes) {
    s.add(new THREE.AxesHelper(5))
  }
  
  // 控制器
  if (props.enableControls) {
    const { OrbitControls } = await import(
      'three/examples/jsm/controls/OrbitControls.js'
    )
    controls = new OrbitControls(c, r.domElement)
    controls.enableDamping = true
    controls.dampingFactor = 0.05
  }
  
  // 自适应
  const observer = new ResizeObserver(entries => {
    const { width: w, height: h } = entries[0].contentRect
    c.aspect = w / h
    c.updateProjectionMatrix()
    r.setSize(w, h)
  })
  observer.observe(container)
  
  // 动画循环
  function animate() {
    animationId = requestAnimationFrame(animate)
    if (controls) controls.update()
    r.render(s, c)
  }
  animate()
  
  emit('ready', { scene: s, camera: c, renderer: r, controls })
}

function cleanup() {
  if (animationId) cancelAnimationFrame(animationId)
  if (renderer.value) {
    renderer.value.dispose()
    const container = containerRef.value
    if (container && renderer.value.domElement) {
      container.removeChild(renderer.value.domElement)
    }
  }
  if (controls) controls.dispose()
}

// 暴露方法给父组件
defineExpose({
  getScene: () => scene.value,
  getCamera: () => camera.value,
  getRenderer: () => renderer.value
})
</script>

<style scoped>
.canvas-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
}
</style>
Vue SFC
<!-- views/Dashboard.vue -->
<template>
  <div class="dashboard">
    <aside class="sidebar">
      <h3>控制面板</h3>
      <label>
        <input type="checkbox" v-model="showGrid" /> 显示网格
      </label>
      <label>
        <input type="checkbox" v-model="wireframe" /> 线框模式
      </label>
      <label>
        背景颜色
        <input type="color" v-model="bgColor" />
      </label>
      <button @click="addRandomCube">添加方块</button>
      <p>对象数量: {{ objectCount }}</p>
    </aside>
    
    <main class="viewport">
      <ThreeCanvas
        :background-color="bgColor"
        :show-axes="showGrid"
        @ready="handleReady"
      />
    </main>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import ThreeCanvas from '@/components/ThreeCanvas.vue'

const showGrid = ref(false)
const wireframe = ref(false)
const bgColor = ref('#1a1a2e')
const objectCount = ref(0)
let threeContext = null

function handleReady(ctx) {
  threeContext = ctx
  addDefaultObject()
}

function addDefaultObject() {
  if (!threeContext) return
  const { scene } = threeContext
  
  const geometry = new THREE.BoxGeometry(2, 2, 2)
  const material = new THREE.MeshStandardMaterial({
    color: 0x4488ff,
    metalness: 0.3,
    roughness: 0.4
  })
  const cube = new THREE.Mesh(geometry, material)
  cube.castShadow = true
  cube.receiveShadow = true
  scene.add(cube)
  objectCount.value++
  
  // 动画
  function animate() {
    cube.rotation.x += 0.005
    cube.rotation.y += 0.01
    requestAnimationFrame(animate)
  }
  animate()
}

function addRandomCube() {
  if (!threeContext) return
  const { scene } = threeContext
  
  const geo = new THREE.BoxGeometry(1, 1, 1)
  const mat = new THREE.MeshStandardMaterial({
    color: Math.random() * 0xffffff
  })
  const mesh = new THREE.Mesh(geo, mat)
  mesh.position.set(
    (Math.random() - 0.5) * 10,
    Math.random() * 3 + 1,
    (Math.random() - 0.5) * 10
  )
  scene.add(mesh)
  objectCount.value++
}

watch(wireframe, (val) => {
  if (!threeContext) return
  threeContext.scene.traverse(child => {
    if (child.isMesh && child.material) {
      child.material.wireframe = val
    }
  })
})
</script>

<style scoped>
.dashboard {
  display: flex;
  height: 100vh;
}
.sidebar {
  width: 280px;
  padding: 20px;
  background: #16213e;
  color: #fff;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.viewport {
  flex: 1;
}
</style>

方式二:TresJS(Vue 3 声明式)

bash
npm install tres @tresjs/core three
Vue SFC
<template>
  <TresCanvas
    window-size
    shadows
    :camera="{ position: [5, 5, 5], fov: 50 }"
    style="{ background: '#1a1a2e' }"
  >
    <TresAmbientLight :intensity="0.4" />
    <TresDirectionalLight
      :position="[10, 20, 10]"
      :intensity="0.8"
      cast-shadow
    />
    
    <TresMesh
      :position-y="1"
      cast-shadow
      @pointer-enter="isHovered = true"
      @pointer-leave="isHovered = false"
    >
      <TresBoxGeometry :args="[2, 2, 2]" />
      <TresMeshStandardMaterial
        :color="isHovered ? '#ff6644' : '#4488ff'"
        :metalness="0.3"
        :roughness="0.4"
      />
    </TresMesh>
    
    <TresOrbitControls enable-damping :damping-factor="0.05" />
  </TresCanvas>
</template>

<script setup>
import { ref } from 'vue'
const isHovered = ref(false)
</script>

Angular 集成

typescript
// services/three.service.ts
import { Injectable, ElementRef, OnDestroy } from '@angular/core'
import * as THREE from 'three'

@Injectable({ providedIn: 'root' })
export class ThreeService implements OnDestroy {
  private scene!: THREE.Scene
  private camera!: THREE.PerspectiveCamera
  private renderer!: THREE.WebGLRenderer
  private animationId: number | null = null
  private controls: any = null

  async init(
    container: ElementRef<HTMLDivElement>,
    options?: { bgColor?: string; showAxes?: boolean }
  ) {
    const el = container.nativeElement
    const width = el.clientWidth
    const height = el.clientHeight

    this.scene = new THREE.Scene()
    this.scene.background = new THREE.Color(options?.bgColor ?? '#1a1a2e')

    this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
    this.camera.position.set(5, 5, 5)

    this.renderer = new THREE.WebGLRenderer({ antialias: true })
    this.renderer.setSize(width, height)
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    this.renderer.shadowMap.enabled = true
    el.appendChild(this.renderer.domElement)

    this.scene.add(new THREE.AmbientLight(0xffffff, 0.4))

    const dirLight = new THREE.DirectionalLight(0xffffff, 0.8)
    dirLight.position.set(10, 20, 10)
    dirLight.castShadow = true
    this.scene.add(dirLight)

    if (options?.showAxes) {
      this.scene.add(new THREE.AxesHelper(5))
    }

    const { OrbitControls } = await import(
      'three/examples/jsm/controls/OrbitControls.js'
    )
    this.controls = new OrbitControls(this.camera, this.renderer.domElement)
    this.controls.enableDamping = true

    const observer = new ResizeObserver((entries) => {
      const { width: w, height: h } = entries[0].contentRect
      this.camera.aspect = w / h
      this.camera.updateProjectionMatrix()
      this.renderer.setSize(w, h)
    })
    observer.observe(el)

    this.animate()
  }

  private animate() {
    this.animationId = requestAnimationFrame(() => this.animate())
    if (this.controls) this.controls.update()
    this.renderer.render(this.scene, this.camera)
  }

  getScene() { return this.scene }
  getCamera() { return this.camera }
  getRenderer() { return this.renderer }

  ngOnDestroy() {
    if (this.animationId) cancelAnimationFrame(this.animationId)
    this.renderer.dispose()
    if (this.controls) this.controls.dispose()
  }
}
typescript
// components/canvas-container.component.ts
import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core'
import { ThreeService } from '../services/three.service'

@Component({
  selector: 'app-canvas-container',
  template: '<div #canvasContainer style="width:100%;height:100%"></div>',
  styles: [':host { display: block; width: 100%; height: 100% }']
})
export class CanvasContainerComponent implements AfterViewInit {
  @ViewChild('canvasContainer') container!: ElementRef<HTMLDivElement>

  constructor(private threeService: ThreeService) {}

  ngAfterViewInit() {
    this.threeService.init(this.container, { bgColor: '#1a1a2e', showAxes: true })
  }
}

框架对比与选型

特性手动集成@react-three/fiberTresJSAngular 手写
学习成本低(懂 Three.js 即可)
代码量
灵活性最高
类型安全取决于 TS 配置
生态插件drei/troika 等tresjs/ecosystem
社区活跃度N/A非常高增长中N/A
适合场景简单项目React 项目Vue 3 项目Angular 项目

选型建议

  • React 项目:优先考虑 @react-three/fiber + @react-three/drei
  • Vue 3 项目:优先考虑 TresJS
  • 简单需求或非主流框架:手写集成
  • 需要精细控制:手写集成(即使使用框架库)

状态管理

推荐模式:单向数据流

code
UI Action → Store 更新 → 订阅者接收 → 更新 Three.js 对象
javascript
// React + Zustand 示例
const useAppStore = create((set) => ({
  // UI 状态
  isPlaying: true,
  selectedId: null,
  
  // 3D 场景配置
  config: {
    ambientIntensity: 0.4,
    sunPosition: [10, 20, 10],
    fogEnabled: false,
    fogColor: '#1a1a2e',
    fogNear: 10,
    fogFar: 50
  },
  
  // 数据驱动列表
  dataPoints: [],
  
  togglePlay: () => set(s => ({ isPlaying: !s.isPlaying })),
  setSelected: (id) => set({ selectedId: id }),
  updateConfig: (patch) => set(s => ({ config: { ...s.config, ...patch } })),
  setDataPoints: (data) => set({ dataPoints: data })
}))

// 在 R3F 组件中使用
function SceneConfig() {
  const config = useAppStore(s => s.config)
  const fogEnabled = config.fogEnabled
  
  return (
    <>
      <fog attach="fog" args={[config.fogColor, config.fogNear, config.fogFar]} 
           visible={fogEnabled} />
      <ambientLight intensity={config.ambientIntensity} />
      <directionalLight position={config.sunPosition} intensity={0.8} />
    </>
  )
}

TypeScript 支持

类型定义

typescript
// types/three-extended.d.ts
import * as THREE from 'three'

declare module 'three' {
  interface Object3D {
    userData: {
      instanceId?: number
      dataType?: string
      originalColor?: THREE.Color
      [key: string]: any
    }
  }
}

// 类型安全的 Three.js 工具函数
type Vector3Like = THREE.Vector3 | [number, number, number] | { x: number; y: number; z: number }

function toVector3(v: Vector3Like): THREE.Vector3 {
  if (v instanceof THREE.Vector3) return v.clone()
  if (Array.isArray(v)) return new THREE.Vector3(...v)
  return new THREE.Vector3(v.x, v.y, v.z)
}

interface SceneOptions {
  backgroundColor?: string
  fog?: {
    color: string
    near: number
    far: number
  }
  shadowMap?: boolean
}

class SceneManager {
  private scene: THREE.Scene
  private camera: THREE.PerspectiveCamera
  private renderer: THREE.WebGLRenderer

  constructor(private container: HTMLElement, options: SceneOptions = {}) {
    this.scene = new THREE.Scene()
    this.init(options)
  }

  private init(options: SceneOptions) {
    if (options.backgroundColor) {
      this.scene.background = new THREE.Color(options.backgroundColor)
    }
    if (options.fog) {
      this.scene.fog = new THREE.Fog(
        options.fog.color,
        options.fog.near,
        options.fog.far
      )
    }
  }

  addObject<T extends THREE.Object3D>(
    object: T,
    position?: Vector3Like
  ): T {
    if (position) {
      object.position.copy(toVector3(position))
    }
    this.scene.add(object)
    return object
  }

  removeObject(object: THREE.Object3D): void {
    this.scene.remove(object)
    if (object instanceof THREE.Mesh) {
      object.geometry.dispose()
      if (Array.isArray(object.material)) {
        object.material.forEach(m => m.dispose())
      } else {
        object.material.dispose()
      }
    }
  }
}

打包优化

Vite 配置

javascript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  build: {
    target: 'esnext',
    rollupOptions: {
      output: {
        manualChunks: {
          three: ['three'],
          'three-examples': [
            'three/examples/jsm/controls/OrbitControls',
            'three/examples/jsm/loaders/GLTFLoader',
            'three/examples/jsm/postprocessing/EffectComposer'
          ]
        }
      }
    }
  },
  optimizeDeps: {
    include: ['three']
  }
})

webpack 配置

javascript
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.(glb|gltf)$/,
        type: 'asset/resource'
      }
    ]
  },
  optimization: {
    splitChunks: {
      cacheGroups: {
        threeVendors: {
          test: /[\\/]node_modules[\\/](three|@react-three)[\\/]/,
          name: 'three-vendors',
          chunks: 'all'
        }
      }
    }
  }
}

按需导入

javascript
// ✅ 推荐:按需导入
import { Scene, PerspectiveCamera, WebGLRenderer } from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'

// ❌ 不推荐:全量导入
import * as THREE from 'three'  // 会增加 bundle 大小

常见问题

Q: 组件重新渲染导致 Three.js 闪烁?

确保 Three.js 对象不在 useState 中,使用 useRefshallowRef

javascript
// React
const sceneRef = useRef(null)  // ✅
// const [scene] = useState(new Scene())  // ❌

// Vue
const scene = shallowRef(null)  // ✅
// const scene = ref(new Scene())  // ❌

Q: 如何处理窗口大小变化?

使用 ResizeObserver 而非 window.resize 事件:

javascript
useEffect(() => {
  const observer = new ResizeObserver(([entry]) => {
    const { width, height } = entry.contentRect
    camera.aspect = width / height
    camera.updateProjectionMatrix()
    renderer.setSize(width, height)
  })
  observer.observe(containerRef.current)
  return () => observer.disconnect()
}, [])

Q: 内存泄漏如何排查?

javascript
// 清理清单
function dispose() {
  // 1. 取消动画帧
  cancelAnimationFrame(animationId)
  
  // 2. 停止控制器
  controls?.dispose()
  
  // 3. 释放几何体和材质
  scene.traverse(obj => {
    if (obj.geometry) obj.geometry.dispose()
    if (obj.material) {
      if (Array.isArray(obj.material)) {
        obj.material.forEach(m => m.dispose())
      } else {
        obj.material.dispose()
      }
    }
  })
  
  // 4. 释放渲染器
  renderer.dispose()
  
  // 5. 移除 DOM 元素
  container.removeChild(renderer.domElement)
  
  // 6. 释放纹理
  textures.forEach(t => t.dispose())
}

Q: SSR(服务端渲染)兼容性?

Three.js 依赖 windowdocument,在 SSR 环境下会报错。解决方案:

jsx
// Next.js / Nuxt
import dynamic from 'next/dynamic'

const ThreeCanvas = dynamic(
  () => import('./ThreeCanvas'),
  { ssr: false }
)

// 或者动态 import
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
return <ThreeCanvas />