Relay Client 3D 完整指南:基于客户端 Relay Resolvers 的数据驱动依赖
【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay
Relay 的Client 3D(客户端数据驱动依赖,Client Data Driven Dependencies)允许你在渲染 3D 组件所需的全部数据字段都由客户端侧的 Relay Resolvers 解析时,按数据内容动态加载对应的 React 组件与代码。本文将围绕 Relay 19 官方文档《Client 3D》展开,结合仓库中react-relay的MatchContainer、useClientQuery实现以及编译器测试用例,完整讲解 Client 3D 的配置方式、完整示例、@module指令约束与底层原理,帮助你直接上手并在实际项目中规避已知的性能陷阱。
什么是 Client 3D
在 Relay 中,数据驱动依赖(Data Driven Dependencies,简称 3D)允许根据"正在渲染的数据本身"动态决定加载哪个组件。当一段数据可能有多种渲染方式时,传统做法是把所有可能组件的代码与数据全部打包下发,再由客户端写一堆条件分支去选择;而 3D 让应用只下载实际被选中的那一个组件及其数据,从而显著降低 JavaScript bundle 体积、减少不必要的网络开销,并把复杂的条件渲染逻辑收敛到声明式的 GraphQL 指令中。
Relay 支持两种 3D:
- Server 3D:3D 组件中渲染所需的所有数据都由 GraphQL 服务器解析,适合服务端场景;
- Client 3D:3D 组件中渲染所需的所有数据字段都由客户端侧的 Relay Resolvers 解析。
适用前提:只有当 3D 组件渲染所需的全部数据字段均由客户端 Relay Resolvers 解析时,才使用 Client 3D。如果数据来自 GraphQL 服务器,应使用 Server 3D。
Relay Resolvers 是 Relay 的一项特性,它允许你用客户端代码扩充 Relay 的 GraphQL 图,把"只有客户端才知道的值"(如本地数据、从其他字段推导出的派生数据)以与服务器状态一致的方式建模进 schema,并通过 Relay 熟悉的取数 API 访问。其底层机制是:用带@RelayResolverdocblock 注释的导出函数定义 resolver,Relay 编译器据此构建客户端 schema 并自动把函数引入生成的产物。Client 3D 正是建立在"这些字段全部由 resolver 解析"这一前提之上。
使用 Client 3D 的配置前提
Client 3D 并非开箱即用(Server 3D 无需任何配置即可启用),你需要在 relay 编译器配置文件中额外添加一个moduleImportConfig字段。配置细节详见>"moduleImportConfig": { "dynamicModuleProvider": { "mode": "Custom", "statement": "() => require('./.<$module>')" }, "surface": "resolvers" }
dynamicModuleProvider的这些子字段是为了在 Meta 内部代码库中区分不同用例而设计的,OSS 场景下按上述方式配置即可。仓库中的编译器集成测试印证了这一配置结构:例如client-3D-resolvers-enabled-client-3D-fragment测试夹具(见 client-3D-resolvers-enabled-client-3D-fragment.graphql)在%project_config%中使用了JSResource模式与surface: "resolvers"的组合,来验证 Client 3D 片段在 resolver 场景下的编译行为:
"moduleImportConfig": { "dynamicModuleProvider": { "mode": "JSResource" }, "surface": "resolvers" }另一个测试夹具query-with-module-directive-custom-import.graphql(见 query-with-module-directive-custom-import.graphql)则展示了Custom模式配合动态import()的写法:
"moduleImportConfig": { "dynamicModuleProvider": { "mode": "Custom", "statement": "() => import('<$module>')" } }从源码结构看,
JSResource模式在 OSS 中主要面向 Meta 内部的 JSResource 体系;OSS 开发者按官方文档采用Custom+require/import语句即可,两种模式最终都会在编译器产物中把 3D 组件替换为配置的导入语句。
完整示例:从 Schema 到组件改造
下面以一个 React 应用中的完整例子,走一遍 Client 3D 的落地过程。核心思路是:使用 Client 3D 时,你不需要修改任何 Relay Resolvers 或 schema,只需改造组件。
第一步:定义客户端 Schema 扩展
在客户端 schema 扩展文件中定义一个接口IClient3D,它是查询上一个字段的返回类型:
type Client3DData { type: String! info: String! } interface IClient3D { id: ID! data: Client3DData! } extend type Query { client3D: IClient3D }第二步:定义实现该接口的 Relay Resolvers
需要 3 个 Relay Resolvers,分别返回实现了IClient3D接口的具体对象。每个 resolver 都包含两个部分:一个带implements IClient3D注释的模型 resolver(返回带__id的模型对象),以及一个定义data字段的字段 resolver。
Client3DBar(对应BAR类型):
export type Client3DModel = { __id: DataID, }; /** * @RelayResolver Client3DBar implements IClient3D */ function Client3DBar(id: DataID): ?Client3DModel { if (id === INVALID_ID) { return null; } return { __id: id, }; } /** * @RelayResolver Client3DBar.data: Client3DData */ function data(client3DModel: Client3DModel): Client3DData { return { type: 'BAR', info: 'someBarInfo', } }Client3DFoo(对应FOO类型):
/** * @RelayResolver Client3DFoo implements IClient3D */ function Client3DFoo(id: DataID): ?Client3DModel { if (id === INVALID_ID) { return null; } return { __id: id, }; } /** * @RelayResolver Client3DFoo.data: Client3DData */ function data(client3DModel: Client3DModel): Client3DData { return { type: 'FOO', info: 'someFooInfo', } }Client3DHelloWorld(对应HELLO_WORLD类型):
/** * @RelayResolver Client3DHelloWorld implements IClient3D */ function Client3DHelloWorld(id: DataID): ?Client3DModel { if (id === INVALID_ID) { return null; } return { __id: id, }; } /** * @RelayResolver Client3DHelloWorld.data: Client3DData */ function data(client3DModel: Client3DModel): Client3DData { return { type: 'HELLO_WORLD', info: 'someHelloWorldInfo', } }可以看到,这三个 resolver 本身并没有任何 3D 相关的痕迹——它们就是普通的 Relay Resolvers。3D 的"动态性"完全由查询端的@module指令与MatchContainer承担。
第三步:改造前的组件(手动条件渲染)
在使用 Client 3D 之前,组件通常长这样:先用useClientQuery发起一个纯客户端查询,拿到数据后在 JSX 里手写一串if / else if分支,按data.type的值选择渲染Client3DFooComponent、Client3DBarComponent还是Client3DHelloWorldComponent:
component Client3DRelayRenderer() { const CLIENT_3D_FRAGMENT = graphql` fragment Client3DRelayRendererClient3DFragment on IClient3D { data { type info } } `; const client3DData = useClientQuery( graphql` query Client3DRelayQuery { client3D { ...Client3DRelayRendererClient3DFragment } } ` ); let component; if (client3DData?.data?.type === 'FOO'): component = <Client3DFooComponent data={client3DData.data} /> else if (client3DData?.data?.type === 'BAR'): component = <Client3DBarComponent data={client3DData.data} /> else if (client3DData?.data?.type === 'HELLO_WORLD'): component = <Client3DHelloWorldComponent data={client3DData.data} /> return ( component ); }这种写法的痛点是:三个子组件的代码全部被静态打包进主 bundle,无论type最终是什么都会下载;而且每新增一种类型,条件分支就要再长一截。
第四步:改造后的组件(@module + MatchContainer)
使用 Client 3D 时,不需要修改 Relay Resolvers 或 schema,只需按以下三步改造组件:
- 为每个实现了
IClient3D的具体类型分别声明 fragment。本例中即FOO_FRAGMENT、BAR_FRAGMENT、HELLO_WORLD_FRAGMENT; - 给 fragment 加上
@module指令,并把与该 fragment 数据对应的 UI 组件名作为name参数传入; - 用 Relay 的
MatchContainer返回最终组件,把查询返回的数据作为matchprop 传入。
改造后的组件代码:
const {graphql, useFragment, useClientQuery, MatchContainer} = require('react-relay'); component Client3DRelayRenderer() { const FOO_FRAGMENT = graphql` fragment Client3DFooComponent_Fragment on Client3DFoo { data { type info } } `; const BAR_FRAGMENT = graphql` fragment Client3DBarComponent_Fragment on Client3DBar { data { type info } } `; const HELLO_WORLD_FRAGMENT = graphql` fragment Client3DHelloWorldComponent_Fragment on Client3DHelloWorld { data { type info } } `; const client3DData = useClientQuery( graphql` query Client3DRelayQuery { client3D { ...Client3DFooComponent_Fragment @module(name: "Client3DFooComponent.react") ...Client3DBarComponent_Fragment @module(name: "Client3DBarComponent.react") ...Client3DHelloWorldComponent_Fragment @module(name: "Client3DHelloWorldComponent.react") } } ` ); return ( <MatchContainer match={client3DData.client3D} /> ); }对比改造前后可以发现:原来分散在 JSX 中的if / else if条件分支全部消失,取而代之的是三个声明式的@module片段展开。每种具体类型对应的组件及其数据(fragment)变成了一个"可动态获取的依赖",只有当该类型被选中时才真正加载。
@module 的合法使用边界
Client 3D 与 Server 3D 一样,不能在同一个具体类型(concrete type)上的多个 fragment 上使用@module(但可以分布在同一个抽象类型上,即 union 或 interface)。
以上面例子来说:Client3DFooComponent_Fragment位于具体类型Client3DFoo上,Client3DBarComponent_Fragment位于具体类型Client3DBar上。如果Client3DBarComponent_Fragment也放在了Client3DFoo上,relay 编译器会直接报错。而这三个具体类型都实现了同一个父接口IClient3D,这是完全允许的——编译器可以据此在运行期分辨应该加载哪个组件。
底层原理:MatchContainer 与 useClientQuery
MatchContainer 如何渲染动态组件
MatchContainer是 Client 3D 的消费端核心组件,源码位于 packages/react-relay/relay-hooks/MatchContainer.js。它接收match(一个@module选择产生的"不透明对象",包含__id、__fragments、__fragmentOwner、__fragmentPropName、__module_component等元数据)、可选的loader(根据模块引用加载对应 React 组件的函数)与props(透传给动态选中组件的属性)。
核心逻辑(对应 MatchContainer.js):
- 对
match值做形状校验:如果它不是对象且非 null/undefined,或缺少合法的 fragment 展开结构(__fragments、__id等),会抛出"MatchContainer: Invalid 'match' value, expected an object that has a '...SomeFragment' spread."之类的错误; - 通过
loader(__module_component)获得动态加载的组件LoadedContainer,并用useMemo基于__fragmentPropName/__id/__fragments/__fragmentOwner构造要传给该组件的 fragment props; - 当组件与 fragment props 都就绪时渲染
<LoadedContainer {...props} {...fragmentProps} />,否则渲染fallback ?? null。
从源码注释可以确认,MatchContainer的 props 中fallback用于兜底、loader用于异步解析模块引用、props会被透传给所有可能被选中的组件——这要求所有@module候选组件都能接受同一组 props。需要特别注意的是,MatchContainer在加载组件或数据时可能会 suspend,因此建议像 Server 3D 一样用React.Suspense包裹。
useClientQuery:纯客户端查询的入口
Client 3D 的查询端使用useClientQuery发起纯客户端查询。其源码位于 packages/react-relay/relay-hooks/useClientQuery.js,实现上它只是对useLazyLoadQuery的一层封装:
hook useClientQuery<TVariables extends Variables, TData, TRawResponse>( gqlQuery: ClientQuery<TVariables, TData, TRawResponse>, variables: NoInfer<TVariables>, options?: { UNSTABLE_renderPolicy?: RenderPolicy, }, ): TData { // client queries can be used with useLazyLoadQuery, but only with `store-only` policy. const query: Query<TVariables, TData> = gqlQuery; return useLazyLoadQuery(query, variables, { ...options, fetchPolicy: 'store-only', }); }要点:
- 它强制使用
fetchPolicy: 'store-only',即只从本地 Relay Store 读取数据、不向服务器发起网络请求——这与"数据全部由客户端 resolver 解析"的定位完全一致; - 当查询里只包含客户端定义的字段时(例如只有 resolver 字段和客户端 schema 扩展字段),必须使用
useClientQuery这类客户端查询 API,而不是useLazyLoadQuery或usePreloadedQuery;如果查询同时包含服务器数据,则仍可使用标准 API(参见 Relay Resolvers 介绍)。
编译产物侧的证据
在 Relay 编译器层面,Client 3D 的@module片段会通过moduleImportConfig的配置生成对应的动态导入代码。仓库的relay-compiler集成测试中保存了真实的编译产物,例如client-3D-resolvers-enabled-client-3D-fragment夹具(见 编译输入 与 编译输出 .expected),它同时包含一个定义在ClientUser/SpecialUser两个 resolver 模型类型上的@module片段展开,以及一个 Server 3D fragment 的对照夹具,用于验证两类 3D 在编译器中的不同处理路径。另一个夹具query-with-module-directive-custom-import.graphql则验证了Custom模式下生成自定义导入语句的编译行为。如果你要深入调试 Client 3D 的产物形态,这些测试夹具是很好的参照物。
局限性:往返次数与嵌套问题
Client 3D 带来了更直观的开发体验、更强的可维护性和更快的性能,但它也存在 Server 3D 所没有的局限。
关键差异在于获取数据所需的往返(round trip)次数:
- Server 3D最多需要两次往返:一次向服务器取数据,一次向 CDN 取代码;
- Client 3D在渲染组件的过程中才执行 resolver 代码,这意味着客户端必须先渲染组件,才能发现到底需要哪些 JavaScript 代码。这可能导致额外的往返,尤其是在嵌套使用 Client 3D时。
举个官方文档中的例子:一篇博客文章用 Client 3D 决定渲染"图片博文"还是"文本博文";而文本博文内部又用 Client 3D 决定采用哪种文本排版格式。这种嵌套会让组件加载变成层层递进的过程,产生多次往返。
关于这一点,文档明确说明:Relay 目前正在着手解决这一缺陷,但相关方案尚未生产化(productionized)。因此在使用 Client 3D 时,请务必避免嵌套使用,以防出现性能退化。如果确实存在嵌套诉求,建议评估 Server 3D 或把内层动态选择上移到更外层。
总结
Client 3D 是 Relay 数据驱动依赖体系在"纯客户端数据"场景下的落地方案,它把 Relay Resolvers 解析出的数据与按需加载的 React 组件通过@module指令和MatchContainer组合在一起:
- 配置:在 relay 编译器配置中新增
moduleImportConfig,OSS 下使用dynamicModuleProvider.mode = "Custom"(自定义statement)与surface = "resolvers"; - 开发流程:定义客户端 schema 扩展 → 编写实现同一接口的多个 Relay Resolvers → 为每个具体类型声明独立 fragment 并加
@module(name: ...)→ 用useClientQuery发起纯客户端查询 → 用MatchContainer渲染; - 约束:同一具体类型上不能出现多个
@modulefragment,但同一抽象类型(union/interface)下可以; - 原理:
useClientQuery强制store-only取数策略,MatchContainer负责校验 match 结构、动态加载组件并注入 fragment props(源码见 MatchContainer.js); - 代价:组件加载依赖先渲染才能发现依赖,嵌套使用会放大往返次数,当前应避免嵌套以规避性能退化。
如需进一步了解 3D 的整体概念与 Server 3D 的完整语法(含@match指令、多 3D 选择key、非 React 模块的ModuleResource.read()等),可继续阅读 数据驱动依赖介绍、Server 3D 与 3D 配置 等配套文档。
【免费下载链接】relayRelay is a JavaScript framework for building>项目地址: https://gitcode.com/gh_mirrors/relay29/relay
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考