Three.js 与 React Three Fiber:在 React 项目中构建 3D 场景

深入对比 Three.js 与 React Three Fiber,介绍在 React 项目中构建 3D 场景的组件化思路、关键机制和常见坑;结合实际工程经验,给出性能优化与方案选择建议。适合想在 React 中引入 3D 功能的开发者阅读。

为什么直接在 React 里用 Three.js 会觉得别扭

很多团队的 3D 功能是从一两个交互模块开始的,比如把产品模型渲染到页面上,或者做一个简单的 360 度展示。最直接的写法是在 useEffect 里初始化 scene、camera、renderer,然后把模型加到 scene 里,在动画循环里更新,最后在组件卸载时清理。这个模式在刚开始时非常顺手,因为需求简单,一个组件就能装下所有逻辑。

AI technology illustration

但随着场景里的东西变多,代码结构会变得奇怪。比如一个页面里有多个模型,每个模型有自己的动画和交互,你会有多个 useEffect 去操作同一个 scene 对象。而 React 组件的渲染顺序和 Three.js 对象的生命周期并不总是一致,很容易出现某个模型还没有创建时,另一个组件就试图去修改它的状态。为了规避这些问题,项目里往往会出现大量 ref 和防御性判断。

一个更典型的场景是:团队维护一个 3D 产品展示页,最初只有一个模型,所有逻辑都放在一个组件里。后来加了第二个模型、加点光源、加摄像机切换,再后来需要根据商品分类动态加载不同模型。这时候你会发现,原来的模式已经没法维持了。因为每个模型的状态、动画、清理逻辑都耦合在一个 useEffect 里,任何一条链路出问题,排查起来都要花费大量时间。

而且资源清理很容易被忽略。Three.js 里的几何体、材质、纹理都是 GPU 资源,组件卸载时如果不手动 dispose,它们不会自动释放。很多 3D 页面在长时间使用后出现卡顿或崩溃,原因往往不是渲染性能,而是卸载逻辑里漏掉了 dispose。

所有这些问题都源于同一件事:Three.js 的场景图需要一个命令式的生命周期管理,而 React 提供的是声明式的组件生命周期。你当然可以用 useEffect 和 useState 手工维护,但这种维护的成本会随着场景复杂度快速上升。R3F 的出现,就是为了把场景图的生命周期映射到 React 组件的生命周期上。

React Three Fiber 到底做了什么事

R3F 的核心是一个 React reconciler,它专门处理 Three.js 的对象。简单说,你可以把 Three.js 的对象当作 JSX 标签来写。<mesh> 对应 new THREE.Mesh()<boxGeometry> 对应 new THREE.BoxGeometry(),组件挂载时创建对象,组件卸载时自动释放它对应的资源。

这是一个很典型的声明式重构。给一个简单例子:创建一个立方体并渲染到屏幕,原生 Three.js 的代码大致是:

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
const cube = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshStandardMaterial({ color: 'orange' })
);
scene.add(cube);
// 接下来还要处理 render loop、resize、dispose

换成 R3F 后:

<Canvas camera={{ fov: 75, near: 0.1, far: 1000 }}>
  <mesh>
    <boxGeometry args={[1, 1, 1]} />
    <meshStandardMaterial color='orange' />
  </mesh>
</Canvas>

这里 Canvas 组件会帮你创建 renderer、scene、camera,并启动渲染循环。mesh 组件挂载时创建 Mesh 对象,卸载时自动 dispose 它关联的 geometry 和 material。不需要再手工维护那套生命周期,也不需要去操作 renderer.domElement 的增减。

需要注意的是,R3F 并没有把 Three.js 变成一个“React 库”。Three.js 里几乎所有类型都可以被转换为 JSX 组件,但底层仍然是 Three.js 对象,渲染循环依然存在。R3F 只是让这些对象的创建、更新、销毁跟着 React 组件走。换句话说,你仍然需要理解 scene、camera、light、geometry、material 这些概念,只是表达方式变了。

围绕 R3F 还有一套非常实用的配套组件库,也就是 drei。它提供了 OrbitControls、Environment、Text、Html 等开箱即用的组件,可以省去不少自己封装的时间。这也在一定程度上降低了 R3F 的上手门槛,但不要把 drei 当成 R3F 的一部分,它在功能上更像一个社区维护的配方集。

关键机制:用组件描述 Three.js 场景

在实际使用 R3F 时,有几个核心机制是你绕不开的。

第一个是 useFrame。R3F 的 Canvas 内部默认会持续渲染每一帧,但不会因为每一帧的渲染而触发 React 重渲染。如果你需要在动画循环里执行逻辑,比如旋转物体、更新相机位置,就把它放在 useFrame 里。这个 hook 的签名和 Three.js 的 render loop 类似,但额外提供了当前渲染状态,包括 clock、camera、viewport 等。

function RotatingBox() {
  const ref = useRef();
  useFrame((state, delta) => {
    ref.current.rotation.y += delta * 0.5;
  });
  return (
    <mesh ref={ref}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color='orange' />
    </mesh>
  );
}

第二个是事件系统。R3F 将 Three.js 的射线检测封装成了类似 React 的 onPointerDown、onPointerMove 事件,你可以直接在组件上绑定交互逻辑。它内部使用 React 事件委托机制,所以性能上是可控的。比如在 mesh 上写一个 onPointerDown,就能实现点击选中物体的功能,这在原生写法里需要手动维护 raycaster 和监听器。

第三个是外部资源的接入。很多现有 Three.js 代码已经有一套自己的资源管理逻辑,比如加载 GLTF 模型。R3F 的 useLoader 可以加载外部资源并缓存,同时结合 React Suspense 实现加载状态的声明式管理。一个典型的模型加载组件可以写成这样:

function Model({ url }) {
  const gltf = useLoader(GLTFLoader, url);
  return <primitive object={gltf.scene} />
}

function Scene() {
  return (
    <Canvas>
      <Suspense fallback={null}>
        <Model url='/models/chair.glb' />
      </Suspense>
    </Canvas>
  );
}

这段代码中 primitive 用于直接把一个非 R3F 管理的 Three.js 对象挂载到场景里。Suspense 则可以让模型加载期间不渲染模型,而是显示 fallback。这种模式在管理多个异步资源时很有用。

第四个需要理解的是状态管理。R3F 并不强制要求你使用任何状态库,组件自己的 state 和 ref 已经足够处理大多数场景。但要注意区分“用于描述场景结构的 state”和“用于驱动动画的 state”。前者可以放心使用 setState,后者则尽量使用 ref,否则很容易造成 React 重渲染与渲染循环打架。

常见误区与容易踩的坑

第一个误区:以为 R3F 会自动处理一切生命周期。确实组件卸载时会自动 dispose 由组件创建的对象,但前提是你用 JSX 组件创建了对象。如果你在 useFrame 里动态创建了 Three.js 对象并 add 到场景,这些对象仍然需要手动清理。

第二个误区:把 React state 当作 Three.js 数据的唯一来源。比如一个物体的位置在动画中每帧变化,如果你用 useState 去存储它的坐标,那每一帧都会触发 React 重渲染。正确的做法是用 ref 或直接在 Three.js 对象上修改属性,仅在用户交互或初始化时使用 state。

第三个误区:忽略场景对象的复用。R3F 会根据 JSX 结构变化而销毁重建对象。如果某个模型的 geometry 或 material 在多个 mesh 之间共享,应该使用 useMemo 或者把共享资源提升到顶层,避免被重复创建。这个问题在模型数量多时尤其明显,会导致加载卡顿和内存上升。

第四个误区:认为 R3F 可以完全替代对 Three.js 的理解。R3F 的 API 更接近 React,但性能分析、光照模型、材质参数、骨骼动画这些概念,仍然需要回到 Three.js 文档里去查。如果对 Three.js 本身不熟悉,调试起来会非常困难。

一个比较常见的场景是:团队在一个 3D 展示页里加载了大量模型,最初每次路由切换都会重建 Canvas。后来发现页面频繁卡顿,排查下来是旧模型的 texture 没有释放,因为 Canvas 组件的父级被卸载时,R3F 会连同内部的场景和资源一起放弃,但一些通过原生代码缓存的纹理是由外部库管理的,并没有跟着组件卸载。这类问题不一定是 R3F 的 bug,而是两种编程模型的边界没有理清。

还有一个容易被忽略的问题:R3F 组件的 key。如果你在列表中渲染多个 mesh,并且每个 mesh 有独立的 key,当列表顺序改变时,R3F 会重新创建对象而不是复用。这在场景中表现出的结果是某些状态会意外重置。要避免这种情况,需要仔细管理 key 的稳定性,尤其是在进行排序、过滤操作时。

性能与可维护性的平衡

R3F 的性能表现是一个经常被讨论的话题。本质上,R3F 带来的性能开销主要来自 React 协调。但大多数情况下,一个场景里的 Three.js 对象数量是有限的,React 协调的开销并不大。真正影响性能的仍然是 Three.js 本身的渲染能力,比如多边形数量、纹理大小、draw call 数量。

所以我的建议是:先用 R3F 的声明式方式组织场景,把渲染相关的压力交给 Three.js 本身去优化。当 profile 后确认 React 协调是瓶颈时,再进行针对性处理。处理手段无非是:用 useMemo 缓存几何体和材质,把频繁变化的属性改到 ref 或 useFrame 里更新,避免大列表反复重建。

这里可以给一个不同方案的对比,帮助你判断哪种方式更适合自己的项目。

方案 代码组织 性能控制 适用场景
React Three Fiber 声明式,组件生命周期自动管理 需要理解 React 协调与 Three.js 渲染的边界,性能瓶颈通常可定位 React 项目内的 3D 交互、可视化、产品展示
原生 Three.js + React 命令式,副作用在 useEffect 中手工管理 可直接操控渲染循环,性能上限高 复杂 3D 应用、编辑器、已有大量 Three.js 代码的项目
自封装 Three.js React 组件 混合式,组件内封装命令式逻辑 可针对业务做定制优化,但开发成本较高 核心 3D 能力需要跨团队复用的中长期项目

注意,这个表格里的“性能控制”并不是说 R3F 一定慢很多。实际上,对于大多数业务场景,R3F 和原生 Three.js 在相同渲染负载下的帧率差别可以忽略。差别主要在于你能多精细地控制渲染管线和资源生命周期。如果你要做的是一个完整的大型 3D 编辑器,里面包含自定义材质、后处理管线、复杂的资源流式加载,那么原生 Three.js 仍然会让你少受一些约束。

反过来,如果你的业务是围绕 React 展开的,比如 3D 模型配置器、营销落地页、数据可视化,R3F 的组件化能让团队更快迭代和维护,性价比更高。性能问题通过 Three.js 层面的优化手段同样可以解决,并不需要牺牲开发效率。

如何决定用不用 React Three Fiber

既然 R3F 优势明显,是不是所有 React 项目里的 3D 场景都应该用它?未必。

如果你的团队完全不熟悉 React,或者当前项目本身就不是 React 技术栈,没必要引入 R3F。如果你已经有一套成熟的 Three.js 代码,是直接写到项目里的,那么迁移到 R3F 的成本可能会高于收益,因为你需要把命令式逻辑重写成声明式结构,并且处理一些边界情况。

在决定之前,可以先问自己几个问题:

  • 3D 场景是否是项目的核心能力?如果是,并且需要非常精细的渲染优化,考虑保留命令式控制和 R3F 并存。
  • 团队是否有足够的时间学习 R3F 的抽象?R3F 并不难,但如果只是临时用一次,引入成本也需要评估。
  • 是否需要和其他 React 生态库深度集成?R3F 在 state 管理、路由组件等方面与 React 模型更加一致。
  • 你的 Three.js 代码量有多大?如果已经有几千行经过验证的命令式代码,重写风险可能高于收益。

另外,R3F 还有一个值得考虑的优点:它让 3D 场景变得更可测试。因为场景中的对象都是 React 组件,你可以利用 React 的测试工具去验证组件的渲染结果,虽然不能完全替代视觉回归测试,但至少可以让逻辑层的测试更容易。

对于中小型团队或者产品早期阶段,R3F 通常是一个更合适的选择。它把 Three.js 的复杂度封在组件边界里,让每个人都不需要完全掌握 Three.js 的所有细节,就能协作开发 3D 功能。当然,这并不意味着可以完全不懂 Three.js,但至少降低了启动成本。

在 React 项目中落地 R3F 的建议

如果你决定尝试 R3F,这里有一条比较稳妥的路径:先从一个简单的 Canvas 开始,用基础几何体搭建几个可交互的对象,感受组件的生命周期和 useFrame 的工作方式。之后再接入真实模型,把模型加载放在 Suspense 边界里,并逐步加入相机控制、灯光阴影等真实场景必备的元素。

接下来注意资源管理。从第一天就约定:所有通过组件创建的对象交给 R3F 自动 dispose;通过 useLoader 加载的资源如果需要缓存,用缓存机制统一管理;动态创建的 Three.js 对象要在代码路径中显式销毁。

性能优化不要一开始就做,等场景功能基本稳定后,用 React DevTools 和 Three.js 的 profiling 工具观察瓶颈。通常需要优先关注的不是 React 重渲染,而是 draw call 数量、材质数量和纹理尺寸。场景中大量重复的模型可以尝试使用 InstancedMesh 来合并渲染,这在 R3F 里也有对应的组件,但你要理解它背后的原理,才能正确使用。

一个比较实际的经验是:不要把所有 Three.js 对象都放进 React state。state 应该只保存那些真正需要影响场景结构的数据,比如相机初始位置、选中模型的 id、播放动画的名称。高频刷新的值请使用 ref 或者直接在 Three.js 对象上修改。

还有一点,不要一开始就引入太多抽象。R3F 的组件模型虽然灵活,但在项目早期,合理的做法是保持组件层级与 Three.js 场景结构相似,比如一个 Scene 组件对应一个场景,一个 Mesh 组件对应一个模型。等团队熟悉了 R3F 的惯用法后,再根据业务需要提炼更上层的组件。

关于版本兼容,R3F 的版本迭代相对较快,不同版本之间的 API 变化会影响使用方式。在团队项目中,最好将 Three.js 和 R3F 的版本锁定在一个经过验证的组合上,升级时单独安排时间测试,而不是随手改 package.json。

结语

Three.js 和 React Three Fiber 并不是二选一的关系。R3F 构建在 Three.js 之上,它提供的不是一个新的渲染引擎,而是一种更适合 React 项目组织场景的方式。它在组件边界、生命周期和交互声明上做了一层很好的封装,让 3D 功能可以像 React 的 UI 一样被维护。

但封装不等于银弹。理解 Three.js 的基本概念仍然是必备的前提,R3F 只是让那些概念更容易和 React 组合起来。真正决定 3D 场景上限的,还是你对渲染性能的理解和对场景资源的掌控。希望这篇文章能让你在“React 项目里构建 3D 场景”这个方向上,少踩一些弯路。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/1000/

(0)
上一篇 1小时前
下一篇 49分钟前

相关推荐