這是一篇由 Coinbase 的資深工程師 Ernie Turner 撰寫的客座文章。Coinbase 在他們的應用程式中廣泛採用了 Relay,並且是 Relay 團隊的堅強盟友。去年,他們協助共同開發了 Relay VSCode 擴充功能。Ernie 同意與我們分享這篇內部工程部落格文章。
如何在服務中斷期間為客戶提供最佳體驗
在理想情況下,Coinbase 的任何服務都不會發生中斷,並且我們 GraphQL schema 中的所有欄位都會始終正確解析。由於這並不實際,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 的目標是使用可為空值的 schema,如同 Relay 團隊的建議。主要的原因是,它將如何處理服務中斷和遺失的查詢資料的決策權交給了客戶端工程師。如果沒有可為空值的 schema,那麼對遺失資料的處理決策將在伺服器端進行(透過將空值冒泡到最近的可為空值的父級),而客戶端程式碼沒有辦法改變此決策。
這個決策得到了 Relay @required
指令 的支持,該指令允許客戶端工程師使用指令註解他們的查詢和片段,告訴 Relay 如何在運行時處理遺失的資料。這減少了工程師原本需要編寫的樣板程式碼。表面上,該指令看起來非常簡單:它只有三個選項,而且都很直接。但是,當嘗試將此指令用於各種用例時,很明顯地,選擇哪個選項並不總是顯而易見,是否要使用該指令的決定也是如此。
@required 的局部性
@required
指令的一個重要功能是,它只會影響您使用它的片段。它永遠不會改變查詢相同欄位的其他片段的行為。這讓您可以在不考慮元件範圍之外的任何情況下,新增或移除該指令。這一點很重要,因為即使不同的元件從相同的查詢中獲取資料,它們的分類也可能不同。能夠使用不同的 @required
參數標記相同查詢的片段中的欄位,對於建立理想的使用者體驗非常重要。
使用 action: LOG 與 action: NONE
LOG
和 NONE
動作都有相同的運行時行為,但 LOG
會將訊息傳送到您選擇的記錄機制,記錄回傳為空值的欄位的完整路徑。對於大多數需要 @required
指令的用例,應該使用 LOG
而不是 NONE
。只有當預期某些使用者的欄位為空值時,才應優先選擇 NONE
。
雖然使用 action: LOG
建立的記錄條目本身不太可能具有可操作性,但是,它可以作為未來錯誤的線索提供有用的信號。能夠查看錯誤的歷史記錄並看到特定的欄位意外地為空值,有助於追蹤使用者在工作流程中可能遇到的未來錯誤。
何時使用 @required(action:LOG/NONE)
LOG/NONE
動作僅應在對於顯示元件中的可選 UI 是必要的欄位上使用。在設計應用程式時,有兩種不同的用例會出現這種情況
- 您的元件是可選 UI,如果欄位或一組欄位為空值,則根本不應呈現
- 您的元件的一部分是可選 UI,並且依賴於一個物件類型欄位,如果缺少一個或多個子欄位,則該物件沒有任何意義
讓我們來看一下包含這兩種用例的片段
fragment MyFragment on Asset {
id
name @required(action: LOG)
slug @required(action: LOG)
color
supply {
total @required(action: LOG)
circulating @required(action: LOG)
}
}
對於這個片段,我們表示如果我們沒有取得 name 或 slug 欄位,則整個片段都無效。如果這些欄位從伺服器回傳為空值,我們就根本無法呈現這個元件。此片段也顯示了如何使用 @required(action: LOG/NONE)
指令來使整個物件類型欄位失效。此片段表示,如果我們沒有 supply.total
或 supply.circulating
欄位,則整個 supply 物件本身都無效,應為空值。然後,這個可為空值將用於隱藏此元件 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 (asset === null) {
return null;
}
return (
<>
<Title color={asset.color}>{asset.name}</Title>
<Subtitle>{asset.slug}</Subtitle>
{asset.supply && (
<SupplyStats total={asset.supply.total} circulating={asset.supply.circulating} />
)}
</>
);
@required
指令在這裡非常出色,因為它消除了我們原本必須編寫的複雜的空值檢查。我們不必檢查 asset.name
或 asset.slug
欄位是否都為空值,我們只需檢查整個片段是否被設為空值並阻止呈現即可。當檢查我們是否應該呈現 SupplyStats 元件時,情況也是如此。我們只需要檢查父欄位是否為空值,就知道兩個子欄位都非空值。
何時使用 @required(action:THROW)
使用 @required(action: THROW)
更為直接。此動作應在呈現您的預期或關鍵 UI 元件所必需的欄位上使用。如果這些欄位從伺服器回傳為空值,您的元件應向最近的 ErrorBoundary 拋出錯誤,並且使用者應看到錯誤訊息。
您的 ErrorBoundary 在樹狀結構中的位置有多高,取決於您想要在發生錯誤時移除多少 UI。例如,如果我們向使用者顯示錯誤而不是資產價格歷史圖表,則繼續顯示時間序列按鈕沒有意義,整個 UI 也應該消失。但是,如果發生這種情況,我們也不希望移除整個畫面。
請確保您的 ErrorBoundary 提供一種機制,讓使用者可以重試失敗的查詢,看看他們是否可以在後續嘗試中取得資料。我們應始終將錯誤訊息與可操作的元素配對,以讓使用者恢復。我們不應依賴使用者能夠(或知道)使用下拉重新整理來重新載入畫面。
關於在陣列中的欄位上使用 @required(action: THROW) 的注意事項
您幾乎不應在同時選取陣列欄位和該陣列欄位的元件中使用 THROW
動作。作為不應這樣做的範例
function Component({ assetPriceRef }) {
const { quotes } = useFragment(
graphql`
fragment ComponentFragment on AssetPriceData {
quotes {
timestamp
price @required(action: THROW)
}
}
`,
assetPriceRef,
);
}
此元件選取 quotes
陣列,以及該陣列中每個項目的 timestamp
和 price
欄位。如果我們想要在沒有收到任何報價時向使用者顯示錯誤,則將 THROW
放置在 quotes
欄位上是可以接受的。但是,如果該陣列中的即使是單一 price 欄位為空值,則將 THROW
放置在 price
欄位上會導致向使用者顯示錯誤。這可能不是我們想要的行為。如果我們正確地收到了過去一天 24 個報價中的 23 個,我們可能還是應該顯示我們擁有的結果,而只省略空值。
相反地,我們應該使用 action: LOG/NONE
,以便我們只使陣列中的單個項目失效,而不是所有項目。然後,我們可以選擇性地篩選掉陣列中的空值(如果需要)。
function Component({ assetPriceRef }) {
const { quotes } = useFragment(
graphql`
fragment ComponentFragment on AssetPriceData {
quotes {
timestamp
price @required(action: LOG)
}
}
`,
assetPriceRef,
);
const validQuotes = quotes.filter(removeNull);
}
何時「不」在欄位上使用 @required
這個問題的無益答案將是「當欄位不是必需時,不要使用 @required
」。當答案通常更為細微時,特別是當您的片段有十幾個或更多的欄位時,這個答案會將什麼是必需的和什麼不是必需的決定簡單化。但是,我們可以遵循一些最佳實務來決定是否將欄位標記為必需。再次提醒,與您的產品經理和設計師合作以幫助您做出這些決定非常重要。
在省略 @required
指令與搭配 LOG/NONE
動作使用之間也有一條微妙的界線。主要差異在於,當該欄位呈現的 UI 是可選 UI 時,您應該省略 @required
指令。
您的應用程式中有些元件可能會呈現不同 UI 分類的組合。例如,單一元件可能同時負責顯示資產的目前價格,以及在一段時間內購買或出售該資產的使用者百分比。這表示該元件混合了「關鍵 UI」(資產價格)和「可選 UI」(買/賣統計資料)。
如果某個欄位用於呈現可選內容,而這些內容可以完全從 UI 中省略,而不會讓使用者感到困惑(請記住,這就是「可選 UI」的定義),那麼您就不應該在該欄位上使用 @required
指令。相反地,您應該在程式碼中加入檢查,以便在該欄位為空值時省略 UI。
function SomeComponent({ queryRef }) {
const { asset } = useFragment(
graphql`
asset {
latestQuote @required(action: THROW)
buyPercent
}`,
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)
hasOfferToStake
}
`,
assetRef,
);
const showStakeOffer = asset.hasOfferToStake ?? false;
return (
<div>
{asset.canTrade && <Button>Trade</Button>}
{showStakeOffer && <Button>Stake your currency</Button>}
</div>
);
}
如果您從本文中學到了什麼,希望是需要仔細思考如何處理停機和服務中斷的問題。處理失敗狀態是建立世界級應用程式的重要環節。在規劃新功能時,請確保您的設計和 PM 團隊與您的團隊達成共識。如果他們沒有為您提供關於在資料遺失時向使用者顯示什麼內容的建議,請積極推動,讓團隊在這些決策上達成共識。
Relay 可以成為協助處理應用程式失敗的強大工具。它能夠精細地協助您決定如何處理失敗,這可能比您習慣的要付出更多努力。但是,從長遠來看,這種額外的努力會得到回報,並大大有助於改善客戶對您的應用程式的體驗。