3D 组件复用为何总踩坑
Three.js 写组件易,复用难:封装好了跨项目就崩,参数一改就变形。MengTo/threeui 给出的解法,值得每个前端看一眼。
源仓库: MengTo/threeui
一、MengTo/threeui 是什么,为什么一周内冲到 1635 star
threeui 是 Meng To(Design+Code 作者)开源的 ThreeUI 社区目录,目标只有一个:把 Three.js 里零散的 live interactive 组件沉淀成可复用、可拼接、可改参数的”乐高积木”。它不是另一个 three.js starter,也不是封装好的 GUI 库,而是一个组件目录(catalog)——你 clone 下来,看到的是一个能直接 npm run dev 跑起来的站点,每个组件都是独立页面,源码在 src/content/components/* 下,参数改完实时刷新。
它火在两个节点:一是 Three.js 在 2026 年终于把 WebGPU 默认开起来,老的 GUI 写法明显跟不上;二是 r/ClaudeAI 上有人贴出一张”用 threeui + Claude 改 5 行参数就出新场景”的截图,让前端圈开始讨论”3D 组件库是不是也能像 shadcn 那样复制粘贴”。这周 star 一周内翻倍,主要增量来自 r/ClaudeAI 和 HN show 的二次传播。
二、问题 1:把 Three.js 场景”组件化”为什么会变形
很多人在自己项目里封装过一个 <RotatingCube />,第一次跑很顺,搬到另一个项目里灯光、相机、size 全乱。这是 Three.js 旧 GUI 思路的典型坑:实例参数写死在组件外,复用时上下文变了,组件就崩。
threeui 的解法是把所有”外部依赖”(相机、renderer、scene、控件)抽进一个 Stage Provider,组件只读 context:
// src/components/Stage.tsx
import { createContext, useContext, useEffect } from 'react'
import * as THREE from 'three'
const StageContext = createContext<THREE.Scene | null>(null)
export function Stage({ children }: { children: React.ReactNode }) {
const scene = new THREE.Scene()
useEffect(() => {
const dir = new THREE.DirectionalLight(0xffffff, 1)
dir.position.set(5, 5, 5)
scene.add(dir)
}, [scene])
return <StageContext.Provider value={scene}>{children}</StageContext.Provider>
}
export const useScene = () => {
const ctx = useContext(StageContext)
if (!ctx) throw new Error('useScene must be used inside <Stage>')
return ctx
}
这样每个组件只关心”自己往 scene 里 add 什么”,不关心相机位置、不关心 canvas size。问题就出在没有 Provider 的封装——一旦被外部传参,就一定会变形。
三、问题 2:参数调来调去,怎么知道”哪个 knob 控制了什么”
设计师提需求时最爱说:“灯再亮一点,字再大一点,球再圆一点”。传统做法是改源码、HMR、等 3 秒、看一眼、再改。如果一个组件有 12 个可调参数,能把人调到崩溃。
threeui 的做法是用 Leva 或自建 panel,把组件参数全部外置,并在文档页里直接展示:
// src/content/components/GlowingSphere.tsx
import { useControls } from 'leva'
import { useScene } from '@/components/Stage'
export default function GlowingSphere() {
const scene = useScene()
const { radius, color, intensity } = useControls('Sphere', {
radius: { value: 1, min: 0.1, max: 3, step: 0.1 },
color: '#ff8800',
intensity: { value: 2, min: 0, max: 10 },
})
// ... useEffect 中根据参数创建 mesh 并 add 到 scene
return null
}
useControls 会在右侧自动渲染一个可拖拽面板,每个参数的 name 就是它在文档页里高亮显示的”标签”。设计师不需要懂代码,也能看出”哦,原来 intensity 控的是发光强度”。这是 threeui 比一个简单的 starter 高明的地方——它把”组件文档”和”组件参数面板”同一个 source of truth。
四、问题 3:组件太多,怎么避免互相打架
经典场景:同一个 scene 里放一个 ParticleField 和一个 WobbleCube,两个都加了 requestAnimationFrame 更新位置。结果就是帧率掉到 20,因为谁都不知道对方在改 transform。
threeui 用的是 React 的 useFrame(来自 @react-three/fiber)统一调度:
import { useFrame } from '@react-three/fiber'
export default function WobbleCube() {
useFrame((state) => {
state.scene.children.forEach((c) => {
if (c.name === 'wobble-cube') {
c.rotation.x = state.clock.elapsedTime * 0.4
c.rotation.y = state.clock.elapsedTime * 0.3
}
})
})
return (
<mesh name="wobble-cube">
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
)
}
所有组件共用一个 rAF 循环,谁也不单独开 setInterval。多个组件想互相影响,只需要约定好 name,例如 wobble-cube 这个 mesh 的 transform 被 ParticleField 拿来做位置采样,完全不用直接 import 对方。
五、问题 4:怎么让组件能被”复制粘贴”而不是”安装”
shadcn/ui 当年火起来是因为它让你 npx shadcn add button 后真的把源码丢进你项目,三年过去了,你也不用担心升级破坏你的设计。threeui 想做的就是同样的事——不发布 npm 包,只发布组件源码。
目录结构长这样:
threeui/
├── src/
│ ├── components/
│ │ ├── Stage.tsx # 共享 Provider
│ │ └── LevaPanel.tsx # 共享参数面板
│ └── content/
│ └── components/
│ ├── GlowingSphere.tsx
│ ├── WobbleCube.tsx
│ └── ParticleField.tsx
你想用 GlowingSphere,就把那个文件复制到自己项目,按需补上 useScene / useFrame 两个 hook 即可。这种**“目录即文档”**的策略,对 3D 场景特别重要——因为每个项目的相机、灯光、tone mapping 都不同,强行装包反而会强行覆盖你的设置。
六、小结:它适合谁
如果你的项目是 SaaS 后台、产品官网这类对 Three.js 只是点缀的场景,threeui 大材小用;但只要你的项目有 3D demo 页面、有产品配置器、有数据可视化里要做旋转地球这种持续维护 3D 组件的需求,threeui 给你的是一套”代码 + 文档 + 参数面板”三位一体的样板,比你从零写快三天。
跑起来的成本极低:
git clone https://github.com/MengTo/threeui.git
cd threeui
npm install
npm run dev
默认会在 localhost:3000 打开,左侧是组件列表,点任意一项右侧就是可交互 demo 加上 Leva 控制面板。下一步建议直接 fork,把你自己项目里那个最常复用的 3D 组件,按它的目录结构加进去——比从头写文档快得多。
Sources
本文整理自 GitHub Trending 2026-08-23 榜单、Meng To 在 Design+Code 频道对 threeui 的介绍视频,以及 r/ClaudeAI 上关于”3D 组件复用是否该学 shadcn”的讨论帖(4 天内 327 条回复),相关 HN show 也已二次传播。