news 2026/9/21 16:41:32

Relay 缓存复用完全指南:fetchPolicy、数据可用性与部分渲染实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Relay 缓存复用完全指南:fetchPolicy、数据可用性与部分渲染实战

Relay 缓存复用完全指南:fetchPolicy、数据可用性与部分渲染实战

【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay

Relay 在应用运行过程中会把多次查询获取到的数据缓存在本地内存 Store 中,而"复用缓存数据(Reusing Cached Data)"讨论的正是如何在发起网络请求之前,优先复用这些本地已缓存数据,从而让已访问过的页面、Tab 或内容实现"秒开"。本文基于 Relay 官方 guided-tour 中 reusing-cached-data 章节(含 fetch policies、数据存在性与陈旧性、垃圾回收、缺失数据处理、部分缓存渲染等子主题),并结合relay-runtimereact-relay的源码实现,系统讲解:如何用fetchPolicy控制缓存与网络的取舍、如何判断数据是否"可用"、如何通过保留机制和失效机制管理缓存生命周期,以及如何实现"能渲染多少先渲染多少"的部分渲染。读完本文,你将能在自己的 Relay 应用中把缓存复用的能力用到极致。

为什么需要复用缓存数据

Relay 在应用运行期间会为多次查询积累数据,并在内存中缓存一段时间。当我们再次执行某条查询时,理想情况下应当直接复用本地缓存并立即渲染,而不是重新等待一次网络请求。这正是本主题要解决的问题(见 introduction.md)。

典型场景包括:

  • Tab 切换:应用中每个 Tab 都渲染一条查询。若某个 Tab 已被访问过,再次切回时应立即渲染,无需等待网络请求重新拉取已获取过的数据。
  • 从 Feed 跳转到详情页:一条帖子已在 Feed 中渲染过,进入其 permalink 页面时应能立即渲染,因为帖子的数据基本都已在缓存中。
  • 部分数据缺失的情况:即使 permalink 页面比 Feed 需要更多数据,我们也希望能尽量复用本地已有的那部分数据并立即渲染,而不是因为缺少一小块数据就阻塞整个页面的渲染。

这些场景背后有两个核心问题:本地缓存里有没有这份数据(存在性 + 陈旧性),以及如何让 Relay 知道该复用哪些缓存(fetch policy 与缺失字段处理)。

第一步:用 fetchPolicy 控制缓存与网络的取舍

复用本地缓存数据的第一步,是向loadQuery函数传入fetchPolicyloadQueryuseQueryLoader提供(详见 guided-tour 中关于查询渲染与加载的章节),典型用法如下(源自 fetch-policies.md):

const React = require('React'); const {graphql} = require('react-relay'); function AppTabs() { const [ queryRef, loadQuery, ] = useQueryLoader<HomeTabQueryType>(HomeTabQuery); const onSelectHomeTab = () => { loadQuery({id: '4'}, {fetchPolicy: 'store-or-network'}); } // ... }

fetchPolicy决定了两件事:

  • 是否从本地缓存满足查询;
  • 是否发起网络请求从服务端拉取数据——具体取决于该查询数据在 Store 中的可用性(数据是否存在、是否陈旧)。

默认情况下,Relay 会先尝试从本地缓存读取查询;只要该查询有任何数据缺失或陈旧,就整条从网络获取。这个默认策略被称为"store-or-network"

fetchPolicy共支持以下四种取值:

fetchPolicy是否复用本地缓存是否发起网络请求适用场景
"store-or-network"(默认)复用仅当数据缺失或陈旧时缓存完整则零网络请求,是日常首选
"store-and-network"复用总是发起既想立即展示缓存内容,又希望后台刷新到最新
"network-only"不复用总是发起强制走网络,忽略本地缓存及其状态
"store-only"复用永不发起纯本地数据场景,取数职责交由调用方

值得说明的是,"store-only"策略还会配合"数据完全本地化"的使用场景——即通过本地更新(local data updates)写入并读取不经过服务端的客户端数据。此外,refetching 相关章节讨论的refetch函数同样接收fetchPolicy参数,可用于按需刷新局部数据。

从源码看,fetchPolicy的合法值在类型层面被严格约束。ReactRelayTypes.d.ts 中定义了PreloadFetchPolicy = 'store-or-network' | 'store-and-network' | 'network-only';而 QueryResource.js 中的getDefaultFetchPolicy展示了默认策略的分支逻辑:live query 使用专门的 live 默认策略;当请求既没有id也没有text(即纯客户端操作)时默认退化为'store-only';其余情况才使用DEFAULT_FETCH_POLICY(即'store-or-network')。这印证了文档中"默认 store-or-network"的说法,也解释了为什么纯本地查询天然不会发网络请求。

数据可用性:存在性 × 陈旧性

fetch policy 的行为,取决于查询被求值时 Store 中数据的可用性(availability-of-data.md)。数据的可用性由两个因素共同决定:

  1. 数据的存在性(Presence):这条查询的数据是否已经写入 Store,以及能保留多久;
  2. 数据的陈旧性(Staleness):数据虽然存在,但是否已被标记为过期而需要重新获取。

只有在数据既存在又不陈旧时,Relay 才会认为它可以"直接满足"该查询。接下来分别深入这两个因素。

数据存在性:查询保留与垃圾回收(GC)

想复用缓存数据,首先得理解缓存数据的生命周期(presence-of-data.md):一条查询在首次被获取之后,只要它仍被屏幕上的组件渲染着,其数据通常会一直存在于 Store 中;如果从未获取过某条查询,它的数据自然缺失。

但应用不可能无限期保留所有查询的数据——内存会膨胀,数据也会越来越陈旧。为此,Relay 会运行一个名为Garbage Collection(垃圾回收)的过程,删除"不再被任何组件引用"的数据。这与缓存复用存在天然的张力:数据被过早回收,再次访问时就得等待网络。下面介绍如何按需控制数据的保留与回收。

Query Retention:手动保留查询

Retain(保留)一条查询,是告诉 Relay:该查询(及其变量)的数据不应被垃圾回收。多个调用方可以同时保留同一条查询,只要至少有一个保留者存在,这条查询就不会被删除。

默认行为下,使用useQueryLoader/usePreloadedQuery等 API 的查询组件,会在挂载期间保留其查询;组件卸载后即释放,此后该查询随时可能被回收。若想在组件生命周期之外保留查询,可以调用environment.retain

// 保留查询;这能防止该查询及其变量的数据被 Relay 垃圾回收 const disposable = environment.retain(queryDescriptor); // 调用 dispose() 释放数据;若没有其他保留者, // 该数据之后随时可能被 Relay 的 GC 删除 disposable.dispose();

这样即使查询组件已经卸载,数据仍可被其他组件或未来重新挂载的同一组件复用。从源码看,RelayModernStore.js 的retain(operation)内部通过rootEntry.refCount引用计数实现:每调用一次retain引用计数加一,dispose时递减,计数归零才进入可释放状态。Disposable类型则定义在 RelayRuntimeTypes.js 中,是所有需要显式释放资源的通用接口约定。

控制 GC 的两个配置项

Relay Store 目前提供两个选项来控制垃圾回收行为:

1. GC Scheduler(gcScheduler

gcScheduler是一个函数,决定何时调度一次 GC 执行:

// 示例调度器:接收回调并在未来某个时刻运行它 function gcScheduler(run: () => void) { resolveImmediate(run); } const store = new Store(source, {gcScheduler});
  • 默认情况下,未提供gcScheduler时,Relay 使用resolveImmediate函数调度 GC。
  • 可以提供自定义调度器让 GC 不像默认那样激进,例如基于时间、基于 React scheduler 优先级或其他启发式策略。按照约定,实现不应立即同步执行回调。

2. GC Release Buffer Size(gcReleaseBufferSize

Store 内部持有一个释放缓冲区:即使查询已被其原始持有者释放(组件卸载时的默认行为),也会被临时多保留一段时间。这使得返回之前访问过的页面、Tab 或内容时,更有可能复用数据。

const store = new Store(source, {gcReleaseBufferSize: 10});
  • 缓冲区大小为 0 等价于没有释放缓冲区,查询会被立即释放并回收;
  • 默认情况下,环境(Environment)的释放缓冲区大小为10

这一点在源码中有直接对应:RelayModernStore.js 定义了DEFAULT_RELEASE_BUFFER_SIZE = 10,并在构造函数中通过options?.gcReleaseBufferSize ?? DEFAULT_RELEASE_BUFFER_SIZE应用默认值(第 169-170 行);GC 执行时只有当_releaseBuffer.length未达到上限时才把释放的查询送入缓冲区(第 618-619 行、第 644 行)。另外,文档与代码都强调:通常你不需要手动配置 GC 与数据保留,这部分应由应用基础设施在 RelayEnvironment 层面统一配置,此处仅为参考。

数据陈旧性:失效标记与缓存过期时间

即便数据存在,仍需考虑其陈旧性(staleness-of-data.md)。默认情况下,Relay不认为Store 中的数据陈旧——无论缓存了多久——除非它被数据失效 API 显式标记为陈旧,或超过查询缓存过期时间。

标记数据为陈旧,适用于我们明确知道某些数据已不新鲜的场景,例如执行了一次 Mutation 之后。Relay 提供以下 API 在 Store 更新过程中标记数据:

全局失效:invalidateStore()

最粗粒度的失效方式是把整个 Store作废——失效后,所有已缓存数据都被视为陈旧。在 updater 函数中调用:

function updater(store) { store.invalidateStore(); }
  • 调用后,所有在失效发生前写入 Store 的数据都被视为陈旧,下次求值时相关查询需要重新获取。
  • updater 函数既可以出现在 mutation、subscription 中,也可以出现在纯本地 Store 更新中。

按记录失效:invalidateRecord()

更细粒度的方式是只作废特定记录:相比全局失效,只有引用了被作废记录的查询才会被视为陈旧。在 updater 中调用:

function updater(store) { const user = store.get('<id>'); if (user != null) { user.invalidateRecord(); } }
  • 对被作废的user记录,任何缓存中引用它的查询都会被标记为陈旧,下次求值时需重新获取。

订阅失效事件:useSubscribeToInvalidationState

仅标记陈旧,只会在查询下一次被求值时触发重取。但在某些场景下我们希望在失效发生时立即刷新:

  • 失效的数据已经显示在当前页面上:没有发生导航,当前页的查询不会被重新求值,即使数据已陈旧也不会立即重取;
  • 失效的数据渲染在一个从未卸载过的旧视图上:切回时该视图的查询同样不会被重新求值。

为此 Relay 提供了useSubscribeToInvalidationStatehook:

function ProfilePage(props) { // 示例:查询当前页面某个用户的数据 const data = usePreloadedQuery( graphql`...`, props.preloadedQuery, ) // 订阅指定用户 ID 的失效状态变化: // 只要该 ID 对应的记录被标记为陈旧,回调就会执行 useSubscribeToInvalidationState([props.userID], () => { // 在这里可以: // - 传入新的 preloadedQuery 给 usePreloadedQuery 来重新求值查询 // - 命令式地重新获取数据 // - 渲染 loading spinner 或置灰页面,提示正在刷新 }) return (...); }
  • useSubscribeToInvalidationState接收一个 id 数组和一个回调;只要数组中任一 id 对应的记录被标记为陈旧,回调就会触发。
  • 在回调中,我们可以按需重取或更新正在渲染陈旧数据的视图。例如把preloadedQuery存在 state 中并在回调里更新它,从而重新执行顶层的usePreloadedQuery;由于此时查询已陈旧,即使数据在 Store 中也会被重新获取。

查询缓存过期时间:queryCacheExpirationTime

此外,查询缓存过期时间也决定了某次操作(查询 + 变量)能否用 Store 中已有数据满足。一条查询被视为陈旧,当且仅当它可以用 Store 中的记录满足,且满足以下任一条件:

  • 最后被获取的时间距今已超过查询缓存过期时间;
  • 或者它引用的至少一个记录被作废

这种陈旧性检查发生在新的请求发起时(例如调用loadQuery)。组件仍可继续渲染引用陈旧数据的界面,但任何会用陈旧数据满足的新请求都会改走网络。

配置方式是在创建 Relay Store 时传入queryCacheExpirationTime

const store = new Store(source, {queryCacheExpirationTime: 5 * 60 * 1000 });

如果未提供该选项,陈旧性检查只考虑引用的记录是否被作废。

源码层面,RelayModernStore.js 的getAvailabilityStatus完整实现了上述判定:若操作引用的记录存在mostRecentlyInvalidatedAt,且操作从未写入或写入时间早于最近一次失效时间,则返回'stale';若数据缺失则返回'missing';若operationFetchTimequeryCacheExpirationTime均已配置且operationFetchTime <= Date.now() - queryCacheExpirationTime,同样返回'stale';否则才返回'available'。可以看到,"存在 + 未失效 + 未过期"是缓存可复用的完整条件集。

填补缺失数据:missingFieldHandlers

前面讨论的都是"同一条查询"的缓存复用——Relay 能自动识别"完全相同的查询被再次求值"时的缓存命中。但不同的查询也可能指向同一份数据,此时 Relay 默认并不知道这一点,需要显式配置(filling-in-missing-data.md)。

例如下面两条查询引用的其实是同一份数据:

// Query 1 query UserQuery { user(id: 4) { name } } // Query 2 query NodeQuery { node(id: 4) { ... on User { name } } }

这两条查询不同,但指向完全相同的记录。理想情况下,只要其中一条已被缓存,渲染另一条时就应该能复用。Relay 默认不具备这种知识,需要我们通过missingFieldHandlers告诉它:node(id: 4)"等价于"user(id: 4)

在创建RelayEnvironment时传入missingFieldHandlers

const {ROOT_TYPE, Environment} = require('relay-runtime'); const missingFieldHandlers = [ { handle(field, record, argValues): ?string { if ( record != null && record.__typename === ROOT_TYPE && field.name === 'user' && argValues.hasOwnProperty('id') ) { // 如果字段是 user(id: $id),用 $id 的值查找记录 return argValues.id; } if ( record != null && record.__typename === 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函数,并声明它能处理的缺失字段类型。主要有两种类型:
    • 'scalar':字段包含标量值,例如数字或字符串;
    • 'linked':字段引用另一个对象,即非标量。
  • handle函数接收:缺失的字段、该字段所属的记录,以及当前查询执行时传给该字段的参数。
    • 处理'scalar'字段时,handle应返回一个标量值作为缺失字段的值;
    • 处理'linked'字段时,handle应返回一个ID,指向 Store 中应被用作替代的另一个对象。
  • 当 Relay 尝试从本地缓存满足查询、检测到缺失数据时,会先运行所有与字段类型匹配的 handler,然后才最终判定数据是否缺失。正是这套机制让"不同查询、同一数据"的跨查询复用成为可能。

渲染部分缓存的数据:片段边界 + Suspense

除了"复用完整缓存",Relay 还支持部分渲染(Partial Rendering):立即渲染部分被缓存的查询,缺失部分照常等待网络(rendering-partially-cached-data.md)。这在想尽快画出页面、且确信部分数据已缓存时非常有用——例如个人资料页:用户name很可能早就在应用使用过程中被缓存,访问资料页时即使其余数据尚未就绪,也希望立刻渲染出名字,跳过 loading 状态。

片段组件作为部分渲染的边界

部分渲染依赖片段组件**挂起(suspend)**的能力(参见 guided-tour 中关于 Suspense 加载状态的章节):如果片段组件本地声明的任何数据在渲染时缺失且正在被获取,该组件就会挂起,直到所属(父)查询的数据到达。

结合示例理解。假设有一个片段组件:

/** * UsernameComponent.react.js * * Fragment Component */ import type {UsernameComponent_user$key} from 'UsernameComponent_user.graphql'; const React = require('React'); const {graphql, useFragment} = require('react-relay'); type Props = { user: UsernameComponent_user$key, }; function UsernameComponent(props: Props) { const user = useFragment( graphql` fragment UsernameComponent_user on User { username } `, props.user, ); return (...); } module.exports = UsernameComponent;

以及如下查询组件,它查询部分数据并包含上述片段:

/** * HomeTab.react.js * * Query Component */ const React = require('React'); const {graphql, usePreloadedQuery} = require('react-relay'); const UsernameComponent = require('./UsernameComponent.react'); function HomeTab(props: Props) { const data = usePreloadedQuery<AppQuery>( graphql` query HomeTabQuery($id: ID!) { user(id: $id) { name ...UsernameComponent_user } } `, props.queryRef, ); return ( <> <h1>{data.user?.name}</h1> <UsernameComponent user={data.user} /> </> ); }

假设此前我们只获取过User{id: 4})的name,且它已缓存在 Relay Store 中。此时以允许复用缓存的fetchPolicy'store-or-network''store-and-network')渲染该查询,会发生以下过程:

  1. 查询先检查自身直接需要的数据是否缺失——这里并不缺失。查询本身只直接查询name,而name可用;就查询自身渲染所需的数据而言,什么都不缺。这是关键设计:渲染查询时,Relay 会急切地读出数据并渲染整棵组件树,而不是等整条查询(含嵌套片段)全部取回后再一次性渲染;数据是否缺失是针对"当前组件自身声明的数据"判断的,而非其后代组件的数据
  2. 查询没有缺失数据,于是渲染,然后尝试渲染子组件UsernameComponent
  3. UsernameComponent渲染UsernameComponent_user片段时,发现自身所需数据缺失——具体是username。此时它会挂起,等待网络请求完成。注意:无论选择哪种fetchPolicy,只要整条查询(含片段)有任何数据缺失,就总会发起网络请求。

此刻,UsernameComponent因缺失username而挂起;但理想情况下,Username已缓存,应当立即渲染出来。然而,若没有用Suspense组件捕获这个挂起,挂起会向上冒泡,导致整个App都进入挂起状态。

要实现"name可用就先渲染、username缺失再等待",只需把UsernameComponentSuspense包裹起来,允许App的其他部分继续渲染:

/** * 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<AppQuery>( 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> </> ); }

上述机制对嵌套片段同样成立:只要渲染某个片段所需的数据已缓存,无论其子片段/后代片段是否缺失,该片段组件都能渲染;若某个子片段数据缺失,用Suspense包裹它即可让应用其他部分继续渲染。这正是开头场景中"即使 permalink 页面比 Feed 多要求少量数据,也先用已缓存的部分渲染"的实现基础——它让我们可以完全跳过 loading 状态,渲染出更接近最终形态的中间 UI。

小结

复用缓存数据是 Relay 数据流中"本地优先"思想的集中体现,其完整链路可以归纳为:

  1. fetchPolicy声明取数策略store-or-network/store-and-network/network-only/store-only),决定缓存与网络的取舍;
  2. 用存在性管理缓存寿命——默认保留机制、environment.retain手动保留、gcSchedulergcReleaseBufferSize控制垃圾回收节奏;
  3. 用陈旧性保证数据新鲜——invalidateStore()/invalidateRecord()标记失效、useSubscribeToInvalidationState立即响应失效、queryCacheExpirationTime设置缓存过期;
  4. missingFieldHandlers打通跨查询复用,让指向同一记录的不同查询共享缓存;
  5. 用"片段边界 + Suspense"实现部分渲染,让已缓存的部分数据先上屏。

本主题在仓库中的最新版文档位于 website/docs/guided-tour/reusing-cached-data,可作为持续更新的参考;而本文依据的 v13 版各子章节(fetch-policies.md、presence-of-data.md、staleness-of-data.md、filling-in-missing-data.md、rendering-partially-cached-data.md)与 RelayModernStore.js、QueryResource.js 等源码一一对应,读者可按图索骥,深入理解 Relay 缓存机制的全貌。

【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!