变异和更新
在本章中,我们将学习如何更新服务器和客户端上的数据。我们将通过两个主要示例:
- 为新闻提要故事实现“点赞”按钮
- 实现对新闻提要故事发表评论的功能
更新数据是一个复杂的领域,Relay 自动处理了其中的许多方面,同时还提供了大量的手动控制,以便您的应用程序在处理可能出现的各种情况时尽可能地健壮。
首先,让我们区分两个术语:
- 变异是指您请求服务器执行修改服务器上数据的操作。这是 GraphQL 的一项功能,类似于 HTTP POST。
- 更新是指您修改 Relay 的本地客户端数据存储。
客户端无法直接操作服务器端的个别数据。相反,变异是透明的、高级别的请求,它们表达了用户的意图——例如,用户添加了某人、加入了某个组、发布了评论、点赞了某个新闻提要故事、屏蔽了某人或删除了评论。(GraphQL 模式定义了可用的变异,以及每个变异接受的输入参数。)
变异可能会对图的状态产生深远且不可预测的影响。例如,假设您加入了一个组。许多事情都会发生:
- 您的姓名将添加到“组成员”列表中
- 该组的成员人数将增加
- 该组将被添加到您的组列表中
- 仅供成员查看的帖子将出现在该组的帖子提要中
- 您推荐的组可能会改变
- 该组的管理员可能会收到通知
- 许多其他用户不可见的效果,例如记录、训练模型或发送电子邮件
- 等等等等
通常,不可能知道变异对图的全部下游影响。因此,在要求服务器执行变异后,客户端只需尽力更新其本地数据存储,尽可能保持数据的连贯性。它通过在变异响应中请求服务器提供特定的更新数据,以及通过称为更新器的命令式代码来实现,该代码修正存储以保持其一致性。
没有一个原则性的解决方案可以涵盖所有情况。即使能够知道变异对图的全部影响,也有一些情况下我们不希望立即显示更新数据。例如,如果您进入某个人的个人资料页面并屏蔽了他们,您不希望该页面上显示的所有内容立即消失。要更新哪些数据的问题最终是一个 UI 设计决策。
Relay 试图让响应变异更新数据变得尽可能容易。例如,如果您想更新某个特定组件,您可以将它的片段扩展到您的变异中,这将要求服务器发送有关该片段所选内容的更新数据。对于其他情况,您必须手动编写一个更新器,该更新器以命令式方式修改 Relay 的本地数据存储。我们将在下面讨论所有这些情况。
实现“点赞”按钮
让我们通过为新闻提要故事实现“点赞”按钮来尝试一下。幸运的是,我们已经准备好了“点赞”按钮,所以请打开Story.tsx
并将其放入Story
组件中,记住将它的片段扩展到 Story 的片段中
import StoryLikeButton from './StoryLikeButton';
...
const StoryFragment = graphql`
fragment StoryFragment on Story {
title
summary
// ... etc
...StoryLikeButtonFragment
}
`;
...
export default function Story({story}: Props) {
const data = useFragment(StoryFragment, story);
return (
<Card>
<PosterByline person={data.poster} />
<Heading>{data.title}</Heading>
<Timestamp time={data.posterAt} />
<Image image={story.thumbnail} width={400} height={400} />
<StorySummary summary={data.summary} />
<StoryLikeButton story={data} />
<StoryCommentsSection story={data} />
</Card>
);
}
现在让我们看看StoryLikeButton.tsx
。目前,它是一个没有执行任何操作的按钮,以及一个点赞计数。
您可以查看它的片段,它获取了likeCount
字段(用于点赞计数)和doesViewerLike
字段(用于确定“点赞”按钮是否突出显示)(如果查看者点赞了故事,即如果doesViewerLike
为 true,则“点赞”按钮将突出显示)
const StoryLikeButtonFragment = graphql`
fragment StoryLikeButtonFragment on Story {
id
likeCount
doesViewerLike
}
`;
我们希望在按下“点赞”按钮时:
- 故事在服务器上被“点赞”
- 我们本地客户端的“
likeCount
”和“doesViewerLike
”字段被更新。
为此,我们需要编写一个 GraphQL 变异。但首先...
变异的结构
除非您先了解以下内容,否则 GraphQL 变异语法会让人感到困惑:
GraphQL 具有两种不同的请求类型:查询和变异——它们的工作方式完全相同。这类似于 HTTP 如何具有 GET 和 POST:从技术上讲,唯一的区别是 POST 请求旨在引起影响,而 GET 请求则不会。同样,变异与查询完全相同,只是变异预计会导致事情发生。这意味着:
- 变异是我们客户端代码的一部分
- 变异声明变量,使客户端能够将数据传递到服务器
- 服务器实现个别字段。给定的变异将这些字段组合在一起,并将它的变量作为字段参数传递。
- 每个字段生成特定类型的数据——标量或指向另一个图节点的边——如果选择了该字段,它将被返回到客户端。对于边而言,将从所链接的节点中选择进一步的字段。
唯一的区别是,在变异中,选择一个字段会使某事发生,以及返回数据。
“点赞”变异
考虑到这一点,我们的变异将如下所示——请将此声明添加到文件中:
const StoryLikeButtonLikeMutation = graphql`
mutation StoryLikeButtonLikeMutation(
$id: ID!,
$doesLike: Boolean!,
) {
likeStory(
id: $id,
doesLike: $doesLike
) {
story {
id
likeCount
doesViewerLike
}
}
}
`;
这很多内容,让我们来分解一下:
- 变异名为
StoryLikeButton
+Like
+Mutation
,因为它必须以模块名称开头,并以 GraphQL 操作结尾。 - 变异声明 变量,这些变量在变异被调度时从客户端传递到服务器。每个变量都有一个名称(
$id
、$doesLike
)和一个类型(ID!
、Boolean!
)。类型后面的!
表示它是必需的,而不是可选的。 - 变异选择 GraphQL 模式定义的变异字段。服务器定义的每个变异字段都对应于客户端可以向服务器请求的某种操作,例如点赞某个故事。
- 变异字段采用参数(就像任何字段都可以做的那样)。在这里,我们将我们声明的变异变量作为参数值传递——例如,
doesLike
字段参数被设置为$doesLike
变异变量。
- 变异字段采用参数(就像任何字段都可以做的那样)。在这里,我们将我们声明的变异变量作为参数值传递——例如,
likeStory
字段返回指向代表变异响应的节点的边。我们可以选择各种字段,以便接收更新的数据。GraphQL 模式指定了变异响应中可用的字段。- 我们选择
story
字段,它是一个指向我们刚刚点赞的故事的边。 - 我们从该故事中选择特定的字段来获取更新数据。这些字段与查询可以关于故事选择的字段相同——事实上,这些字段是我们之前在片段中选择的字段。
- 我们选择
当我们向服务器发送此变异时,我们将获得一个响应,它与查询一样,与我们发送的变异的形状匹配。例如,服务器可能会向我们发送以下内容:
{
"likeStory": {
"story": {
"id": "34a8c",
"likeCount": 47,
"doesViewerLike": true
}
}
}
我们的任务是更新本地数据存储以合并此更新的信息。Relay 将在简单情况下处理此操作,而在更复杂的情况下,则需要自定义代码以智能地更新存储。
但我们现在说得有些过早了——让我们让该按钮触发变异。这是我们组件现在的外观——我们需要将onLikeButtonClicked
事件连接到StoryLikeButtonLikeMutation
的执行。
function StoryLikeButton({story}) {
const data = useFragment(StoryLikeButtonFragment, story);
function onLikeButtonClicked() {
// To be filled in
}
return (
<>
<LikeCount count={data.likeCount} />
<LikeButton value={data.doesViewerLike} onChange={onLikeButtonClicked} />
</>
)
}
为此,我们添加对useMutation
的调用
import {useMutation, useFragment} from 'react-relay';
function StoryLikeButton({story}) {
const data = useFragment(StoryLikeButtonFragment, story);
const [commitMutation, isMutationInFlight] = useMutation(StoryLikeButtonLikeMutation);
function onLikeButtonClicked() {
commitMutation({
variables: {
id: data.id,
doesLike: !data.doesViewerLike,
},
})
}
return (
<>
<LikeCount count={data.likeCount} />
<LikeButton value={data.doesViewerLike} onChange={onLikeButtonClicked} />
</>
)
}
useMutation
钩子返回一个函数commitMutation
,我们可以调用它来告诉服务器做一些事情。
我们传递一个名为variables
的选项,在该选项中,我们为变异定义的变量(即id
和doesViewerLike
)提供值。这告诉服务器我们正在讨论哪个故事,以及我们是在点赞还是取消点赞。我们从片段中读取的故事的id
,而我们是否点赞则来自切换我们渲染的当前值。
该钩子还会返回一个布尔标志,告诉我们变异何时正在进行。我们可以使用它来使用户体验更愉快,方法是在变异进行时禁用按钮
<LikeButton
value={data.doesViewerLike}
onChange={onLikeButtonClicked}
disabled={isMutationInFlight}
/>
有了这些,我们现在应该能够点赞某个故事了!
Relay 如何自动处理变异响应
但是 Relay 如何知道更新我们点击的故事呢?服务器返回了一个具有以下形式的响应:
{
"likeStory": {
"story": {
"id": "34a8c",
"likeCount": 47,
"doesViewerLike": true
}
}
}
只要响应包含一个具有id
字段的对象,Relay 就会检查存储中是否已包含具有匹配 ID 的记录(该记录的id
字段中)。如果匹配,Relay 将将响应中的其他字段合并到现有记录中。这意味着,在这个简单的情况下,我们不需要编写任何代码来更新存储。
在变异响应中使用片段
请记住,变异与查询类似。为了确保变异响应始终包含我们想要渲染的数据,而不是维护一个单独的必须手动保持最新状态的字段集,我们可以简单地将片段传播到变异响应中。
const StoryLikeButtonLikeMutation = graphql`
mutation StoryLikeButtonLikeMutation(
$id: ID,
$doesLike: Boolean,
) {
likeStory(id: $id, doesLike: $doesLike) {
story {
...StoryLikeButtonFragment
}
}
}
`;
现在,如果我们添加或删除数据需求,所有必要的数据(但不多)将包含在变异响应中。这通常是编写变异响应的明智方法。您可以从任何组件中传播任何片段,而不仅仅是触发变异的组件。这有助于您保持整个 UI 的最新状态。
使用乐观更新程序改进用户体验
变异需要时间才能执行,但我们始终希望 UI 以某种方式立即更新,以向用户提供他们已采取操作的反馈。在当前示例中,点赞按钮在变异发生时处于禁用状态,然后在变异完成后,UI 会更新到新状态,因为 Relay 将更新后的数据合并到其存储中,并重新渲染受影响的组件。
通常,最好的反馈是简单地假装操作已完成:例如,如果您按了点赞按钮,该按钮会立即进入与您看到您已经点赞的内容时相同的突出显示状态。或者以发布评论为例:我们希望立即显示您的评论已发布。这是因为变异通常足够快且可靠,以至于我们不需要用单独的加载状态来打扰用户。但是,有时变异确实会导致失败。在这种情况下,我们希望回滚所做的更改,并将您恢复到尝试变异之前的状态:我们显示为已发布的评论应该消失,而评论的文本应该重新出现在您编写它的撰写器中,这样如果要再次尝试发布,数据就不会丢失。
管理这些所谓的乐观更新手动操作起来很复杂,但 Relay 有一个强大的系统来应用和回滚更新。您甚至可以同时进行多个变异(例如,如果用户依次点击了几个按钮),Relay 将跟踪需要在失败的情况下回滚的更改。
变异分三个阶段进行
- 首先是乐观更新,您将本地数据存储更新到您预期并希望立即向用户显示的任何状态。
- 然后您实际在服务器上执行变异。如果成功,服务器将响应更新的信息,这些信息可以在第三步中使用。
- 变异完成后,您将回滚乐观更新。如果变异失败,您就完成了 - 回到起点。如果变异成功,Relay 会将简单的更改合并到存储中,然后应用一个最终更新,用从服务器接收到的实际新信息以及您想要做的任何其他更改更新本地数据存储。
有了这些背景知识,让我们继续为点赞按钮编写乐观更新程序,以便它在点击时立即更新到新状态。
步骤 1 - 向 commitMutation 添加 optimisticUpdater 选项
转到StoryLikeButton
,并在对commitMutation
的调用中添加一个新选项
function StoryLikeButton({story}) {
...
function onLikeButtonClicked(newDoesLike) {
commitMutation({
variables: {
id: data.id,
doesLike: newDoesLike,
},
optimisticUpdater: store => {
// TODO fill in optimistic updater
},
})
}
...
}
此回调接收一个store
参数,该参数表示 Relay 的本地数据存储。它有各种用于读取和写入本地数据的方法。我们在乐观更新程序中进行的所有写入将在变异分派时立即应用,然后在变异完成后回滚。
步骤 2 - 创建可更新片段
我们可以通过编写一种特殊类型的片段(称为可更新片段)来读取和写入本地存储中的数据。与常规片段不同,它不会传播到查询并发送到服务器。相反,它允许我们使用我们已经知道和喜爱的相同 GraphQL 语法从本地存储中读取数据。继续并添加此片段定义
function StoryLikeButton({story}) {
...
optimisticUpdater: store => {
const fragment = graphql`
fragment StoryLikeButton_updatable on Story
@updatable
{
likeCount
doesViewerLike
}
`;
},
...
}
它与任何其他片段完全相同,但用@updatable指令进行了注释。
与普通片段不同,可更新片段不会传播到查询,也不会选择要从服务器获取的数据。相反,它们会选择 Relay 本地数据存储中已有的数据,以便可以更新数据。
步骤 3 - 调用 readUpdatableFragment
我们将此片段以及我们作为道具接收到的原始片段引用(它告诉我们我们点赞了哪一个故事)传递给store.readUpdatableFragment
。它返回一个称为updatableData
的特殊对象
function StoryLikeButton({story}) {
...
optimisticUpdater: store => {
const fragment = graphql`
fragment StoryLikeButton_updatable on Story @updatable {
likeCount
doesViewerLike
}
`;
const {
updatableData
} = store.readUpdatableFragment(
fragment,
story
);
},
...
}
步骤 4 - 修改可更新数据
现在updatableData
是一个对象,它代表我们本地存储中存在的现有故事。我们可以读取和写入片段中列出的字段
function StoryLikeButton({story}) {
...
optimisticUpdater: store => {
const fragment = graphql`
fragment StoryLikeButton_updatable on Story @updatable {
likeCount
doesViewerLike
}
`;
const {updatableData} = store.readUpdatableFragment(fragment, story);
const alreadyLikes = updatableData.doesViewerLike;
updatableData.doesViewerLike = !alreadyLikes;
updatableData.likeCount += (alreadyLikes ? -1 : 1);
},
...
}
在这个例子中,我们切换了doesViewerLike
(这样当你已经点赞了故事时点击按钮就会让你取消点赞)并相应地增加或减少点赞次数。
Relay 记录了我们对updatableData
所做的更改,并在变异完成后将其回滚。
现在,当您点击点赞按钮时,您应该会看到 UI 立即更新。
添加评论 - 连接上的变异
Relay 可以完全自动完成的唯一事情是我们已经看到的事情:将变异响应中的节点与存储中具有相同 ID 的现有节点合并。对于其他任何事情,我们必须给 Relay 提供更多信息。
让我们看看连接的情况。我们将实现向故事发布新评论的功能。
服务器的变异响应仅包含新创建的评论。我们必须告诉 Relay 如何将该故事插入故事与其评论之间的连接中。
回到StoryCommentsSection
,添加一个组件来发布新评论,并记住将其片段传播到我们的片段中
import StoryCommentsComposer from './StoryCommentsComposer';
const StoryCommentsSectionFragment = graphql`
fragment StoryCommentsSectionFragment on Story
...
{
comments(after: $cursor, first: $count)
@connection(key: "StoryCommentsSectionFragment_comments")
{
...
}
...StoryCommentsComposerFragment
}
`
function StoryCommentsSection({story}) {
...
return (
<>
<StoryCommentsComposer story={data} />
...
</>
);
}
我们现在应该在评论部分的顶部看到一个撰写器
现在看一下StoryCommentsComposer.tsx
里面的内容
function StoryCommentsComposer({story}) {
const data = useFragment(StoryCommentsComposerFragment, story);
const [text, setText] = useState('');
function onPost() {
// TODO post the comment here
}
return (
<div className="commentsComposer">
<TextComposer text={text} onChange={setText} onReturn={onPost} />
<PostButton onClick={onPost} />
</div>
);
}
步骤 1 - 定义评论发布变异
就像之前一样,我们需要定义一个变异。它将向服务器发送故事 ID 和要添加的评论文本
const StoryCommentsComposerPostMutation = graphql`
mutation StoryCommentsComposerPostMutation(
$id: ID!,
$text: String!,
) {
postStoryComment(id: $id, text: $text) {
commentEdge {
node {
id
text
}
}
}
}
`;
在这里,模式允许我们在变异响应中选择新创建的评论的新创建的边。我们选择了它,并将用它通过将此边插入连接来更新本地存储。
步骤 2 - 调用 commitMutation 来发布它
现在,我们使用useMutation
钩子来访问commitMutation
回调,并在onPost
中调用它
function StoryCommentsComposer({story}) {
const data = useFragment(StoryCommentsComposerFragment, story);
const [text, setText] = useState('');
const [commitMutation, isMutationInFlight] = useMutation(StoryCommentsComposerPostMutation);
function onPost() {
setText(''); // Reset the UI
commitMutation({
variables: {
id: data.id,
text,
},
})
}
...
}
步骤 3 - 添加声明式连接处理程序
此时,我们可以从网络日志中发现,点击发布将向服务器发送变异请求 - 您甚至可以看到评论已发布,因为它出现在您刷新页面时。但是,UI 中没有任何反应。我们需要告诉 Relay 将新创建的评论附加到从故事到其评论的连接中。
您会注意到,在我们上面编写的变异响应中,我们选择了commentEdge
。这是指向新创建的评论的边。我们只需要告诉 Relay 要将该边添加到哪些连接中。Relay 提供了称为@appendEdge
、@prependEdge
和@deleteEdge
的指令,您可以在变异响应中的边上添加这些指令。然后,当您运行变异时,您将传入要修改的连接的 ID。Relay 将根据您的指定,从这些连接中追加、预先追加或删除边。
我们希望新创建的评论出现在列表的顶部,因此我们将使用@prependEdge
。对变异定义进行以下添加
mutation StoryCommentsComposerPostMutation(
$id: ID!,
$text: String!,
$connections: [ID!]!,
) {
postStoryComment(id: $id, text: $text) {
commentEdge
@prependEdge(connections: $connections)
{
node {
id
text
}
}
}
}
我们向变异添加了一个名为connections
的变量。我们将用它来传入我们要更新的连接。
$connections
变量仅用作@prependEdge
指令的参数,该指令由 Relay 在客户端处理。因为$connections
没有作为任何字段的参数传递,所以它不会发送到服务器。
步骤 4 - 将连接 ID 作为变异变量传入
我们需要识别要添加新边的连接。连接由两部分信息标识
- 它位于哪个节点上 - 在这种情况下,是我们发布评论到的故事。
@connection
指令中提供的键,它允许我们在有多个连接位于同一个节点上时区分连接。
我们使用 Relay 提供的特殊 API 将此信息传递到变异变量中
import {useFragment, useMutation, ConnectionHandler} from 'react-relay';
...
export default function StoryCommentsComposer({story}: Props) {
...
function onPost() {
setText('');
const connectionID = ConnectionHandler.getConnectionID(
data.id,
'StoryCommentsSectionFragment_comments',
);
commitMutation({
variables: {
id: data.id,
text,
connections: [connectionID],
},
})
}
...
}
我们传递给getConnectionID
的字符串"StoryCommentsSectionFragment_comments"
是我们 fetch 连接时在StoryCommentSection
中使用的标识符 - 作为提醒,以下是它的样子
const StoryCommentsSectionFragment = graphql`
fragment StoryCommentsSectionFragment on Story
...
{
comments(after: $cursor, first: $count)
@connection(key: "StoryCommentsSectionFragment_comments")
{
...
}
`;
同时,参数data.id
是我们连接到的特定故事的 ID。
有了这个更改,我们应该会看到评论在变异完成后出现在评论列表中。
总结
变异允许我们要求服务器进行更改。
- 与查询一样,变异由字段组成,接受变量,并将这些变量作为参数传递给字段。
- 由变异选择的字段构成变异响应,我们可以用它来更新存储。
- Relay 会自动将响应中的节点与存储中具有匹配 ID 的节点合并。
@appendEdge
、@prependEdge
和@deleteEdge
指令允许我们将突变响应中的项目插入或删除到存储中的连接中。- 更新器允许我们手动操作存储。
- 乐观更新器在突变开始之前运行,并在突变完成后回滚。