這篇文章主要介紹 GraphQL 在 Client 的使用,為了方便,本文會直接使用 React 建立一個 Web demo 去介紹 Apollo 在 React 中的使用方法,當然在 ReactNative 中用法幾乎一模一樣。Apollo Client 是一個 GraphQL Client Library ,Apollo Client (以下簡稱 Apollo) 可以讓我們很方便的去和 GraphQL server 通訊。

為什麼要使用 GraphQL Client Library

你當然可以自己用 Http 去構造一個 GraphQL 請求,然後自己去處理網路問題,以及資料的快取問題等等,這樣我們就需要自己去處理很多業務邏輯以外的事情,而一個優秀的 GraphQL Client Library 可以幫助我們解決以下的一些問題 :

  • 直接傳送 Query 和 Mutation 到伺服器
  • 解析伺服器端的 Response 並 normalize 資料快取到本地
  • 根據定義的 Schema 構建相應的 Query 和 Mutation
  • 繫結 UI ,當資料發生變化時重新整理 UI

我們比較了 ApolloRelay ,最終選擇了在專案中使用 Apollo 。Relay 是由 Facebook 開發的開源的 GraphQL Client , 功能豐富並且做了很多效能優化,由於是一個大而全的庫,學習難度比較大,而且 Relay 對於我們的 App 有點過於複雜,所以最終選擇了 Apollo 。

Apollo 是一個由社群驅動開發的 GraphQL client,容易理解,可拓展性強,功能強大,可以在主流的開發平臺上面使用。JavaScript 的版本可以在 React ,Angular , Ember ,Vue 等主流的 Web 開發框架使用。Apollo 也提供了 Android 和 iOS 的版本。除此之外,Apollo 解決了我們上面提到的那些問題, Apollo 理解起來也比較簡單,容易上手。

在 React 中整合 Apollo

新建一個 React App

#!/bin/bash
yarn global add create-react-app  
create-react-app web  

安裝 Apollo

#!/bin/bash
cd web  
yarn add apollo-boost react-apollo graphql  

apollo-boost 包含了下面這些 packages

  • apollo-client:Apollo 的所有操作都從這裡開始,提供了豐富的 API
  • apollo-cache-inmemory: Apollo 提供的 Cache
  • apollo-link-http: Apollo 用來和 Server 端通訊
  • apollo-link-error: Apollo Client 內部錯誤處理
  • apollo-link-state: 本地狀態管理
  • graphql-tag: 提供 gql 方法,方便定義 queries 和 mutations

react-apollo: 連線 Apollo 和 React 的 UI 元件

graphql: 分析和檢查我們寫的 query string

建立一個 Apollo Client

import ApolloClient from "apollo-boost"

const client = new ApolloClient({  
    uri: "http://0.0.0.0:5001/graphql"
})

export default client  

利用 apollo-boost 提供的方法可以快速構建一個 ApolloClient,接下來我們就可以用這個 Client 去和 GraphQL server 通訊了,Apollo 提供了多種方式與服務端通訊,如果你只需要一個與服務端通訊的 Client,你可以直接使用 ApolloClient 提供的 query 方法請求資料。

下圖展示了我們的 GraphQL server 提供的可以查詢的欄位,關於如何搭建 GraphQL server 可以看我們之前的文章 : 用 GraphQL 快速搭建服務端 API,這裡就不贅述了。

從圖中可以看出 server 的 Query 裡面提供了 poem 欄位,需要一個 Int 型別的引數 idpeom 的型別是 PoemQueryPoemQuery 裡面有一些欄位可以查詢。現在我們要用 ApolloClient 查詢一個 id 為 1 的 poem

首先定義 Query string templete:

import gql from "graphql-tag"

export const QUERY_POEM = gql`  
    query Poem($id: Int!) {
        poem(id: $id) {
            name
            content
        }
    }
`

接下來使用 ApolloClient 的 query 方法獲取資料:

import client from "./**";  
import { QUERY_POEM } from './**';

client.query({  
    query: QUERY_POEM,
    variables: {
        id: 1,
    }
}).then((data)=>{
    console.warn('------', data);
})

client的 query 方法把 QUERY_POEM 和我們提供的 id 引數組裝在一起並向 server 請求資料,最後我們拿到的 data 結構如下:

{
  "data": {
    "poem": {
      "name": "臨江仙",
      "content": "滾滾長江東逝水,\n浪花淘盡英雄。",
      "__typename": "PoemQuery"
    }
  }
}

到這裡我們就完成了一次資料的 Query, __typename 欄位並沒有出現在我們的 Query string 中,這是 ApolloClient 的預設行為 ,主要用於資料的 Normalization 。更多的關於 query 的引數點選這裡,當然 ApolloClient 也提供了 mutate 方法讓我們直接修改 server 的資料, 這裡就不贅述了,ApolloClient.mutate API

Query & Mutation Component

為了更好的配合 React ,Apollo 提供了另外一種 query 和 mutate 資料的方式 : Query & Mutation component,為了連線 React Component 和 ApolloClient,我們需要在 App 的 root component 外面包一層 ApolloProvider :

import { ApolloProvider } from 'react-apollo';  
import client from "./**";

class App extends Component {

  render() {
    return (
      <ApolloProvider client={client}>
        <div className="App">
          <header className="App-header">
            <h1 className="App-title">Write Poem</h1>
          </header>
          {your root component}
        </div>
      </ApolloProvider>
    );
  }
}

接下來我們需要寫一個 PoemDetail 頁面,我們用 Query 來請求資料,寫法如下:

import React, { Component } from 'react';  
import {Query} from 'react-apollo';  
import { QUERY_POEM } from '../gql/Query';

class PoemDetail extends Component {

    render = () => {
        return (
            <Query query={QUERY_POEM} variables={{id: 1}}>
                {({ loading, error, data }) => {
                    if (loading) return <div>Fetching</div>
                    if (error) return <div>Error</div>
                    const name = data.poem.name;
                    const content = data.poem.content;
                    return (
                        <div>
                            <h1>{name}</h1>
                            <p>{content}</p>
                        </div>
                    )
                }}
            </Query>
        )
    }
}

從程式碼中可以看到,Query 中我們傳入了兩個 props,和我們之前直接用 clinet.query 方法大致相同,Query 只是把這個過程封裝起來,而我們只需要根據返回的結果去處理自己的 UI 顯示邏輯。當然, Query 被 render 的時候就開始請求資料了。除了 loadingerrordataQuery 還提供了其他的 API : Query API overview,這裡就不贅述了。把 PoemDetail 放在上面 root component 的位置,執行我們的 demo 即可看到:

image-20180830113814675

那麼如何在 GraphQL server 上建立和更新資料呢?接下來我們講講 Mutation

image-20180829110358445

image-20180829110509349

從圖中可以看到,我們的 server 上面提供了 createPoem mutation,createPoem 需要我們提供兩個引數 : name & content ,在 createPoem 裡面有一個 PoemQuery 型別的欄位 poem,所以可以這樣定義一個 Mutation string templete :

import gql from "graphql-tag"

export const CREATE_POEM = gql`  
    mutation CreatePoem($name: String!, $content: String!) {
        createPoem(name: $name, content: $content) {
            poem {
                name
                content
            }
        }
    }
`

接下來我們寫一個 PoemEditor

import React, { Component } from 'react';  
import './PoemEditor.css'  
import { Mutation } from "react-apollo";  
import { CREATE_POEM } from '../gql/Mutation';

class PoemEditor extends Component {

    constructor(props) {
        super(props)
        this.state = {
            poemName:"",
            poemContent: "",
        }
    }

    nameChange = (event) => {
        this.setState({poemName: event.target.value})
    }

    contentChange = (event) => {
        this.setState({poemContent: event.target.value})
    }

    saveClick = () => {
        this.props.doMutate && this.props.doMutate({
            variables: {
                name: this.state.poemName,
                content: this.state.poemContent,
            }
        })
    }

    render = () => {
        return (
            <div className="poem-editor">
                <input className="edit-title" placeholder="標題" value={this.state.poemName} onChange={this.nameChange}/>
                <textarea className="edit-content" placeholder="內容" value={this.state.poemContent} onChange={this.contentChange}/>
                <button className="save-btn" onClick={this.saveClick}>儲存</button>
            </div>
        )
    }
}

export const PoemEditorMutation = ()=>{  
   return(
       <Mutation mutation={CREATE_POEM}>
           {(doMutate, { data })=>{
               return (<PoemEditor doMutate={doMutate}/>)
           }}
       </Mutation>
   )
}

PoemEditor 接受一個 doMutate 的 prop ,在 PoemEditor 外面包了一層 Mutation, 和 Query 一樣,Mutation 也是把 client.mutate 方法封裝起來,並提供了一些 API 方便我們操作,與 Query 不同的是,我們需要把自己呼叫 doMutate 方法去觸發真正的 Mutation,正如我們上面的程式碼一樣,我們把 doMutate 方法傳給了 PoemEditor儲存 按鈕被點選的時候才去呼叫 doMutate ,這個時候才去和 Server 打交道,建立一個新的 poem ,到這個裡我們就完成了一個簡單的 mutate 操作。更多關於 Mutation 的 API : Mutation API overview

Apollo Cache

上面簡單介紹了 QueryMutation 的用法,接下來就要介紹用這兩個 Component 帶來的好處。在開發 App 的過程中,我們常常會遇到這樣的情形,有一個列表展示了一些話題的資訊,點選每一個話題會進入一個話題詳情頁,這個時候使用者點贊或者評論都會改變這個話題的狀態,我們就需要去更新列表中對應的話題的狀態來保證資料的一致性。那麼在 Apollo 中我們怎麼去更新列表的狀態呢?答案就是什麼都不用做,只要我們用了 QueryMutation ,Apollo 可以自己檢測出某個資料的變化,並通知所有用到這個資料的地方更新 UI 。接下來舉個簡單的例子:

新新增一個 PoemList , server 端相應的添加了一個 poemList Query 和 一個 updatePoem Mutation

export const QUERY_POEM_LIST = gql`  
    query PoemList {
        poemList {
            id
            name
            content
        }
    }
`
import React, { Component } from 'react';  
import './PoemList.css'  
import { QUERY_POEM_LIST } from '../gql/Query';  
import { Query } from 'react-apollo';

class PoemList extends Component {  
    render = () => {
        return (
            <div className="list-bar">
                <Query query={QUERY_POEM_LIST}>
                    {({loading, error, data})=>{
                        if (loading) return <div>Fetching</div>
                        if (error) return <div>Error</div>
                        var poemList = data.poemList;
                        return poemList.map((poem)=>{
                            return (
                                <div>
                                    <h3>{poem.name}</h3>
                                    <div>{poem.content}</div>
                                </div>
                            )
                        })
                    }}
                </Query>
            </div>
        )
    }
}
export default PoemList  

然後修改 PoemEditor ,將 Mutation 由 CREATE_POEM 換成 UPDATE_POEM 內容如下:

export const UPDATE_POEM = gql`  
    mutation UpdatePoem($id: Int!, $name: String!, $content: String!) {
        updatePoem(id: $id, name: $name, content: $content) {
            poem {
                id
                name
                content
            }
        }
    }
`

為了方便,我們直接修改 id為 1 的 poem ,修改 PoemEditor 中的 saveClick 方法,在 variables 中新增一個 id 引數:

saveClick = () => {  
    this.props.doMutate && this.props.doMutate({
        variables: {
            id: 1,
            name: this.state.poemName,
            content: this.state.poemContent,
        }
    })
}

最後將這兩個 component 放到 App 裡:

import React, { Component } from 'react';  
import logo from './logo.svg';  
import PoemList from './components/PoemList';  
import {PoemEditorMutation} from './components/PoemEditor';  
import './App.css';  
import { ApolloProvider } from 'react-apollo';  
import client from "./conf/apollo";  
import { QUERY_POEM } from './gql/Query';  
import PoemDetail from './components/PoemDetail';


class App extends Component {

  render() {
    return (
      <ApolloProvider client={client}>
        <div className="App">
          <header className="App-header">
            <h1 className="App-title">Write Poem</h1>
          </header>
          <div className="App-body">
            <PoemList/>
            <PoemEditorMutation/>
          </div>
        </div>
      </ApolloProvider>
    );
  }
}

export default App;  

執行結果如圖:

mutate

我們可以看到,當我們修改了 name 之後, 列表裡的 name 也跟著變了,我們的程式碼裡也沒有做什麼額外的操作,這就是 Apollo 的便利之處。Apollo 在獲取到 server 的資料之後會先 Normalize 資料,然後存到 apollo-cache-inmemory 中,通過 react-apollo 中的 Component 將資料和 UI 繫結起來,這樣對於共享同一資料來源的 UI Component 來說就能保持一致性。

既然 Apollo 自己就管理好了自己的 Cache , 那我們能不能自己操作 Apollo 的 Cache 呢?當然可以!還是以 demo 為例,假如我們新建了一個 poem ,但是我們什麼都不做的話,我們的 poem list 裡面是不會有我們新加的 poem 的,這個時候我們有兩種辦法,一種是重新 Query 一遍 poem list,另一種就是將新加的 poem 寫到 cache 裡面。第一種就不說了,我們來用第二種方法實現我們的需求。

首先還是要將 PoemEditor 裡面 Mutation 改回 CREATE_POEM ,相應的 saveClick 方法也要改回去。然後在 Mutation 裡面加上 update 引數:

const _mutateUpdate = (cache, { data: { createPoem } }) => {  
    let { poemList } = cache.readQuery({
        query: QUERY_POEM_LIST,
    })
    poemList = [createPoem.poem].concat(poemList);
    cache.writeQuery({
        query: QUERY_POEM_LIST,
        data: { poemList },
    })
}

export const PoemEditorMutation = ()=>{  
   return(
        <Mutation mutation={CREATE_POEM} update={_mutateUpdate}>
            {(doMutate, {data })=>{
               return (<PoemEditor doMutate={doMutate}/>)
            }}
        </Mutation>
    )
}

update 方法接受兩個引數,一個是 Apollo Cache 的例項 ,一個是本次 Mutation 返回的結果 ,在 _mutateUpdate 方法裡面, 我們先用 cache.readQuerypoemList 取出來 ,然後把新建出來的 poem 插到最前面,最後用 cache.writeQuery 把新的 poemList 寫回到 cache 裡面,這樣我們就完成了 PoemList 的更新。更多關於 cache 的內容:Direct Cache Access ,執行起來就是這個樣子的 :

write-cache

到這裡我們已經用 Apollo + React 寫出了一個簡單的記錄詩句的 web app。

更多的關於 ApolloClient 的配置

這裡要囉嗦幾句,我們的 demo 中使用了 apollo-boost 中提供的 ApolloClient,這裡面的Apollo 提供了一些預設的配置,如果自定義一些 cache 或者 link 的行為,最好從 apollo-clinet 裡面 import ApolloClient , apollo-boost 中的 ApolloClient 可配置的靈活性較低。更多相關內容請參考 Apollo Link ,篇幅有限,這裡就不一一介紹了。

小結

本文介紹了 Apollo 在 React 中的基礎用法,從零開始構建了一個支援查詢資料,建立資料,更新資料的 Web App , 雖然比較簡陋,也基本涵蓋了 app 開發的常用操作。介紹了 QueryMutation componet 的基本用法,熟練的掌握這兩個 componet 的用法,可以極大地簡化我們的開發工作。更多高階的用法,可以去查詢官方文件。

最後,談一談使用 GraphQL 帶來的一些好處。開發客戶端,我們常常需要去實現一個非常複雜的 UI ,往往我們需要傳送多個請求才能把整個頁面的資料全部載入完畢。我們也常常遇到,伺服器的返回結果中少了某個欄位或者多了一堆我們不需要的欄位。開發移動端的 app ,少了某個欄位甚至會導致 app crash ,這些都是我們不想看到的。使用 GraphQL 能夠很好的解決這些問題,客戶端只需要根據 UI 定義好 Query string ,返回的就是我們想要的結果。對於某些小的 UI 改動,完全不需要去修改 server ,在 Query string 中新增或者減少相應的欄位即可,靈活方便。

對於文章中有描述不當的地方,歡迎批評指正。

完整的程式碼點選 graphqldemo : 包擴了 Web 和 Server 端的實現。