Relay 的数据获取 Hook(usePreloadedQuery、useLazyLoadQuery)在查询尚未返回时会主动"挂起"组件渲染,本指南将围绕这一机制,系统讲解如何借助 React Suspense 与 Suspense Boundary 编排加载 UI(如 glimmer 占位、spinner),并深入到 react-relay 的 hooks 源码 解释挂起背后的实现原理。读完本文,你将掌握粗/细粒度加载边界的设计、查询/片段/分页等 API 与 Suspense 的集成方式,以及用fetchQuery绕过 Suspense 的实战方案。
为什么需要 Suspense:数据到达前的"等待渲染"
上一节的指南中提到,usePreloadedQuery和useLazyLoadQuery会渲染从服务器获取到的查询数据,但没有具体说明:在数据仍在获取的过程中,如何渲染加载 UI(例如一个 glimmer)。这正是本节要解决的问题。
Relay 渲染加载状态依赖React Suspense。Suspense 是 React 的一项能力:它允许组件中断(或者说"挂起")渲染,以便等待某个异步资源(代码、图片或数据)加载完成。当组件"挂起"时,它向 React 表明该组件"尚未准备好"被渲染,直到它等待的异步资源加载完毕;资源加载完成后,React 会再次尝试渲染该组件。
这项能力对组件表达"渲染所需"的异步依赖(数据、代码、图片)非常有用,它让 React 能够跨组件树协调加载状态的渲染,直到这些异步资源就绪。更一般地,使用 Suspense 让我们在应用首次加载、或在不同状态之间过渡时,能更精确地设计加载状态,并避免加载序列未经设计时常见的加载元素(如 spinner)意外闪烁问题。
用 Suspense Boundary 渲染加载回退(fallback)
当一个组件挂起时,我们需要在等待其"就绪"期间,用fallback占位内容替换它渲染。为此我们使用 React 提供的Suspense组件:
const React = require('React'); const {Suspense} = require('React'); function App() { return ( // 使用 Suspense 作为包裹层渲染 fallback <Suspense fallback={<LoadingGlimmer />}> <CanSuspend /> </Suspense> ); }Suspense组件可以包裹任意组件;如果目标组件挂起,Suspense会渲染提供的fallback,直到其所有后代都"就绪"(即子树中被挂起的所有组件全部解析)。通常,fallback用于渲染 glimmer、占位符之类的加载状态。
应用中往往有多个不同的内容区块可能挂起,我们可以分别为它们用Suspense提供加载状态:
/** * App.react.js */ const React = require('React'); const {Suspense} = require('React'); function App() { return ( // LoadingGlimmer 通过 Suspense 的 fallback 渲染 <Suspense fallback={<LoadingGlimmer />}> <MainContent /> {/* MainContent 可能会挂起 */} </Suspense> ); }这里的运行机制可以这样拆解:
- 如果
MainContent因为等待某个异步资源(如数据)而挂起,包裹它的Suspense组件会检测到挂起,并在MainContent就绪前渲染fallback元素(此处即LoadingGlimmer)。注意这也会传递性地包含MainContent的后代——它们也可能挂起。
粗粒度加载:合并多个子树的加载状态
Suspense 的一个优点在于,你可以对组件树的不同部分分别累积加载状态,进行粒度控制:
/** * App.react.js */ const React = require('React'); const {Suspense} = require('React'); function App() { return ( // 所有内容共用一个 LoadingGlimmer,通过 Suspense 的 fallback 渲染 <Suspense fallback={<LoadingGlimmer />}> <MainContent /> <SecondaryContent /> {/* SecondaryContent 也可能挂起 */} </Suspense> ); }- 这里
MainContent和SecondaryContent在加载各自的异步资源时都可能挂起;将它们包在同一个Suspense中,可以显示单一加载状态,直到它们全部就绪,然后一次性渲染全部内容(单次绘制)。 - 事实上,
MainContent和SecondaryContent挂起的原因可以各不相同(不止是取数),但同一个Suspense都能在子树内所有组件就绪前渲染 fallback。同样,这也传递性地涵盖二者的后代。
细粒度加载:让先就绪的内容先渲染
反过来,你也可以决定把加载 UI 做得更细,用Suspense包裹组件树中更小或独立的部分:
/** * App.react.js */ const React = require('React'); const {Suspense} = require('React'); function App() { return ( <> {/* 为 LeftHandColumn 显示独立的加载 UI */} <Suspense fallback={<LeftColumnPlaceholder />}> <LeftColumn /> </Suspense> {/* 为 Main 和 Secondary 内容显示另一个独立的加载 UI */} <Suspense fallback={<LoadingGlimmer />}> <MainContent /> <SecondaryContent /> </Suspense> </> ); }- 此时我们看到两套独立的加载 UI:一套直到
LeftColumn就绪,另一套直到MainContent和SecondaryContent都就绪。 - 细粒度包裹的强大之处在于:它允许其他组件在各自就绪后尽早渲染。上面的例子中,
LeftColumn一旦就绪就能立即渲染,而不必等待内容区域,这可能比内容区就绪的时间更早。
过渡(Transitions)与更新的挂起
SuspenseBoundary 的 fallback 让我们能描述初次渲染某个内容时的加载占位;但应用还会有不同内容之间的过渡。具体而言,当你在一个已经挂载的 Boundary 内切换两个组件时,新切换到的组件可能还没加载完它的所有异步依赖,因此它也可能挂起。
在这些场景下,我们仍然会显示SuspenseBoundary 的 fallback。但这也意味着:为了显示 fallback,我们会隐藏已渲染的现有内容。在后续支持并发渲染的 React 版本中,React 将提供选项来避免这种情况——挂起时不会用 Suspense fallback 隐藏已经渲染好的内容。
Relay 如何在内部使用 Suspense
查询(Queries):查询组件是可挂起组件
在 Relay 中,查询组件就是会挂起的组件,因此我们用 Suspense 在查询获取期间渲染加载状态。假设我们有下面这样的查询渲染组件:
/** * MainContent.react.js * * Query Component(查询组件) */ const React = require('React'); const {graphql, usePreloadedQuery} = require('react-relay'); function MainContent(props) { // 获取并渲染一条查询 const data = usePreloadedQuery( graphql`...`, props.queryRef, ); return (...); }/** * App.react.js */ const React = require('React'); const {Suspense} = require('React'); function App() { return ( // LoadingGlimmer 通过 Suspense 的 fallback 渲染 <Suspense fallback={<LoadingGlimmer />}> <MainContent /> {/* MainContent 可能会挂起 */} </Suspense> ); }这里的运行机制是:
MainContent是查询渲染器,负责获取并渲染查询。当它尝试获取查询时,会挂起渲染,表明自己尚未准备好;查询获取完成后,它才会解析(resolve)。- 包裹
MainContent的Suspense组件检测到挂起后,在MainContent就绪前(也就是查询返回前)渲染fallback元素(此处即LoadingGlimmer)。
源码佐证:usePreloadedQuery与useLazyLoadQuery最终都汇聚到 useLazyLoadQueryNode.js,后者在渲染阶段调用QueryResource.prepareWithIdentifier(...)读取查询资源。在 QueryResource.js 中,核心逻辑清晰可见:如果缓存值是一个 Promise(代表网络请求尚未完成),就会执行throw cachedValue——这正是让 React 识别"挂起"并转向Suspensefallback 的标准机制;如果缓存值是Error则直接抛出错误(对应 Error Boundary);否则返回就绪的查询结果。也就是说,"查询组件挂起"本质上是在渲染阶段抛出一个 Promise,React 借此暂停该子树并显示 fallback。
从 useLazyLoadQuery.js 可以看到,useLazyLoadQuery在渲染时通过fetchQuery(environment, query)构造fetchObservable,并把fetchPolicy、fetchKey、UNSTABLE_renderPolicy一并传入useLazyLoadQueryNode;而usePreloadedQuery(usePreloadedQuery.js)则优先复用loadQuery预取的sourceobservable(仅当环境匹配时),否则回退到渲染期重新执行并去重(de-dupe)查询。
挂起与 fetchPolicy 的关系:何时允许直接渲染
QueryResource的_fetchAndSaveQuery(QueryResource.js)根据fetchPolicy决定"是否需要发起网络请求"与"是否允许立即渲染":
| fetchPolicy | shouldFetch(是否请求网络) | shouldAllowRender(是否允许立即渲染) |
|---|---|---|
store-only | 否 | 是 |
store-or-network | 数据不完整时才请求 | 数据完整或启用 partial render 时允许 |
store-and-network | 总是请求 | 数据完整或启用 partial render 时允许 |
network-only(默认分支) | 总是请求 | 否(必须等网络返回) |
当shouldAllowRender为 false 时,Relay 会把该查询的 Promise 缓存起来(QueryResource.js),并给这个 Promise 打上displayName = 'Relay(' + operation.fragment.node.name + ')'便于调试——这正对应"在此查询根节点挂起"。DEFAULT_FETCH_POLICY = 'store-or-network'是查询的默认策略,而 live query(含执行期 resolver)默认使用store-and-network(QueryResource.js)。
此外,Relay 使用 SuspenseResource.js 管理挂起期间的数据保留:渲染阶段通过temporaryRetain临时保留数据(服务端环境直接跳过),并设置 5 分钟(TEMPORARY_RETAIN_DURATION_MS)的自动释放定时器,防止组件因未提交而泄漏数据;一旦组件提交(commit),permanentRetain会把保留转移给组件生命周期,组件卸载时再释放。
片段(Fragments):部分渲染的边界
片段同样与 Suspense 集成,以支持渲染@defer延迟的数据,或渲染 Relay Store 中部分可用的数据(即部分渲染(partial rendering))。
关键规则(详见部分渲染指南):
- 片段组件在渲染时,如果它本地声明的数据缺失且正在获取,就会挂起,直到其所属查询(父查询)获取完成;
- 数据缺失只按"本地声明的字段"判定——片段通过片段展开(fragment spread)引入的子片段数据缺失,不会影响外层片段/查询的缺失判定;
- 因此,如果查询本身的数据已缓存,可以立即渲染外层内容,而把缺失数据的子片段单独用
Suspense包裹,实现"先渲染已缓存部分,再等缺失部分",从而跳过加载状态:
/** * HomeTab.react.js * * Query Component */ const React = require('React'); const {Suspense} = require('React'); const {graphql, usePreloadedQuery} = require('react-relay'); const UsernameComponent = require('./UsernameComponent.react'); function HomeTab() { const data = usePreloadedQuery( graphql` query AppQuery($id: ID!) { user(id: $id) { name ...UsernameComponent_user } } `, props.queryRef, ); return ( <> <h1>{data.user?.name}</h1> {/* 用 Suspense 包裹 UserComponent,即使 username 缺失, 也允许 App 的其他部分继续渲染。 */} <Suspense fallback={<LoadingSpinner label="Fetching username" />}> <UsernameComponent user={data.user} /> </Suspense> </> ); }这一机制对嵌套片段同样成立:只要渲染某片段所需的数据已在本地缓存,该片段组件就能渲染,无论其子/后代片段的数据是否缺失。
转换(Transitions):刷新、重取与连接分页
除了查询与片段,Relay 的取数类 API同样与 Suspense 集成,这些场景下 API 也会挂起:
- 刷新(Refreshing)与重取(Refetching):见数据获取介绍与刷新查询指南。例如
useQueryLoader场景下,事件处理器中调用loadQuery(variables, {fetchPolicy: 'network-only'})会强制走网络、跳过本地缓存,随之而来的重渲染会让usePreloadedQuery再次挂起,因此必须保证MainContent外层有SuspenseBoundary 来显示 fallback;useLazyLoadQuery场景下,则通过递增fetchKey并配合network-only触发同样的挂起。 - 连接(Connections):分页与连接渲染相关 API(
usePaginationFragment、useRefetchableFragment等)也集成 Suspense。例如 usePaginationFragment.js 内部基于useRefetchableFragmentInternal与useLoadMoreFunction实现loadNext/loadPrevious/refetch,其重取路径同样会在新数据未就绪时挂起(useRefetchableFragmentInternal.js 通过useQueryLoader/fetchQuery发起新请求)。
进阶实战:需要避免挂起时的替代方案
Suspense 的挂起会隐藏已渲染内容来显示 fallback。在某些场景(例如刷新数据时不希望整块内容闪成 spinner),需要避开挂起。官方推荐的做法是改用fetchQuery(见刷新查询指南):
/** * App.react.js */ const AppQuery = require('__generated__/AppQuery.graphql'); function App(props: Props) { const environment = useRelayEnvironment(); const [queryRef, loadQuery] = useQueryLoader( AppQuery, props.appQueryRef /* initial query ref */ ); const [isRefreshing, setIsRefreshing] = useState(false) const refresh = useCallback(() => { if (isRefreshing) { return; } const {variables} = props.appQueryRef; setIsRefreshing(true); // fetchQuery 会获取查询并把数据写入 Relay Store, // 这样重渲染时数据已在本地缓存,从而不会挂起 fetchQuery(environment, AppQuery, variables) .subscribe({ complete: () => { setIsRefreshing(false); // *在* 查询获取完成之后,再调用 loadQuery 生成新的 queryRef。 // 此时查询数据已在缓存中,使用 'store-only' fetchPolicy 避免挂起。 loadQuery(variables, {fetchPolicy: 'store-only'}); } error: () => { setIsRefreshing(false); } }); }, [/* ... */]); return ( <React.Suspense fallback="Loading query..."> <MainContent isRefreshing={isRefreshing} refresh={refresh} queryRef={queryRef} /> </React.Suspense> ); }要点拆解:
- 刷新时用组件自身维护的
isRefreshing状态代替挂起,可以在MainContent内部渲染忙碌 spinner,而不会隐藏MainContent; - 事件处理器先调用
fetchQuery把数据写入本地 Relay Store,请求完成后调用loadQuery拿到新的queryRef再传给usePreloadedQuery渲染新数据; - 由于此时数据已在缓存中,
loadQuery使用'store-only'策略只读缓存,避免挂起。
小结:Suspense 加载状态的正确姿势
- 用
<Suspense fallback={...}>包裹可能挂起的查询/片段组件,fallback 渲染 glimmer 或占位符; - 用一个
Suspense合并多个区块的加载状态(粗粒度),或用多个Suspense让先就绪的内容先渲染(细粒度),避免闪烁; - 记住 Relay 的挂起机制(QueryResource.js 中"缓存 Promise 并 throw"),理解
fetchPolicy如何影响是否立即渲染; - 刷新、重取、分页等取数 API 同样会挂起,记得在对应组件外层保留
SuspenseBoundary; - 需要保留已渲染内容时,用
fetchQuery+ 手动 loading 状态 +store-only的loadQuery组合绕过挂起。
相关延伸阅读:查询与渲染、错误状态处理、部分缓存数据渲染、刷新查询。
- 前端
- 开发工具
【免费下载链接】relay
Relay is a JavaScript framework for building>项目地址:https://gitcode.com/gh_mirrors/relay29/relay
相关推荐
Relay 加载状态指南:使用 React Suspense 与 usePreloadedQuery / useLazyLoadQuery 实现数据驱动的 Loading UI
Relay 加载状态指南:使用 React Suspense 与 usePreloadedQuery / useLazyLoadQuery 实现数据驱动的 Lo
前端开发工具Relay 加载状态指南:结合 React Suspense 与 Suspense Boundary 设计数据驱动的加载 UI
Relay 加载状态指南:结合 React Suspense 与 Suspense Boundary 设计数据驱动的加载 UI 导读 在 Relay 应用中,
前端开发工具Relay 渲染指南:用 Suspense 为查询渲染加载状态(Loading States)
Relay 渲染指南:用 Suspense 为查询渲染加载状态(Loading States) Relay(react relay)中的 usePreloade
前端开发工具