Relay 在应用运行过程中会把多次查询获取到的数据缓存到本地 store 中。为了让页面在切换 Tab、返回已访问过的帖子详情页时能立即渲染、跳过网络等待,我们需要借助fetchPolicy明确告诉 Relay 何时该命中本地缓存、何时该发起网络请求。本文基于仓库内 v18.0.0 版本文档 fetch-policies.md 展开,结合react-relay与relay-runtime的源码实现,讲清四种 fetch policy 的行为差异、store数据"可用性"的判定机制,以及与之配套的数据保留、失效与部分渲染方案,读完即可在真实项目中落地配置。
复用缓存的第一步:把fetchPolicy传给loadQuery
复用本地缓存数据的第一步,是向loadQuery函数传入一个fetchPolicy。loadQuery通常由useQueryLoaderHook 提供,完整的调用关系在 Fetching Queries 章节 中有详细介绍。下面是一个完整的示例:
const React = require('React'); const {graphql} = require('react-relay'); function AppTabs() { const [ queryRef, loadQuery, ] = useQueryLoader(HomeTabQuery); const onSelectHomeTab = () => { loadQuery({id: '4'}, {fetchPolicy: 'store-or-network'}); } // ... }传入的fetchPolicy将决定两件事:
- 是否应该从本地缓存(store)中满足查询;
- 根据该查询的数据在 store 中的 可用性,是否需要发起网络请求从服务端获取查询结果。
从源码结构看,useQueryLoader内部(packages/react-relay/relay-hooks/useQueryLoader.js)会把options中的fetchPolicy、networkCacheConfig透传给真正的 loadQuery.js 实现;loadQuery在每次调用时会生成新的fetchKey,保证同一个查询在多次调用时会被独立评估,避免 Suspense 缓存误复用旧的查询引用(见 loadQuery.js)。
四种 Fetch Policy 的完整语义
默认情况下,Relay 会先尝试从本地缓存读取查询;如果该查询的任何数据 缺失 或 过期,它会从网络获取整个查询。这个默认策略就叫"store-or-network"。
具体来说,fetchPolicy可以是以下四种取值之一:
| 取值 | 复用本地缓存 | 发起网络请求 | 适用场景 |
|---|---|---|---|
"store-or-network"(默认) | 会 | 仅当查询有数据缺失或过期时才发起;若查询已完全缓存,则不发起 | 大多数场景:能复用缓存就复用,必要时才回源 |
"store-and-network" | 会 | 始终发起,无论 store 中数据是否缺失或过期 | 需要即时刷新且不想等待(先用缓存渲染,同时后台更新) |
"network-only" | 不会 | 始终发起,完全忽略本地缓存及其缺失/过期状态 | 对数据新鲜度要求极高、必须走后端最新数据 |
"store-only" | 只会 | 永不发起网络请求 | 纯本地数据(如 client-only 数据)读取与操作,或由调用方自行负责拉取 |
这四种取值的类型定义可以在 packages/relay-runtime/util/RelayRuntimeTypes.js 中找到:
export type FetchQueryFetchPolicy = 'store-or-network' | 'network-only'; export type FetchPolicy = FetchQueryFetchPolicy | 'store-and-network' | 'store-only';注意,Refetching 章节 中讨论的refetch函数同样接受一个fetchPolicy参数,因此上述四种策略在"重新获取不同数据"的场景下同样适用。
源码中的关键实现:store 检查与网络请求的取舍
在 packages/react-relay/relay-hooks/loadQuery.js 的checkAvailabilityAndExecute中,可以清楚地看到这四种策略的分流逻辑:
const shouldFetch = fetchPolicy !== 'store-or-network' || environment.check(operation).status !== 'available';也就是说:
- 当策略不是
store-or-network时,shouldFetch恒为true,一定会走executeDeduped发起网络执行; - 当策略是
store-or-network时,才真正调用environment.check(operation)检查 store 中数据是否available(可用),不可用才发起请求。
这里environment.check的返回结果就是 store 对一次查询的可用性判定。该类型在 packages/relay-runtime/store/RelayStoreTypes.js 中定义:
export type OperationAvailability = | {status: 'available', fetchTime: ?number} | {status: 'stale'} | {status: 'missing'};三种状态含义如下:
available:查询所需的全部本地数据都存在且未过期,可以直接从 store 渲染,不需要网络请求;missing:查询有部分数据在 store 中缺失,必须发起网络请求;stale:数据存在,但已被标记为过期(例如记录被显式失效,或超过查询缓存过期时间),需要重新获取。
在 RelayModernEnvironment.check 中,如果配置了missingFieldHandlers或查询涉及客户端抽象类型,则会走_checkSelectorAndHandleMissingFields,先通过 missing field handlers 尝试补齐缺失字段,再最终判定;否则直接委托给this._store.check(operation)。这就是为什么"数据缺失"的判定并不仅仅是看 store 里有没有,还受缺失字段处理器影响。
store 中数据可用性由什么决定
fetch policy 的行为取决于查询评估那一刻 store 中数据的可用性。可用性由两个因素决定:数据的存在性(presence)与数据的过期性(staleness),详见 availability-of-data.md。
数据的存在性与垃圾回收
要复用 store 中的缓存数据,首先要理解这些数据的生命周期:数据是否存在于 store 中、能存在多久。一般地,一个查询在首次被获取之后,只要它还在屏幕上被渲染,其数据就会存在于 store 中;如果某个查询从未被获取过,那它的数据自然就是缺失的。
但应用运行越久,累积的数据会越来越大、越来越旧,Relay 不能无限期地在内存中保留所有已获取的数据。为此 Relay 运行一个名为垃圾回收(Garbage Collection)的机制,删除不再被任何组件引用的数据。这本身与"复用缓存"存在张力:数据过早被删除,后续再想复用就得重新等网络请求。好在通常不需要自己操心 GC 与数据保留的配置——应用基础设施会在RelayEnvironment层配置好——但理解它有助于排查缓存复用失效的问题。
查询保留(Query Retention)是控制 GC 的关键:保留一个查询意味着告诉 Relay,该查询及其变量对应的数据不应被删除。多个调用方可以同时保留同一个查询,只要还有至少一个调用方在保留,数据就不会被 GC。默认情况下,使用useQueryLoader/usePreloadedQuery等 API 的查询组件,在挂载期间会保留查询,卸载后即释放,数据随时可能被回收。若想在组件生命周期之外保留数据,可以使用environment.retain():
// 保留查询;这会阻止该查询及其变量的数据被 Relay 垃圾回收 const disposable = environment.retain(queryDescriptor); // 释放 disposable 将释放该查询及变量的数据, // 之后若没有其他方保留,数据随时可能被 GC 删除 disposable.dispose();控制垃圾回收的两个 Store 配置
Relay Store 提供两个选项来控制 GC 行为,在 RelayModernStore 构造函数 中均有对应字段:
1.gcScheduler—— 决定何时调度一次 GC 执行:
// 示例调度函数:接受一个回调并安排在未来的某个时间执行 function gcScheduler(run: () => void) { resolveImmediate(run); } const store = new Store(source, {gcScheduler});- 若不提供,Relay 默认使用
resolveImmediate调度 GC(源码见 RelayModernStore.js); - 可提供自定义调度函数,让 GC 不那么激进,例如基于时间或 React scheduler 优先级等启发式策略。按约定,实现不应立即执行回调。
2.gcReleaseBufferSize—— 控制 release buffer 大小。Relay Store 内部持有一个释放缓冲区,在查询被原持有者释放后(默认即组件卸载时)仍临时保留指定数量的查询,使返回之前访问过的页面/Tab 时更有可能复用数据:
const store = new Store(source, {gcReleaseBufferSize: 10});- 缓冲区大小为 0 等价于没有释放缓冲区,查询会被立即释放并回收;
- 默认环境下的释放缓冲区大小为 10。
源码中 RelayModernStore.js 使用options?.gcReleaseBufferSize ?? DEFAULT_RELEASE_BUFFER_SIZE读取该配置;_pushToReleaseBuffer在缓冲区满时挤出最早的根并调度 GC(RelayModernStore.js)。
数据过期性:显式失效与查询缓存过期时间
假设数据存在于 store 中,还需要考虑其过期性。默认情况下,无论数据在缓存中待了多久,Relay 都不会认为它过期——除非它被显式标记为过期,或者超过了查询缓存过期时间(query cache expiration time)。
全局失效整个 store:调用invalidateStore()会使失效之前写入的所有数据都变为过期状态,下次评估时需要重新获取:
function updater(store) { store.invalidateStore(); }invalidateStore可以在 mutation、subscription 或本地 store 更新的 updater 中调用。
按记录失效:也可以只失效 store 中的特定记录,只有引用了这些记录的查询会被视为过期:
function updater(store) { const user = store.get('<id>'); if (user != null) { user.invalidateRecord(); } }订阅失效事件:标记为过期只会在下次评估时触发重新获取。若希望数据失效时立即重新获取(例如当前页面正在展示的数据、或从未卸载的前一个视图的数据),可以用useSubscribeToInvalidationStateHook:
function ProfilePage(props) { const data = usePreloadedQuery( graphql`...`, props.preloadedQuery, ) // 订阅指定用户 ID 的失效状态变化, // 每当该记录被标记为过期时回调触发 useSubscribeToInvalidationState([props.userID], () => { // 在这里可以: // - 传入新的 preloadedQuery 给 usePreloadedQuery 重新评估查询 // - 命令式地重新获取数据 // - 渲染 loading 提示或置灰页面以表示正在刷新 }) return (...); }查询缓存过期时间:另一个影响"过期"判定的因素是queryCacheExpirationTime。一个查询如果可以用 store 中的记录满足,且满足以下任一条件,则被视为过期:
- 距上次获取的时间超过了查询缓存过期时间;
- 其引用的记录中至少有一条被失效过。
该过期检查发生在新的请求发起时(例如调用loadQuery)。引用了过期数据的组件仍能继续渲染这些数据,但任何会被过期数据满足的新请求都会走向网络。配置方式:
const store = new Store(source, {queryCacheExpirationTime: 5 * 60 * 1000 });如果不提供该配置,过期检查只会看引用的记录是否被失效过。源码中 getAvailabilityStatus 实现了这一逻辑:当operationFetchTime与queryCacheExpirationTime均存在且operationFetchTime <= Date.now() - queryCacheExpirationTime时返回stale。
部分缓存数据下的渲染:renderPolicy 与 Suspense
当使用允许复用缓存的策略(store-or-network或store-and-network)渲染一个部分缓存的查询时,Relay 支持"部分渲染":立即渲染已缓存的部分,而不是等整个查询全部获取完成。
关键机制在于以 Fragment 作为部分渲染的边界。fragment 组件在其本地声明的数据缺失且正在获取时会挂起(suspend),直到其所属的父查询被获取完成。而 Relay 判定数据缺失时只看本地声明的字段——被 fragment spread 引用的数据缺失不会导致外层查询被判定为缺失。因此,即使某个 fragment 的数据还没到,外层查询已缓存的部分依然可以先行渲染,只需用Suspense包住缺失数据的 fragment 组件即可:
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 包裹 UsernameComponent, 即使 username 缺失,也可以先渲染 App 的其他部分 */} <Suspense fallback={<LoadingSpinner label="Fetching username" />}> <UsernameComponent user={data.user} /> </Suspense> </> ); }嵌套 fragment 的处理方式相同:只要某 fragment 所需数据已缓存就能渲染,其子 fragment 数据缺失时用Suspense兜底即可。这种能力可以让我们完全跳过 loading 状态,渲染出更接近最终形态的中间 UI,相关完整示例与讨论见 rendering-partially-cached-data.md。
让不同查询共享缓存:missingFieldHandlers
默认情况下,Relay 只能识别"完全相同的查询"之间的缓存复用:同一个查询获取两次,第二次评估时就知道数据已缓存。但不同查询可能指向同一份数据,例如:
# Query 1 query UserQuery { user(id: 4) { name } } # Query 2 query NodeQuery { node(id: 4) { ... on User { name } } }这两个查询不同,但引用的是完全相同的数据。Relay 默认不知道node(id: 4)与user(id: 4)指向同一对象,需要通过在RelayEnvironment上提供missingFieldHandlers来编码这种等价关系:
const {ROOT_TYPE, Environment} = require('relay-runtime'); const missingFieldHandlers = [ { handle(field, record, argValues): ?string { // 为 node 字段添加处理器 if ( record != null && record.getType() === ROOT_TYPE && field.name === 'node' && argValues.hasOwnProperty('id') ) { return argValues.id } if ( record != null && record.getType() === ROOT_TYPE && field.name === 'user' && argValues.hasOwnProperty('id') ) { // 如果字段是 user(id: $id),按 $id 的值查找记录 return argValues.id; } if ( record != null && record.getType() === ROOT_TYPE && field.name === 'story' && argValues.hasOwnProperty('story_id') ) { // 如果字段是 story(story_id: $story_id),按 story_id 值查找 return argValues.story_id; } return undefined; }, kind: 'linked', }, ]; const environment = new Environment({/*...*/, missingFieldHandlers});要点:
missingFieldHandlers是一个handler 数组,每个 handler 必须包含一个handle函数以及它能处理的缺失字段的kind。主要处理两类字段:'scalar':包含标量值(数字、字符串等)的字段;'linked':引用另一个对象(非标量)的字段。
handle函数接收缺失的字段、该字段所属的记录、以及本次查询执行中传给该字段的参数:- 处理
'scalar'字段时,返回一个标量值作为缺失字段的值; - 处理
'linked'字段时,返回一个ID,指向 store 中应替代该缺失字段的另一个对象。
- 处理
- 当 Relay 尝试从本地缓存满足查询并检测到缺失数据时,会在最终判定缺失之前,先运行所有与字段类型匹配的缺失字段处理器。
详细讨论见 filling-in-missing-data.md。从这里可以看到,fetch policy 判定"数据缺失"的最终结果,实际上是 store 检查(environment.check)叠加 missing field handlers 补齐之后的综合结论。
实践建议与完整决策链路
综合以上内容,在实际项目中选择 fetch policy 时可以参考以下思路:
- 大多数页面使用默认的
store-or-network:能复用缓存就复用,有缺失/过期才回源,兼顾速度与新鲜度; - 需要即时展示且允许后台刷新的场景使用
store-and-network:先用缓存秒开,同时后台拉取最新数据覆盖; - 对新鲜度有硬性要求(如支付状态、权限变更)使用
network-only:跳过 store 直接走网络,避免展示过期数据; - 纯本地数据(client-only、本地更新产生的数据)使用
store-only:永不发网络请求; - 数据保留与 GC:依赖默认的 release buffer(默认 10 条)让返回过的页面快速复用;必要时用
environment.retain()延长关键数据的存活时间;需要更长时间保留数据时可通过queryCacheExpirationTime与gcScheduler调整策略; - 跨查询复用:当不同查询指向同一数据对象时,配置
missingFieldHandlers让 store 检查能识别等价字段,从而提升store-or-network的缓存命中率; - 渲染体验:结合 fragment 边界与
Suspense,在部分数据缺失时先渲染已缓存部分,避免整页 loading。
最终,一次loadQuery(..., {fetchPolicy})的完整决策链路可以概括为:
loadQuery 收到 fetchPolicy ├─ store-only:直接返回,不发网络请求 ├─ store-and-network / network-only:必定发起网络请求 └─ store-or-network:environment.check(operation) ├─ available:直接用 store 数据渲染 ├─ stale:视为需要重新获取,发起网络请求 └─ missing:先经 missingFieldHandlers 补齐尝试 └─ 仍缺失 → 发起网络请求理解这条链路,你就能准确预判每种策略下用户会看到缓存数据、loading 状态还是最新数据,从而把 Relay 的缓存能力真正用起来。
- 前端
- 开发工具
【免费下载链接】relay
Relay is a JavaScript framework for building>项目地址:https://gitcode.com/gh_mirrors/relay29/relay
相关推荐
Relay 的 Fetch Policies(数据获取策略)完整指南:从 store-or-network 到 store-only 的缓存复用实战
Relay 的 Fetch Policies(数据获取策略)完整指南:从 store or network 到 store only 的缓存复用实战 导读 本文
前端开发工具Relay 缓存数据复用实战指南:Fetch Policy、数据可用性与垃圾回收全解析
Relay 缓存数据复用实战指南:Fetch Policy、数据可用性与垃圾回收全解析 在 Relay 驱动的数据型 React 应用中,随着用户不断使用,Re
前端开发工具Relay Fetch Policy 完全指南:用 loadQuery 与四种 fetchPolicy 精准控制缓存复用与网络请求
Relay Fetch Policy 完全指南:用 loadQuery 与四种 fetchPolicy 精准控制缓存复用与网络请求 Relay 的数据获取是"缓
前端开发工具