跳至主要內容

彈性 Relay 應用程式

·閱讀時間 22 分鐘
客座文章

這是一篇由 Coinbase 的資深工程師 Ernie Turner 撰寫的客座文章。Coinbase 已在其應用程式中徹底採用 Relay,並且是 Relay 團隊的堅定盟友。去年,他們協助共同開發了 Relay VSCode 擴充功能。Ernie 同意與我們分享這篇內部工程部落格文章。

如何在服務中斷期間為客戶提供最佳體驗

在理想的世界中,Coinbase 的任何服務都不會發生中斷,而且我們 GraphQL 結構描述中的所有欄位都會一直正確解析。由於這並非實際情況,Coinbase 應用程式應能彈性應對停機時間,並將對客戶的影響降到最低:單一服務發生停機不應阻止使用者使用或與整個應用程式互動。然而,當我們的應用程式未如預期運作時,向使用者傳達問題也很重要。顯示傳達停機時間和重試按鈕的錯誤訊息,比讓使用者對缺少內容或無法互動的 UI 感到困惑要好。

這篇文章將涵蓋在 Relay 應用程式中處理遺失資料的常見模式和最佳實務。

畫面架構和錯誤邊界

在我們討論處理 GraphQL 查詢中的服務停機和失敗之前,我們先來討論更廣泛的畫面架構,以及 React 錯誤邊界在正確使用時如何協助建立更好的使用者體驗。

就像生活中的大多數事情一樣,錯誤邊界應適度使用。讓我們看看 Coinbase Retail 應用程式中的常見畫面。

上述畫面中的任何區塊都可能無法取得呈現所需的資料,但我們如何處理這些失敗,才能區分使用者使用我們應用程式的體驗。例如,僅針對任何失敗使用單一畫面層級的錯誤邊界,會導致應用程式在發生任何錯誤時都無法使用,而不管該錯誤的重要性為何。相反地,將每個元件都包裝在其自己的錯誤邊界中,可能會產生同樣糟糕的體驗。最後,完全省略有錯誤的元件與其他兩個選項一樣糟糕。沒有一體適用的方法,因此讓我們分解每個選項,並說明它們為何會產生糟糕的使用者體驗。

全螢幕錯誤

上面的 UI 是 Coinbase 的全螢幕錯誤回退,如果服務發生中斷,且我們無法取得在此畫面上呈現元件所需的資料,就會顯示此 UI。在某些情況下,這實際上會產生良好的使用者體驗。我們可能沒有向使用者提供有關發生情況的詳細資訊,但在大多數情況下,無法提供技術原因,也不會改善使用者的體驗。然而,我們正在告訴他們某些事情運作不正常,並給他們一個明確的「重試」按鈕,嘗試讓應用程式再次運作。

如果我們向使用者顯示此錯誤的原因是我們無法載入不重要的內容,例如資產價格歷史圖表或他們的關注列表狀態,我們就不應該關閉整個畫面。僅因為我們無法告訴他們比特幣是否在他們的關注列表中,就隱藏比特幣的目前價格並阻止使用者交易,是一種負面的使用者體驗。

此 UI 的另一個缺點是它會向使用者隱藏所有應用程式導覽。即使我們有充分的理由向使用者顯示全螢幕錯誤,這也不代表我們應該在此過程中隱藏應用程式的其餘部分。使用者仍然應該能夠導覽至不同的畫面。實際上,我們應該只向使用者顯示「全螢幕錯誤」,而不是「全應用程式錯誤」。

到處都是錯誤訊息

上面圖片中的 UI 在許多方面都更糟。這是先前體驗的相反情況,顯示使用者全螢幕錯誤會更好。價格歷史圖表的錯誤訊息是有道理的,因為使用者會預期此 UI 會出現在此畫面上,但如果使用者甚至看不到比特幣的價格或找到「交易」按鈕,我們真的應該向他們顯示第一個螢幕截圖中的 UI(但帶有導覽)- 因為此畫面的核心目標和目的已遺失。

此影像也說明了錯誤邊界可能過於普遍。整個價格歷史圖表和時間範圍選擇器應只有一個錯誤訊息,而不是每個時間範圍都有一個錯誤訊息。

空白回退

上面的 UI 與先前的範例一樣糟糕。在這種情況下,我們的錯誤邊界會回退至空白內容。對於某些 UI 元素,這是有道理的。關注列表旁邊遺失的「分享」按鈕對此 UI 而言並不重要,因此省略它是有道理的。然而,隱藏比特幣的目前價格、價格歷史圖表和「交易」按鈕會使 UI 無法使用,甚至有些誤導。即使是不每天使用該應用程式的使用者也會知道有些不對勁。我們也沒有給使用者任何重試失敗的選項 - 使用者只會看到空白內容,而且無法復原。

使用者應該看到什麼?

以下兩個螢幕截圖顯示了使用者更好的體驗範例。第一個螢幕截圖是如果我們無法取得比特幣的目前價格,或如果我們無法判斷使用者是否允許交易時,使用者應該看到的內容。第二個螢幕截圖是如果我們無法取得比特幣價格的目前變化或價格歷史記錄時,使用者更好的體驗。

這一切都指出需要對畫面上的 UI 區塊進行分類:什麼對使用者的體驗至關重要、使用者期望看到的 UI 以及哪些支援內容對體驗是選用的。

關鍵 vs 預期 vs 選用 UI

應用程式畫面中的並非所有 UI 元素都相同。UI 的某些部分對於畫面的核心目的至關重要,而其他部分可能只是對使用者更有資訊性和幫助。對於 Coinbase 的應用程式設計,我們會將 UI 元素分為三個類別:「關鍵」、「預期」和「選用」。

關鍵 UI 元素

畫面中定義使用者與 UI 互動的核心資訊或互動的部分。如果 UI 中沒有這些元素,畫面就沒有意義,如果這些元素遺失,使用者會感到困惑和/或憤怒,因為不清楚為什麼應用程式沒有如預期運作。

假設我們無法載入顯示這些關鍵 UI 元素所需的資料。在這種情況下,我們應該向使用者顯示全螢幕錯誤訊息,說明問題(如果可能),並附帶一個重試按鈕,讓他們可以輕鬆嘗試重新請求遺失的資料。

讓使用者與缺少關鍵 UI 元素的應用程式互動,會造成困惑、憤怒,如果使用者在不知道發生什麼事的所有詳細資訊的情況下能夠完成交易,甚至可能造成資金損失。

關鍵 UI 元素範例

  • Coinbase 應用程式主畫面上的使用者目前投資組合餘額
  • 訂單預覽畫面上的資產價格、付款方式和總購買價格
  • 在「賺取」畫面上的使用者終身收入和每項資產的收入

預期 UI 元素

預期 UI 元素是畫面中可能無法服務於畫面核心目的的部分,但大多數使用者會預期這些元素存在。如果畫面中缺少預期 UI 元素,使用者可能會認為有問題,但這不會阻止他們執行畫面的核心動作。

如果我們無法載入顯示這些預期 UI 元素所需的資料,我們應該向使用者顯示元件本地錯誤訊息,告訴他們遺失了預期的 UI。這些錯誤訊息也應附帶一個重試按鈕,讓使用者重新請求遺失的資料。使用者看到或與本機化的錯誤互動的機率較低,這是可以接受的,因為它們並非畫面核心目的所必需的。

讓使用者與缺少預期 UI 元素的應用程式互動應該是可以接受的,但可能會造成對正在發生的事情的困惑。完全省略這些 UI 元素而不附帶錯誤訊息會造成更糟糕的體驗。

預期 UI 元素範例

  • 在「購買資產」畫面(使用者輸入購買金額的位置)上的資產目前價格
  • 資產詳細資料畫面上的價格歷史圖表
  • Coinbase 卡片畫面上的近期交易清單

選用 UI 元素

可選的 UI 元素是指螢幕上純粹輔助主要用途的部分。有些使用者可能會注意到這些元素缺失,但其他使用者可能完全不知道它們應該存在。無論哪種情況,使用者都不會因為這些元素的缺失而無法完成螢幕上的主要目標。

如果我們無法載入顯示這些可選 UI 元素所需的資料,我們應該直接將它們從 UI 中省略。然而,這會帶來以下風險:

A. 使用者可能不知道有任何東西遺失。B. 除非使用者完整重新整理螢幕,否則將沒有任何方法讓使用者重新請求此 UI 的資料。

開發人員應考慮這些缺點,並確保它們不會導致負面的使用者體驗。相反地,應記錄這些失敗,以便在使用者體驗不佳時通知產品工程師。

可選 UI 元素的範例

  • 資產詳細資訊畫面上的優惠卡片
  • 交易畫面上的資產類別區塊(Coinbase 新上架、漲幅最大等)
  • 首頁的新聞摘要

讓我們回到上面的圖片,將 UI 的各個區塊分類到這些類別中。

元素分類的限制

在上面的範例中,我們有一個螢幕,其中包含兩個關鍵元件、兩個預期元件和一個可選元件。應用程式中的大多數螢幕都應該只有少數幾個關鍵的 UI 元件。對於某些螢幕,整個 UI 可能由單一的關鍵元件組成。

預期元素也是如此。如果我們有一個螢幕由五個獨立的預期 UI 元素組成,我們最終會看到上面螢幕截圖中佈滿「重試」按鈕的情況。請盡可能將單一螢幕上的預期元素和重試按鈕數量限制為一到兩個。

下拉重新整理

對於上述所有情況,行動應用程式上的使用者應該能夠下拉重新整理,以重試螢幕上的任何失敗請求。對於 Relay 應用程式,這通常表示重試完整的螢幕層級查詢。如果螢幕上有任何錯誤訊息或因為資料遺失而隱藏的元件,使用下拉重新整理應始終嘗試修復所有這些錯誤狀況。

與您的產品經理和設計師合作

所有這些分類都是主觀的 — 並且上面的所有範例都只是一種觀點,設計師或產品經理可能對螢幕應如何降級有不同的看法。在設計應用程式 UI 時,跨部門協調非常重要。團隊應諮詢工程師、設計師和產品經理,以確保整個應用程式中的螢幕無縫且符合品牌形象。

Relay 如何提供協助

將您的螢幕分類成不同區塊後,下一步是將適當的 ErrorBoundaries 新增到您的應用程式,並根據元件的分類來配置其 GraphQL 片段。這正是 Relay 可以提供協助的地方。根據我們使用 Relay 應用程式的經驗,我們針對如何處理 GraphQL 查詢中遺失的資料制定了一些最佳實務。

背景

我們在 Coinbase 的目標是使用可為 null 的結構描述,如同 Relay 團隊建議。主要原因是將如何處理服務中斷和遺失的查詢資料的決策權交給客戶端工程師。如果沒有可為 null 的結構描述,則對遺失的資料的處理決策會在伺服器上進行(透過將 null 值冒泡到最近的可為 null 的父級),並且客戶端程式碼沒有辦法更改此決策。

這個決策受到 Relay @required 指令的支援,該指令允許客戶端工程師使用指令註解他們的查詢和片段,以告知 Relay 如何在執行階段處理遺失的資料。這減少了工程師原本需要撰寫的樣板程式碼。從表面上看,該指令看起來非常簡單:它只有三個選項,而且都很直接。然而,當嘗試將此指令用於各種用例時,很明顯地,選擇哪個選項並非總是顯而易見,是否要使用該指令的決定也並非如此。

@required 的局部性

@required 指令的一個很棒的功能是,它只會影響您使用它的片段。它永遠不會變更查詢相同欄位的其他片段的行為。這允許您新增或移除該指令,而無需考慮元件範圍之外的任何內容。這很重要,因為不同的元件可能會有不同的分類,即使它們從相同的查詢中取得資料也是如此。能夠使用不同的 @required 引數標記相同查詢片段中的欄位,對於建立理想的使用者體驗非常重要。

使用 action: LOG vs action: NONE

LOGNONE 動作都具有相同的執行階段行為,但 LOG 會將訊息傳送到您選擇的記錄機制,記錄傳回為 null 的欄位的完整路徑。對於大多數需要 @required 指令的用例,應優先使用 LOG 而不是 NONE。只有在預期某些使用者欄位為 null 時,才應優先使用 NONE

雖然使用 action: LOG 建立的記錄項目本身不太可能可操作,但是,它可以作為未來錯誤的線索。能夠查看錯誤的歷史記錄,並看到特定欄位意外為 null,有助於追蹤使用者在工作流程中可能遇到的未來錯誤。

何時使用 @required(action:LOG/NONE)

LOG/NONE 動作應僅在您的元件中顯示可選 UI 時所需的欄位上使用。在設計應用程式時,有兩種不同的使用案例會出現這種情況:

  1. 您的元件是可選 UI,如果一個或一組欄位為 null,則根本不應呈現。
  2. 您的元件的一部分是可選 UI,並且依賴於一個物件類型欄位,而該物件類型欄位若沒有一個或多個子欄位則沒有意義。

讓我們看一下包含這兩個用例的片段:

fragment MyFragment on Asset {
id
name @required(action: LOG)
slug @required(action: LOG)
color
supply {
total @required(action: LOG)
circulating @required(action: LOG)
}
}

對於這個片段,我們表示如果我們沒有取得名稱或 slug 欄位,則整個片段都無效。如果這些欄位從伺服器傳回為 null,我們就完全無法呈現這個元件。此片段還示範了如何使用 @required(action: LOG/NONE) 指令使整個物件類型欄位失效。這個片段表示,如果我們沒有 supply.totalsupply.circulating 欄位中的任何一個,那麼整個 supply 物件本身就無效,並且應為 null。此可為 null 性將用於隱藏此元件 UI 的可選部分。

現在讓我們看看我們的元件將如何處理此查詢的結果:

const asset = useFragment(
graphql`
fragment MyFragment on Asset {
id
name @required(action: LOG)
slug @required(action: LOG)
color
supply {
total @required(action: LOG)
circulating @required(action: LOG)
}
}
`,
assetRef,
);

// If we couldn't get the required asset name or slug fields, hide this entire UI
if (asset === null) {
return null;
}
// Otherwise hide certain portions of the UI if data is missing
return (
<>
<Title color={asset.color}>{asset.name}</Title>
<Subtitle>{asset.slug}</Subtitle>
{asset.supply && (
<SupplyStats total={asset.supply.total} circulating={asset.supply.circulating} />
)}
</>
);

@required 指令在這裡真正發揮作用,因為它消除了我們原本必須撰寫的複雜的 null 檢查。我們不必檢查 asset.nameasset.slug 欄位是否都為 null,而是可以簡單地檢查我們的整個片段是否已設定為 null 並阻止呈現。檢查我們是否應呈現 SupplyStats 元件時也是如此。我們只需要檢查父欄位是否為 null,即可知道兩個子欄位不為 null。

何時使用 @required(action:THROW)

使用 @required(action: THROW) 更為直接。此動作應在呈現預期或關鍵 UI 元件時所需的欄位上使用。如果這些欄位從伺服器傳回為 null,則您的元件應向最近的 ErrorBoundary 擲回錯誤,且使用者應看到錯誤訊息。

您的 ErrorBoundary 在樹狀結構中的位置取決於您希望在發生錯誤時移除多少 UI。例如,如果我們向使用者顯示錯誤而不是資產價格歷史圖表,則保留時間序列按鈕仍然可見是沒有意義的,整個 UI 也應該消失。但是,如果發生這種情況,我們也不希望移除整個螢幕。

請確保您的 ErrorBoundary 提供讓使用者重試失敗查詢的機制,以查看他們是否可以在後續嘗試中取得資料。我們應該始終將錯誤訊息與可操作的元素配對,讓使用者能夠復原。我們不應該依賴使用者能夠(或知道)使用下拉重新整理來重新載入螢幕。

關於在陣列中的欄位上使用 @required(action: THROW) 的注意事項

您幾乎不應該在選取陣列欄位和該陣列欄位的元件中使用 THROW 動作。以下是一個不應這樣做的範例:

function Component({ assetPriceRef }) {
const { quotes } = useFragment(
graphql`
fragment ComponentFragment on AssetPriceData {
quotes {
# Returns an array of items
timestamp
price @required(action: THROW)
}
}
`,
assetPriceRef,
);
}

此元件選取 quotes 陣列以及該陣列中每個項目的 timestampprice 欄位。如果我們想要在沒有收到任何報價時向使用者顯示錯誤,則在 quotes 欄位上放置 THROW 是可以接受的。但是,如果在該陣列中甚至只有一個 price 欄位為 null,則在 price 欄位上放置 THROW 會導致向使用者顯示錯誤。這可能不是我們想要看到的行為。如果我們正確地取得了過去一天 24 個報價中的 23 個,我們應該仍然顯示我們擁有的結果,而只是省略空值。

相反地,我們應該使用 action: LOG/NONE,這樣我們就只會使陣列中的單個項目失效,而不是所有項目。然後,我們可以選擇性地篩選出陣列中的 null 值(如果需要)。

function Component({ assetPriceRef }) {
const { quotes } = useFragment(
graphql`
fragment ComponentFragment on AssetPriceData {
quotes {
# Returns an array of items
timestamp
price @required(action: LOG)
}
}
`,
assetPriceRef,
);
const validQuotes = quotes.filter(removeNull);
}

何時不應在欄位上使用 @required

對於這個問題,一個沒有幫助的答案會是「當欄位不是必要的時候,就不要使用 @required」。這個答案過於簡化了關於什麼是必要和什麼不是必要的決策,而這個決策通常更為細膩,尤其當你的片段包含十幾個或更多的欄位時。然而,我們可以遵循一些最佳實踐來決定是否將一個欄位標記為必要。再次強調,與您的產品經理 (PM) 和設計師合作,以幫助您做出這些決策非常重要。

在省略 @required 指令與使用帶有 LOG/NONE 行為的指令之間也存在一條細微的界線。主要的區別在於,當該欄位呈現的 UI 是可選 UI 時,您應該省略 @required 指令。

您應用程式中的某些元件可能會呈現不同分類 UI 的組合。例如,單一元件可能負責顯示資產的當前價格,以及在一段時間內有多少百分比的用戶買入或賣出該資產。這意味著該元件混合了關鍵 UI(資產價格)和可選 UI(買入/賣出統計數據)。

如果一個欄位是用於呈現可選內容,這些內容可以完全從 UI 中省略,而不會讓用戶感到困惑(請記住,這就是可選 UI 的定義),那麼您不應該在該欄位上使用 @required 指令。相反,您應該在程式碼中添加檢查,以在欄位為 null 時省略 UI。

function SomeComponent({ queryRef }) {
const { asset } = useFragment(
graphql`
asset {
latestQuote @required(action: THROW) # Required data
buyPercent # Optional data
}`,
queryRef,
);

return (
<div>
<div>Price: {asset.latestQuote}</div>
{asset.buyPercent !== null && (
<>
<div>Buy Percent: {asset.buyPercent}</div>
<div>Sell Percent: {1 - asset.buyPercent}</div>
</>
)}
</div>
);
}

在這個例子中,在 buyPercent 欄位上使用 @required(action: LOG/NONE) 是不正確的,因為這會使整個片段失效,而這不是我們想要看到的行為。

另一個較少見的省略 @required 指令的情況是,當您可以提供安全的後備值時。如果使用不當,為欄位提供後備/預設值可能會非常危險。雖然在少數情況下,回退到預設值可能是安全的,但這種情況通常非常罕見,應盡量避免。然而,如果您可以提供安全的後備值,您應該避免在該欄位上添加 @required,而改用後備值。

以下是一些關於何時提供後備值的指南

  • 數值欄位(代表數字的數字或字串)的後備值不應使用。
    • 使用 0 來代替缺失的值總是會給用戶帶來更多的困惑。Coinbase 是一家金融公司,如果我們不能向用戶顯示準確的值,我們根本不應該顯示它們。向用戶顯示他們的帳戶餘額為 $0.00 顯然比向他們顯示錯誤訊息更糟糕。這是一個明顯的用例,但即使是諸如資產的價格變動百分比、Coinbase 卡的 APY% 或用戶可以通過 Coinbase Earn 賺取的金額等地方,如果我們沒有實際值,也絕不應顯示 0。
  • 布林值欄位的後備值應謹慎使用。
    • 布林值欄位的後備值的第一個選擇通常是將欄位設定為 false。根據布林值欄位所代表的內容,回退到 false 可能會比向用戶顯示錯誤訊息帶來更糟糕的客戶體驗。對於像 isEligibleForOffer 這樣的欄位,回退到 false 可能是可以接受的,因為這很可能是在顯示可選內容。對於像 hasCoinbaseOneSubscription 這樣的欄位回退到 false 是不可接受的,因為對於 CoinbaseOne 的訂閱者來說,內容是預期的,用戶會對應用程式中為何缺少該 UI 感到困惑。
  • 對於陣列欄位,回退到空陣列應謹慎使用。
    • 如果您向用戶顯示他們的 Coinbase 卡交易列表,回退到空陣列是一個壞主意,但如果您向用戶顯示最近新增的資產列表,則回退到空陣列以省略 UI 顯示可能是可以接受的,因為元件已經必須處理陣列為空的情況。
  • 字串欄位通常應直接處理 null。
    • 在某些情況下,您可能希望對於返回為 null 的字串欄位回退到空字串,但通常,如果您將欄位保持為 null,則會產生相同的程式碼路徑。架構中的大多數字串欄位都不應該是空的,因此回退到空字串可能會產生負面的用戶體驗,用戶將會看到一個空字串而不是實際內容。
function SomeComponent({ queryRef }) {
const asset = useFragment(
graphql`
fragment MyFragment on Asset {
canTrade @required(action: THROW) # Required data
hasOfferToStake # Optional data
}
`,
assetRef,
);

const showStakeOffer = asset.hasOfferToStake ?? false;

return (
<div>
{asset.canTrade && <Button>Trade</Button>}
{showStakeOffer && <Button>Stake your currency</Button>}
</div>
);
}

總結

如果您從本文中學到任何東西,希望是需要深入思考如何處理停機和服務中斷。處理失敗狀態是建構世界級應用程式的重要組成部分。在規劃新功能時,請確保您的設計和產品經理團隊與您的團隊保持一致。如果他們沒有提供關於在資料遺失時該向用戶顯示什麼的建議,請堅持要求團隊就這些決策達成共識。

Relay 可以成為幫助處理應用程式錯誤的強大工具。它在處理錯誤方面提供的細緻能力,可能需要比您習慣的更多工作。然而,這種額外的努力會在長期內獲得回報,並大大提高客戶在應用程式中的體驗。