iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 27
1
Modern Web

Think in GraphQL系列 第 27

GraphQL 前端 (1) - 使用 React + Apollo Client 設計一個部落格系統

header

讓我來接著來練習如何使用加入 mutation ,來實作一個簡單的部落格發文系統吧!

今天目標:

  1. 展示自己的文章列表 (僅標題)
  2. 展示單篇文章 (標題 + 內容)
  3. 新增文章的頁面

1. 建立環境與設定專案

這邊一樣使用 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 即可):

img

有了一個 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.jsRoot 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;

就會出現如下圖的效果:

img

一個 Post 列表就出現囉!

2. React-router 設定

在開始開發其他功能之前,讓我們先安裝個 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 了!

3. 功能 - 單篇文章展示頁面

對於一個部落格,我們的首頁有了文章標題列表,但重點是每一篇文章裡的內容,因此讓我們來實作一個新的 Route 來顯示一個完整的文章頁面。

這邊我們分三步驟:

  1. 新增 /posts/:id route
  2. 將文章列表的標題改為文章連結
  3. 建立「文章展示」功能處理的頁面

3-1. 新增 /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>
    );
  }
}

3-2. 將文章列表的標題改為文章連結

打開 src/PostList.js ,加上 react-routerLink 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>
);

結果會如下圖:

img

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

img

3-3. 建立「文章展示」功能處理的頁面

第三步讓我們回到新建立的 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 連結就能夠回到首頁。

img

4. 功能 - 新增文章功能

接著新增文章功能也是類似的流程:

  1. 新增 /posts/new route
  2. 在文章列表頁面加上「增加文章」按鈕
  3. 建立「新增文章頁面」Component

4-1 新增 /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 看看新頁面有沒有被成功加入。

4-2. 在文章列表頁面加上「增加文章」按鈕

打開 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>
);

結果如圖:

img

4-3. 建立「新增文章頁面」Component

最後我們會在 src/AddPost.js 用到 Mutation component 來處理 GraphQL mutation 。

使用 Mutation component 時至少要將 gql 物件傳入 Mutation component 的 mutation prop 中,並且在 Mutation prop 裡面提供一個 render function React 處理。

而這個 render function 會提供兩個參數:

  1. mutation function 用來讓我們觸發 Mutation Request ,而這個 function 可以帶入 variables 參數作為 Mutation variables 。需注意只有使用到 mutation function時才會觸發喔。
  2. mutation 的 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>
  );
}
...

完成後就可以來測試看看囉~隨便輸入一些資料:

img

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

img

如果你連那一下下都不能容忍,可以直接在 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 的程式碼:

Edit React-GraphQL-Blog


Reference


上一篇
GraphQL 前端: Apollo Client 攜手 React 擁抱 GraphQL
下一篇
GraphQL 前端 (2) - 文章按讚及刪除文章
系列文
Think in GraphQL30

尚未有邦友留言

立即登入留言