
讓我來接著來練習如何使用加入 mutation ,來實作一個簡單的部落格發文系統吧!
今天目標:
這邊一樣使用 create-react-app 來建立專案,以及安裝套件。
~ $ npx create-react-app react-graphql-blog
~ $ cd react-graphql-blog
~/react-graphql-blog $ npm install --save apollo-boost react-apollo graphql
因為我們這篇會用到 materialize 套件來幫助我們做畫面的美化以及 icon 支援,因此請打開 public/index.html ,在 head 裡面新增這兩行:
<head>
  ...
  <link
    rel="stylesheet"
    href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.98.0/css/materialize.min.css"
  />
  <link
    href="https://fonts.googleapis.com/icon?family=Material+Icons"
    rel="stylesheet"
  />
</head>
接著我們來到 src/App.js ,設定這次的 project 。同樣在這邊我會提供我寫好的 Apollo LaunchPad 範例 ,使用時直接將 url 帶入 https://j9qzxq4pjp.lp.gql.zone/graphql 。
import React, { Component } from 'react';
import ApolloClient, { InMemoryCache } from 'apollo-boost';
import { ApolloProvider } from 'react-apollo';
const client = new ApolloClient({
  uri: 'https://j9qzxq4pjp.lp.gql.zone/graphql',
});
class App extends Component {
  render() {
    return (
      <ApolloProvider client={client}>
        <div>
          <h2>My first Apollo app ?</h2>
        </div>
      </ApolloProvider>
    );
  }
}
npm start 後就會出現下圖的畫面 (這邊我用 codeSandbox ,若在本地端打開 localhost:3000 即可):

有了一個 React + Apollo Client 專案後,我們先試著把 Post 列表印出來! 新建一個檔案 src/PostList.js ,在裡面加入之前提過的 Query component 。
// src/PostList.js
import React from "react";
import { Query, Mutation } from "react-apollo";
import { gql } from "apollo-boost";
const GET_POSTS_FOR_AUTHOR = gql`
  query PostsForAuthor {
    posts {
      id
      title
      body
    }
  }
`;
const Posts = () => (
  <Query query={GET_POSTS_FOR_AUTHOR}>
    {({ loading, error, data, refetch }) => {
      if (loading) return <p>Loading...</p>;
      if (error) return <p>Error :(</p>;
      const postList = data.posts.map(({ id, title }) => {
        return (
          <li key={id} className="collection-item">
            { title }
          </li>
        );
      });
      return (
        <div>
          <ul className="collection">{postList}</ul>
        </div>
      );
    }}
  </Query>
);
export default Posts;
接著將 src/PostList 加進 src/App.js ,然後在 src/App.js 用 Root component 包起來,讓接下來的 component 都能夠自動置中間。
...
const App = ({ children }) => {
  return <div className="container">{children}</div>;
};
class Root extends Component {
  render() {
    return (
      <ApolloProvider client={client}>
        <div>
          <App>
            <h2>My Blog ?</h2>
            <PostList />
          </App>
        </div>
      </ApolloProvider>
    );
  }
}
export default Root;
就會出現如下圖的效果:

一個 Post 列表就出現囉!
在開始開發其他功能之前,讓我們先安裝個 react-router 幫助我們導入 Route 來實作新功能!首先一樣先安裝,不過這邊的 demo 我是安裝第三版的 react-router 。
~/react-graphql-blog $ npm install --save react-router@^3.2.1
安裝成功後,打開 src/App.js 讓我們來看如何引入以及使用,因為這邊主要是介紹 GraphQL ,所以 react-router 的細節就不贅述了。
...
import { Router, Route, hashHistory, IndexRoute } from 'react-router';
...
const App = ({ children }) => {
  return (
    <div className="container">
      <h2>My Blog ?</h2>
      {children}
    </div>
  );
};
class Root extends Component {
  render() {
    return (
      <ApolloProvider client={client}>
        <Router history={hashHistory}>
          <Route path="/" component={App}>
            <IndexRoute component={PostList} />
          </Route>
        </Router>
      </ApolloProvider>
    );
  }
}
再次打開 localhost:3000 後,如果照常顯示就代表成功設定好 react-router 了!
對於一個部落格,我們的首頁有了文章標題列表,但重點是每一篇文章裡的內容,因此讓我們來實作一個新的 Route 來顯示一個完整的文章頁面。
這邊我們分三步驟:
/posts/:id route/posts/:id route新增 route 前先建立 src/PostPage.js 並在裡面新增一個展示用 component。
import React from "react";
const PostPage = (props) => (<div> Post Page ({props.params.id})</div>);
export default PostPage;
接著打開 src/App.js ,引入 PostPage component 並加入新增的 Route component ,而路徑則是用 post/:id。
// src/App.js
...
+ import PostPage from "./PostPage";
...
class Root extends Component {
  render() {
    return (
      <ApolloProvider client={client}>
        <Router history={hashHistory}>
          <Route path="/" component={App}>
            <IndexRoute component={PostList} />
+            <Route path="posts/:id" component={PostPage} />
          </Route>
        </Router>
      </ApolloProvider>
    );
  }
}
打開 src/PostList.js ,加上 react-router 的 Link component ,它可以自動幫我們管理路徑以及紀錄瀏覽歷程。
// src/PostList.js
+ import { Link } from "react-router";
...
const Posts = () => (
  <Query query={GET_POSTS}>
    {({ loading, error, data, refetch }) => {
      if (loading) return <p>Loading...</p>;
      if (error) return <p>Error :(</p>;
      const postList = data.posts.map(({ id, title }) => {
        return (
+          <li key={id} className="collection-item">
+            <Link to={`/posts/${id}`}>{title}</Link>
+          </li>
        );
      });
      return (
        <div>
          <ul className="collection">{postList}</ul>
        </div>
      );
    }}
  </Query>
);
結果會如下圖:

接著可以試試看點擊其中一個標題看看是否能成功進入頁面。

第三步讓我們回到新建立的 src/PostPage.js 並添加功能。
另外因為這次的 query 需要參數,因此要在 Query component 加入 variables prop ,而 postId 的值則由跟著網址所傳入的 this.props.id 來提供。
import React, { Component } from "react";
import { Query } from "react-apollo";
import { gql } from "apollo-boost";
import { Link } from "react-router";
const GET_POST_DETAIL = gql`
  query PostDetail($postId: ID!) {
    post(id: $postId) {
      id
      title
      body
      author {
        name
      }
    }
  }
`;
class PostPage extends Component {
  render() {
    return (
      <Query
        query={GET_POST_DETAIL}
        variables={{ postId: this.props.params.id }}
      >
        {({ loading, error, data }) => {
          if (loading) return <p>Loading...</p>;
          if (error) return <p>Error :(</p>;
          return (
            <div>
              <h3>{data.post.title}</h3>
              <p>{data.post.body}</p>
              <Link to="/">Back</Link>
            </div>
          );
        }}
      </Query>
    );
  }
}
export default PostPage;
完成後回到首頁,點擊第一個標題,就會進入文章頁面囉 ! 再點擊 Back 連結就能夠回到首頁。

接著新增文章功能也是類似的流程:
/posts/new route/posts/new route新增 route 前一樣先建立 src/PostAdd.js 並在裡面新增一個展示用 component。
import React from "react";
const PostAdd = () => (<div> Add Post Page</div>);
export default PostAdd;
接著打開 src/App.js ,引入 PostAdd component 並加入新增的 Route component ,務必注意要加在 post/:id 上方才不會被 :id 的字串比對給取代掉。
// src/App.js
...
+ import PostAdd from "./PostAdd";
...
class Root extends Component {
  render() {
    return (
      <ApolloProvider client={client}>
        <Router history={hashHistory}>
          <Route path="/" component={App}>
            <IndexRoute component={PostList} />
+            <Route path="posts/new" component={PostAdd} />
            <Route path="posts/:id" component={PostPage} />
          </Route>
        </Router>
      </ApolloProvider>
    );
  }
}
接著在首頁網址後面加上 posts/new 看看新頁面有沒有被成功加入。
打開 src/PostList.js 新增「增加文章」按鈕。這邊一樣使用 Link component 。
const Posts = () => (
  <Query query={GET_POSTS}>
    {({ loading, error, data, refetch }) => {
      if (loading) return <p>Loading...</p>;
      if (error) return <p>Error :(</p>;
      const postList = data.posts.map(({ id, title }) => {
        return (
          <li key={id} className="collection-item">
            <Link to={`/posts/${id}`}>{title}</Link>
          </li>
        );
      });
      return (
        <div>
          <ul className="collection">{postList}</ul>
+          <Link to="/posts/new" className="btn-floating btn-large red right">
+            <i className="material-icons">add</i>
+          </Link>
        </div>
      );
    }}
  </Query>
);
結果如圖:

最後我們會在 src/AddPost.js 用到 Mutation component 來處理 GraphQL mutation 。
使用 Mutation component 時至少要將 gql 物件傳入 Mutation component 的 mutation prop 中,並且在 Mutation prop 裡面提供一個 render function  React 處理。
而這個 render function 會提供兩個參數:
variables 參數作為 Mutation variables 。需注意只有使用到 mutation function時才會觸發喔。
loading (觸發 mutation 後執行中), data (觸發後值行完成回傳的資料) 以及 error (觸發後執行失敗) 資料。因此一個 Mutation component 會如此呈現:
const MUTATE = gql`mutation ($id: ID!) {
 mutate(id: $id) {
  result
 }
}`;
return (
  <Mutation mutation={MUTATE}>
    {(mutate, { data, loading, error }) => {
      if (loading) return "mutating...";
      if (error) return alert('Error');
      const id = /* data processing... */
      return <button onClick={() => mutate( { variables: { id } })} >Go!</button>
    }}
  </Mutation>
);
更多的用法請參考: Apollo Client - Mutation
回到 src/AddPost.js ,這裡我們會在 Mutation component 裡面置入一個 form (表單) 幫助我們處理輸入資訊以及送出。資料送出後,呼叫 hashHistory.push("/") 回到首頁。
import React from "react";
import gql from "graphql-tag";
import { Link, hashHistory } from "react-router";
import { Mutation } from "react-apollo";
const ADD_POST = gql`
  mutation AddPost($title: String!, $body: String) {
    addPost(title: $title, body: $body) {
      id
      title
      author {
        id
        name
      }
      votes
    }
  }
`;
const PostAdd = () => {
  let titleInput;
  let bodyInput;
  return (
    <Mutation mutation={ADD_POST} >
      {(addPost, { data }) => (
        <div>
          <Link to="/">Back</Link>
          <form
            onSubmit={e => {
              e.preventDefault();
              addPost({
                variables: { title: titleInput.value, body: bodyInput.value }
              }).then(() => hashHistory.push("/"));
              titleInput.value = "";
              bodyInput.value = "";
            }}
          >
            <label>Post Title:</label>
            <input
              ref={node => {
                titleInput = node;
              }}
            />
            <label>Post Body:</label>
            <input
              ref={node => {
                bodyInput = node;
              }}
            />
            <button type="submit">Add Post</button>
          </form>
        </div>
      )}
    </Mutation>
  );
};
export default PostAdd;
完成後,如果你可以從首頁按 Add 按鈕進來並看到這個畫面就算成功囉!
![https://i.imgur.com/jbNTt0p.png]
不過這邊還有一個問題,那就是 Apollo Client 的 cache 會幫我們紀錄資料的 state ,因此新增完文章回首頁時, Apollo Client 會自動帶入之前 Qeury 取得的舊文章列表而非含有新的文章的列表,此時你還需要重整頁面新的文章才會浮現出來。
這當然不是我們想要的效果,我們想要新增完文章回首頁時,能夠立即看到新的文章列表,所以我們可以使用 Mutation component 的 refetchQueries prop 來幫助我們重新抓取文章列表。
這個 refetchQueries prop 會在 mutation 結束後觸發一個 function (mutationResult) => Array<{ query, variables }> | string ,參數傳入的 mutationResult 裡面含有同樣的 data, loading, error ,而回傳部分規定要是一個 Array ,裡面要有你所希望重新發出的 query 。
我們希望重新發出的 query 就是文章列表的 query ,因此我們直接把 src/PostList.js 的 query object 複製過來 (之後會把 query object 都集中整理),並且放入 refetchQueries prop 的 function 中。
+ const GET_POSTS_FOR_AUTHOR = gql`
+   query PostsForAuthor {
+     posts {
+       id
+       title
+       body
+     }
+   }
+ `;
const PostAdd = () => {
  let titleInput;
  let bodyInput;
  return (
    <Mutation
      mutation={ADD_POST}
+      refetchQueries={() => {
+        return [{ query: GET_POSTS_FOR_AUTHOR }];
+      }}
    >
    ...
    </Mutation>
  );
}
...
完成後就可以來測試看看囉~隨便輸入一些資料:

送出後回到首頁,第一眼雖然還是舊的文章列表,但約等個一下下 (文章列表 query 重新索取中),最新的文章標題就跟著一起出現囉~

如果你連那一下下都不能容忍,可以直接在 Mutation component 加入 awaitRefetchQueries prop 並設為 true,這樣一來,等到 refetchQueries 完成後才會跳回首頁。
    <Mutation
      mutation={ADD_POST}
      refetchQueries={() => {
        return [{ query: GET_POSTS_FOR_AUTHOR }];
      }}
+      awaitRefetchQueries={true}
    >
    ...
    </Mutation>
今天將簡單講解了 Mutation component 的使用,可以看到加入 Mutation 後整個程式複雜了許多,光是處理資料呈現的同步問題就令人傷腦筋。但其實 Apollo Client 的 cache 功能可以另外設定,甚至可以做到某個物件更新時, Apollo Client 會跟著比對 type 與 id 來自動更新 cache 裡面的資料,讓使用體驗更加順暢、開發更容易!
不過明天我們要先繼續新增其他功能如「刪除文章」、「更新文章」、「文章按讚」等功能,之後會再花篇幅介紹如何設定 cache 。
最後附上本篇 project 的程式碼:
請問RequireAuth這個HOC判斷使用者登入的部分,可以用localStorage.getItem("x-token")取代嗎?