文档转换
对您的 GraphQL 文档进行自定义修改
本文假设您已熟悉GraphQL 查询的组成部分以及抽象语法树的抽象语法树(AST)概念。要探索 GraphQL AST,请访问AST Explorer。
您是否注意到Apollo客户端修改了您的查询——例如添加了__typename
字段——在将这些查询发送到您的GraphQL服务器之前?这是通过文档转换完成的,这些函数在对查询执行前修改GraphQL文档。
Apollo客户端提供了一种高级功能,允许您定义自己的GraphQL文档转换来修改GraphQL查询。本文将解释如何制作和使用自定义GraphQL文档转换。
概述
文档转换允许您以编程方式修改在应用程序中用于查询数据的GraphQL文档。GraphQL文档是一个AST,它定义了一个或多个操作和片段,该数据从原始GraphQL查询字符串中使用gql
函数解析出来。您可以使用DocumentTransform
类创建自己的文档转换。然后将创建的转换传递到ApolloClient
构造函数。
import { DocumentTransform } from '@apollo/client';const documentTransform = new DocumentTransform((document) => {// modify the documentreturn transformedDocument;});const client = new ApolloClient({documentTransform});
生命周期
Apollo 客户端在所有操作之前都会运行文档转换,这包括对每个 GraphQL 请求执行文档转换。这适用于所有执行网络请求的 API,例如 useQuery
钩子或 refetch
函数以及在 ObservableQuery
上。
文档转换在请求的生命周期早期执行。这使得缓存能够看到 GraphQL 文档的修改——这是与在 Apollo Link 中对 GraphQL 文档进行修改的一个关键区别。由于文档转换在请求生命周期早期执行,这使您能够向文档转换中添加 @client
指令以将字段转变为仅本地字段,或者添加对在 片段库 中定义的片段的选择集。
与内置转换交互
Apollo 客户端自带必要的内置文档转换,这些转换对于客户端的功能至关重要。
- 在查询的每个 选择集 中添加
__typename
字段,以便识别由 GraphQL 操作 返回的所有对象的类型。 - 使用在 片段库 中定义的片段的 GraphQL 文档在发送网络请求之前添加到文档中(需要 Apollo Client 3.7 及以上版本)。
对于自定义文档转换,与这些内置功能交互至关重要。为了最大限度地利用您的自定义文档转换,Apollo 客户端会运行这些内置转换两次:一次在您的转换之前,一次在您的转换之后。
在自定义转换之前运行内置转换允许您的转换看到为每个字段的 __typename
字段添加到选择集中,并发修改片段库中定义的片段定义。Apollo 客户端知道您的转换可能会向 GraphQL 文档添加新的选择集或新的片段选择集。因此,Apollo 客户端在您的自定义转换之后重新运行内置转换。
运行内置转换两次是一个方便的特性,因为这意味着您无需记住为任何添加的 __typename
字段。同样,您也不需要查找添加到 GraphQL 文档中的 选择集的片段定义。
编写自定义文档转换
例如,让我们编写一个确保在 currentUser
查询时始终选择 id
字段的 document 转换。
首先,我们必须使用 Apollo Client 提供的 DocumentTransform
类创建一个新建的 document 转换。该 DocumentTransform
构造函数接收一个回调函数,它在转换每个 GraphQL document 时运行。GraphQL document
作为参数传递给这个回调函数。
import { DocumentTransform } from '@apollo/client';const documentTransform = new DocumentTransform((document) => {// Modify the document});
为了修改 document,我们引入从 graphql-js
中导入的 visit
函数,该函数遍历 AST 并允许我们修改其节点。该 visit
函数接受 GraphQL AST 作为第一个参数,接受一个访问者作为第二个参数。
import { DocumentTransform } from '@apollo/client';import { visit } from 'graphql';const documentTransform = new DocumentTransform((document) => {const transformedDocument = visit(document, {// visitor});return transformedDocument;});
访问者允许您访问 AST 中的许多节点类型,例如指令、片段和字段。在我们的示例中,我们只关心访问字段,因为我们想修改查询中的 currentUser
字段。为了访问字段,我们需要定义一个在遍历遇到字段时会调用的 Field
回调函数。
const transformedDocument = visit(document, {Field(field) {// ...}});
本示例使用了简短的访问者语法,它为我们定义了该节点上的 enter
函数。这相当于以下代码:
visit(document, {Field: {enter(field) {// ...}}});
我们的 document 转换只需修改名为 currentUser
的字段,因此我们需要检查该字段的 name
属性,以确定我们是否正在处理 currentUser
字段。让我们添加一个条件检查,如果遇到任何不是 currentUser
的字段就提前返回。
const transformedDocument = visit(document, {Field(field) {if (field.name.value !== 'currentUser') {return;}}});
如果我们的 Field
实例访客返回 undefined
,这将通知函数保持节点不变。
既然我们确定我们正在处理 currentUser
字段,我们需要找出我们的 id
字段是否已经是 currentUser
字段选择集的一部分。这确保我们在查询中不会意外地重复选择字段。
为了做到这一点,我们获取字段的 selectionSet 属性,并遍历其 selections
属性以确定是否包含 id
字段。
需要注意的是, selectionSet 可能包含字段和片段的选择。我们的实现只需要对字段进行检查,所以我们还要检查选择的 kind
属性。如果在名为 id
的字段上找到匹配项,我们可以停止对 AST 的遍历。
我们将引入来自 Kind
的枚举,它允许我们比较选择项的 kind
属性,以及 BREAK
哨兵,这将指导函数停止对 AST 的遍历。
import { visit, Kind, BREAK } from 'graphql';const transformedDocument = visit(document, {Field(field) {// ...const selections = field.selectionSet?.selections ?? [];for (const selection of selections) {if (selection.kind === Kind.FIELD &&selection.name.value === 'id') {return BREAK;}}}});
为了使我们的文档变换保持简单,它不会遍历 currentUser 字段内的片段以确定这些片段是否包含一个 id
字段。此文档变换的更完整版本可能执行此检查。
既然我们知道缺失了 id
字段,我们可以在 currentUser
字段的选择集中添加它。为此,我们可以创建一个新字段并为其命名 id
。这表示为一个具有 kind
属性设置为 Kind.FIELD
及一个定义字段的 name
节点的普通对象。
const idField = {kind: Kind.FIELD,name: {kind: Kind.NAME,value: 'id',},};
我们现在从访客返回一个修改后的字段,它将id
字段添加到currentUser
字段的selectionSet
中。这更新了我们的GraphQL
const transformedDocument = visit(document, {Field(field) {// ...const idField = {// ...};return {...field,selectionSet: {...field.selectionSet,selections: [...selections, idField],},};}});
此示例将id
字段添加到选择集的末尾。顺序无关紧要 — 您可以将字段放在selections
数组中的其他位置。
太好了!我们现在有了工作文档转换,确保在发送包含currentUser
字段的查询到我们的服务器时,总是选中id
字段。为了完整性,以下是完成此示例后的自定义文档转换的定义。
import { DocumentTransform } from '@apollo/client';import { visit, Kind, BREAK } from 'graphql';const documentTransform = new DocumentTransform((document) => {const transformedDocument = visit(document, {Field(field) {if (field.name.value !== 'currentUser') {return;}const selections = field.selectionSet?.selections ?? [];for (const selection of selections) {if (selection.kind === Kind.FIELD &&selection.name.value === 'id') {return BREAK;}}const idField = {kind: Kind.FIELD,name: {kind: Kind.NAME,value: 'id',},};return {...field,selectionSet: {...field.selectionSet,selections: [...selections, idField],},};},});return transformedDocument;});
检查文档转换
我们可以通过调用transformDocument
函数并传入一个GraphQL查询来检查我们的自定义文档转换。
import { print } from 'graphql';const query = gql`query TestQuery {currentUser {name}}`;const documentTransform = new DocumentTransform((document) => {// ...});const modifiedQuery = documentTransform.transformDocument(query);console.log(print(modifiedQuery));// query TestQuery {// currentUser {// name// id// }// }
我们使用由print
函数导出由graphql-js
,使查询可读。
同样,我们可以验证传入的不查询currentUser的查询不受我们的转换影响。
const query = gql`query TestQuery {user {name}}`;const modifiedQuery = documentTransform.transformDocument(query);console.log(print(modifiedQuery));// query TestQuery {// user {// name// }// }
使用文档转换查询服务器
函数transformDocument
对于检查你的文档转换来说很有用。然而,在实践中,这将由Apollo客户端为你完成。
让我们将我们的文档转换添加到Apollo客户端并查询服务器。网络请求将包含更新的GraphQL查询,并且从服务器返回的数据将包括id
字段
import { ApolloClient, DocumentTransform } from '@apollo/client';const query = gql`query TestQuery {currentUser {name}}`;const documentTransform = new DocumentTransform((document) => {// ...});const client = new ApolloClient({// ...documentTransform});const result = await client.query({ query });console.log(result.data);// {// currentUser: {// id: "...",// name: "..."// }// }
组合文档转换
你可能注意到ApolloClient
构造函数仅接受单个documentTransform
选项。随着您向文档转换添加新功能,它可能会变得难以管理。而DocumentTransform
类简化了将多个转换分成单个转换的过程。
结合多个文档转换
您可以使用concat()
函数将多个文档转换组合在一起。这形成了一系列按顺序执行的一个接一个的文档转换。
const documentTransform1 = new DocumentTransform(transform1);const documentTransform2 = new DocumentTransform(transform2);const documentTransform = documentTransform1.concat(documentTransform2);
这里documentTransform1
与documentTransform2
结合成一个单个的文档变换。在documentTransform
上调用transformDocument()
函数将GraphQL文档通过documentTransform1
然后通过documentTransform2
。在GraphQL文档中的更改可由documentTransform2
看到。
关于性能的注意事项
组合多个变换是一个强大的功能,它使得分割变换逻辑变得容易,这可以增强可维护性。根据你的访问者的实现方式,这可能会导致GraphQL文档AST的多次遍历。大多数情况下,这不应该是个问题。我们建议使用来自BREAK
的graphql-js
防止不必要的遍历。
假设你正在发送非常大的查询,这需要多次遍历,而且你已经使用BREAK
哨兵优化了你的访问者。在这种情况下,最好将变换组合成一个单次遍历AST的单个访问者。
有关文档缓存部分,了解Apollo Client如何应用优化以减轻将相同的GraphQL文档变换多次的性能影响。
条件执行文档变换
有时,你可能需要根据GraphQL文档有条件地运行文档变换。你可以通过在DocumentTransform
构造函数上调用split()
静态函数来有条件地运行变换。
import { isSubscriptionOperation } from '@apollo/client/utilities';const subscriptionTransform = new DocumentTransform(transform);const documentTransform = DocumentTransform.split((document) => isSubscriptionOperation(document),subscriptionTransform);
此示例使用添加到Apollo Client的3.8版本中的isSubscriptionOperation
实用函数。同样,isQueryOperation
和isMutationOperation
实用函数也可以使用。
这里subscriptionTransform
只针对subscription操作运行。对于所有其他操作,不修改GraphQL文档。生成的文档变换将首先检查document
是否为subscription操作,如果是,则继续运行subscriptionTransform
。如果不是,则跳过subscriptionTransform
,并直接返回GraphQL文档。
此外,split
函数还允许你将第二个文档变换传递给它的函数,允许你实现if/else条件。
const subscriptionTransform = new DocumentTransform(transform1);const defaultTransform = new DocumentTransform(transform2)const documentTransform = DocumentTransform.split((document) => isSubscriptionOperation(document),subscriptionTransform,defaultTransform);
在这里,subscriptionTransform
只用于 subscription 操作。对于所有其他操作,GraphQL 文档将通过 defaultTransform
运行。
为什么我应该使用 split()
函数而不是在转换函数内进行条件检查?
有时候,使用 split()
函数比在转换函数内运行条件检查更加高效。
例如,你可以在转换函数内部添加条件检查来运行转换
const documentTransform = new DocumentTransform((document) => {if (shouldTransform(document)) {// ...return transformedDocument}return document});
考虑使用 concat()
函数合并多个 document 转换的情况:
const documentTransform1 = new DocumentTransform(transform1);const documentTransform2 = new DocumentTransform(transform2);const documentTransform3 = new DocumentTransform(transform3);const documentTransform = documentTransform1.concat(documentTransform2).concat(documentTransform3);
使用 split()
函数使得跳过整个 document 转换链变得更加容易。
const documentTransform = DocumentTransform.split((document) => shouldTransform(document),documentTransform1.concat(documentTransform2).concat(documentTransform3));
文档缓存
你应该努力使你的 document 转换具有确定性。这意味着,当给出相同的输入 GraphQL 文档时,文档转换总是输出相同的转换后的 GraphQL 文档。DocumentTransform
类通过为每个输入 GraphQL 文档缓存转换结果来优化这种情况。这通过避免不必要的工作来加快对文档转换的重复调用。
该 DocumentTransform
类将进一步记录所有转换过的文档。这意味着,将已转换文档传递到文档转换将立即返回 GraphQL 文档。
const transformed1 = documentTransform.transformDocument(document);const transformed2 = documentTransform.transformDocument(transformed1);transformed1 === transformed2; // => true
在实际中,这种优化对你来说是不可见的。 Apollo Client 会为你调用 transformDocument
函数。 这种优化主要有利于 Apollo Client 的内部操作,而被转换的文档在代码库的多个区域传输。
非确定性文档转换
在极少数情况下,你可能需要依赖于转换函数之外的环境条件,该条件会改变文档转换的结果。 由于文档转换的自动缓存,当该环境条件在调用你的文档转换之间发生变化时,这成为一个问题。
在这些情况下,你不必完全禁用 document 缓存,而是可以提供一个自定义的缓存键,该键将用于缓存文档转换的结果。 这确保了你的转换只按必要调用,同时保持了运行时条件的灵活性。
为了自定义缓存键,将 getCacheKey
函数作为选项传递到 DocumentTransform
构造函数的第二个参数。 此函数接收将被传递到你的转换函数的文档,并期望返回一个数组。
以下是一个示例,它取决于用户是否已经连接到网络。 这是一个依赖于用户连接状态进行文档转换的例子。
const documentTransform = new DocumentTransform((document) => {if (window.navigator.onLine) {// Transform the document when the user is online} else {// Transform the document when the user is offline}},{getCacheKey: (document) => [document, window.navigator.onLine]});
⚠️ 强烈建议您将 document
作为缓存的键的一部分。document
在此示例中,如果省略了缓存的键中的document
,文档转换将仅输出两个转换后的文档:一个用于true
条件,另一个用于false
条件。使用缓存键中的document
确保您的应用程序中每个独特的文档都会相应地进行转换。
您可以通过从getCacheKey
函数返回undefined
来有条件地禁用某些GraphQL
文档的缓存。这将强制文档转换运行,无论输入的GraphQL
文档是否已被看到。
const documentTransform = new DocumentTransform((document) => {// ...},{getCacheKey: (document) => {// Always run the transform function when `shouldCache` is `false`if (shouldCache(document)) {return [document]}}});
作为最后的手段,您可以将文档缓存完全禁用以强制您的转换函数在每次使用文档转换时运行。将cache
选项设置为false
以禁用缓存。
const documentTransform = new DocumentTransform((document) => {// ...},{cache: false});
组合转换中的缓存
当您使用concat()
函数组合多个文档转换时,会尊重每个文档转换的缓存配置。这允许您混合和匹配带有不同缓存配置的转换,并确信生成的GraphQL
文档已正确转换。
const cachedTransform = new DocumentTransform(transform);const varyingTransform = new DocumentTransform(transform, {getCacheKey: (document) => [document, window.navigator.onLine]});const conditionalCachedTransform = new DocumentTransform(transform, {getCacheKey: (document) => {if (shouldCache(document)) {return [document]}}});const nonCachedTransform = new DocumentTransform(transform, {cache: false});const documentTransform =cachedTransform.concat(varyingTransform).concat(conditionalCachedTransform).concat(nonCachedTransform);
我们建议将非缓存的文档转换添加到concat()
链的末尾。文档缓存依赖于引用等价性来确定是否已看到GraphQL
文档。如果非缓存的文档转换定义在缓存的转换之前,缓存的转换将在每次运行时存储由非缓存的文档转换创建的新GraphQL
文档。这可能导致内存泄漏。
TypeScript 和 GraphQL 代码生成器
GraphQL 代码生成器是一种流行的工具,用于为您生成的GraphQL
文档生成 TypeScript 类型。它通过静态分析您的代码来搜索GraphQL
查询字符串来实现。
文档转换给这个工具带来了一些挑战。因为文档转换在运行时使用,所以静态分析无法理解文档转换中对GraphQL
文档应用的更改。
幸运的是,GraphQL
代码生成器提供了一个文档转换功能,允许您将 Apollo 客户端的文档转换连接到GraphQL
代码生成器。在传递给GraphQL
代码生成器配置的transform
函数中使用您的文档转换:
import type { CodegenConfig } from '@graphql-codegen/cli';import { documentTransform } from './path/to/your/transform';const config: CodegenConfig = {schema: 'https://127.0.0.1:4000/graphql',documents: ['src/**/*.tsx'],generates: {'./src/gql/': {preset: 'client',documentTransforms: [{transform: ({ documents }) => {return documents.map((documentFile) => {documentFile.document = documentTransform.transformDocument(documentFile.document);return documentFile;});}}]}}}
您可能不需要文档转换
文档转换是Apollo Client的一个强大功能。阅读完这篇文章后,您可能会急于找到尽可能多的这个功能的应用场景。虽然我们鼓励您在使用此功能时保持理智,但使用它可能会产生一些隐藏的成本。
考虑一下在一个大型生产应用程序中工作的情景,这个应用程序横跨您的组织内部的多个团队。文档转换通常定义在代码库中与GraphQL查询定义非常远的地方。并非所有开发者都了解它们的存在,或者理解它们对最终GraphQL文档的影响。
在将文档发送到网络之前,文档转换可以对GraphQL文档进行无限修改。您可能会发现自己处于从GraphQL操作返回的结果与原始GraphQL文档不符的情况。当文档转换删除字段或进行其他破坏性更改时,这可能会变得特别令人困惑。
请首先考虑使用现有技术,例如代码质量检查。例如,如果您要求您的GraphQL文档中的每个选择集都应包含一个id
字段,您可能更希望创建一个发出警告的规则,当您忘记包含该字段时会更有用。这使得从您的GraphQL查询中明确地期望什么,因为代码质量检查规则是在您的GraphQL查询定义的地方应用的。@ DOCUMENT 转换使这种关系成为隐式。
我们鼓励您记录自己的文档转换,以创建一个共享的知识库,以帮助避免混淆。这并不意味着我们认为这个功能是危险的。毕竟,Apollo Client几乎在它的整个生命周期中都在执行文档转换,并且这对于其核心功能是必要的。
我可以用这个来定义我自己的自定义指令吗?
乍一看,文档转换似乎是创建和定义自定义指令的一个很好的地方,因为它们可以检测GraphQL文档中的存在。但是,文档转换无法访问缓存,也不能与您的GraphQL服务器返回的数据交互。如果您的自定义指令需要访问这些功能,您将很难找到使其工作的方式。
自定义指令局限于依赖于修改GraphQL自身文档的使用场景。
以下是一个使用类似于DSL的指令作为示例,该指令依赖于特征标记系统来有条件地包含查询中的字段。文档转换将自定义@feature指令修改为常规的@include指令,并将变量定义添加到查询中。
const query = gql`query MyQuery {myCustomField @feature(name: "custom", version: 2)}`;const documentTransform = new DocumentTransform((document) => {// convert `@feature` directives to `@include` directives and update variable definitions});documentTransform.transformDocument(query);// query MyQuery($feature_custom_v2: Boolean!) {// myCustomField @include(if: $feature_custom_v2)// }
API参考
选项
属性
布尔值
确定是否缓存转换后的GraphQL文档。缓存可以提高对同一输入文档的文档转换的重复调用速度。将此选项设置为false
将完全禁用文档转换的缓存。禁用时,此选项比getCacheKey
选项具有更高的优先级。
默认值是 true
。
(document: DocumentNode) => DocumentTransformCacheKey | undefined
定义一个用于 GraphQL 文档的自定义缓存键,该键将确定是否在给定相同的 GraphQL 文档输入时重新运行文档转换。返回一个定义缓存键的数组。返回 undefined
将禁用该 GraphQL 文档的缓存。
注意: 数组中的项可以是任何类型,但还需要引用稳定以保证稳定的缓存键。
此函数的默认实现返回 document
作为缓存键。