React Fiber Suspense
为啥下面这个场景第一次切换的时候会导致浏览器类似于刷新的情况,理论上来说只有对应的 canvas 会闪一下之类的,但是现在侧边栏啥的也会被刷新,
这个场景是某个大屏项目中的一个组件
但是只有第一次切到这个 url 的时候才会出现
export default function CloudScene() {
let modelUrl = "/model/0e6eebc905fefa5f6980fb045535446a.glb";
const gltf = useLoader(GLTFLoader, modelUrl);
useEffect(() => {
if (!gltf) return;
}, [gltf]);
return (
<Canvas camera={{ position: [0, 0, 100], fov: 45 }}>
);
}
而像下面这样的基础场景就没有导致浏览器刷新的现象
function BaseComponent() {
return (
)
}
导致整个页面(包括侧边栏)像刷新一样闪烁的罪魁祸首是 React Suspense 的边界逃逸 (Suspense Boundary Escape)。
这是一个在将 React DOM 与 React Three Fiber (R3F) 结合使用时非常经典的“坑”。
🔍 为什么会发生这种情况?
R3F 中的 useLoader(以及 Drei 中的 <Environment files="...">)在底层使用了 React Suspense 来处理异步加载。它的工作原理是:在资源加载完成前,抛出一个 Promise。
在你的 CloudScene 代码中,你把 useLoader 写在了 <Canvas> 的外部(属于常规的 React DOM 树)。
- 第一次加载:
useLoader触发,抛出 Promise 暂停渲染。因为你在当前组件中没有提供<Suspense>边界,这个“暂停”状态会一直向上冒泡,直到撞上你在全局配置的<Suspense>(通常在 React Router 的路由层或者 Layout 层)。这会导致 React 卸载当前的整个 DOM 树(包括你的侧边栏),渲染那个最顶层的 loading fallback,等模型加载完再重新挂载,视觉上就像“刷新”或“闪烁”了一次。 - 后续加载: R3F 默认会缓存已经加载过的模型资源。再次切换到这个 URL 时,
useLoader直接从缓存读取,不再抛出 Promise,所以就不会闪烁了。
而你的 BaseComponent 没有加载任何外部资产,没有触发 Suspense,所以一切正常。
🛠️ 解决方案
要解决这个问题,你需要将所有加载外部资产的逻辑,放入 <Canvas> 内部的子组件中,并在 <Canvas> 内部使用 <Suspense> 将其包裹起来。这样,挂起的就只有 Canvas 内部的 3D 渲染,而不会影响外层的网页 DOM(如侧边栏)。
请将你的代码重构为如下结构:
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment, useGLTF } from '@react-three/drei';
// 1. 将模型单独抽离成一个 R3F 子组件
function CloudModel() {
const modelUrl = "/model/0e6eebc905fefa5f6980fb045535446a.glb";
// 推荐使用 Drei 封装的 useGLTF,比底层的 useLoader(GLTFLoader, url) 更好用,支持自动解压 DRACO 等
const gltf = useGLTF(modelUrl);
return <primitive object={gltf.scene} />;
}
// 可选:为了大屏体验更好,可以在组件外部提前预加载模型
useGLTF.preload("/model/0e6eebc905fefa5f6980fb045535446a.glb");
export default function CloudScene() {
return (
<Canvas camera={{ position: [0, 0, 100], fov: 45 }}>
<OrbitControls enableZoom />
{/* 2. 在 Canvas 内部使用 Suspense 拦截加载状态 */}
{/* fallback 可以是 null,或者是一个用 <mesh> 画的简单的 loading 几何体 */}
<Suspense fallback={null}>
{/* Environment 加载 .hdr 也会触发 Suspense,所以也要放进来 */}
<Environment files="/studio_2_blue.hdr" background={true} />
<CloudModel />
</Suspense>
</Canvas>
);
}
💡 针对数据看板项目的进阶建议
在大屏数据看板项目中,用户体验极其依赖首屏渲染的平滑度。为了避免即使限制了 Suspense 边界,Canvas 内部仍然出现短暂的“黑屏”或空洞,建议采用以下策略:
- 预加载 (Preloading): 就像上面代码中的
useGLTF.preload()一样,你可以在入口文件或路由层就提前触发资源的拉取,这样等用户切到这个大屏组件时,模型已经在内存里了,实现真正的秒切。 - 使用更小的占位符 (Fallback): 在
<Suspense fallback={<LoadingMesh />}>中,你可以传入一个非常轻量级的几何体(比如一个带有线框材质的 Box),作为模型解析过程中的视觉过渡。
谈到 React Fiber 的 Suspense 异步加载哲学,我们实际上是在聊 React 从一个“单纯的 UI 渲染库”演进为“分布式任务调度系统”的底层思维变革。
在传统的全栈或前端开发中,我们习惯了命令式(Imperative)地处理异步:写一个 isLoading 状态,在 useEffect 里发请求,回来后再 setLoading(false)。
而 Suspense 带来的是一场声明式(Declarative)的哲学颠覆:“让组件只专注于‘数据就绪后的渲染逻辑’,如果数据没准备好,组件就‘躺平(挂起)’,把等待的控制权完全交给上层边界。”
1. 传统异步加载的痛点:状态碎片化与“瀑布流”
在没有 Suspense 之前,如果一个复杂的工业大屏有地图、热图、资产列表三个组件,我们通常会这么写:
// 传统做法:满屏幕的 isLoading 胶水代码
function Dashboard() {
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchMapData().then(() => setLoading(false))
}, [])
if (loading) return <Spinner />
return <MapScene />
}
这种模式有三大死穴:
- Loading 瀑布流(Waterfall):父组件加载完才渲染子组件,子组件再去发请求加载孙组件,页面就会像挤牙膏一样,一会儿弹出一个转圈圈,视觉极其破碎。
- 竞态条件(Race Conditions):高频切换路由或资产时,先发出的请求后到,导致老数据覆盖新数据。
- 组件间无法同步协同:很难做到“等 3D 地图模型和 UWB 节点数据都准备好了,再同时展示界面”。
2. Suspense 的底层魔法:组件“抛出”了 Promise
Suspense 的核心哲学非常奇葩且优雅:如果组件发现数据(或 3D 纹理、模型)还没加载完,它就会直接“掀桌子”——向外抛出(throw)一个 Promise。
是的,你没听错。在 JS 里我们习惯了 throw new Error(),但 React 允许数据加载器(如 Drei 的 useTexture 或者是 React 19 的 use())在数据未就绪时,throw 一个正在进行的 Promise。
🔄 Fiber 是如何配合演这出戏的?
这就不得不提 React Fiber 架构 的高明之处了。Fiber 将渲染阶段分为了 Render 阶段(可中断) 和 Commit 阶段(同步变动 DOM)。
- 遇到阻碍:当 Fiber 树在 Render 阶段 向下遍历到你的
MapScene组件时,组件执行内部的加载逻辑,发现 4K SVG 地图还在下载中。 - 捕获中断:组件立刻
throw promise。React Fiber 引擎在最外层用类似try...catch的机制捕获了这个 Promise。 - 任务挂起:Fiber 默默记下:“这个子树现在卡住了”。因为 Render 阶段是可中断的,React 直接抛弃(或挂起)当前这一帧的渲染工作,绝对不会让未完成的残缺组件进入 Commit 阶段(这就避免了网格出来了,图片还是白块的尴尬场景)。
- 展示垫片:React 掉头向上寻找最近的
<Suspense fallback={<Loading />} />边界,把fallback里的组件挂载到屏幕上。 - 唤醒与重试:当那个被抛出的 Promise 成功 resolve(地图下载完毕)时,它会通知 React。React Fiber 重新调度,从刚才卡住的组件开始,启动新一轮完美的 Render。
3. 为什么 Drei/R3F 在新版中全面拥抱这个哲学?
回到你之前遇到的 Drei <Image> 组件移除 onLoad 的报错。在 3D (WebGL) 领域,Suspense 的哲学简直是救命稻草。
❌ 过去基于回调的痛苦(onLoad 时代)
在 Three.js 传统开发里,你需要监听各种 TextureLoader.load(url, onLoad)。这会导致资产在运行时“闪现”:网格模型(Mesh)先变成了没有皮肤的纯白体,等几百毫秒图片下载完了,纹理才突兀地“贴”上去。这在数字孪生项目中属于明显的视觉 Bug。
现代的声明式流(Suspense 时代)
新版的 Drei 彻底顺应了 React Fiber 的大势。当你写下:
function MapApp() {
return (
<Suspense fallback={<CanvasLoader />}>
{/* 只要 MapScene 内部任何一张 4K 贴图、任何一个 GLTF 模型没加载完 */}
{/* 整个 3D 画布都会静止在 fallback 状态,绝不呈现半成品 */}
<Canvas>
<MapScene />
</Canvas>
</Suspense>
)
}
对于 MapScene 来说,它内部的现代代码是这样的:
export function MapScene() {
// 隐式哲学:useTexture 内部如果发现缓存没有,直接 throw Promise 挂起当前组件
// 你的代码完全不需要写 if (loading) 返回空,直接当做它是同步获取数据的!
const texture = useTexture("/map.svg")
return (
<mesh>
<planeGeometry />
<meshBasicMaterial map={texture} />
</mesh>
)
}
组件变得纯粹极了:我默认只要我执行了,数据就一定是 100% 完美的。 至于等待的痛苦,让上层的 <Suspense> 容器去背。
🛠️ 总结
React Fiber 的 Suspense 异步哲学,本质上是把“异步的时间线”抹平,让我们用“同步的思维”去写组件。它把零散在各个组件里的 Loading 状态收口,变成了全局或局部排版上的 “调度边界(Orchestration Boundaries)”。
你目前正在构建的总装车间 UWB 系统,当未来需要同时加载厂房 3D 模型(大文件)、多路 WebSocket 节点、以及 4K 矢量底图时,Suspense 能够确保它们以原子级(要么一起出,要么都不出)的方式完整呈现在甲方眼前,这就是工业级架构所追求的确定性与高内质。
目前你的 UWB 项目里,除了底图,还有其他大型的 3D 资产(比如厂房产线的 .gltf 或 .obj 模型)需要一起参与多组件的协同加载调度吗?
浙公网安备 33010602011771号