ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

@react-three/test-renderer 测试渲染器 API 全解析:为 react-three-fiber 场景编写可断言的单元测试

2026/9/10 10:10:21 拓冰建站 浏览量
@react-three/test-renderer 测试渲染器 API 全解析:为 react-three-fiber 场景编写可断言的单元测试 react-three/test-renderer 测试渲染器 API 全解析为 react-three-fiber 场景编写可断言的单元测试【免费下载链接】react-three-fiber A React renderer for Three.js项目地址: https://gitcode.com/GitHub_Trending/re/react-three-fiberreact-three/test-renderer是 react-three-fiber 生态中专用于 Node 环境的实验性 React 测试渲染器它基于react-three/fiber自身的 reconciler 将 THREE.js 场景图包装成可遍历、可查询、可触发事件的测试实例让你无需 WebGL 与浏览器即可对mesh /、useFrame等 3D 场景逻辑做断言。读完本文你将掌握create()/act()/advanceFrames()/fireEvent()等核心 API 的完整用法以及ReactThreeTestInstance的属性与方法能够像测试普通 React 组件一样为 3D 场景编写专业、稳定的单元测试。一、为什么需要专门的 3D 测试渲染器在 react-three-fiber 中编写了复杂的 WebGL 场景后你自然会想对它做自动化测试。但常规思路会遇到两个障碍THREE 元素不在 DOM 中react-dom无法渲染mesh /这类元素因为react-three/fiber拥有自己独立的 reconciler会把元素挂载到独立的 React root 上react-dom看不到场景内部的树结构WebGL 环境依赖真实的THREE.WebGLRenderer需要浏览器与 GPU 上下文在 CI 或纯 Node 环境下难以运行。react-three/test-renderer下文简称 RTTR正是为解决这一问题而生它在内部复用了react-three/fiber的 reconciler将完整的 scene graph 暴露出来并包装成带断言工具集的测试实例。本质上它可以让你在不创建 WebGL 渲染器、不启动渲染循环的情况下拿到场景图快照见 README。该包测试框架无关可与 jest、jasmine 等主流断言框架搭配使用仓库自带的测试即基于 jest见 jest.config.js 与 RTTR.core.test.tsx。二、安装与版本要求根据 README 与 package.json推荐安装方式如下yarn add react-three/fiber three yarn add -D react-three/test-renderer版本要求来自 package.json 的peerDependencies依赖版本要求react^19.0.0react-three/fiber9.0.0three0.156仓库当前版本为9.1.0通过preconstruct构建为 CJS / ESM 双格式main指向dist/react-three-test-renderer.cjs.jsmodule指向dist/react-three-test-renderer.esm.js。三、create()创建测试渲染器3.1 基本签名const renderer await ReactThreeTestRenderer.create(element, options)create接收一个 THREE 元素例如mesh /返回一个 Promise需要await获取渲染器实例。默认情况下它不会创建真正的THREE.WebGLRenderer也没有渲染循环但会渲染出完整的场景图。返回值包含scene、getInstance、toTree、toGraph、fireEvent、advanceFrames、update、unmount等成员下面逐一展开。从源码看src/index.tsxcreate内部会先通过createCanvas(options)生成一个 mock canvas再调用 fiber 的createRoot(canvas)并配置await _root.configure({ frameloop: never, // 关键关闭自动渲染循环 size: { width: options?.width ?? 1280, height: options?.height ?? 800, top: 0, left: 0, }, ...options, events: undefined, // 关闭真实事件系统由 fireEvent 接管 })这里有两个值得注意的默认值画布尺寸默认1280 x 800对应useThree中size.width/size.height且frameloop被固定为never保证测试环境完全受控、不产生非确定性的帧循环。3.2 CreateOptions// RenderProps 来自 react-three/fiber interface CreateOptions extends RenderPropsHTMLCanvasElement { width?: number // 画布宽度默认 1280 height?: number // 画布高度默认 800 }CreateOptions继承自 fiber 的RenderPropsHTMLCanvasElement因此react-three/fiber的Canvas /相关配置项如camera、gl、shadows、dpr等原则上也适用width/height用于控制 mock canvas 的尺寸。在 RTTR.hooks.test.tsx 中可以看到实际用法await ReactThreeTestRenderer.create(Component /, { width: 1280, height: 800 })测试随后断言useThree返回的size为{ height: 800, width: 1280, top: 0, left: 0 }验证了尺寸配置确实传递到了 store。3.3scenerenderer.scene返回根级 “react three test instance” 对象即ReactThreeTestInstance类型为Scene是后续一切断言的入口。你可以通过它向下查找更深的测试实例。例如const { scene } await ReactThreeTestRenderer.create( mesh boxGeometry args{[2, 2]} / meshBasicMaterial / /mesh, ) expect(scene.type).toEqual(Scene) // 根实例类型是 Scene expect(scene.children[0].type).toEqual(Mesh) // 第一个子节点是 Mesh源码中scene由wrapFiber(_scene)包装生成src/index.tsx其中_scene取自 fiber store 的state.scene上挂载的__r3f实例。3.4getInstance()renderer.getInstance()返回根 THREE 元素对应的实例即真实的THREE.Object3D等对象若不可用则返回null。注意如果根元素是函数组件则无法工作——函数组件没有自己的实例它们只是渲染逻辑的容器。源码会先判断 canvas 是否已卸载mockRoots.has(canvas)随后沿 fiber 节点向下遍历直到找到带有stateNode的节点再通过reconciler.getPublicRootInstance(root)取出公共根实例见 src/index.tsx。3.5toTree()renderer.toTree()返回一个代表渲染树的对象与react-test-renderer的toTree()行为类似会包含所有以 React 组件形式编写的元素。每个节点形如{ type, props, children }见 src/types/public.ts 中的TreeNodetype 采用首字母小写的约定如mesh转换逻辑见 src/helpers/tree.ts。3.6toGraph()renderer.toGraph()返回一个代表THREE.js 场景图scene graph的对象每个节点为{ type, name, children }见 src/types/public.ts 中的SceneGraphItem。与toTree()不同它不会包含所有元素例如使用attach挂载的 geometry、material、color 等不会出现在图中。转换逻辑见 src/helpers/graph.ts它会递归 fiber 子节点并对primitive类型的节点额外并入真实 THREE.js 对象的子节点。3.7fireEvent()renderer.fireEvent(testInstance, eventName, mockEventData)原生方法用于向渲染树中特定部分触发事件传入树中的某个元素与事件名第三个参数mockEventData会被合并进传给事件处理器的MockSyntheticEvent。事件命名遵循 camelCase 约定如pointerUp也可以直接传事件处理器名如onPointerUp。源码实现src/fireEvent.ts中toEventHandlerName(pointerUp)会转换成onPointerUp再依次尝试props[onPointerUp]与props[pointerUp]若都找不到处理器会console.warn提示但不会抛出异常。const handlePointerDown jest.fn() const { scene, fireEvent } await ReactThreeTestRenderer.create( mesh onPointerDown{handlePointerDown} boxGeometry args{[2, 2]} / meshBasicMaterial / /mesh, ) await fireEvent(scene.children[0], onPointerDown, { offsetX: 640, offsetY: 400 }) await fireEvent(scene.children[0], pointerDown) // 等价写法 expect(handlePointerDown).toHaveBeenCalledTimes(2)上面的例子直接取自 RTTR.events.test.tsx可以看到两种命名方式都有效且自定义的offsetX/offsetY数据会出现在事件对象中。MockSyntheticEventtype MockSyntheticEvent { camera: Camera // 渲染场景的默认相机 stopPropagation: () void target: ReactThreeTestInstance currentTarget: ReactThreeTestInstance sourceEvent: MockEventData ...mockEventData // 你传入的自定义数据会被展开合并进来 }从 src/fireEvent.ts 的实现可以看出camera直接取自 fiber store 的state.camerastopPropagation是一个空操作占位保证调用不抛错而...data展开保证了自定义字段如offsetX可被事件处理器读取。3.8advanceFrames()renderer.advanceFrames(frames, delta)原生方法用于推进帧从而执行订阅了 GL 渲染循环的回调例如useFrame。它需要两个参数推进的帧数以及传给订阅者的delta值从而营造一个更可控的测试环境。delta可以是单个数值也可以是数值数组数组模式下逐帧取对应增量见 src/index.tsx 中advanceFrames的实现。经典用例来自 RTTR.hooks.test.tsxconst Component () { const meshRef React.useRefTHREE.Mesh(null!) useFrame((_, delta) { meshRef.current.rotation.x delta }) return ( mesh ref{meshRef} boxGeometry args{[2, 2]} / meshBasicMaterial / /mesh ) } const renderer await ReactThreeTestRenderer.create(Component /) expect(renderer.scene.children[0].instance.rotation.x).toEqual(0) await ReactThreeTestRenderer.act(async () { await renderer.advanceFrames(2, 1) // 推进 2 帧每帧 delta 1 }) expect(renderer.scene.children[0].instance.rotation.x).toEqual(2)advanceFrames(2, 1)精确地执行了 2 次useFrame回调每次 delta 为 1使旋转量从 0 变为 2——这正是“受控帧循环”的价值不依赖真实时钟测试结果完全可复现。3.9update()renderer.update(element)使用新的根元素重新渲染整棵树模拟一次 React 更新并随之更新子树。如果新元素与旧元素类型和 key 相同则树会被更新复用而非重建若已卸载的根被再次 update会输出警告RTTR: attempted to update an unmounted root!。内部通过_root.render(newElement)并在act中执行见 src/index.tsx。在 RTTR.core.test.tsx 中可见状态更新后直接断言renderer.scene.children[0].instance.position.x的场景const renderer await ReactThreeTestRenderer.create(Component /) expect(renderer.scene.children[0].instance.position.x).toEqual(7) // 组件挂载后 setState 生效3.10unmount()renderer.unmount()卸载整棵树并触发相应的生命周期事件如componentWillUnmount。从源码看卸载后getInstance()会因 canvas 不再存在于mockRoots而返回null。四、act()为断言做准备ReactThreeTestRenderer.act(callback)与react-test-renderer的act()类似用于在断言前“安定”组件状态。不同之处在于使用 RTTR 时你不需要手动把ReactThreeTestRenderer.create和renderer.update包进act它们内部已经处理只需要在需要推动异步更新如advanceFrames、fireEvent、异步 setState时使用。act直接来自 React 自身的导出import { act } from react见 src/index.tsx因此它遵循 React 的 act 语义。Act 示例基于 jestimport ReactThreeTestRenderer from react-three/test-renderer const Mesh () { const meshRef React.useRef() useFrame((_, delta) { meshRef.current.rotation.x delta }) return ( mesh ref{meshRef} boxGeometry args{[2, 2]} / meshBasicMaterial / /mesh ) } const renderer await ReactThreeTestRenderer.create(Mesh /) expect(renderer.scene.children[0].instance.rotation.x).toEqual(0) await ReactThreeTestRenderer.act(async () { await renderer.advanceFrames(2, 1) }) expect(renderer.scene.children[0].instance.rotation.x).toEqual(2)五、ReactThreeTestInstance测试实例 APIReactThreeTestInstance是包装create()返回元素的内部类提供一系列属性与方法增强测试体验整体与react-test-renderer的 API 高度镜像。完整说明见 rttr-instance.md。5.1 属性属性类型说明instanceTObject该测试实例对应的实例对象即 THREE 初始化的类实例THREE.Mesh、THREE.Scene等typestring测试实例的 THREE 类型如Scene、Meshpropsobject当前传给元素的 props包含隐藏的如attachgeometry这类在 reconciler 中自动应用的 propsparentReactThreeTestInstance \| null父测试实例无父时返回nullchildrenReactThreeTestInstance[]按children属性返回子测试实例不包含Geometry、Material 等 attach 挂载项allChildrenReactThreeTestInstance[]返回全部子测试实例粒度与toTree()一致捕获树中所有 React 组件源码实现src/createTestInstance.ts中children与allChildren的关键差异在于getChildren的exhaustive选项默认过滤掉带props.attach的子节点而allChildren则全部保留此外对于primitive类型节点还会为其 THREE.js 对象子节点创建“虚拟实例”一并并入子列表。5.2 查询方法testInstance.find(test) // 找到唯一满足 test(testInstance) 的实例不是恰好一个则抛错 testInstance.findAll(test) // 找到所有满足条件的实例一个都没有时返回空数组 [] testInstance.findByType(type) // 按类型找唯一实例不是恰好一个则抛错 testInstance.findAllByType(type) // 按类型找全部实例无匹配返回 [] testInstance.findByProps(props) // 按 props 找唯一实例不是恰好一个则抛错 testInstance.findAllByProps(props) // 按 props 找全部实例无匹配返回 []findByProps/findAllByProps还支持 RegExp 匹配器testInstance.findByProps({ name: /^mesh_01$/ }) testInstance.findAllByProps({ name: /^mesh_\d$/ })RegExp 匹配逻辑在 src/helpers/testInstance.ts 的matchProps中实现当过滤值instanceof RegExp且目标 props 值为字符串时会执行filter.test(value)否则走严格相等比较。这意味着你既能做精确匹配也能做模式匹配。关于“恰好一个”的抛错语义expectOnesrc/helpers/testInstance.ts在 0 个匹配时抛出RTTR: No instances found ...多于 1 个时抛出RTTR: Expected 1 but found N instances ...。这些行为在 RTTR.methods.test.tsx 中均有覆盖expect(() scene.find((node) node.props.color 0x0000ff)).toThrow() // 有 2 个匹配抛错 expect(() scene.findByType(BufferGeometry)).toThrow() // 0 个匹配抛错 expect(scene.findAllByType(Mesh)).toHaveLength(2) // 精确批量查找 expect(scene.findAllByProps({ color: 0x0000ff })).toHaveLength(2) // 批量 props 查找5.3findAll的遍历规则findAll的底层实现在 src/helpers/testInstance.ts默认从根节点开始includeRoot为true递归遍历allChildren并始终包含子树的根。而实例方法findAll调用时显式传入了{ includeRoot: false }见 src/createTestInstance.ts即实例上的查询不会把自身算作候选只会搜索其子树——这也解释了为什么scene.find((node) node.instance.name mesh_01)可以命中场景下的子节点。六、底层原理RTTR 是如何“假装”渲染的RTTR 能在纯 Node 环境渲染 THREE 场景核心在于三块 mock 机制Mock Canvassrc/createTestCanvas.ts优先使用document.createElement(canvas)jsdom 环境否则构造一个带getContext的假 canvas 对象其getContext返回WebGL2RenderingContextmock实现见 src/WebGL2RenderingContext.ts并在globalThis上补充WebGLRenderingContext/WebGL2RenderingContext全局类保证 three.js 拿到上下文后不会真的调用 GPU。复用 fiber 的 reconcilersrc/index.tsx通过import { createRoot, reconciler, _roots as mockRoots } from react-three/fiber创建独立 root并把 store 从mockRoots.get(canvas)中取出用于后续的fireEvent与advanceFrames。RTTR 在入口处执行了extend(THREE as any)将全部 THREE 类注册进 fiber 的元素目录使mesh /等 JSX 标签可被解析。实例包装的 WeakMap 缓存src/createTestInstance.tswrapFiber使用WeakMapInstance, ReactThreeTestInstance保证同一个 fiber 始终返回同一个测试实例包装对象避免每次访问parent/children时产生身份不一致的实例。七、waitFor处理异步条件的辅助函数除了create与actRTTR 还导出了一个名为waitFor的辅助函数见 src/helpers/waitFor.tsReactThreeTestRenderer.waitFor(callback, options?) // options: { interval?: number; timeout?: number } 默认 interval50ms, timeout5000ms它会反复执行callback直到返回真值或返回null/undefined为止超时则抛出Timed out after ${timeout}ms.错误。整个轮询包裹在act中适合等待异步加载或异步状态更新完成的场景。八、综合实战一个完整的测试用例结合前面所有 API编写一个覆盖“渲染 → 查询 → 触发事件 → 推进帧 → 更新 → 卸载”全流程的 jest 测试import React from react import * as THREE from three import { useFrame } from react-three/fiber import ReactThreeTestRenderer from react-three/test-renderer const RotatingMesh () { const meshRef React.useRefTHREE.Mesh(null!) useFrame((_, delta) { meshRef.current.rotation.x delta }) return ( mesh namerotor onPointerDown{(e) console.log(e.offsetX, e.offsetY)} boxGeometry args{[2, 2]} / meshBasicMaterial color{0x0000ff} / /mesh ) } describe(RotatingMesh, () { it(renders, rotates and fires events, async () { const renderer await ReactThreeTestRenderer.create(RotatingMesh /) // 1. 场景结构断言 expect(renderer.scene.type).toEqual(Scene) expect(renderer.scene.findAllByType(Mesh)).toHaveLength(1) // 2. 实例查询按 props含 RegExp const mesh renderer.scene.findByProps({ name: /^rotor$/ }) expect(mesh.instance).toBeInstanceOf(THREE.Mesh) // 3. 场景图快照 expect(renderer.toGraph()[0].type).toEqual(Mesh) // 4. 受控推进帧验证 useFrame await ReactThreeTestRenderer.act(async () { await renderer.advanceFrames(3, 0.5) }) expect(mesh.instance.rotation.x).toBeCloseTo(1.5) // 5. 触发事件 const spy jest.spyOn(console, log) await renderer.fireEvent(mesh, pointerDown, { offsetX: 640, offsetY: 400 }) expect(spy).toHaveBeenCalledWith(640, 400) // 6. 更新与卸载 await renderer.update(RotatingMesh /) expect(renderer.scene.findAllByType(Mesh)).toHaveLength(1) await renderer.unmount() expect(renderer.getInstance()).toBeNull() }) })提示若希望观察update时生命周期如componentDidMount/ setState带来的变化可参考 RTTR.core.test.tsx 中Component挂载后把pos从 3 更新为 7 的断言写法。九、测试覆盖地图从仓库测试学习最佳实践仓库自带的测试是学习 RTTR 用法的第一手资料位于 packages/test-renderer/src/tests测试文件覆盖主题RTTR.core.test.tsxJSX 渲染、hooks、useTransition、空场景、复合组件、toGraph/toTree、Fragments、primitive与子树、快照断言RTTR.events.test.tsxfireEvent的两种命名方式、mockEventData 传递、未找到处理器时的降级行为RTTR.hooks.test.tsxuseThree的 store 状态、useLoader加载模型、advanceFrames驱动useFrameRTTR.methods.test.tsxparent/children、find/findAll、findByType/findAllByType、findByProps/findAllByProps含 RegExp快照文件见 RTTR.core.test.tsx.snap可直观看到toTree()/toGraph()的 JSON 输出形态。十、实用要点速查create/update/unmount内部已处理act只需对advanceFrames、fireEvent、异步回调包一层acttoTree()反映React 组件树含函数组件与 attach 项toGraph()反映THREE 场景图不含 attach 项两者都是快照测试的好帮手children与allChildren的区别在于是否包含 attach 挂载项Geometry / Material 等find/findByType/findByProps要求恰好一个匹配否则抛错——这能及早暴露“查询条件过于宽泛”的问题findAll系列则返回数组无匹配为空数组props中可能包含attach这类 reconciler 内部 props断言时请留意fireEvent的事件名可写pointerUp或onPointerUp找不到处理器时只告警不抛错默认画布尺寸 1280×800可通过CreateOptions.width/height覆盖并会反映到useThree的size中。相关文档与源码索引API 文档React Three Test Renderer API、React Three Test Instance API包说明与安装packages/test-renderer/README.md核心实现src/index.tsxcreate/advanceFrames、src/createTestInstance.ts实例包装与查询、src/fireEvent.ts事件触发、src/createTestCanvas.tsmock canvas树与图转换src/helpers/tree.ts、src/helpers/graph.ts类型定义src/types/public.tsCreateOptions、Renderer、MockSyntheticEvent、Tree、SceneGraph测试用例RTTR.core.test.tsx、RTTR.events.test.tsx、RTTR.hooks.test.tsx、RTTR.methods.test.tsx【免费下载链接】react-three-fiber A React renderer for Three.js项目地址: https://gitcode.com/GitHub_Trending/re/react-three-fiber创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考