Relay 在客户端内存 Store 中缓存查询结果,以便在无需网络请求的情况下复用数据、快速渲染页面。但数据不会永远驻留:Relay 会通过垃圾回收(Garbage Collection)删除不再被任何组件引用的数据。本文基于 Relay 官方指南(presence-of-data.md)展开,系统讲解数据何时存在于 Store、何时被回收,以及如何通过environment.retain、gcScheduler、gcReleaseBufferSize精确控制数据的保留时长,帮助你在“复用缓存”与“控制内存”之间找到平衡。
数据何时存在:缓存的生命周期
理解 Relay 缓存复用的第一步,是弄清楚数据在 Store 中的生命周期(lifetime)——即数据是否存在于 Store 中,以及它会存在多久。
基本规律如下:
- 查询首次被获取后,该查询及其变量对应的数据会写入 Relay Store;
- 只要该查询正在屏幕上被渲染,其数据一般会持续存在;
- 从未被获取过的查询,其数据自然缺失于 Store 中。
也就是说,数据的存在与否,首先取决于“是否被获取过”,其次取决于“是否仍在被使用”。但这里有一个不可避免的矛盾:随着应用不断获取不同的查询,Store 中的数据会无限累积,变得过大、过陈旧(stale)。为了控制内存占用,Relay 运行一个名为**垃圾回收(Garbage Collection)**的进程,删除不再使用的数据。
设计要点:垃圾回收的存在,与“复用缓存”的目标天然存在张力。如果数据被过早删除,后续再次复用时就会落空,导致必须重新发起网络请求才能渲染界面。因此,如何让想要复用的数据在需要的时间内保持缓存,是本节(乃至整条 reusing-cached-data 指南线)的核心命题。
Relay 的垃圾回收机制
Relay 对本地内存 Store 执行垃圾回收的方式是:删除任何不再被应用中任何组件引用的数据。
从实现层面看,这一机制由 RelayModernStore 承担,关键逻辑如下:
- 每个查询(operation)在 Store 内部对应一个root entry,记录在
_roots(一个Map)中,并维护一个引用计数refCount(见 RelayModernStore.js#L115-L123); - 调用
environment.retain()会使对应 root entry 的refCount加一;释放(dispose)时减一(见 RelayModernStore.js#L383-L440); - 当
refCount降到 0 时,该查询的数据进入“可被回收”状态——但如果配置了 release buffer,会先进入缓冲期; - GC 的实际执行由
_collect()生成器与_gcStep()分步驱动,通过gcScheduler调度(见 RelayModernStore.js#L854-L886)。
值得注意的细节:即使某个查询从未被显式 retain,只要gcReleaseBufferSize > 0且 release buffer 未满,查询数据在写入时也会被临时记入_roots并放入 release buffer,以便近期内复用(见_recordSourceOperation,RelayModernStore.js#L606-L634)。
:::note 通常你不需要操心垃圾回收与数据保留的配置——这些应由应用基础设施在RelayEnvironment层面统一配置。本文内容供理解与参考,属于“知道底层如何运转”的知识储备。 :::
Query Retention:手动保留查询数据
保留(retain)一个查询,是向 Relay 表明:该查询及其变量对应的数据不应被垃圾回收删除。多个调用方可以同时保留同一个查询;只要至少有一个调用方还在保留,该查询的数据就不会从 Store 中被删除。
默认行为:组件挂载期间的自动保留
默认情况下,任何使用useQueryLoader/usePreloadedQuery及其他相关 API 的查询组件,会在自身挂载期间自动保留所渲染的查询。组件卸载后,它们会**释放(release)**该查询——这意味着此后任意时刻该查询的数据都可能被删除。
这套自动保留逻辑对应 Store 中 root entry 引用计数的增减:组件挂载时计数加一,卸载时计数减一;归零即进入可回收状态。
在组件生命周期之外保留:environment.retain
如果需要在组件的生命周期之外继续保留某个查询,可以使用environment.retain(operationDescriptor)操作:
// 保留查询;这将阻止 Relay 对 // 该查询及其变量的数据执行垃圾回收 const disposable = environment.retain(queryDescriptor); // 释放 disposable 将放开对该查询及其变量数据的保留, // 意味着如果它没有被其他地方保留, // 就可能被 Relay 的垃圾回收随时删除 disposable.dispose();如上所述,这样可以做到即使查询组件已卸载,仍继续保留查询数据,让其他组件或未来再次挂载的同一组件能够复用这些保留的数据。
完整示例:从 graphql 标签到 OperationDescriptor
environment.retain接收的是 Relay 内部的操作描述符(OperationDescriptor),而不是裸的graphql模板字符串。需要先用getRequest获取请求描述符,再通过createOperationDescriptor绑定具体变量:
const { createOperationDescriptor, getRequest, graphql, } = require('relay-runtime'); // graphql 查询对象 const query = graphql`...`; // 构造 Relay 内部的查询表示 const queryRequest = getRequest(query); const queryDescriptor = createOperationDescriptor( queryRequest, variables, ); // 保留查询;这将阻止 Relay 对 // 该查询及其变量的数据执行垃圾回收 const disposable = environment.retain(queryDescriptor); // 释放 disposable 将放开对该查询及其变量数据的保留, // 意味着如果它没有被其他地方保留, // 就可能被 Relay 的垃圾回收随时删除 disposable.dispose();更完整的用法可参考 retaining-queries.md,其中同样强调:
Relay 会根据挂载的查询组件自动管理查询数据的保留,因此产品代码中通常不需要直接调用
retain。对于高级或特殊场景,查询数据的保留应由基础设施层代码(如 Router)统一处理。
适用场景:典型的例子是路由级代码——当用户离开某个页面、组件卸载后,Router 可以在导航层面保留该页面对应的查询数据,使得用户返回时能瞬间渲染,而无须等待网络请求。
控制 Relay 的垃圾回收策略
目前可以在创建 Relay Store 时提供2 个选项来控制垃圾回收行为。它们的真实签名可在 RelayModernStore 构造函数 中确认。
GC Scheduler:何时执行 GC
gcScheduler是一个可传给 Relay Store 的函数,用于决定一次 GC 执行应该被调度到何时运行:
// 示例调度函数 // 接收一个回调,并将其调度到未来的某个时间执行 function gcScheduler(run: () => void) { resolveImmediate(run); } const store = new Store(source, {gcScheduler});要点:
- 默认行为:如果不提供
gcScheduler,Relay 会使用resolveImmediate来调度 GC。resolveImmediate是基于 Promise 的setImmediate替代方案,将回调放到微任务队列中尽快执行(见 resolveImmediate.js); - 自定义策略:可以提供自己的调度函数,让 GC 比默认行为更不激进,例如基于时间、基于 scheduler 优先级或其他启发式规则;
- 约定:按惯例,实现不应立即执行回调(即不应同步调用
run),而应将其延后到合适的时机。
源码中的调度链路为:scheduleGC()创建_gcRun生成器后调用gcScheduler(_gcStep);_gcStep每执行完一步若未完成,会再次调用gcScheduler调度下一步,从而把一次完整 GC 分摊到多次调度中(见 RelayModernStore.js#L854-L886)。此外,在乐观更新(optimistic update)期间可通过holdGC()挂起 GC,待恢复后再执行。
GC Release Buffer Size:释放缓冲大小
Relay Store 内部维护一个释放缓冲区(release buffer),用于在查询被其原始持有者释放后(默认发生在渲染该查询的组件卸载时),仍然临时保留指定数量的查询。这使得在用户返回之前访问过的页面、标签页或内容时,更有可能(且更容易)复用缓存数据。
配置方式是在创建 Store 时指定gcReleaseBufferSize:
const store = new Store(source, {gcReleaseBufferSize: 10});要点:
- 缓冲区大小为 0等价于没有释放缓冲区,查询一旦被释放就会被立即回收;
- 默认大小:环境的 release buffer 大小为10。
源码层面的对应关系(RelayModernStore.js#L169-L170):
this._gcReleaseBufferSize = options?.gcReleaseBufferSize ?? DEFAULT_RELEASE_BUFFER_SIZE; // 10缓冲区的运作机制(RelayModernStore.js#L642-L651):
- 被释放(
refCount为 0)的 root entry 会被推入_releaseBuffer; - 当缓冲区长度超过
gcReleaseBufferSize时,最旧(最早加入)的条目被逐出(evict),并从_roots中删除,随后调度 GC; - 因此,缓冲区越大,越多的最近访问过的查询得以保留,复用命中率越高,但同时内存占用也越大——这是需要在内存与复用率之间权衡的配置。
测试用例describe('GC with a release buffer', ...)明确验证了该行为:例如“在 caller 释放后仍将数据保留在 release buffer 中”“如果数据不过期,即使只发布数据也会被保留在 release buffer 中”(见 RelayModernStore-test.js 附近的测试组)。这从侧面印证:release buffer 正是“页面/标签往返复用”的核心机制。
实践建议
综合官方指南与源码实现,可总结出如下实践建议:
- 默认不动 GC 配置:默认的
gcScheduler(resolveImmediate)与gcReleaseBufferSize = 10已覆盖大多数场景。GC 与保留策略应由应用基础设施(Environment 层、Router 层)统一配置,产品代码一般无需干预; - 需要跨页面复用数据时,用 retain:若某个查询的数据在组件卸载后仍需被复用(如返回上一页、切换标签页),在 Router 等基础设施层调用
environment.retain()并在适当时机dispose(),是最直接、最可控的方式; - 权衡 release buffer 大小:增大
gcReleaseBufferSize可提升近期访问数据的复用命中率,但会增加内存占用;设为 0 则关闭缓冲、立即回收,适合对内存敏感的场景; - 自定义 gcScheduler 控制回收时机:若 GC 过于频繁导致卡顿,可按时间或优先级延后调度;注意约定上不要同步执行回调。
延伸阅读
- 缓存复用总览:reusing-cached-data/introduction.md
- 数据是否过期:staleness-of-data.md
- 获取策略(
store-or-network等):fetch-policies.md - 手动保留查询的完整 API:retaining-queries.md
- 核心实现:RelayModernStore.js、resolveImmediate.js
- 行为验证测试:RelayModernStore-test.js、RelayModernEnvironment-Retain-test.js
- 前端
- 开发工具
【免费下载链接】relay
Relay is a JavaScript framework for building>项目地址:https://gitcode.com/gh_mirrors/relay29/relay
相关推荐
Relay 数据驻留与垃圾回收全指南:理解 Presence of Data、Query Retention 与 GC 策略
Relay 数据驻留与垃圾回收全指南:理解 Presence of Data、Query Retention 与 GC 策略 导读 本指南聚焦 React Re
前端开发工具Relay 缓存数据的存续性:理解 Relay 垃圾回收、Query Retention 与 GC 策略配置
Relay 缓存数据的存续性:理解 Relay 垃圾回收、Query Retention 与 GC 策略配置 Relay 的核心设计之一是内置的规范化缓存(no
前端开发工具Relay 缓存数据的存在性与垃圾回收:掌握 Query Retention 与 GC 配置
Relay 缓存数据的存在性与垃圾回收:掌握 Query Retention 与 GC 配置 在 Relay 中,"复用缓存数据"是一个高频且容易踩坑的主题:只
前端开发工具