標籤:

解讀GraphQL(三):Relay—面向未來的前端框架

註:Relay現在已經放出Relay Modern的RC版了,也就是常說的Relay 1.0和Relay 2。和本文里期望的類似,框架的各方面也已經變得更加簡單透明,Fat Query也不復存在,我們也可以從中看到更大比例的靜態化設計……甚至比Relay Classic更容易預測和優化。這意味著Relay從一個很難上手的內部框架已經向一個更貼近開源風格的前端框架轉變。

這篇文章是這個系列的尾聲,也是我們可以一窺GraphQL,Relay生態究竟可以為我們提供怎樣優勢的時候了。

我們都知道,一項技術的普及,很大程度來源於舊工具解決某些新問題過於棘手的問題。我們看到前端迅猛發展的過程中,社區逐漸開始拋棄jQuery和Angular——因為當我們的場景越來越複雜時,它們就會開始失控,讓我們面臨越來越多的維護和性能問題。即使對於現在流行的React + Redux而言,這個方案依然有很多不便之處,但我們的前端發展還沒有那麼快,所以它們遭遇的痛點有限,社區也沒有大規模拋棄它們。但這不說明這些痛點不存在,而是我們的前端應用還沒發展那麼快,因此也不是非得用更好的技術就不能實現我們的需求。

然而前端逐漸趨於複雜的趨勢還沒有結束,Facebook的場景更複雜苛刻,因此在我們之前就遭遇了即將到來的問題。Relay是他們比Flux更進一步的解決方案,它把我們不需要關注的地方全都抽象到了Relay的代碼庫中,解決了絕大多數讓我們頭疼的前端問題。

……但這不代表你現在就開始採用:

鑒於Relay現在並非主流解決方案,並且它的API也未經簡化,有時候看起來難以理解,因此並不推薦在普通場景直接使用:

  • 與第一次接觸React類似,這個lib非常奇怪,和之前用的框架完全不同,文檔還特別難讀。時至今日,大家反而發現React的API遠比Angular少且簡單。所以其實學習曲線分為兩部分:一是你是否熟悉類似的解決方案,二是這個方案本來簡單與否——Angular有更多更複雜的指令和依賴注入系統。其實Relay的心智模型要比Redux還簡單,而後者經過社區的反覆安利現在已經非常主流了。現在React/Redux在我們這期雷達的框架象限中已經在4個Adopt中佔2個名額了。
  • 雖然Relay本身概念不是特別複雜,但它的API實現非常不符合直覺。這和剛出現Flux時Facebook官方放出的Flux庫類似,API又臭又長,完全不知道在說什麼。直到Redux的出現,用更簡潔的API實現了等價功能才被廣泛接受,Relay也類似,社區也在逐漸出現其他GraphQL客戶端。
  • Relay解決的痛點存在於各種SPA和移動端開發中,但對於現在的主流場景還不算致命,隨著時間的推移,這很可能會改變。

本文的主要價值在於我們可以看到這些被Facebook仔細總結過的問題,當在自己的項目中看到這些問題可能會引發更多的思考。因為大多數時候,部分問題會貫穿客戶端開發的始終,另外一部分問題則是進一步擴展之後的坑,這些可能會是寶貴的經驗。

開始之前:

其實我一年之前試用過一下Relay,像Apollo這些簡單的lib其實大部分跟著Code迅速敲一邊就知道是怎麼回事了。然而對於Relay,由於它封裝了太多的內容,導致Relay文檔的第一篇Tutorial看起來不知所云,完全沒有幫助,於是就沒繼續看。前一陣和豪門的某個同事聊到這個奇怪的lib時,他也認為好像寫文檔的喝多了。於是當天我就回顧了一下Relay的文檔,當我跳過Code部分,看到文檔後面分別從GraphQL和Relay的出發點來敘述各種細節時,事情才開始make sense起來。

起初想自己寫一篇關於Relay的文章,最後發現其實還是用自己的話和自己的例子敘述完全相同的內容,於是這篇其實主要算搬運。所以初衷是這樣的:我知道有非常多的人跟我一樣看了第一頁就跑了,所以把更有價值後兩頁內容搬運過來(為了輔助說明,有部分增刪改)。

對於字太多不看黨:這裡有一個圖文並茂的擬人版Relay生動敘述了主旨思想,很有意思。相信很多人已經看過Flux和Redux版了。

另外,如果覺得下面的cache機制不容易理解——可以把Relay的cache看做文明系列,或者魔獸爭霸之類的戰爭迷霧。也就是你沒探地圖的時候是黑的,這時候你沒cache過,但是你走開的時候雖然不是黑的,但你的cache可能是過期的,等你過去又update了。自認為比喻得十分恰當……

從GraphQL的角度出發

GraphQL允許客戶端在一次請求中準確聲明視圖所有所需的數據。與傳統的REST方式相比,它的請求效率要更高,而且也避免了服務端API代碼之間雷同的問題。更重要的是,GraphQL將服務端開發與客戶端開發徹底解耦,客戶端視圖的變動完全不會影響到服務端的代碼結構。

請求數據

我們現在有一個請求stories列表的需求,除此之外我們還要請求每個story的詳情。我們用面向資源的嚴格REST可能會這麼做:

// Fetch the list of story IDs but not their details:rest.get(/stories).then(stories => // This resolves to a list of items with linked resources: // `[ { href: "http://.../story/1" }, ... ]` Promise.all(stories.map(story => rest.get(story.href) // Follow the links ))).then(stories => { // This resolves to a list of story items: // `[ { id: "...", text: "..." } ]` console.log(stories);});

這種做法會出現n+1次HTTP請求,更別提對資料庫的請求了:1次用來獲取列表,n次用來獲取每個story詳情。用GraphQL,我們不用專門維護一個同時獲取stories和stoy信息的API,同時一次請求所有數據:

graphql.get(`query { stories { id, text } }`).then( stories => { // A list of story items: // `[ { id: "...", text: "..." } ]` console.log(stories); });

這個請求有兩點好處:

  • 所有的數據都一次請求完畢
  • 服務端和客戶端完全解耦:客戶端聲明自己需要的數據,而不依賴服務端來編寫正確的數據結構。

客戶端緩存

反覆重新獲取數據會讓應用變得很慢。比如,當你跳轉到獲取一個stories列表和每個story詳情的頁面意味著我們需要重新請求整個列表。我們現在用國際統一方式來解決:緩存。 在嚴格的REST系統里我們會將返回的數據,以uri為單位緩存,也就是每個特定的uri緩存一次:

var _cache = new Map();rest.get = uri => { if (!_cache.has(uri)) { _cache.set(uri, fetch(uri)); } return _cache.get(uri);};

這種緩存策略也可以對GraphQL生效,最簡單的方式就是和REST一樣,以query為單位來緩存:

var _cache = new Map();graphql.get = queryText => { if (!_cache.has(queryText)) { _cache.set(queryText, fetchGraphQL(queryText)); } return _cache.get(queryText);};

現在我們的新請求就會立刻返回緩存的結果了。這對於提高性能來說是很有效的方式,但這回引起數據一致性問題。

緩存一致性

用GraphQL的時候,我們的Query之間其實經常會有交集,也就是重複的部分。但我們上面的辦法沒辦法cache掉這些交集,因為我們的cache是以url或者單個query為單位的。比如說我們發這麼個請求,請求所有stories:

query { stories { id, text, likeCount } }

然後再重新請求下其中的一個story,假設這時候它的likeCount可能已經增加了:

query { story(id: "123") { id, text, likeCount } }

現在我們通過不同的方式可以獲得不同的likeCount,如果你用上面的query會拿到舊的count,如果你用下面的query會拿到新一點的count。

將圖緩存起來

其實正確cache GraphQL請求的方式應該是將query的嵌套結構打平,以獲得一個稱為records的集合。Relay內置實現了從ID到records以map形式緩存的過程。每個record也可以用一個link指向其他的record用以描述一個有向有環圖,這個link是一個可以從頂層map開始搜尋的特殊數據結構。用這個辦法,我們即使通過不同的方式請求也會獲取到相同的cache結果。 如下有個請求story下text和作者名字的請求:

query { story(id: "1") { text, author { name } }}

上述請求的返回值可能是這樣的:

query: { story: { text: "Relay is open-source!", author: { name: "Jan" } }}

即便返回值是嵌套的,Relay也會把它拍扁,變成records。所以對上面那個返回,Relay會這麼cache:

Map { // `story(id: "1")` 1: Map { text: Relay is open-source!, author: Link(2), }, // `story.author` 2: Map { name: Jan, },};

這只是一個最簡單的例子:實際使用起來Relay還會處理一對多和分頁的場景。

使用緩存

當我們接收到數據時我們會把它寫進緩存里,當我們在緩存中找到我們要的我們就直接讀取(概念上跟_cache.has(key)是類似的,只不過GraphQL是從圖裡面讀cache)。

寫入緩存

寫入緩存要先遍歷返回的GraphQL結構,然後返回一個拍平的cache records。直覺上這個辦法很直接,但這隻對很簡單的query有效。思考一下這麼個query:user(id: "456") { photo(size: 32) { uri } }——我們應該怎麼存這個photo?光用photo當cache鍵會導致不同參數之間的衝突(比如再緩存一個photo(size: 64) {...})。當我們分頁時也有同樣的問題:如果我們用stories(first: 10, offset: 10)獲取第11個到20個story的時候,返回結果應該拼接到緩存列表內(而不是單獨緩存,想像一下你在列表新加了一條數據,如果按照傳統page/{n}的API方式處理,所有的分頁都是錯位的,因此緩存必須全部失效,因此正確做法應該是緩存一個列表,這樣和頁數就完全無關了)。

因此所有拍平的GraphQL緩存結構應當包含參數信息然後並排放在一起。比如說剛才的photo就應該緩存在photo_size(32)這種鍵下來算上參數一起來唯一標識它自己。

讀取緩存

如果要讀取緩存的話我們的query就要遍歷各個field。其實這樣做聽起來起來和我們在GraphQL服務端做的事情完全相同。讀取緩存其實跟構建一個服務端可執行的GraphQL一樣,只是變成了一個更簡單的特例:1.因為我們是按特定規則存進去的,因此我們不用像在服務端那樣聲明自己的resolver,這個規則是自動的。2.因為這個cache graph在本地,所以請求cache是同步的:不然我們cache了,不然就沒cache,沒有請求的中間狀態。

Relay內部實現了許多種query遍歷的方式:比如它可以自己讀cache,也可以接受服務端的response。比如說,當我們發出一個query的時候,Relay會自動遍歷並Diff,然後決定哪些field沒被獲取過(跟React diff Virtual DOM樹的原理是類似的)。這樣我們就可以減少我們query的實際請求數量,甚至如果所有的field都被cache過Relay就直接不發請求了。假如我們首頁的某個組件會先發這麼一個請求:

query { viewer { name, company } }

比如我們進入下一頁時,可能是個人中心頁面,我們的某個組件會請求更具體的信息:

query { viewer { name, company, email, phone } }

當你打開Network面板的時候,你會發現Relay只請求了:

query { viewer { email, phone } }

更新緩存

我們可以發現,Relay這種扁平的cache池在緩存互相重疊的各種query時是不會重複的。無論你怎麼請求數據,一個具體record只可能緩存在同一個地方。我們回顧一下上面緩存不一致的小例子:

query { stories { id, text, likeCount } }

當我們cache這個query返回的數據時,每個stories列表中的每個story都會存成一個record,然後stories這個欄位會Link到所有的上述record上。 於是我們接著請求了其中的某個story:

query { story(id: "123") { id, text, likeCount } }

當Relay拍平這個query返回的數據時,Relay就能根據每個實體身上的id(GraphQL的ID類型跟UUID類似,是全局唯一,自動分配的,只不過默認是Base64的),找到這個已經存在的123 record並更新,而不是去新建一個。於是所有新的query如果要請求likeCount都一定會拿到這個新的cache結果。

數據/視圖一致性

一個扁平的cache結構保證了緩存一致性。但對於View來說呢?我們知道React會相應數據變化,渲染最新數據。我們現在考慮一個渲染story內容、評論以及作者名字和照片的場景,比如這就是我們要發的GraphQL query:

query { node(id: "1") { text, author { name, photo }, comments { text, author { name, photo } } }}

當你發出這麼個query,Relay會緩存成這樣(你能發現story和comment都會指向同一個author):

// Note: This is pseudo-code for `Map` initialization to make the structure// more obvious.Map { // `story(id: "1")` 1: Map { author: Link(2), comments: [Link(3)], }, // `story.author` 2: Map { name: Yuzhi, photo: http://.../photo1.jpg, }, // `story.comments[0]` 3: Map { author: Link(2), },}

這個作者自己佔了個沙發——也是挺常見的情形。接下來,假如這個作者改了自己的photo,然後其他組件又要去獲取這個更新的話,以下是唯一會變動的地方:

Map { ... 2: Map { ... photo: http://.../photo2.jpg, },}

photo的值已經變了,於是Relay就去修改record 2,僅此而已。cache其他的地方完全沒變。但很明顯,我們的view需要相應這種變化:這個story作者的photo,和他自己占沙發的photo都要更新。 熟悉React的人的第一反應就是"上immutable.js",因為當你變化節點的時候,整棵樹的引用就會產生變化(類似Java有常量池的字元串)——但我們看看這樣會發生什麼:

ImmutableMap { 1: ImmutableMap {/* same as before */} 2: ImmutableMap { ... // other fields unchanged photo: http://.../photo2.jpg, }, 3: ImmutableMap {/* same as before */}}

如果我們把2換成一個新的immutable record,我們會得到一個全新的cache對象。但我們已經拍平了這個緩存結構,這時候因為1和3並沒變,因此他們的Link就失效了。

實現視圖一致性

面對這個扁平cache,要更新視圖其實有多種方法可以選擇。Relay的辦法是自動訂閱每個view指向cache的ID集合,也就是每個特定引用的集合。在上面那個例子中,story的view會訂閱story(1),author(2)和comments(3和其他comments)。當有新數據寫入cache時,Relay會找出所有被影響的ID,然後只會通知那些訂閱這個ID的所有view。這樣就僅會通知重繪那些需要重繪的,而不需要重繪的就會無視這個update(實現原理是Relay container安全高效地override了shouldComponentUpdate)。如果沒有這種設計的話,任何一個小變化都會導致所有view重繪。

注意除了更新以外,所有的寫入cache的操作也會影響view,因為寫入僅僅是另一種形式的更新。

Mutations

我們現在已經了解query的過程,也知道view是如何更新的了。但我們還沒談到寫入操作。在GraphQL里,我們把寫入操作成為mutation。我們可以把它當做有副作用的query。比如下面就是個用current user來like一下某個story的mutation:

// Give a human-readable name and define the types of the inputs,// in this case the id of the story to mark as liked.mutation StoryLike($storyID: String) { // Call the mutation field and trigger its side effects storyLike(storyID: $storyID) { // Define fields to re-fetch after the mutation completes likeCount }}

但mutation很可能導致我們的某些query結果產生變化。這時候你可能會問:為什麼服務端就不能直接告訴我哪些欄位已經變化了呢?其實這個問題非常複雜。GraphQL只是一個可以封裝任何存儲系統(或者僅僅將多種資源整合起來)的抽象層,它可以實現在任何語言上。更重要的是,GraphQL的核心目的是產生客戶端渲染視圖所需的數據結構

我們發現其實GraphQL schema經常和你的database schema經常有本質上的區別。簡單地說,數據變化可能意味著你的database變化,也可能意味著你產品可見的數據(比如GraphQL)的變化。最恰當的例子就是隱私:你要在客戶端獲取一個age欄位,在database里會有一大堆欄位來控制UAC來決定你是不是能看見這個age(比如是不是好友,這個人的age是不是公開的,有沒有屏蔽請求方之類的欄位)。

所以考慮到現實約束,GraphQL客戶端還是得聲明mutation之後哪些query可能會變化。但是我們怎麼聲明呢?Facebook在實現Relay的過程中考慮過幾種方式,我們按順序看一下,就容易理解為什麼Relay如此設計了:

  • 方案1:重新請求所有曾經query過的數據。即使一個非常小的變化也會重新請求所有數據,這樣非常低效。
  • 方案2:重新請求正在被view渲染的數據。這樣快了一點,但沒被顯示的數據就沒被更新,當你切換view的時候就又不能保證數據一致性了。
  • 方案3:我們將可能變化的query聲明出來,然後重新請求這些數據。我們將其稱為fat-query。其實這也很低效,因為一般來說我們只會展示部分fat-query要請求的數據,這樣我還沒請求過的數據fat-query會浪費感情地幫我也獲取了。
  • 方案4(也就是Relay採用的):重新獲取fat-query和cache過的所有欄位的交集。除了cache數據本身,Relay也會在cache中標記這條數據是被哪條query請求來的。我們將其稱為tracked queries。Relay用這種求交集的方法,就總可以精確地請求僅需要更新的欄位了。

數據獲取的API

我們已經看過Relay底層是如何封裝數據緩存的了。我們現在退一步看看我們應該如何獲取數據:

  • 從一個嵌套的view結構中找到所有要請求的欄位。
  • 處理所有非同步請求,並賦值給應用內的狀態。
  • 處理網路錯誤。
  • 遇到錯誤之後重試。
  • 在query或mutation後更新本地cache。
  • 還要串列請求mutation防止race conditions。
  • 有時還要在伺服器返回之前直接update視圖,讓用戶感覺延遲更低(這稱為optimistically update)。 我們發現基於命令式API的傳統數據請求流程會讓開發人員處理太多不必要的複雜性。拿optimistically update來說,其實要做的事情很明顯:當用戶點擊"like"按鈕時,直接將按鈕變成"liked"然後發請求到服務端。但這實現起來就經常很複雜了。命令式的做法通常要求我們實現以下步驟:找到相應的view然後控制狀態按下button,然後開始發送網路請求,如果掛掉還要重試,如果重試不成功再顯示錯誤等等。對於數據獲取這個過程來說也一樣:其實聲明我們需要什麼數據就很大程度上直接決定了如何以及何時獲取。這時候我們就可以來看看Relay的上層的聲明式設計了。

從Relay的角度出發

Relay的數據獲取方式其實很大程度來自於React的使用經驗。具體地說,React將前端複雜的介面變成了可復用的組件,這允許開發者專註開發應用中被解耦出來的一部分。更重要的是,所有這些組件都是聲明式的:它允許開發者僅聲明在某種狀態下組件應該渲染成什麼樣子,完全不管這個組件是如何被渲染的。不同於以前直接在DOM上賦值修改數據,React接受描述UI的數據結構來幫你渲染。我們來看一些場景來理解這種思想在Relay理是怎麼體現的。

為視圖獲取數據

在客戶端中,絕大多數情況下你都要做的事情是:為一個嵌套的view獲取所有它需要的數據,在數據獲取前可能需要一個進度條,然後獲取到數據之後開始顯示。 一個解決方案是讓根組件獲取數據然後傳遞給子組件。然而,這種做法會引入耦合:每次你修改子組件的時候都要修改所有用到渲染這個組件的父組件。這種耦合意味著更多的bug和更慢的開發速度。最終我們發現這種做法並不會讓你在React這種組件模型中收益:這種數據依賴放進組件內部才比較自然。

另外一個看起來很靠譜的辦法是調用render()鉤子時開始請求數據。這也是通常的做法,我們渲染一個stories組件,然後請求stories,然後獲取完之後開始顯示具體的story,最後再去請求所有的story詳情。這聽起來還行,但是問題是這個請求是分段的:先得渲染出來一層組件,才知道下一步應該請求什麼,然後再渲染,再去請求。這會導致請求和渲染交替串列,最後渲染過程變得極慢無比。因此我們需要提前或者靜態地知道我們要獲取什麼數據。

最終Relay採用了靜態方法:組件的query都是靜態成員,因此可以立刻找齊所有query,用來描述整個要渲染的組件樹需要什麼數據。但這種方式需要我們有辦法把所有的query結合成query樹,這樣才能一次載入出所有數據,因此這也是Relay需要GraphQL來支持的核心原因——每組件內部query之間要可以結合成完整的query,而不是拿某部分query去直接調用某個請求API。

數據組件(即Container)

Relay允許開發者像React一樣創建一個數據container來為組件提供數據,而這種容器實質上就是在外麵包一層組件而已。一個現實問題是React組件的目的是為了復用,因此我們的container也得跟著可復用才行。舉例來說,一個<Story>組件必須要可以渲染任何Story數據。至於渲染的具體內容,取決於交給組件的數據:<Story story={ ... } />。在Relay裡面對等的概念就是fragment——一個聲明某個GraphQL類型需要獲取什麼內容的具名query片段,比如我們給Story的fragment長這樣:

fragment on Story { text author { name photo }}

然後我們就可以把這段fragment放進Story作為組件的數據container了:

// Plain React component.// Usage: `<Story story={ ... } />`class Story extends React.Component { ... } // "Higher-order" component that wraps `<Story>`var StoryContainer = Relay.createContainer(Story, { fragments: { // Define a fragment with a name matching the `story` prop expected above story: () => Relay.QL` fragment on Story { text, author { ... } } ` }})

渲染

在React中,渲染需要兩個東西:一個是你要渲染的組件,另外一個是你要把這個組件要渲染進的root DOM節點。渲染Relay container也類似:我們需要渲染一個要渲染的container,還有一個root query。所以類似ReactDOM.render(component, domNode),Relay有<Relay.Renderer Container={...} queryConfig={...}。所以container就是我們要渲染的東西,而queryConfig就是我們要請求的東西:

ReactDOM.render( <Relay.Renderer Container={StoryContainer} queryConfig={{ queries: { story: () => Relay.QL` query { node(id: "123") /* our `Story` fragment will be added here */ } ` }, }} />, rootEl)

Relay.Renderer可以將所有query合併,並與cache池做diff,然後請求所有沒cache的信息,之後更新cache,最後渲染StoryContainer。默認載入過程中不顯示內容,不過你也可以為這個Renderer添加一個render屬性來渲染一個loading組件。正如React可以讓你避免和DOM打交道一樣,Relay也可以幫你避免手動管理網路請求。

數據封裝

組件之間有隱式依賴很常見。舉例說,<StoryHeader>會用到一些不一定會被獲取到的數據——這些數據大多被其他部分獲取,比如<Story>。當我們修改<Story>然後去除部分請求邏輯時,<StoryHeader>會直接掛掉。這種bug,特別是在大項目里很難快速發現,因為只有渲染所有用到這個組件的地方之後你才發現它掛掉了。手動和自動化測試能幫你的其實很有限,因為它還是會反覆break,對於這種問題最好的辦法是讓框架來幫你從根源上避免這些問題,讓它不可能出現。

我們可以看到Relay container會自動保證所有數據獲取到之後再渲染。此外,container也帶來了數據封裝的好處。Relay只允許組件聲明和自己相關fragment內的欄位。所以一個組件獲取了一個Story的text,另外一個獲取了author,他們之間的數據是不可見的。事實上,甚至父級組件都不能知道子級到底請求了什麼數據:如果知道的話,這又破壞了封裝性。

在此基礎上,Relay還更進一步:它在內部隱式地通過props驗證你是不是真獲取到需要的數據了。如果<Story>渲染了<StoryHeader>但沒加入後者的fragment,Relay會警告你後者沒有拿到本應拿到的數據。當有其他組件也請求完全相同的數據時,Relay甚至能提醒你檢查你是不是在不知情的情況下又重複造了一個雷同的組件。這些檢查可以有效幫助你避免在組件化開發中絕大多數的Code smell。

小結

GraphQL提供了強大、高效並與客戶端解耦的工具鏈。Relay提供了完全聲明式的數據同步機制(和Apollo不同,Mutation通過fat-query和cache池交集的辦法完成了聲明式API)。通過將請求什麼怎麼完全分離的方式,Relay給開發者默認保證了絕大部分的健壯性,透明性和性能優化。它也會讓React的組件化設計更進一步。雖然React,Relay和GraphQL每一個都非常強大,但這三者的結合為開發者提供了快速開發優質可擴展的完整UI解決方案


推薦閱讀:

將 React 應用優化到 60fps
React 16 的異常/錯誤處理
react中createFactory, createClass, createElement分別在什麼場景下使用,為什麼要這麼定義?
React + HOC + Redux 極簡指南
2016-我的前端之路:工具化與工程化

TAG:GraphQL | React |