解讀GraphQL(二): 使用Apollo Data構建GraphQL應用
今天的主角是Apollo Data,它提供了一套與GraphQL相關的服務端/客戶端工具,大幅簡化GraphQL的開發。並且Apollo client分別有React(Native),Angular2,IOS,以及正在開發的Android版本。如果你所使用的平台不在上述範圍內,Apollo client也有原生JavaScript版本可用。它內部基於Redux編寫,因此你可以用非常科幻的Redux devtools檢測應用狀態,然而你並不需要了解Redux也可以使用Apollo。Talk is cheap,第一篇已經解釋了很多主要概念,Apollo也非常簡單。因此這篇將以Code為主。
文章較長,開頭先部分引用下結尾來提取一個TL;DR版本來說明主旨思想:我們以完全聲明式的代碼完成了query和渲染:也就是說我們代碼上只關心What,隻字不提When和How。這會極大地提高代碼的可維護性,簡化開發,因為狀態和賦值總是萬惡之源,更別提在JavaScript環境下必須大量使用非同步操作的情形。在不犧牲SPA複雜業務邏輯的情況下,通過GraphQL,我們可以把前端的開發工作重新簡化成編寫類似編寫HTML的簡單活動。
Task0:GraphQL Hello World
我們就先遵循慣例,通過GraphQL Hello一下:
const { graphql, buildSchema } = require(graphql) const schema = buildSchema(` type Query { hello: String } `) const resolver = { hello: () => Hello GraphQL! } const query = { hello } graphql(schema, query, resolver).then(console.log.bind(console))
首先我們定義了一個schema,一個schema的頂層要有一個Query類型,我們的所有查詢都是從這層開始的,Query類型裡面可以嵌套我們要查詢的其他欄位。我們用一個schema,一條查詢以及一個Resolver查詢出來一個{ data: { hello: Hello GraphQL! } }的結果。這個例子主要為了展示GraphQL雖然主要用在HTTP場景中,但它其實可以並不關心協議,你可以將其應用在任何地方。
Task1: Setup一個能用的React Webpack環境
今天我們會用到和GraphQL結合最自然的React client。不過眾所周知,我們處在一個JavaScript fatigue的時代,setup一個JavaScript項目有時候過於複雜了。本打算以shell這種最簡單的方式setup一下,結果發現光shell本身就快佔了一頁……於是還是做了個僅有我們需要功能的最簡Boilerplate:
git clone https://github.com/namelos/react-minimal-boilerplatecd react-minimal-boilerplate npm install npm start
然後打開localhost:3000這樣我們就能看到React渲染的頁面了。
Task2: 構建一個Hello World GraphQL Server
先安裝依賴,其中graphql是GraphQL的js核心實現;graphql-tools是Apollo組寫的一些工具,讓我們可以用簡單的GraphQL Schema而不是複雜的代碼構建我們的GraphQL server;而graphql-server-express顧名思義就是給express用的server中間件了:
npm install graphql graphql-tools graphql-server-express --save mkdir server && touch server/schema.js
然後我們接著在剛創建的schema.js寫入以下內容,由於我沒給server端setup babel,因此只能用commonjs require了。
/* server/schema.js */const { makeExecutableSchema } = require(graphql-tools) const Schema = ` type Query { hello(name: String!): String } `const Resolvers = { Query: { hello: (_, {name}) => `Hello, ${name}` }} module.exports = makeExecutableSchema({ typeDefs: Schema, resolvers: Resolvers })
這一段其實很類似第一段沒伺服器的版本,我們提供一個Schema,然後提供了實際的Resolvers,把它們合併起來就可以接受查詢了。其中,這個Schema規定了hello這個查詢,接受一個必填String參數(!的意思是必填,和許多語言的?正相反,GraphQL類型默認是可空的,只有加!的時候才是不可空的)。
之後修改下server.js,加上這兩行和它們需要require的東西。我們在這裡將GraphQL放到了/graphql的endpoint上,graphql-server-express默認接受POST請求,所以要給express再加上個body-parser。此外我們還在graphiql的endpoint加上了我們的開發工具graphiql,並指向/graphql:/* server.js */const bodyParser = require(body-parser)const { graphqlConnect, graphiqlExpress } = require(graphql-server-express)// ...app.use(/graphql, bodyParser.json(), graphqlConnect({ schema: Schema })) app.use(/graphiql, graphiqlExpress({ endpointURL: /graphql }) app.get(*, (req, res) => res.sendFile(path.join(__dirname, index.html)))// ...
這時候打開localhost:3000/graphiql,熟悉的界面又出來了。恭喜,你已經完成一個GraphQL server,快試一下查詢吧:
query ($someName: String!) { hello(name: $someName) }
這個query是什麼?以前頂層不都是一個大括弧嘛?其實頂層大括弧就是query開頭的簡寫。後面我們在query上增加了一個($someName: String!)的意思是增加一個query變數,作為整個query的參數使用,比如我下面就交給了hello的name參數,當然顯而易見的是它們的類型必須相同。我們還可以看到GraphiQL的左下角有個很猥瑣的Query Variables可以讓你輸入為Query準備的變數,於是我們輸入:
{"someName": "GraphQL"}
跟預期的一樣,返回{ "data": { "hello": "Hello GraphQL" } }。但是,你可能會發現,我們在GraphiQL中並不需要這個變數就能查詢到結果,那這個變數到底是幹什麼用的呢?其實它是讓客戶端可以動態改變的參數,在下面我們會看到如何使用它。
Task3: 在客戶端展示我們的Hello GraphQL!
老樣子,接著安裝客戶端package。apollo-client是通用的js client,而react-apollo是給react使用的binding,最後graphql-tag是讓我們生成query的小工具。不像其他React庫設置那麼繁瑣,我們僅僅在組件頂層把一個默認指向/graphql的client交給一個Provider組件,整個應用的所有組件就都有了自動獲取數據的能力:
npm install apollo-client react-apollo graphql-tag --save
將client/index.js修改成以下內容:
import React from reactimport { render } from react-domimport Apollo from apollo-clientimport { ApolloProvider, graphql } from react-apolloimport gql from graphql-tagconst client = new Apollo() const HelloComponent = ({ data }) => <h1>{data.hello}!</h1>const HelloQuery = gql` query ($someName: String!){ hello(name: $someName) } `const Hello = graphql(HelloQuery)(HelloComponent) render( <ApolloProvider client={client}> <Hello someName="GraphQL"/> </ApolloProvider>, document.querySelector(#app))
刷新頁面,我們就能看見渲染出來的Hello, GraphQL!了。我們剛剛增加的query變數被注入了查詢,除了將常量當做變數之外,更多時候我們將變數聲明為一個回調函數,這樣我們就更容易在組件內控制這個查詢參數了。我們把套在組件外面,並為組件提供數據的查詢成為Container。當組件被渲染時,query就會被調用來自動從服務端同步數據。 在這裡graphql是一個科里化函數,你可以看到連續兩個括弧。這樣做的好處是你可以將一個query container放在多個組件上面,用來多處顯示這些數據,也不用重複查詢。 在這個例子里,我們可以看到我們並沒有管理髮送請求,獲取數據以及數據怎麼被注入React組件這些小事,而僅僅聲明一個查詢,然後把查詢和組件綁定到一起,Apollo就自動幫我們完成了這些事情。這樣,我們跳過了非同步請求和狀態賦值這些坑,直接以聲明的形式完成了組件渲染。
Task4: Todo MVC
我們現在完成了一個最簡應用,在這個基礎上,我們已經熟悉了基本API。但要繼續展示更多的概念,我們需要稍微複雜點的的應用,於是我們再造一遍TodoMVC……為了簡單我們只放進來展示和Add的功能。 於是我們修改下schema:
type Todo { id: Int! text: String} type Query { todos: [Todo] todo(id: Int!): Todo } type Mutation { addTodo(text: String!): Todo }
這個schema定義了Todo類型,todos和todo這兩個查詢,還有一個叫做addTodo的Mutation,這就算是我們給前端的契約,你也可以用這個schema生成json Contract來給IDE或者測試使用。再來修改下resolver:
const generateId = (() => { let id = 0 return () => id++ })() const todos = [] const Resolvers = { Query: { todos: () => todos, todo: (_, {id}) => todos.find(todo => todo.id == id) }, Mutation: { addTodo: (_, {text}) => { let todo = { id: generateId(), text } todos.push(todo) return todo } }}
我們現在以最簡單的形式完成了這幾個API。上面兩段很簡單,只是個id generator和一個被當做DB的todos。在現實的場景里,你可以很容易地接入一個真的DB進來。
這時候我們發現schema和resolver是有很簡單的對應關係的,就好像我們平常用MVC框架裡面的route和action類似,只不過對於複雜的查詢,不同resolver可能被執行很多次。這樣我們的代碼粒度就更細,也更簡單——每個resolver只用做一件非常具體的事情,而怎麼組裝則是在高層的query和graphql本身包辦了,這樣是不是很單一職責+依賴倒置?並且在resolver中我們不用關心request到底是什麼,只接受參數,然後返回和schema一致的類型就好了。如果類型不對,根據這個欄位可空與否,會返回null或直接報錯。比起上面寫過的的resolvers,下面多了一個傳說中的Mutation:我們平常用Query來返回數據,用Mutation來寫,實現上其實沒很大區別,值得注意的是graphql沒辦法約束你的query不能產生副作用,因此要注意不要在query中篡改數據。這是一種明顯的讀寫分離的思想,並且對於客戶端來說,進行讀取任何並不會影響其他視圖狀態,但進行寫入就可能出現數據不同步的情況,要單獨處理,所以我們要盡量分開它們。 這時候我們重啟下server讓代碼生效(node server代碼在內存跑,里要重啟才能生效,客戶端直接刷新就好),然後試一下插入數據:mutation { addTodo(text: "Finish my first GraphQL app") { id }}
然後再查詢一下:
{ query { todos { text } } }
我們看到mutation也是有返回類型的,我們的mutation直接返回Todo類型,這樣方便我們mutate成功之後在客戶端更新數據。
現在我們的server已經能正常工作了,讓我們來看看主角客戶端(在GraphQL的世界裡客戶端總是甲方……),由於我們已經插入過數據了,沒重啟伺服器可以直接先做展示。讓我們先從我們的基礎組件開始,因為它最簡單:const Todo = ({ todo: { id, text } }) => <li>{id}: {text}</li> Todo.fragments = { todo: gql` fragment Todo on Todo { id text } ` }
這個組件本身簡單得不能再簡單了,實質就是一個無instance返回Virtual DOM對象的函數,沒啥好說的,這也是很多人喜歡React的原因,你可以將界面簡單地以組件樹的形式編寫出來。
而下面的查詢就有點奇怪了,這個fragment是啥呢?其實它是我們GraphQL query的組件。怎麼用我們繼續往下看:const TodosComponent = ({ data }) => <ul> { data.todos && data.todos.map((todo, i) => <Todo todo={todo} />) } </ul> const TodosQuery = gql` query { todos { ...Todo } } ${Todo.fragments.todo} `const Todos = graphql(TodosQuery)(TodosComponent)
然後再修改下render就可以正常展示了:
render(<ApolloProvider client={client}> <Todos /> </ApolloProvider>, document.querySelector(#app))
這裡又回到我們熟悉的場景了:graphql接受一個query和一個組件。我們只是用字元串插值將Todo的query合併到了Todos的查詢中,fragment就好像graphql的對象碎片,可以用跟esNext一樣的...語法來展開這個類型。這個query的結果還是發一個query去獲取整個todos,但是todo裡面的內容正如Todo組件自己聲明的query一樣,獲取id和text
這樣,我們就把query也組件化了,並且這種組件化的組織形式和組件本身是同構的:每有一個類型對應的組件,就將對這個類型的query和這個組件對應起來,這樣我們就獲得了一個有點像著名的7層協議抽象分層的組件化系統:<Todos />這個組件下面要渲染<Todo />這個子組件,它只應該知道要查詢所有的todos,並將每條todo交給<Todo />這個組件,至於todo裡面是什麼,它完全不關心,這是<Todo />自己應該關心的事情。在以往的React特別是Flux / Redux開發中,比較惱人的地方就是你總要將許多許多東西從父級向子級傳遞——這就導致了組件之間雖然是明顯分層的,但它們之間還是必須向對方暴露API,而這種API在視圖上是非常不穩定的。這是實際的React開發中每天都要反覆發生的事情。 在React中的組件化設計中,由於組件粒度經常比MVVM更細,因此遞歸嵌套的風格佔據了核心地位,以至於在其他領域工作良好的Mixins也被當做反模式——因為Mixins並不是嵌套的,因此官方在新的API中移除Mixins,推薦使用Higher Order Component嵌套來解決組合問題。但是這也帶來的嵌套過深傳遞繁瑣的問題。在GraphQL客戶端中,我們將query和組件以同樣的形式嵌套,這樣就徹底避免了嵌套需要多層傳遞的問題。 這種概念被Facebook成為co-location:在客戶端一個有數據要顯示的組件,必定在服務端中大多有一個實體對應:你所渲染的<Todo id="1" text="foo" />在服務端就有一個Todo(id: 1, text: "foo")的數據存在。因此,很大程度上我們要簡化我們的開發,目的就是拉近二者的距離,避免不必要的彎路。 我們也可以在這裡抽空對比一下基於URL的RESTful方案。對於大部分的應用,我們的Todo list可能要對應某個具體用戶。於是我們先要請求用戶資源,然後再去分別獲取Todo資源。當然Todo是個很小的東西,可能就內聯到todos中了。我們可以換做考慮article這些大一點的內容。我們可能需要多次請求,因為它們可能是不同資源:GET user/12345/articles GET article/{articleId}
這種請求可能比想像的要長,因為我們必須等待第一個請求返回才知道第二個請求的參數。 和React只有一個render入口,root component計算出整個Virtual DOM渲染一樣——root query也會遞歸地集齊所有碎片,然後一次發出查詢,這種場景可能就是:
{ user(id: 12345) { articles { title content comments { user { name } content } } } }
考慮以上場景,用簡單的url表示,不然要分資源做多個不同的API,不然就要寫一些很奇怪的back-end for front-end api了。 如果為GraphQL的這種模式找個類似的REST替代品的話,我們再看下這種co-location如果在REST上使用的情況,相信很多同學都跟我做過一樣的事,即便用的不是React:
@url(articleId => `article/${articleId}`)class Article extends Component {// ...}
其實這樣寫也不是不可以,我們某個組件對應某一種資源,但是因為URL表現力有限,所以API還是很容易Break,也容易重複請求。其實這樣寫很容易坑自己,但展示了一種良好的聲明式思想……換做GraphQL由於你可以決定返回的結構,這種用法就不再是個反模式了。
Task5: 說好的Mutation呢
我們完成了查詢,這時候該做點修改了:
const AddTodoComponent = ({ mutate }) => { let input const handleSubmit = e => { e.preventDefault() mutate({variables: {text: input.value}}) } return <form onSubmit={ handleSubmit }> Enter todo here: <input type="text" ref={ el => input = el }/> </form>} const AddTodoMutation = gql` mutation addTodo($text: String!){ addTodo(text: $text) { text id } } ` const AddTodo = graphql(AddTodoMutation)(AddTodoComponent)
這個組件也很直接,一個輸入框,回車就發一個mutation請求。
render(<ApolloProvider client={client}> <div> <Todos /> <AddTodo /> </div> </ApolloProvider>, document.querySelector(#app))
然後我們看看效果……好像哪裡不太對。我們的確成功Update了狀態,但mutation的返回值並沒有按想像地一樣添加到渲染的列表中,必須刷新才能看到結果,太坑了。
我們現在有兩種辦法:- mutate是一個promise,我們可以直接.then(concateToTodoList)這樣直接把數據手動set進去。這跟我們平常開發SPA一樣,在服務端和客戶端Duplicate很多邏輯,難以維護。
- 把AddTodo嵌套進Todos里,拿走Todos的data屬性,然後.then(() => data.refetch())這樣可以讓todos重新獲取它的todos query,至少這樣不用再前端重寫這種邏輯了:
const TodosComponent = ({ data }) => <div> <ul>{ data.todos && data.todos.map((todo, i) => <Todo todo={todo} key={i} />) }</ul> <AddTodo data={data} /> </div>// ... const AddTodoComponent = ({ mutate, data }) => { let input const handleSubmit = e => { e.preventDefault() mutate({variables: {text: input.value}}) .then(_ => data.refetch()) } // ... }
但這只是最簡單的情況,我的AddTodo恰巧和Todos相關。換個更複雜的場景,比如我們的這個TodoList只是頁面中的一小部分,而頁面中頂部的狀態欄中有Todo數量的統計。這時我AddTodo一下,我就要把應用最外層頂部狀態欄的的data引用也獲取到……再或者這個操作會影響應用中很多組件的狀態——這時候很多人會想到Redux,但它要求你要在前端維護一套store,也就是維護前端的邏輯,又得在前端Duplicate代碼。那麼,我們可不可以發出這個mutation之後,既不在前端獲取數據之後手動維護狀態插入數據,又不用手動調用其他組件的refetch()呢?
對於這種懶到家的設想,Apollo這個簡單易用的庫還沒那麼聰明能幫我們簡單實現。這時候我們需要Facebook自己家的Relay來幫忙了,相比Apollo這種簡單的容易用代碼展示的庫,當我們談到Relay會帶來更多的概念。小結
本篇的代碼都在這裡,篇幅有限,光靠上面的代碼拼接起來可能有些困難。這個庫上手難度比較低,有興趣的同學可以嘗試下。 我們在本篇看到了GraphQL客戶端有能力一個查詢查到大量不相干的數據,這可以將並行乃至串列的請求合併為一個請求,這可以大幅提升響應速度。 我們還看到了GraphQL客戶端的設計思路:Co-location——我們將組件與query類型以同樣的方式組織,避免了大量無用的傳遞代碼。並且我們以完全聲明式的代碼完成了query和渲染:也就是說我們代碼上只關心What,隻字不提When和How。這會極大地提高代碼的可維護性,簡化開發,因為狀態和賦值總是萬惡之源,更別提在JavaScript環境下必須大量使用非同步操作的情形。在不犧牲SPA複雜邏輯的情況下,通過GraphQL,我們可以把前端的開發工作重新簡化成編寫類似編寫HTML的簡單活動。我們也看到這種優雅的風格並不能很好地在mutation這種寫入操作上重現。至於如何在mutation上維持這種風格,這就有待我們對Cache,query和伺服器推送等內容進行更深入的討論了。
推薦閱讀:
※2016-我的前端之路:工具化與工程化
※我對Flexbox布局模式的理解
※為什麼在知乎上 React 的評價這麼低?
※阿里雲前端周刊 - 第 21 期
※React + antd 主題色切換/模板切換