我对GraphQL的片段协作和变量感到疑惑
最近,我利用一些空闲时间小心翼翼地摸索着Apollo,但有一些事情始终让我感到困惑,所以我决定写下备忘录。
我事先声明,这并没有特定的结论。
GraphQL的Fragment colocation是什么意思
让我们整理一下关于客户端GraphQL中碎片集合的相关内容,以便描述我所面临的问题感受。
“colocate”这个词本身在平常不太常听到(我也只在GraphQL的语境中使用过它)。它的意思好像是“一起放置”。
在中国,我们要将什么与什么放在一起呢?那就是GraphQL查询和组件(这里的组件指的是React或Angular中的视图部件单位)。
GraphQL有一个重要特点是“客户端决定包含在查询响应中的数据”。考虑到REST API中,URL的确定意味着响应格式是唯一的,这一点对于客户端来说是一个重大的区别。
客户端本质上是指屏幕。因此,前面提到的特点是指“查询的响应由渲染屏幕的组件决定”。
前面提到的”将查询和组件放在一起”的colocation概念与GraphQL的理念非常契合。
从这里开始,我们将以GitHub API的GraphQL查询作为主题,思考有关Fragment的问题。
query AppQuery {
viewer {
repositories(first: 10) {
nodes {
name,
url,
description,
languages(first: 10, orderBy: { field: SIZE, direction: DESC }) {
nodes {
id,
name,
},
},
},
pageInfo {
hasPreviousPage,
hasNextPage,
endCursor,
startCursor,
},
totalCount,
},
},
}
这是一个查询自己GitHub仓库前10个的查询。
嗯,考虑将其放置于屏幕组件中,仍然存在一些问题。
-
- レポジトリリストやレポジトリの詳細部分の再利用性が低い
- そもそも長い
因此,我们将查询分割如下。
fragment RepoItem on Repository {
name,
url,
description,
languages(first: 10, orderBy: { field: SIZE, direction: DESC }) {
nodes {
id,
name,
},
},
}
fragment RepoList on User {
repositories(first: 10) {
nodes {
...RepoItem,
},
pageInfo {
hasPreviousPage,
hasNextPage,
endCursor,
startCursor,
},
totalCount,
},
}
query AppQuery {
viewer {
...RepoList,
},
}
通过将GraphQL的数据层次结构拆分为部分片段,例如对于查询”我收藏的仓库列表”,可以重复使用RepoList。
回想起「查询的响应确定了要绘制的屏幕组件」的特点时,我们可以得出结论,提取的片段应该由相应的组件决定所需的字段。
换句话说,组件层次结构与查询片段层次结构相匹配似乎是一个好主意。
图中虚线表示的是词语组合。
在Facebook Relay和Apollo Client等主要的GraphQL客户端库中,也提到了组件和片段的相互配合34。
组件实现示例
实际上,关于之前的查询,我会在这篇帖子的末尾附上一个使用Angular + Apollo实现的例子。
为了更容易传达colocation的状态,我们将HTML模板和GraphQL编写在同一个.ts文件中。但是,colocation的本质是“一起放置”,所以可以准备一个与组件对应的目录,并将.html和.graphql文件分开放置。
代码片段和变量
到目前为止,没有什么特别的问题,但是从我想在片段中使用变量的时候开始,情况变得不太妙了。
我們嘗試將分頁參數作為變數附加到先前的查詢中。
fragment RepoItem on Repository {
# 略
}
fragment RepoList on User {
repositories(first: $first, after: $after) {
nodes {
...RepoItem,
},
pageInfo {
hasPreviousPage,
hasNextPage,
endCursor,
startCursor,
},
totalCount,
},
}
query AppQuery($first: Int!, $after: String) {
viewer {
...RepoList,
},
}
在GraphQL的语法中,只有Query(或Mutation)才能定义变量。
不存在一种语法,可以在fragment中定义封闭的变量。
因此,上述的 repositories(first: $first, after: $after)部分会变成状态,即”知道了RepoList片段在父查询中定义了哪些变量”。
孩子必须意识到自己需要了解亲层知识,这实在让人不舒服。
各个客户端库的现状
这个问题实际上是关于 GraphQL 的语法,如果在 fragment 的范围内能够定义变量,似乎就能解决。事实上,根据实际调查,有一个名为 https://github.com/facebook/graphql/issues/204 的问题讨论了 fragment scoped variable。
这个问题在讨论Relay Modern时提出来了,但最终只是将experimentalFragmentVariables选项合并到graphql-js中而已,目前的状况。
这个选项实际上是一个相当微妙的东西,原本是在Facebook内部使用的GraphQL实现中存在的一个功能。这个功能似乎是“片段能够定义全局变量”的一个谁需要的特性。
以前面给出的例子为例,即使构建了如下查询,解析器也不会报错。
fragment RepoList on User {
# クエリに変数定義が無くてもエラーにはならないが、 $firstはglobalなscope
repositories(first: $first, after: $after) {
# 略
},
}
query AppQuery {
viewer {
...RepoList,
},
}
最后,Relay Modern使用了独特的指令@arguments / @argumentsDefinitions,使得可以在片段范围内定义变量。
fragment RepoList on User @argumentsDefinitions(
first: { type: "Int" },
after: { type: "String" },
) {
repositories(first: $first, after: $after) {
# 略
},
}
query AppQuery($first: Int!, $after: String) {
viewer {
...RepoList @arguments(first: $first, after: $after),
},
}
受到Relay Modern的影响,Apollo也提出了类似功能的需求,但截至2019年1月目前似乎没有特别的进展。
-
- https://github.com/apollographql/apollo-client/issues/2723
- https://github.com/apollographql/apollo-feature-requests/issues/18
最后
关于这次写的内容,我并不是说无论如何都无法解决,就不能创建应用程序之类的话。说实话,即使变量是在查询全局的情况下,只要适当地引入命名规范等,我认为在fragment之间变量冲突几乎不会发生。
只是觉得无法单单靠Apollo解决而感到不舒服而已。如果有人对这个问题有任何解决方法,可以在评论中告诉我。
附录:使用Angular + Apollo在片段可置放地点上。
完整版请参见 https://github.com/Quramy/apollo-angular-example。
import gql from 'graphql-tag';
import { Component, Input } from '@angular/core';
import { RepoItem } from './__generated__/RepoItem'
const fragment = gql`
fragment RepoItem on Repository {
id,
name,
url,
description,
languages(first: 10, orderBy: { field: SIZE, direction: DESC }) {
nodes { id, name },
},
}
`;
@Component({
selector: 'app-repo-item',
styleUrls: ['./repo-item.component.css'],
template: `
<section>
<header class="title">
<a [href]="repoItem.url" targe="_blank">
{{repoItem.name}}
</a>
</header>
<p class="desc" *ngIf="repoItem.description">{{repoItem.description}}</p>
<p class="desc" *ngIf="!repoItem.description">(no description)</p>
<ul class="langs" *ngIf="repoItem.languages.nodes">
<li class="lang-label" *ngFor="let lang of repoItem.languages.nodes">
{{lang.name}}
</li>
</ul>
</section>
`,
})
export class RepoItemComponent {
static fragment = fragment;
@Input() repoItem: RepoItem;
}
import gql from 'graphql-tag';
import { Component, Input } from '@angular/core';
import { RepoItemComponent } from '../repo-item/repo-item.component';
import { RepoList } from './__generated__/RepoList';
import { Apollo } from 'apollo-angular';
const fragment = gql`
${RepoItemComponent.fragment}
fragment RepoList on User {
repositories(first: 10) {
nodes {
...RepoItem,
},
pageInfo {
hasPreviousPage,
hasNextPage,
endCursor,
startCursor,
},
totalCount,
}
}
`;
@Component({
selector: 'app-repo-list',
styleUrls: ['./repo-list.component.css'],
template: `
<div *ngIf="repoList.repositories && repoList.repositories.nodes as repositories">
<app-repo-item
class="item"
*ngFor="let node of repositories"
[repoItem]="node"
></app-repo-item>
<app-simple-pager
[hasPrev]="repoList.repositories.pageInfo.hasPreviousPage"
[hasNext]="repoList.repositories.pageInfo.hasNextPage"
(prev)="prev()"
(next)="next()"
></app-simple-pager>
</div>
`,
})
export class RepoListComponent {
static fragment = fragment;
@Input() repoList: RepoList;
constructor(private apollo: Apollo) { }
prev() { /* 略 */ }
next() { /* 略 */ }
}
import gql from 'graphql-tag';
import { Component, OnInit } from '@angular/core';
import { Apollo } from 'apollo-angular';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AppQuery } from './__generated__/AppQuery';
import { RepoListComponent } from './repo-list/repo-list.component';
const appQuery = gql`
${RepoListComponent.fragment}
query AppQuery {
viewer {
...RepoList,
},
}
`;
@Component({
selector: 'app-root',
styleUrls: ['./app.component.css'],
template: `
<div class="container">
<h1>
Your GitHub repositories
</h1>
<div *ngIf="data$ | async as data">
<app-repo-list [repoList]="data.viewer"></app-repo-list>
</div>
</div>
`,
})
export class AppComponent implements OnInit {
data$: Observable<AppQuery>;
constructor(private apollo: Apollo) { }
ngOnInit() {
this.data$ = this.apollo.watchQuery<AppQuery>({
query: cursorsQuery,
}).valueChanges.pipe(map(({ data }) => data));
}
}