Relay 查询刷新(Refreshing Queries)实战指南:用 useQueryLoader 与 useLazyLoadQuery 拉取最新数据
本篇指南聚焦 Relay 中"刷新查询"(refreshing a query)这一核心场景:以完全相同的变量重新请求并渲染同一份查询数据,从而从服务器获取最新版本。文章围绕 useQueryLoader / loadQuery 与 useLazyLoadQuery 两条主流路线,结合 network-only / store-only 等 fetchPolicy 的取舍,给出可复制的完整代码示例,并深入底层源码(loadQuery.js、useQueryLoader.js、useLazyLoadQuery.js、fetchQuery.js)解释其工作原理。读完你可以在"点击按钮刷新计数"等典型交互中,自主选择最合适的刷新实现,并理解 Suspense 边界在其中扮演的角色。
什么是"刷新查询"
在 Relay 中,"刷新查询"(refreshing a query)特指:用与最初渲染时完全相同的 variables 重新获取同一查询的数据,以获得服务器上最新的版本。例如"朋友数量"这种随时可能变化的计数,用户点击"Fetch latest count"按钮时,我们希望拿到的仍然是同一个 user(id: $id) 的 friends { count },只是数据是最新的。
这与本目录下的另外两篇文档所讲的场景有本质区别:
- 用不同数据重新渲染查询:refetching-queries-with-different-data.md 关注"换一批数据"(例如切换当前展示的条目,改变
id变量); - 刷新 Fragment:refreshing-fragments.md 关注针对单个 fragment 的定向刷新。
刷新查询则是"同样的数据、最新的版本",是上述所有场景中请求成本最低、实现最简单的一种。它的实现方式取决于你最初用哪种方式获取该查询:useQueryLoader / loadQuery 路线,还是 useLazyLoadQuery 路线。
刷新前先考虑:能否使用实时功能
在动手实现"手动刷新"之前,Relay 官方首先建议评估一个问题:这个数据是否适合用实时能力来自动保持最新,从而免去周期性手动刷新的麻烦。
一个典型例子是 GraphQL Subscriptions:当服务器数据变化时,订阅会主动推送更新,客户端无需发起新的完整查询。当然,使用订阅需要额外配置:
- 服务器端需要支持订阅操作;
- 客户端的 network layer 需要配置好 WebSocket 等传输层。
如果实时方案在当前场景下成本过高或不合适(例如数据变化频率低、服务器不支持订阅),再回到手动刷新方案。这也是 Relay 文档反复强调的:先想清楚是否真的需要手动刷新,实时功能往往是更省力的答案。
方案一:useQueryLoader / loadQuery 路线
如果查询最初是通过 useQueryLoader 加载的(即文档 Fetching Queries for Render 中描述的 "render-as-you-fetch" 模式),刷新查询只需再次调用 loadQuery 即可:
/**
* App.react.js
*/
const AppQuery = require('__generated__/AppQuery.graphql');
function App(props: Props) {
const [queryRef, loadQuery] = useQueryLoader(
AppQuery,
props.appQueryRef /* initial query ref */
);
const refresh = useCallback(() => {
// Load the query again using the same original variables.
// Calling loadQuery will update the value of queryRef.
// The fetchPolicy ensures we always fetch from the server and skip
// the local data cache.
const {variables} = props.appQueryRef;
loadQuery(variables, {fetchPolicy: 'network-only'});
}, [/* ... */]);
return (
<React.Suspense fallback="Loading query...">
<MainContent
refresh={refresh}
queryRef={queryRef}
/>
</React.Suspense>
);
}
/**
* MainContent.react.js
*/
// Renders the preloaded query, given the query reference
function MainContent(props) {
const {refresh, queryRef} = props;
const data = usePreloadedQuery(
graphql`
query AppQuery($id: ID!) {
user(id: $id) {
name
friends {
count
}
}
}
`,
queryRef,
);
return (
<>
<h1>{data.user?.name}</h1>
<div>Friends count: {data.user.friends?.count}</div>
<Button
onClick={() => refresh()}>
Fetch latest count
</Button>
</>
);
}
逐点拆解
- 在事件处理器中调用
loadQuery:loadQuery被放在refresh回调(如按钮的onClick)中,而不是渲染期间,因此网络请求会立即开始;随后新生成的queryRef被传入使用usePreloadedQuery的MainContent,从而渲染更新后的数据。 fetchPolicy: 'network-only':确保每次刷新都强制走网络、跳过本地数据缓存(Relay Store)。关于四种 fetchPolicy 的完整语义见下文。- Suspense 行为:因为
network-only必然发起网络请求,调用loadQuery会让组件重新渲染并导致usePreloadedQuery挂起(suspend)(详见 Loading States with Suspense),因此必须用<React.Suspense>包裹MainContent以展示 fallback 加载态。
源码视角:loadQuery 为什么"再调用一次"就是刷新
从源码看,每次调用 loadQuery 都会生成一个新的 fetchKey:
// packages/react-relay/relay-hooks/loadQuery.js
// Every time you call loadQuery, we will generate a new fetchKey.
// This will ensure that every query reference that is created and
// passed to usePreloadedQuery is independently evaluated,
// even if they are for the same query/variables.
// Specifically, we want to avoid a case where we try to refetch a
// query by calling loadQuery a second time, and have the Suspense
// cache in usePreloadedQuery reuse the cached result instead of
// re-evaluating the new query ref and triggering a refetch if
// necessary.
fetchKey++;
这段注释揭示了关键设计意图:第二次调用 loadQuery 时,Suspense 缓存不会"复用"上一次的查询结果,而是强制重新求值并重新发起请求。此外,loadQuery 内部还会执行 environment.retain(operation) 将查询数据保留在 store 中(防止被垃圾回收),并在构造返回的 PreloadedQuery 时写入 fetchKey、fetchPolicy、networkCacheConfig 等字段(见 loadQuery.js),供 usePreloadedQuery 消费。
在 useQueryLoader 一侧(useQueryLoader.js),每次调用 loadQuery 回调都会:
- 用
loadQuery()生成新的PreloadedQuery; - 把它加入
undisposedQueryReferencesRef集合跟踪; - 调用
setQueryReference更新 state,触发重新渲染。
同时,useQueryLoader 会通过 releaseQuery() / dispose() 清理旧的 query reference,避免 store 中数据泄漏(useQueryLoader.js)。这保证了"反复刷新"不会造成旧数据无限堆积。
方案一的进阶:需要避免 Suspense 怎么办
network-only 必然触发挂起,而 Suspense fallback 会隐藏已经渲染好的内容。如果希望刷新期间保留当前界面(例如只在角落显示一个小 spinner),可以改用 fetchQuery + 手动管理 loading state 的组合:
注:在未来的 React 版本中,当支持并发渲染(concurrent rendering)后,React 将提供一种选项,在挂起时避免用 Suspense fallback 隐藏已渲染的内容(见 OssAvoidSuspenseNote.md)。在当前版本中,仍需自行实现。
/**
* 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 will fetch the query and write
// the data to the Relay store. This will ensure
// that when we re-render, the data is already
// cached and we don't suspend
fetchQuery(environment, AppQuery, variables)
.subscribe({
complete: () => {
setIsRefreshing(false);
// *After* the query has been fetched, we call
// loadQuery again to re-render with a new
// queryRef.
// At this point the data for the query should
// be cached, so we use the 'store-only'
// fetchPolicy to avoid suspending.
loadQuery(variables, {fetchPolicy: 'store-only'});
}
error: () => {
setIsRefreshing(false);
}
});
}, [/* ... */]);
return (
<React.Suspense fallback="Loading query...">
<MainContent
isRefreshing={isRefreshing}
refresh={refresh}
queryRef={queryRef}
/>
</React.Suspense>
);
}
逐点拆解
- 手动
isRefreshingloading state:既然避免挂起,就要自己跟踪刷新状态,用它渲染忙碌 spinner 等加载 UI,而不隐藏MainContent。 - 先
fetchQuery再loadQuery的两段式流程:事件处理器中先调用fetchQuery,它会发起网络请求并把结果写入本地 Relay Store;当请求complete后,再调用loadQuery拿到新的queryRef,传给usePreloadedQuery渲染更新数据。isRefreshing的error分支也记得复位,避免状态卡死。 fetchPolicy: 'store-only':此时查询数据已经缓存在 store 中,loadQuery用store-only只会读取已缓存数据,不会再次发起网络请求,从而避免挂起。
源码视角:fetchQuery 为何"只写缓存不挂起"
fetchQuery 的实现位于 packages/relay-runtime/query/fetchQuery.js:它返回一个 RelayObservable,由调用方决定何时 .subscribe();数据到达后通过 environment.lookup(operation.fragment) 读回快照,其副作用是把网络响应写入 store,但不直接驱动 React 渲染,因此不会触发 Suspense。同时注意 fetchQuery 默认 fetchPolicy 就是 'network-only'(const fetchPolicy = options?.fetchPolicy ?? 'network-only';),即"总是走网络、忽略缓存",正好契合刷新语义。源码注释还提醒:fetchQuery 默认不 retain 数据,这正是示例中为什么在刷新完成后还要调用 loadQuery 来重新获得受 retain 的 queryRef 的原因。
方案二:useLazyLoadQuery 路线
如果查询最初是通过 useLazyLoadQuery 在渲染期间懒加载的(见 Lazily Fetching Queries during Render),刷新方式则是更新 state 中的 options,让 useLazyLoadQuery 用新的 fetchKey 与 fetchPolicy 重新渲染并重新获取:
/**
* App.react.js
*/
const AppQuery = require('__generated__/AppQuery.graphql');
function App(props: Props) {
const variables = {id: '4'};
const [refreshedQueryOptions, setRefreshedQueryOptions] = useState(null);
const refresh = useCallback(() => {
// Trigger a re-render of useLazyLoadQuery with the same variables,
// but an updated fetchKey and fetchPolicy.
// The new fetchKey will ensure that the query is fully
// re-evaluated and refetched.
// The fetchPolicy ensures that we always fetch from the network
// and skip the local data cache.
setRefreshedQueryOptions(prev => ({
fetchKey: (prev?.fetchKey ?? 0) + 1,
fetchPolicy: 'network-only',
}));
}, [/* ... */]);
return (
<React.Suspense fallback="Loading query...">
<MainContent
refresh={refresh}
queryOptions={refreshedQueryOptions ?? {}}
variables={variables}
/>
</React.Suspense>
);
}
/**
* MainContent.react.js
*/
// Fetches and renders the query, given the fetch options
function MainContent(props) {
const {refresh, queryOptions, variables} = props;
const data = useLazyLoadQuery(
graphql`
query AppQuery($id: ID!) {
user(id: $id) {
name
friends {
count
}
}
}
`,
variables,
queryOptions,
);
return (
<>
<h1>{data.user?.name}</h1>
<div>Friends count: {data.user.friends?.count}</div>
<Button
onClick={() => refresh()}>
Fetch latest count
</Button>
</>
);
}
逐点拆解
- 事件处理器中更新 state:
refresh回调通过setRefreshedQueryOptions设置新的 options,触发MainContent(使用useLazyLoadQuery)以新的fetchKey和fetchPolicy重新渲染并重新获取查询。 - 递增
fetchKey:每次刷新都传入新的fetchKey((prev?.fetchKey ?? 0) + 1),确保查询被完全重新求值并重新获取。 fetchPolicy: 'network-only':强制走网络、跳过本地缓存,与方案一语义一致。- Suspense 行为:由于
network-only必然产生网络请求,state 更新会让组件挂起(见 Loading States with Suspense),因此同样必须用<React.Suspense>包裹MainContent。
源码视角:fetchKey 与 fetchPolicy 如何驱动重新获取
useLazyLoadQuery 的 options 类型定义(useLazyLoadQuery.js)明确说明了这两个字段的作用:
fetchKey:(string | number),传入新的fetchKey可以强制对当前查询与变量重新求值——即使变量没变、组件没有重新挂载。这类似于给 React 组件传入不同的key触发重挂载:若fetchKey与上一次渲染不同,当前查询会重新对照 store 求值,并根据当前fetchPolicy与缓存状态决定是否重新请求。fetchPolicy:决定"何时使用缓存数据、何时发起网络请求",四种取值的完整语义如下。
调用时,useLazyLoadQuery 会基于 gqlQuery、variables、networkCacheConfig 构造 operation descriptor(useMemoOperationDescriptor),再连同 fetchKey、fetchPolicy 一起交给内部的 useLazyLoadQueryNode(useLazyLoadQuery.js)。正因为 fetchKey 变化会强制重新求值,"递增 fetchKey" 便成为 useLazyLoadQuery 路线下触发刷新的关键机制。
fetchPolicy 四种取值速查表
下表汇总了 useLazyLoadQuery / loadQuery options 中 fetchPolicy 的四种取值(与文档 Fetch Policies 一致):
| fetchPolicy | 是否复用本地缓存 | 是否发网络请求 | 典型使用场景 |
|---|---|---|---|
store-or-network(默认) | 是 | 仅当查询数据缺失/过期时才发 | 常规首次加载 |
store-and-network | 是 | 总是发送 | 希望先渲染缓存再静默更新 |
network-only | 否 | 总是发送 | 手动刷新,强制拉取最新数据 |
store-only | 是 | 从不发送 | 刷新数据已入缓存后重新渲染,避免挂起 |
方案二的进阶:需要避免 Suspense 怎么办
与方案一相同,如果不想用 Suspense fallback 隐藏已渲染内容,同样改用 fetchQuery 先拉取并写入 store,再更新 state 让 useLazyLoadQuery 用 store-only 读取缓存:
/**
* App.react.js
*/
import type {AppQuery as AppQueryType} from 'AppQuery.graphql';
const AppQuery = require('__generated__/AppQuery.graphql');
function App(props: Props) {
const variables = {id: '4'}
const environment = useRelayEnvironment();
const [refreshedQueryOptions, setRefreshedQueryOptions] = useState(null);
const [isRefreshing, setIsRefreshing] = useState(false)
const refresh = useCallback(() => {
if (isRefreshing) { return; }
setIsRefreshing(true);
// fetchQuery will fetch the query and write
// the data to the Relay store. This will ensure
// that when we re-render, the data is already
// cached and we don't suspend
fetchQuery(environment, AppQuery, variables)
.subscribe({
complete: () => {
setIsRefreshing(false);
// *After* the query has been fetched, we update
// our state to re-render with the new fetchKey
// and fetchPolicy.
// At this point the data for the query should
// be cached, so we use the 'store-only'
// fetchPolicy to avoid suspending.
setRefreshedQueryOptions(prev => ({
fetchKey: (prev?.fetchKey ?? 0) + 1,
fetchPolicy: 'store-only',
}));
}
error: () => {
setIsRefreshing(false);
}
});
}, [/* ... */]);
return (
<React.Suspense fallback="Loading query...">
<MainContent
isRefreshing={isRefreshing}
refresh={refresh}
queryOptions={refreshedQueryOptions ?? {}}
variables={variables}
/>
</React.Suspense>
);
}
逐点拆解
- 手动
isRefreshingloading state:与方案一相同,用自定义 state 呈现忙碌提示,不隐藏MainContent。 - 先
fetchQuery再更新 state 的两段式流程:先调用fetchQuery拉取并写入 store;请求完成后,更新 state 使useLazyLoadQuery以新的fetchKey与fetchPolicy重新渲染。 fetchPolicy: 'store-only':state 更新时查询数据已缓存,store-only只读缓存、不发请求,从而避免挂起。
两种路线、两种策略的选型总结
| 维度 | useQueryLoader / loadQuery | useLazyLoadQuery |
|---|---|---|
| 触发方式 | 重新调用 loadQuery(variables, options) | 更新 state 中的 fetchKey 与 fetchPolicy |
| 刷新 fetchPolicy | network-only(接受挂起) | network-only(接受挂起) |
| 避免挂起方案 | fetchQuery + loadQuery(..., {fetchPolicy: 'store-only'}) | fetchQuery + 更新 state 为 store-only |
| 依赖 | 需要 useRelayEnvironment 获取 environment(避免挂起方案) | 需要 useRelayEnvironment 获取 environment(避免挂起方案) |
| 底层机制 | loadQuery 每次生成新 fetchKey 并 retain 数据(loadQuery.js) | useLazyLoadQuery 依据变化的 fetchKey 重新求值(useLazyLoadQuery.js) |
选择建议:
- 如果项目已采用 render-as-you-fetch(
usePreloadedQuery+useQueryLoader),优先用方案一,刷新即"再调一次loadQuery",心智负担最小; - 如果项目以
useLazyLoadQuery为主,用方案二,借助递增fetchKey完成刷新; - 无论哪条路线,只要刷新期间希望保留已渲染内容,就切换到
fetchQuery+store-only的组合,并自行管理isRefreshing状态; - 而所有手动刷新方案的共同前提是:先评估该数据是否适合用 GraphQL Subscriptions 等实时能力自动更新,避免为低价值场景引入不必要的网络请求与复杂度。
需要进一步了解同族场景时,可继续阅读 refreshing-fragments.md(刷新 fragment)与 refetching-queries-with-different-data.md(以不同数据重新渲染查询),三者共同构成 Relay 的完整 refetching 能力矩阵。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



