跳至主要内容
版本:v18.0.0

GraphQL 思维

GraphQL 通过关注产品开发者和客户端应用程序的需求,为客户端提供了一种获取数据的新方法。它提供了一种方法让开发者指定视图所需的确切数据,并使客户端能够通过单个网络请求获取该数据。与传统方法(例如 REST)相比,GraphQL 帮助应用程序更有效地获取数据(与面向资源的 REST 方法相比)并避免服务器逻辑的重复(这可能会出现在自定义端点中)。此外,GraphQL 帮助开发者将产品代码和服务器逻辑解耦。例如,产品可以获取更多或更少的信息,而无需更改每个相关的服务器端点。这是一种获取数据的绝佳方式。

在本文中,我们将探讨构建 GraphQL 客户端框架的意义以及这与更传统的 REST 系统的客户端相比如何。在此过程中,我们将了解 Relay 背后的设计决策,并看到它不仅仅是一个 GraphQL 客户端,也是一个用于声明式数据获取的框架。让我们从头开始,获取一些数据!

获取数据

假设我们有一个简单的应用程序,它获取故事列表以及每个故事的一些详细信息。以下是在面向资源的 REST 中的示例。

// Fetch the list of story IDs but not their details:
rest.get('/stories').then(stories =>
// This resolves to a list of items with linked resources:
// `[ { href: "http://.../story/1" }, ... ]`
Promise.all(stories.map(story =>
rest.get(story.href) // Follow the links
))
).then(stories => {
// This resolves to a list of story items:
// `[ { id: "...", text: "..." } ]`
console.log(stories);
});

请注意,这种方法需要向服务器发出n+1个请求:1 个用于获取列表,n 个用于获取每个项目。使用 GraphQL,我们可以在单个网络请求中向服务器获取相同数据(而无需创建自定义端点,然后我们必须维护该端点)。

graphql.get(`query { stories { id, text } }`).then(
stories => {
// A list of story items:
// `[ { id: "...", text: "..." } ]`
console.log(stories);
}
);

到目前为止,我们只是将 GraphQL 用作典型 REST 方法的更高效版本。请注意 GraphQL 版本中的两个重要优势。

  • 所有数据都在一次往返中获取。
  • 客户端和服务器是解耦的:客户端指定所需的数据,而不是依赖于服务器端点返回正确的数据。

对于一个简单的应用程序来说,这已经是一个不错的改进。

客户端缓存

从服务器重复获取信息可能会非常慢。例如,从故事列表导航到列表项,然后返回到故事列表意味着我们必须重新获取整个列表。我们将通过标准解决方案解决此问题:缓存

在面向资源的 REST 系统中,我们可以基于 URI 维护一个响应缓存

var _cache = new Map();
rest.get = uri => {
if (!_cache.has(uri)) {
_cache.set(uri, fetch(uri));
}
return _cache.get(uri);
};

响应缓存也可以应用于 GraphQL。基本方法与 REST 版本类似。查询本身的文本可以用作缓存键。

var _cache = new Map();
graphql.get = queryText => {
if (!_cache.has(queryText)) {
_cache.set(queryText, fetchGraphQL(queryText));
}
return _cache.get(queryText);
};

现在,对于以前缓存的数据的请求可以直接得到答复,而无需发出网络请求。这是一种改进应用程序感知性能的实用方法。但是,这种缓存方法可能会导致数据一致性问题。

缓存一致性

使用 GraphQL,多个查询的结果重叠的情况非常普遍。但是,我们上一节中的响应缓存没有考虑这种重叠 - 它基于不同的查询进行缓存。例如,如果我们发出一个查询以获取故事

query { stories { id, text, likeCount } }

然后稍后重新获取其中一个故事,该故事的 likeCount 自此已增加

query { story(id: "123") { id, text, likeCount } }

我们现在将看到不同的 likeCount,具体取决于故事的访问方式。使用第一个查询的视图将看到过时的计数,而使用第二个查询的视图将看到更新后的计数。

缓存图

GraphQL 缓存的解决方案是将层次结构响应规范化为记录的扁平集合。Relay 将此缓存实现为一个从 ID 到记录的映射。每个记录都是一个从字段名称到字段值的映射。记录也可以链接到其他记录(允许它描述循环图),这些链接存储为一种特殊的引用类型,引用回顶级映射。使用这种方法,每个服务器记录仅存储一次,无论它是如何获取的。

以下是一个查询示例,它获取故事的文本及其作者的姓名。

query {
story(id: "1") {
text,
author {
name
}
}
}

以下是一个可能的响应。

{
"query": {
"story": {
"text": "Relay is open-source!",
"author": {
"name": "Jan"
}
}
}
}

虽然响应是分层的,但我们将通过扁平化所有记录来缓存它。以下是如何 Relay 缓存此查询响应的示例。

Map {
// `story(id: "1")`
1: Map {
text: 'Relay is open-source!',
author: Link(2),
},
// `story.author`
2: Map {
name: 'Jan',
},
};

这只是一个简单的示例:实际上,缓存必须处理一对多关联和分页(以及其他内容)。

使用缓存

那么我们如何使用此缓存呢?让我们看一下两个操作:在收到响应时写入缓存,以及从缓存读取以确定是否可以本地满足查询(等同于上面的 _cache.has(key),但适用于图)。

填充缓存

填充缓存涉及遍历层次结构的 GraphQL 响应并创建或更新规范化的缓存记录。最初,可能看起来仅响应足以处理响应,但实际上,只有对于非常简单的查询才是这样。考虑 user(id: "456") { photo(size: 32) { uri } } - 我们应该如何存储 photo?在缓存中使用 photo 作为字段名称将不起作用,因为不同的查询可能会获取相同的字段,但参数值不同(例如 photo(size: 64) {...})。分页也会出现类似的问题。如果我们使用 stories(first: 10, offset: 10) 获取第 11 到第 20 个故事,则应将这些新结果附加到现有列表中。

因此,GraphQL 的规范化响应缓存需要并行处理有效负载和查询。例如,上面提到的 photo 字段可能在缓存中使用生成的字段名称(例如 photo_size(32))进行缓存,以便唯一标识字段及其参数值。

从缓存读取

要从缓存读取,我们可以遍历查询并解析每个字段。但是等等:这听起来完全像是 GraphQL 服务器在处理查询时所做的。事实正是如此!从缓存读取是执行程序的一种特殊情况,其中 a) 不需要用户定义的字段函数,因为所有结果都来自固定数据结构,并且 b) 结果始终是同步的 - 我们要么已将数据缓存起来,要么没有。

Relay 实现了几种查询遍历的变体:与一些其他数据(如缓存或响应有效负载)一起遍历查询的操作。例如,当获取查询时,Relay 会执行一个“差异”遍历,以确定哪些字段丢失(非常类似于 React 对虚拟 DOM 树进行差异处理)。这可以减少许多常见情况下获取的数据量,甚至允许 Relay 在查询完全缓存时完全避免网络请求。

缓存更新

请注意,这种规范化的缓存结构允许缓存重叠的结果,而不会出现重复。无论记录是怎样获取的,它都只存储一次。让我们回到之前不一致数据示例,看看此缓存如何在该场景中提供帮助。

第一个查询是用于获取故事列表。

query { stories { id, text, likeCount } }

使用规范化响应缓存,将为列表中的每个故事创建一个记录。stories 字段将存储指向每个记录的链接。

第二个查询重新获取了其中一个故事的信息。

query { story(id: "123") { id, text, likeCount } }

当此响应被规范化时,Relay 可以基于其 id 检测到此结果与现有数据重叠。Relay 不会创建新记录,而是会更新现有的 123 记录。因此,新的 likeCount 可用于所有查询,以及可能引用此故事的任何其他查询。

数据/视图一致性

规范化缓存可确保缓存一致性。但是我们的视图呢?理想情况下,我们的 React 视图始终反映缓存中的当前信息。

考虑渲染故事的文本和评论以及相应的作者姓名和照片。以下是 GraphQL 查询。

query {
story(id: "1") {
text,
author { name, photo },
comments {
text,
author { name, photo }
}
}
}

首次获取此故事后,我们的缓存可能如下所示。请注意,故事和评论都链接到同一记录 author

// Note: This is pseudo-code for `Map` initialization to make the structure
// more obvious.
Map {
// `story(id: "1")`
1: Map {
text: 'got GraphQL?',
author: Link(2),
comments: [Link(3)],
},
// `story.author`
2: Map {
name: 'Yuzhi',
photo: 'http://.../photo1.jpg',
},
// `story.comments[0]`
3: Map {
text: 'Here\'s how to get one!',
author: Link(2),
},
}

此故事的作者也对此故事发表了评论 - 非常常见。现在假设其他视图获取了关于作者的新信息,她的个人资料照片已更改为新的 URI。以下是我们缓存数据中唯一更改的部分。

Map {
...
2: Map {
...
photo: 'http://.../photo2.jpg',
},
}

photo 字段的值已更改;因此,记录 2 也已更改。仅此而已。缓存中的其他内容都没有受到影响。但显然,我们的视图需要反映更新:UI 中作者的两个实例(作为故事作者和评论作者)都需要显示新照片。

标准响应是“只使用不可变数据结构” - 但让我们看看如果我们这样做了会发生什么。

ImmutableMap {
1: ImmutableMap // same as before
2: ImmutableMap {
... // other fields unchanged
photo: 'http://.../photo2.jpg',
},
3: ImmutableMap // same as before
}

如果我们用新的不可变记录替换 2,我们也会得到缓存对象的新的不可变实例。但是,记录 13 未被触碰。由于数据是规范化的,我们无法仅通过查看 story 记录来判断 story 的内容是否已更改。

实现视图一致性

有多种解决方案可用于使视图与扁平化的缓存保持一致。Relay 采用的一种方法是维护一个映射,将每个 UI 视图映射到它引用的 ID 集。在这种情况下,故事视图将订阅故事(1)、作者(2)和评论(3 以及其他任何评论)的更新。在将数据写入缓存时,Relay 会跟踪哪些 ID 受影响并仅通知订阅这些 ID 的视图。受影响的视图重新渲染,不受影响的视图选择退出重新渲染以提高性能(Relay 提供了一个安全但有效的默认 shouldComponentUpdate)。如果没有此策略,即使是最小的更改也会导致每个视图重新渲染。

请注意,此解决方案也适用于写入:对缓存的任何更新都会通知受影响的视图,而写入只是更新缓存的另一种方式。

变异

到目前为止,我们已经了解了查询数据和保持视图更新的过程,但我们还没有研究写入操作。在 GraphQL 中,写入操作被称为**变异**。我们可以将它们视为具有副作用的查询。以下是一个调用变异的示例,该变异可能会将给定故事标记为当前用户喜欢。

// Give a human-readable name and define the types of the inputs,
// in this case the id of the story to mark as liked.
mutation StoryLike($storyID: String) {
// Call the mutation field and trigger its side effects
storyLike(storyID: $storyID) {
// Define fields to re-fetch after the mutation completes
likeCount
}
}

请注意,我们正在查询可能因变异而发生变化的数据。一个显而易见的问题是:为什么服务器不能直接告诉我们发生了什么变化?答案是:这很复杂。GraphQL 抽象了任何数据存储层(或多个来源的聚合),并且与任何编程语言一起使用。此外,GraphQL 的目标是提供一种对构建视图的产品开发人员有用的形式的数据。

我们发现,GraphQL 架构通常与数据在磁盘上存储的形式略有不同甚至完全不同。简而言之:您的底层数据存储(磁盘)中的数据变化与您的产品可见架构(GraphQL)中的数据变化并不总是存在 1:1 的对应关系。隐私就是最好的例子:返回用户界面字段(如age)可能需要访问数据存储层中的多个记录,以确定活动用户是否被允许查看age(我们是朋友吗?我的年龄是公开的吗?我是否屏蔽了你?等等)。

考虑到这些现实世界的限制,GraphQL 中的方法是让客户端查询在变异后可能发生变化的内容。但是,我们在该查询中究竟要放什么呢?在开发 Relay 的过程中,我们探索了几个想法——让我们简要地看一下它们,以便了解为什么 Relay 使用了它所使用的方法。

  • 选项 1:重新获取应用程序曾经查询过的所有内容。即使只有这部分数据的一小部分实际上发生了变化,我们仍然需要等待服务器执行整个查询,等待下载结果,然后等待再次处理它们。这非常低效。

  • 选项 2:仅重新获取活动渲染视图所需的查询。这比选项 1 有所改进。但是,当前查看的缓存数据将不会更新。除非以某种方式将这些数据标记为已过期或从缓存中清除,否则后续查询将读取过时信息。

  • 选项 3:重新获取可能在变异后发生变化的固定字段列表。我们将此列表称为**胖查询**。我们发现这也不够高效,因为典型的应用程序只渲染胖查询的一个子集,但这种方法需要获取所有这些字段。

  • 选项 4(Relay):重新获取可能发生变化的内容(胖查询)与缓存中的数据之间的交集。除了数据缓存之外,Relay 还记得用于获取每个项目的查询。这些被称为**跟踪查询**。通过将跟踪查询和胖查询交叉,Relay 可以精确地查询应用程序需要更新的信息集,而不会更多。

数据获取 API

到目前为止,我们已经了解了数据获取的较低层级方面,并了解了各种熟悉的概念如何转换为 GraphQL。接下来,让我们退一步,看看产品开发人员在数据获取方面经常面临的一些更高层级的挑战。

  • 获取视图层次结构的所有数据。
  • 管理异步状态转换并协调并发请求。
  • 管理错误。
  • 重试失败的请求。
  • 在收到查询/变异响应后更新本地缓存。
  • 排队变异以避免竞争条件。
  • 在等待服务器响应变异时乐观地更新 UI。

我们发现,典型的使用命令式 API 进行数据获取的方法迫使开发人员处理过多的非必要复杂性。例如,考虑乐观 UI 更新。这是一种在等待服务器响应时向用户提供反馈的方式。做什么的逻辑可能非常清楚:当用户点击“喜欢”时,将故事标记为已喜欢并向服务器发送请求。但是,实现通常要复杂得多。命令式方法要求我们实现所有这些步骤:进入 UI 并切换按钮,启动网络请求,如有必要重试它,如果失败则显示错误(并取消切换按钮),等等。数据获取也是如此:指定我们需要哪些数据通常会决定如何何时获取数据。接下来,我们将探索使用**Relay**解决这些问题的方案。


此页面是否有用?

通过以下方式帮助我们改进网站 回答几个简短的问题.