跳至主要內容

·5 分鐘閱讀

Relay 的應用程式撰寫方法實現了最佳執行效能和應用程式可維護性的獨特結合。在這篇文章中,我將描述大多數應用程式在資料擷取方面被迫做出的權衡,然後描述 Relay 的方法如何讓您避開這些權衡,並在多個權衡維度上實現最佳結果。


在 React 等基於元件的 UI 系統中,一個重要的決定是在 UI 樹狀結構中的哪個位置擷取資料。雖然可以在 UI 樹狀結構中的任何位置進行資料擷取,為了了解其中的權衡,讓我們考慮兩種極端情況

  • 葉節點:直接在每個使用資料的元件內擷取資料
  • 根節點:在 UI 的根節點擷取所有資料,並使用 prop drilling 將其傳遞到葉節點

您在 UI 樹狀結構中的哪個位置擷取資料,會影響應用程式效能和可維護性的多個維度。不幸的是,對於幼稚的資料擷取,這兩種極端情況對於所有維度都不是最佳的。讓我們來看看這些維度,並考慮哪些維度會隨著資料擷取更接近葉節點而改善,哪些維度會隨著資料擷取更接近根節點而改善。

載入體驗

  • 🚫 葉節點:如果各個節點擷取資料,您將最終得到請求級聯,其中您的 UI 需要串行發出多個請求往返(瀑布式),因為 UI 的每一層都受到其父層渲染的阻礙。此外,如果多個元件恰好使用相同的資料,您將最終多次擷取相同的資料
  • ✅ 根節點:如果您的所有資料都在根節點擷取,您將發出單個請求並渲染整個 UI,而不會有任何重複的資料或級聯請求

Suspense 級聯

  • 🚫 葉節點:如果每個元件都需要單獨擷取資料,則每個元件都會在初始渲染時暫停。使用目前的 React 實作,取消暫停會導致從最接近的父級 Suspense 邊界重新渲染。這表示您將必須在初始載入期間重新評估產品元件程式碼 O(n) 次,其中 n 是樹狀結構的深度。
  • ✅ 根節點:如果您的所有資料都在根節點擷取,您將暫停一次,並且僅評估一次產品元件程式碼。

可組合性

  • ✅ 葉節點:在新位置中使用現有元件就像渲染它一樣簡單。移除元件就像不渲染它一樣簡單。同樣地,新增/移除資料相依性可以在本機完全完成。
  • 🚫 根節點:將現有元件新增為另一個元件的子元件需要更新每個包含該元件的查詢,以擷取新資料,然後將新資料透過所有中間層傳遞。同樣地,移除元件需要將這些資料相依性追溯到每個根元件,並確定您移除的元件是否是該資料的最後一個剩餘使用者。相同的動態適用於將新的資料新增/移除到現有元件。

細微更新

  • ✅ 葉節點:當資料變更時,每個讀取該資料的元件都可以單獨重新渲染,避免需要重新渲染不受影響的元件。
  • 🚫 根節點:由於所有資料都源自根節點,當任何資料更新時,它始終會強制根元件更新,從而強制重新渲染整個元件樹狀結構,這會很耗費資源。

Relay

Relay 利用 GraphQL 片段和編譯器建置步驟來提供更佳的替代方案。在使用 Relay 的應用程式中,每個元件都會定義一個 GraphQL 片段,該片段會宣告其需要的資料。這包括元件將渲染的具體值,以及它將渲染的每個直接子元件的片段(依名稱參考)。

在建置時,Relay 編譯器會收集這些片段,並為應用程式中的每個根節點建置單個查詢。讓我們看看此方法如何針對上述每個維度發揮作用

  • ✅ 載入體驗 - 編譯器產生的查詢會在單個往返中擷取表面所需的所有資料
  • ✅ Suspense 級聯 - 由於所有資料都在單個請求中擷取,我們只會暫停一次,並且它就在樹狀結構的根節點
  • ✅ 可組合性 - 從元件新增/移除資料,包括渲染子元件所需的片段資料,都可以在單個元件內在本機完成。編譯器會負責更新所有受影響的根查詢
  • ✅ 細微更新 - 由於每個元件都定義了一個片段,Relay 可以確切知道每個元件使用了哪些資料。這讓 Relay 可以執行最佳更新,其中資料變更時會重新渲染最少的元件集

總結

如您所見,Relay 使用宣告式可組合資料擷取語言 (GraphQL) 以及編譯器步驟,讓我們可以在上面概述的所有權衡維度上實現最佳結果

葉節點根節點GraphQL/Relay
載入體驗🚫
Suspense 級聯🚫
可組合性🚫
細微更新🚫

·4 分鐘閱讀

Relay 團隊很高興宣布 Relay v15 的發布。雖然此版本是主要版本更新,並包含一些重大變更,但我們預期大多數使用者將不受影響,並會體驗到無縫升級。您可以在 v15 版本說明中找到完整的變更清單。

Relay 15 中的新功能?

支援介面上的 @refetchable

先前無法在伺服器介面類型上的片段定義中新增 @refetchable 指令。

// schema.graphql

interface RefetchableInterfaceFoo @fetchable(field_name: "id") {
id: ID!
}

extend type Query {
fetch__RefetchableInterfaceFoo(id: ID!): RefetchableInterfaceFoo
}

// fragment

fragment RefetchableFragmentFoo on RefetchableInterfaceFoo
@refetchable(queryName: "RefetchableFragmentFooQuery") {
id
}

持久查詢改進

如果您使用基於 URL 的持久查詢,您現在可以指定自訂標頭與持久查詢的請求一起傳送。例如,這可用於將授權標頭傳送到您的查詢持久性 URL 端點。

persistConfig: {
url: 'example.com/persist',
headers: {
Authorization: 'bearer TOKEN'
}
}

對於基於檔案的持久查詢,我們新增了一個新的功能旗標 compact_query_text,該旗標會從持久查詢文字中移除所有空白。這可以使檔案縮小 60% 以上。這個新的功能旗標可以在您的 Relay 設定檔中啟用。

persistConfig: {
file: 'path/to/file.json',
algorithm: 'SHA256'
},
featureFlags: {
compact_query_text: true
}

類型安全更新現在支援遺失欄位處理常式

類型安全更新程式現在支援遺失欄位處理常式。先前,如果您在類型安全更新程式中選取 node(id: 4) { ... on User { name, __typename } },但該使用者以其他方式擷取(例如使用 best_friend { name }),您將無法使用類型安全更新程式存取和修改該使用者。

在此版本中,我們在類型安全更新程式中新增了對遺失欄位處理常式的支援,這表示如果為節點設定了遺失欄位處理常式(如此範例),您將可以使用此遺失欄位處理常式更新使用者的名稱。

為了支援此功能,遺失欄位處理常式的簽名已變更。處理常式的 record 引數以前接收 Record 類型(這是一個未輸入的資料包)。現在它接收 ReadOnlyRecordProxy。此外,類型為 NormalizationLinkedField 的欄位引數現在為 CommonLinkedField,這是一個包含在 ReaderLinkedFieldNormalizationLinkedField 中找到之屬性的類型。

Flow 類型改進

Flow 使用者現在可以從具有更多 Relay API 的 graphql 文字中推斷類型。Flow 使用者不再需要明確輸入 usePreloadedQueryuseQueryLoaderuseRefetchableFragmentusePaginationFragmentuseBlockingPaginationFragment API 方法的傳回值。

Relay Resolver 改進

自上次發布以來,我們的大部分開發工作都投入到了改進 Relay Resolver(在圖表中公開衍生資料的機制)。值得注意的是,Relay Resolver 仍然是實驗性的,未來可能會發生 API 變更。

更簡潔的 docblock 標籤

Relay Resolver 函數的註解已簡化。在許多情況下,您現在可以使用 ParentType.field_name: ReturnType 語法來定義您的 Relay Resolver 公開的新欄位。

之前

/**
* @RelayResolver
* @onType User
* @fieldName favorite_page
* @rootFragment myRootFragment
*/

之後

/**
* @RelayResolver User.favorite_page: Page
* @rootFragment myRootFragment
*/

在上述範例中,Page 類型是結構描述類型。如果您的 Relay Resolver 不傳回結構描述類型,您可以使用固定 RelayResolverValue 值作為傳回類型

/**
* @RelayResolver User.best_friend: RelayResolverValue
* @rootFragment myRootFragment
*/

在每個檔案中定義多個解析器

在此版本之前,我們只允許每個檔案一個 Relay Resolver,並且要求 Relay Resolver 函數是預設匯出。在 Relay 15 中,您現在可以在每個檔案中定義多個 Relay Resolver,並使用具名匯出。

/**
* @RelayResolver User.favorite_page: Page
* @rootFragment favoritePageFragment
*/
function usersFavoritePage(){
...
}

/**
* @RelayResolver User.best_friend: RelayResolverValue
* @rootFragment bestFriendFragment
*/
function usersBestFriend(){
...
}

module.exports = {
usersFavoritePage,
usersBestFriend
}

快樂查詢!

·22 分鐘閱讀
客座文章

這是一篇由 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

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

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

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

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

  1. 您的元件是可選 UI,如果欄位或一組欄位為空值,則根本不應呈現
  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)
}
}

對於這個片段,我們表示如果我們沒有取得 name 或 slug 欄位,則整個片段都無效。如果這些欄位從伺服器回傳為空值,我們就根本無法呈現這個元件。此片段也顯示了如何使用 @required(action: LOG/NONE) 指令來使整個物件類型欄位失效。此片段表示,如果我們沒有 supply.totalsupply.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 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 指令在這裡非常出色,因為它消除了我們原本必須編寫的複雜的空值檢查。我們不必檢查 asset.nameasset.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 {
# Returns an array of items
timestamp
price @required(action: THROW)
}
}
`,
assetPriceRef,
);
}

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

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

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」。當答案通常更為細微時,特別是當您的片段有十幾個或更多的欄位時,這個答案會將什麼是必需的和什麼不是必需的決定簡單化。但是,我們可以遵循一些最佳實務來決定是否將欄位標記為必需。再次提醒,與您的產品經理和設計師合作以幫助您做出這些決定非常重要。

在省略 @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) # 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>
);
}

總結

如果您從本文中學到了什麼,希望是需要仔細思考如何處理停機和服務中斷的問題。處理失敗狀態是建立世界級應用程式的重要環節。在規劃新功能時,請確保您的設計和 PM 團隊與您的團隊達成共識。如果他們沒有為您提供關於在資料遺失時向使用者顯示什麼內容的建議,請積極推動,讓團隊在這些決策上達成共識。

Relay 可以成為協助處理應用程式失敗的強大工具。它能夠精細地協助您決定如何處理失敗,這可能比您習慣的要付出更多努力。但是,從長遠來看,這種額外的努力會得到回報,並大大有助於改善客戶對您的應用程式的體驗。

·12 分鐘閱讀

我們非常興奮地宣布今天以開放原始碼的形式發布新的基於 Rust 的 Relay 編譯器預覽版(如 v13.0.0-rc.1)!這個新的編譯器速度更快,支援新的執行階段功能,並為未來的額外成長奠定了堅實的基礎。

在這個版本發布之前,Meta 的程式碼庫一直在增長,沒有停止的跡象。在我們這種規模下,編譯程式碼庫中所有查詢所花費的時間直接損害了開發人員的生產力。儘管我們嘗試了許多策略來最佳化我們基於 JavaScript 的編譯器(如下所述),但我們逐步提高效能的能力無法跟上程式碼庫中查詢數量的增長。

因此,我們決定用 Rust 重寫編譯器。我們選擇 Rust 是因為它速度快、記憶體安全,並且可以輕鬆地在執行緒之間安全地共享大型資料結構。開發工作於 2020 年初開始,編譯器於當年年底在內部發布。部署過程順利,沒有中斷應用程式的開發。最初的內部基準測試表明,編譯器的平均效能提高了近 5 倍,P95 提高了近 7 倍。從那時起,我們進一步提高了編譯器的效能。

這篇文章將探討為什麼 Relay 有編譯器、我們希望透過新的編譯器解鎖什麼、它的新功能,以及我們為什麼選擇使用 Rust 語言。如果您急於開始使用新的編譯器,請查看編譯器套件的 README發行說明

為什麼 Relay 有編譯器?

Relay 有編譯器是為了提供穩定性保證並實現卓越的執行階段效能。

要了解原因,請考慮使用該框架的工作流程。使用 Relay,開發人員使用一種名為 GraphQL 的宣告式語言來指定每個元件需要哪些資料,而不是如何獲取資料。然後,編譯器將這些元件的資料依賴性拼接成查詢,這些查詢會取得給定頁面的所有資料,並預先計算 Artifact,使 Relay 應用程式具有如此高的效能和穩定性。

在這個工作流程中,編譯器:

  • 允許獨立推論元件,使一大類錯誤變得不可能,以及
  • 將盡可能多的工作轉移到建置時間,顯著提高使用 Relay 的應用程式的執行階段效能。

讓我們依次探討每一個。

支援本地推論

使用 Relay,元件僅透過使用 GraphQL 片段指定自己的資料需求。然後,編譯器將這些元件的資料依賴性拼接成查詢,這些查詢會取得給定頁面的所有資料。開發人員可以專注於編寫元件,而無需擔心其資料依賴性如何適合更大的查詢。

但是,Relay 更進一步地實現了本地推論。編譯器還會產生 Relay 執行階段用於讀取給定元件的片段所選資料的檔案(我們稱之為資料遮罩)。因此,元件永遠不會存取(在實務上,而不僅僅是在類型層級!)它沒有明確要求的任何資料。

因此,修改一個元件的資料依賴性不會影響另一個元件看到的資料,這表示開發人員可以獨立推論元件。這使 Relay 應用程式具有無與倫比的穩定性,並使一大類錯誤變得不可能,並且是 Relay 可以擴展到許多開發人員接觸同一個程式碼庫的關鍵部分。

提升執行階段效能

Relay 還利用編譯器將盡可能多的工作轉移到建置時間,從而提高 Relay 應用程式的效能。

由於 Relay 編譯器具有所有元件資料依賴性的整體知識,因此它能夠編寫與手動編寫一樣好(通常甚至更好)的查詢。它能夠透過以在執行階段速度較慢的方式最佳化查詢來做到這一點。例如,它會修剪從產生的查詢中永遠無法存取的程式碼分支,並展平查詢的相同部分。

而且,由於這些查詢是在建置時間產生的,因此 Relay 應用程式永遠不會從 GraphQL 片段產生抽象語法樹 (AST)、操縱這些 AST 或在執行階段產生查詢文字。相反地,Relay 編譯器會將應用程式的 GraphQL 片段替換為預先計算好的最佳化指令(以純 JavaScript 資料結構的形式),這些指令描述如何將網路資料寫入儲存區並讀取出來。

這種安排的另一個好處是,Relay 應用程式組合不包含架構,而且當使用持久化查詢時,也不包含 GraphQL 片段的字串表示形式。這有助於減少應用程式大小,節省使用者的頻寬並提高應用程式效能。

事實上,新的編譯器更進一步,以另一種方式節省使用者的頻寬 - Relay 可以在建置時間將每個查詢文字告知應用程式的伺服器,並產生唯一的查詢 ID,這表示應用程式永遠不需要透過使用者緩慢的網路傳送可能非常長的查詢字串。當使用此類持久化查詢時,唯一必須透過網路傳送以發出網路請求的內容是查詢 ID 和查詢變數!

新的編譯器能做什麼?

相較於動態語言,編譯式語言有時會被認為會引入摩擦,並拖慢開發人員的速度。然而,Relay 利用編譯器來減少摩擦,並使常見的開發人員任務變得更容易。例如,Relay 公開了用於常見互動的高階原語,這些互動很容易出錯,例如分頁和使用新變數重新提取查詢。

這些互動的共同點是,它們需要從舊的查詢產生新的查詢,因此涉及樣板程式碼和重複 — 這是自動化的理想目標。Relay 利用編譯器的全域知識,使開發人員能夠透過新增一個指令和變更一個函式呼叫來啟用分頁和重新提取。就這樣。

但是,讓開發人員能夠輕鬆新增分頁只是冰山一角。 我們對編譯器的願景是,它提供更多用於交付功能和避免樣板程式碼的高階工具,為開發人員提供即時協助和見解,並且由其他用於處理 GraphQL 的工具可以使用的部分組成。

這個專案的主要目標是,重寫後的編譯器架構應能讓我們在未來幾年實現此願景。

雖然我們尚未達到目標,但我們在每個標準上都取得了顯著的成就。

例如,新的編譯器支援新的 @required 指令,如果讀取時給定的子欄位為 null,則會使父連結欄位失效或拋出錯誤。這聽起來可能只是微不足道的生活品質改進,但如果您的元件程式碼有一半是 null 檢查,@required 看起來就相當不錯!

未使用 @required 的元件
使用 @required 的元件

接下來,編譯器支援僅限內部使用的 VSCode 擴充功能,該擴充功能可在您輸入時自動完成欄位名稱,並在滑鼠懸停時顯示類型資訊,以及其他許多功能。我們尚未公開,但我們希望在某個時候公開!我們的經驗是,此 VSCode 擴充功能使處理 GraphQL 資料變得更容易和更直覺。

最後,新的編譯器被編寫為一系列獨立的模組,可以由其他 GraphQL 工具重複使用。我們稱之為 Relay 編譯器平台。在內部,這些模組被重複用於其他程式碼產生工具,以及用於不同平台上的其他 GraphQL 用戶端。

編譯器效能

到目前為止,我們已經討論了為什麼 Relay 有編譯器,以及我們希望重寫能帶來什麼。但我們尚未討論為什麼我們決定在 2020 年重寫編譯器:效能。

在決定重寫編譯器之前,隨著我們的程式碼庫成長,編譯程式碼庫中所有查詢所需的時間逐漸但毫不留情地變慢。我們提高效能的能力無法跟上程式碼庫中查詢數量的成長,我們看不到任何逐步擺脫這種困境的方法。

達到 JavaScript 的極限

先前的編譯器是用 JavaScript 編寫的。這是一個很自然的語言選擇,原因如下:它是我們團隊最有經驗的語言,它是編寫 Relay 執行階段的語言(允許我們在編譯器和執行階段之間共用程式碼),以及編寫 GraphQL 參考實作和行動 GraphQL 工具的語言。

編譯器的效能維持了相當長一段時間:Node/V8 配備了高度最佳化的 JIT 編譯器和垃圾收集器,如果您小心謹慎(我們是),速度可能會非常快。但編譯時間不斷增加。

我們嘗試了許多策略來跟上

  • 我們已使編譯器能夠增量編譯。為了回應變更,它只會重新編譯受該變更影響的相依性。
  • 我們已識別出哪些轉換速度較慢(也就是說,flatten),並盡可能進行演算法上的改進(例如新增記憶化)。
  • 官方的 graphql npm 套件的 GraphQL 結構描述表示需要數 GB 的記憶體來表示我們的結構描述,因此我們用自訂的分支取代了它。
  • 我們在最熱門的程式碼路徑中進行了效能分析器引導的微最佳化。例如,我們停止使用 ... 運算子來複製和修改物件,而是傾向於在複製物件時明確列出物件的屬性。這保留了物件的隱藏類別,並使程式碼能夠更好地 JIT 最佳化。
  • 我們將編譯器重組為 shell 執行多個工作執行緒,每個工作執行緒處理單一結構描述。在 Meta 之外,具有多個結構描述的專案並不常見,因此即使這樣,大多數使用者也會使用單執行緒編譯器。

這些最佳化不足以跟上 Relay 在內部快速採用的速度。

最大的挑戰是 NodeJS 不支援使用共享記憶體的 多執行緒程式。最佳的方法是啟動多個透過傳遞訊息進行通訊的工作執行緒。

這在某些情況下效果很好。例如,Jest 採用此模式,並在轉換檔案時使用所有核心來執行測試。這是很合適的,因為 Jest 不需要跨程序共享太多資料或記憶體。

另一方面,我們的結構描述實在太大了,無法在記憶體中有多個實例,因此根本沒有好的方法可以在 JavaScript 中有效率地使用每個結構描述多個執行緒並行化 Relay 編譯器。

決定使用 Rust

在我們決定重寫編譯器後,我們評估了許多語言,以了解哪一種最能滿足我們專案的需求。我們想要一種速度快、記憶體安全且支援並行的語言 — 最好是在建置時而不是在執行階段捕獲並行錯誤。同時,我們想要一種在內部受到良好支援的語言。這將選擇範圍縮小到幾個選項

  • C++ 符合大多數標準,但感覺很難學習。而且,編譯器在安全性方面的協助不如我們所願。
  • Java 也可能是一個不錯的選擇。它可以很快,而且是多核心的,但提供的低階控制較少。
  • OCaml 是編譯器領域中經過驗證的選擇,但多執行緒具有挑戰性。
  • Rust 速度快、記憶體安全且支援並行。它可以輕鬆安全地跨執行緒共享大型資料結構。由於圍繞 Rust 的普遍熱情、我們團隊的一些先前經驗以及 Facebook 其他團隊的使用,這是我們明顯的首選。

內部推出

結果證明 Rust 非常適合!主要由 JavaScript 開發人員組成的團隊發現 Rust 很容易上手。而且,Rust 的進階類型系統在建置時捕獲了許多錯誤,幫助我們保持了高速開發。

我們在 2020 年初開始開發,並於該年底在內部推出了編譯器。最初的內部基準測試顯示,編譯器的平均效能提高了近 5 倍,P95 的效能提高了近 7 倍。從那時起,我們進一步提高了編譯器的效能。

在 OSS 中發布

今天,我們很高興發布新版本的編譯器,作為 Relay v13 的一部分。新的編譯器功能包括

您可以在 README發行說明 中找到有關編譯器的更多資訊!

我們將繼續在編譯器中開發功能,例如讓開發人員能夠存取圖形上的衍生值、新增支援更符合人體工學的語法來更新本機資料,以及完整開發我們的 VSCode 擴充功能,我們希望將所有這些都發布到開放原始碼。我們對此版本感到自豪,但還有更多內容即將推出!

感謝

感謝 Joe Savona、Lauren Tan、Jason Bonta 和 Jordan Eldredge 為此部落格文章提供精彩的回饋。感謝 ch1ffa、robrichard、orta 和 sync 提交與編譯器錯誤相關的問題。感謝 MaartenStaa 新增 TypeScript 支援。感謝 @andrewingram 指出啟用 @required 指令有多困難,現在預設已啟用。還有許多其他人做出了貢獻 — 這確實是社群的努力!

·6 分鐘閱讀

我們非常興奮地發布 Relay Hooks,這是迄今為止最方便開發人員使用的 Relay 版本,並於今天 提供給 OSS 社群!Relay Hooks 是一組新的、經過重新思考的 API,用於使用 React Hooks 提取和管理 GraphQL 資料。

新的 API 與現有的、基於容器的 API 完全相容。雖然我們建議使用 Relay Hooks 編寫任何新程式碼,但將現有容器遷移到新的 API 是可選的,基於容器的程式碼將繼續工作

雖然這些 API 是新發布的,但它們並非未經測試:重寫後的 Facebook.com 完全由 Relay Hooks 驅動,自 2019 年中期以來,這些 API 一直是在 Facebook 上使用 Relay 的建議方法。

此外,我們還發布了重寫的 導覽更新的文件,其中總結了自首次開發 Relay 以來我們所學到的,用於建構可維護的資料驅動應用程式的最佳實務。

雖然在開始使用 Relay 之前,我們還有很長的路要走才能達到我們想要的簡單程度,但我們相信這些步驟將使 Relay 開發人員體驗得到顯著改善。

發布了什麼?

我們發布了 Relay Hooks,這是一組基於 React Hooks 的 API,用於處理 GraphQL 資料。我們還藉此機會發布了其他改進,例如更穩定的 fetchQuery 版本,以及使用 getDataID 自訂 Relay 中物件識別碼的能力(如果您的伺服器沒有全域唯一的 ID,這會很有用。)

請參閱 發行說明,以取得發布內容的完整清單。

Hooks API 有什麼優勢?

新發布的 API 在以下幾個方面改善了開發人員體驗

  • 用於提取查詢、使用片段載入資料、分頁、重新提取、變異和訂閱的基於 Hooks 的 API,通常比同等的基於容器的解決方案需要更少的程式碼行,並且具有更少的間接性。
  • 這些 API 具有更完整的 Flow 和 Typescript 覆蓋範圍。
  • 這些 API 利用編譯器功能來自動化容易出錯的任務,例如產生重新提取和分頁查詢。
  • 這些 API 具備設定提取策略的功能,讓您可以決定查詢應從儲存區滿足的條件,以及何時應發出網路請求。
  • 這些 API 讓您可以在元件渲染之前開始提取資料,這是基於容器的解決方案無法實現的。這能讓使用者更快看到資料。

以下範例展示了新 API 的一些優勢。

使用不同變數重新提取片段

首先,讓我們看看如何使用 Hooks API 使用不同的變數重新提取片段

type Props = {
comment: CommentBody_comment$key,
};

function CommentBody(props: Props) {
const [data, refetch] = useRefetchableFragment<CommentBodyRefetchQuery, _>(
graphql`
fragment CommentBody_comment on Comment
@refetchable(queryName: "CommentBodyRefetchQuery") {
body(lang: $lang) {
text
}
}
`,
props.comment,
);

return <>
<CommentText text={data?.text} />
<Button
onClick={() =>
refetch({ lang: 'SPANISH' }, { fetchPolicy: 'store-or-network' })
}>
>
Translate
</Button>
</>
}

將此與等效的基於容器的範例進行比較。基於 Hooks 的範例程式碼較少,不需要開發人員手動編寫重新提取查詢,重新提取的變數具有類型檢查,並明確指出如果查詢可以從儲存區中的資料滿足,則不應發出網路請求。

在渲染元件之前開始提取資料

新的 API 允許開發人員透過在元件渲染之前開始提取資料,更快地向使用者顯示內容。使用基於容器的 API 無法以這種方式預提取資料。請看以下範例

const UserQuery = graphql`
query UserLinkQuery($userId: ID!) {
user(id: $userId) {
user_details_blurb
}
}
`;

function UserLink({ userId, userName }) {
const [queryReference, loadQuery] = useQueryLoader(UserQuery);

const [isPopoverVisible, setIsPopoverVisible] = useState(false);

const maybePrefetchUserData = useCallback(() => {
if (!queryReference) {
// calling loadQuery will cause this component to re-render.
// During that re-render, queryReference will be defined.
loadQuery({ userId });
}
}, [queryReference, loadQuery]);

const showPopover = useCallback(() => {
maybePrefetchUserData();
setIsPopoverVisible(true);
}, [maybePrefetchUserData, setIsPopoverVisible]);

return <>
<Button
onMouseOver={maybePrefetchUserData}
onPress={showPopover}
>
{userName}
</Button>
{isPopoverVisible && queryReference && (
<Popover>
<React.Suspense fallback={<Glimmer />}>
<UserPopoverContent queryRef={queryReference} />
</React.Suspense>
</Popover>
)}
</>
}

function UserPopoverContent({queryRef}) {
// The following call will Suspend if the request for the data is still
// in flight:
const data = usePreloadedQuery(UserQuery, queryRef);
// ...
}

在此範例中,如果查詢無法從本機快取中的資料滿足,則會在使用者將滑鼠懸停在按鈕上時發出網路請求。當最終按下按鈕時,使用者將因此更快地看到內容。

相較之下,基於容器的 API 會在元件渲染時發出網路請求。

用於資料提取的 Hooks 和 Suspense

您可能已經注意到這兩個範例都使用了 Suspense。

儘管 Relay Hooks 將 Suspense 用於其某些 API,但React 中用於資料提取的 Suspense 的支援、一般指南和使用要求仍未準備就緒,React 團隊仍在定義即將發布的版本中此指南的內容。在 React 17 中使用 Suspense 時,存在一些限制。

儘管如此,我們現在發布 Relay Hooks 是因為我們知道這些 API 在支援即將發布的 React 版本方面走在正確的軌道上。即使 Relay 的 Suspense 實作的某些部分可能仍會變更,Relay Hooks API 本身是穩定的;它們已在內部被廣泛採用,並已在生產環境中使用超過一年。

有關此主題的深入探討,請參閱Suspense 相容性使用 Suspense 的載入狀態

接下來該去哪裡

請查看入門指南遷移指南導覽

感謝

Relay Hooks 的發布不僅僅是 React 資料團隊的工作。我們要感謝幫助實現這一目標的貢獻者

@0xflotus、@AbdouMoumen、@ahmadrasyidsalim、@alexdunne、@alloy、@andrehsu、@andrewkfiedler、@anikethsaha、@babangsund、@bart88、@bbenoist、@bigfootjon、@bondz、@BorisTB、@captbaritone、@cgriego、@chaytanyasinha、@ckknight、@clucasalcantara、@damassi、@Daniel15、@daniloab、@earvinLi、@EgorShum、@eliperkins、@enisdenjo、@etcinit、@fabriziocucci、@HeroicHitesh、@jaburx、@jamesgeorge007、@janicduplessis、@jaroslav-kubicek、@jaycenhorton、@jaylattice、@JonathanUsername、@jopara94、@jquense、@juffalow、@kafinsalim、@kyarik、@larsonjj、@leoasis、@leonardodino、@levibuzolic、@liamross、@lilianammmatos、@luansantosti、@MaartenStaa、@MahdiAbdi、@MajorBreakfast、@maraisr、@mariusschulz、@martinbooth、@merrywhether、@milosa、@mjm、@morrys、@morwalz、@mrtnzlml、@n1ru4l、@Nilomiranda、@omerzach、@orta、@pauloedurezende、@RDIL、@RicCu、@robrichard、@rsmelo92、@SeshanPillay25、@sibelius、@SiddharthSham、@stefanprobst、@sugarshin、@taion、@thedanielforum、@theill、@thicodes、@tmus、@TrySound、@VinceOPS、@visshaljagtap、@Vrq、@w01fgang、@wincent、@wongmjane、@wyattanderson、@xamgore、@yangshun、@ymittal、@zeyap、@zpao 和 @zth。

開源專案relay-hooks讓社群能夠試驗 Relay 和 React Hooks,並為我們提供了寶貴的回饋。useSubscription hook 的想法源自該儲存庫中的一個問題。感謝 @morrys 驅動這個專案,並在我們的開源社群中扮演如此重要的角色。

感謝您們協助實現這一切!