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

變更 & 更新

在本章中,我們將學習如何在伺服器和用戶端更新資料。我們將探討兩個主要範例

  • 為新聞提要文章實作「讚」按鈕
  • 實作在新聞提要文章上發表評論的功能

更新資料是一個複雜的問題領域,而 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。目前,它是一個不執行任何動作的按鈕,以及一個讚數。

Like button

您可以查看其片段以了解它是否提取了「讚數」的 likeCount 欄位,以及 doesViewerLike 來判斷是否已醒目提示「讚」按鈕(如果觀看者按讚了該文章,也就是如果 doesViewerLike 為 true,則會醒目提示)

const StoryLikeButtonFragment = graphql`
fragment StoryLikeButtonFragment on Story {
id
likeCount
doesViewerLike
}
`;

我們希望在您按下「讚」按鈕時

  1. 文章在伺服器上被「按讚」
  2. 我們本機用戶端的「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 的選項,在其中我們為變更定義的變數提供值,即 iddoesViewerLike。這會告知伺服器我們正在談論哪個文章,以及我們是否正在按讚或取消按讚。我們從片段中讀取的文章的 id,而我們是否按讚或取消按讚則來自切換我們呈現的任何目前值。

掛鉤也會傳回一個布林值旗標,告知我們變更何時正在進行中。我們可以利用這點,在變更發生時停用按鈕,讓使用者體驗更好

<LikeButton
value={data.doesViewerLike}
onChange={onLikeButtonClicked}
disabled={isMutationInFlight}
/>

有了這個功能,我們現在應該可以按讚文章了!

Relay 如何自動處理變更回應

但是 Relay 如何知道要更新我們點擊的文章?伺服器傳回了一個具有此格式的回應

{
"likeStory": {
"story": {
"id": "34a8c",
"likeCount": 47,
"doesViewerLike": true
}
}
}

每當回應包含一個帶有 id 欄位的物件時,Relay 會檢查資料儲存區中是否已存在具有相符 ID 的記錄。如果找到相符的記錄,Relay 會將回應中的其他欄位合併到現有的記錄中。這表示,在這種簡單的情況下,我們不需要編寫任何程式碼來更新資料儲存區。


在 Mutation 回應中使用片段

請記住,mutation 和查詢非常相似。為了確保 mutation 回應始終包含我們想要呈現的資料,而不是使用一組必須手動保持更新的獨立欄位,我們可以簡單地將片段擴散到我們的 mutation 回應中。

const StoryLikeButtonLikeMutation = graphql`
mutation StoryLikeButtonLikeMutation(
$id: ID,
$doesLike: Boolean,
) {
likeStory(id: $id, doesLike: $doesLike) {
story {
...StoryLikeButtonFragment
}
}
}
`;

現在,如果我們新增或移除資料需求,所有必要的資料(但不會多餘)都會包含在 mutation 回應中。這通常是撰寫 mutation 回應的明智方法。您可以從任何元件擴散任何片段,而不僅僅是觸發 mutation 的元件。這有助於您保持整個 UI 的更新。


使用樂觀更新器改善 UX

Mutation 需要一些時間才能執行,但我們總是希望 UI 以某種方式立即更新,以向使用者提供他們已執行動作的回饋。在目前的範例中,「喜歡」按鈕在 mutation 發生時會停用,然後在 mutation 完成後,當 Relay 將更新的資料合併到其資料儲存區並重新渲染受影響的元件時,UI 會更新為新的狀態。

通常,最好的回饋是直接假裝操作已完成:例如,如果您按下「喜歡」按鈕,該按鈕會立即進入與您看到已喜歡的內容時相同的醒目顯示狀態。或者以發布評論為例:我們希望立即顯示您的評論已發布。這是因為 mutation 通常夠快且可靠,我們不需要為使用者提供單獨的載入狀態。但是,有時 mutation 會以失敗告終。在這種情況下,我們希望回滾我們所做的變更,並讓您回到我們嘗試 mutation 之前的狀態:我們顯示為已發布的評論應該消失,而評論的文字應重新出現在您撰寫它的編輯器中,以便在您想再次嘗試發布時不會遺失資料。

手動管理這些所謂的樂觀更新很複雜,但 Relay 有一個強大的系統來應用和回滾更新。您甚至可以同時執行多個 mutation(例如,如果使用者按順序點擊多個按鈕),而 Relay 會追蹤在發生故障時需要回滾哪些變更。

Mutation 分為三個階段進行

  • 首先是樂觀更新,您會將本機資料儲存區更新為您預期並希望立即向使用者顯示的任何狀態。
  • 然後您實際上在伺服器上執行 mutation。如果成功,伺服器會回應更新的資訊,這些資訊可以在第三步中使用。
  • 當 mutation 完成時,您會回滾樂觀更新。如果 mutation 失敗,您就完成了 — 回到您開始的地方。如果 mutation 成功,Relay 會將簡單的變更合併到資料儲存區中,然後應用結論更新,以使用從伺服器收到的實際新資訊,加上您想要進行的任何其他變更來更新本機資料儲存區。

Mutation flowchart


有了這些背景知識,讓我們繼續為我們的「喜歡」按鈕撰寫一個樂觀更新器,以便在點擊時立即更新為新狀態。

步驟 1 — 將 optimisticUpdater 選項新增至 commitMutation

前往 StoryLikeButton,並在對 commitMutation 的呼叫中新增一個新選項

function StoryLikeButton({story}) {
...
function onLikeButtonClicked(newDoesLike) {
commitMutation({
variables: {
id: data.id,
doesLike: newDoesLike,
},
optimisticUpdater: store => {
// TODO fill in optimistic updater
},
})
}
...
}

此回呼接收一個 store 引數,代表 Relay 的本機資料儲存區。它具有用於讀取和寫入本機資料的各種方法。我們在樂觀更新器中所做的所有寫入都會在分派 mutation 時立即應用,然後在完成時回滾。

步驟 2 — 建立可更新的片段

我們可以透過撰寫一種稱為可更新片段的特殊片段來讀取和寫入本機資料儲存區中的資料。與一般片段不同,它不會擴散到查詢中並傳送到伺服器。相反地,它讓我們可以使用我們已經知道並喜歡的相同 GraphQL 語法從本機資料儲存區讀取資料。繼續並新增此片段定義

function StoryLikeButton({story}) {
...
optimisticUpdater: store => {
const fragment = graphql`
fragment StoryLikeButton_updatable on Story
@updatable
{
likeCount
doesViewerLike
}
`;
},
...
}

它與任何其他片段完全相同,但使用 @updatable 指示詞進行註解。

與一般片段不同,可更新片段不會擴散到查詢中,也不會選取要從伺服器擷取的資料。相反地,它們會選取 Relay 本機資料儲存區中已有的資料,以便可以更新該資料。

步驟 3 — 呼叫 readUpdatableFragment

我們將此 片段連同我們接收作為屬性的 原始片段 ref(告訴我們我們喜歡哪個故事)傳遞給 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 所做的變更,並會在 mutation 完成後回滾這些變更。

現在當您點擊「喜歡」按鈕時,您應該會看到 UI 立即更新。


新增評論 — 連線上的 Mutation

Relay 可以完全自動執行的唯一一件事是我們已經看到的:將 mutation 回應中的節點與儲存區中具有相同 ID 的現有節點合併。對於其他任何事情,我們必須向 Relay 提供更多資訊。

讓我們看看連線的情況。我們將實作在故事上發布新評論的功能。

伺服器的 mutation 回應僅包含新建立的評論。我們必須告訴 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} />
...
</>
);
}

我們現在應該會在評論區的頂部看到一個編輯器

Comments composer screenshot

現在看看 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 — 定義評論發布 Mutation

就像之前一樣,我們需要定義一個 mutation。它會將故事 ID 和要新增的評論文字傳送到伺服器

const StoryCommentsComposerPostMutation = graphql`
mutation StoryCommentsComposerPostMutation(
$id: ID!,
$text: String!,
) {
postStoryComment(id: $id, text: $text) {
commentEdge {
node {
id
text
}
}
}
}
`;

在這裡,結構描述允許我們選取作為 mutation 回應一部分的新建立評論的新建立邊緣。我們選取它,並將使用它來更新本機資料儲存區,方法是將此邊緣插入連線。

步驟 2 — 呼叫 commitMutation 來發布它

現在我們使用 useMutation hook 來存取 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 — 新增宣告式連線處理常式

在這一點上,我們可以從網路記錄中發現,點擊「發布」會向伺服器發送 mutation 要求 — 您甚至可以看到該評論已發布,因為如果您重新整理頁面,它會出現。但是,UI 中沒有任何反應。我們需要告訴 Relay 將新建立的評論附加到從故事到其評論的連線。

您會注意到,在我們上面撰寫的 mutation 回應中,我們選取了 commentEdge。這是新建立評論的邊緣。我們只需要告訴 Relay 要將該邊緣新增到哪些連線。Relay 提供了稱為 @appendEdge@prependEdge@deleteEdge 的指示詞,您可以將它們放在 mutation 回應中的邊緣上。然後,當您執行 mutation 時,您會傳入您想要修改的連線 ID。Relay 會按照您指定的方式從這些連線附加、前置或刪除邊緣。

我們希望我們新建立的評論出現在清單的頂部,因此我們將使用 @prependEdge。對 mutation 定義進行以下新增

  mutation StoryCommentsComposerPostMutation(
$id: ID!,
$text: String!,
$connections: [ID!]!,
) {
postStoryComment(id: $id, text: $text) {
commentEdge
@prependEdge(connections: $connections)
{
node {
id
text
}
}
}
}

我們已在 mutation 中新增一個名為 connections 的變數。我們將使用它來傳入我們想要更新的連線。

注意

$connections 變數僅用作 @prependEdge 指示詞的引數,該指示詞由 Relay 在用戶端上處理。由於 $connections 未作為引數傳遞給任何欄位,因此它不會傳送到伺服器。

步驟 4 — 將連線 ID 作為 Mutation 變數傳入

我們需要識別要新增新邊緣的連線。連線使用兩條資訊來識別

  • 它所來自的節點 — 在這種情況下,是我們正在發布評論的故事。
  • @connection 指示詞中提供的金鑰,讓我們可以在多個連線來自相同節點的情況下區分連線。

我們使用 Relay 提供的特殊 API 將此資訊傳入 mutation 變數中

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" 是我們在 StoryCommentSection 中擷取連線時使用的識別碼 — 提醒您一下,以下是它的樣子

const StoryCommentsSectionFragment = graphql`
fragment StoryCommentsSectionFragment on Story
...
{
comments(after: $cursor, first: $count)
@connection(key: "StoryCommentsSectionFragment_comments")
{
...
}
`;

同時,引數 data.id 是我們正在連線的特定故事的 ID。

進行此變更後,我們應該會在 mutation 完成後看到評論出現在評論清單中。


總結

Mutation 讓我們要求伺服器進行變更。

  • 與查詢一樣,Mutation 由欄位組成,接受變數,並將這些變數作為引數傳遞給欄位。
  • 由 Mutation 選擇的欄位構成mutation 回應,我們可以利用它來更新 Store。
  • Relay 會自動將回應中的節點與 Store 中 ID 相符的節點合併。
  • @appendEdge@prependEdge@deleteEdge 指令讓我們可以將 mutation 回應中的項目插入或移除 Store 中的 Connections。
  • Updaters 讓我們可以手動操作 store。
  • 樂觀更新器會在 mutation 開始之前執行,並在 mutation 完成時回滾。