Skip to content

Latest commit

 

History

History
749 lines (586 loc) · 26.3 KB

02_frontend_dev.md

File metadata and controls

749 lines (586 loc) · 26.3 KB

Chapter 2. Frontend 開発一巡り

ToC

はじめに

本章では、 Chapter 1. で触れた GraphQL サービスに接続するクライアントサイドのアプリケーションを作っていきます。

作成する機能は次のようになります。

  • 商品一覧のページ
  • 商品詳細のページ
    • 商品詳細ページからは商品のレビューを投稿可能

開発にあたっては下記のスタックを利用します。

  • React.js
  • Apollo Client

プロジェクトのスケルトンを用意しているので、まずは起動を確認してください。

$ cd frontend
$ npm i
$ npm start

http://localhost:4000 を開いてみましょう。

Apollo Client の導入

まずは Apollo Client をプロジェクトに導入しましょう。

$ npm i @apollo/client graphql -S

src/App.tsx ファイルを開き、Apollo Client の設定を行います。

import React from "react";
import { HashRouter, Switch, Route, Redirect } from "react-router-dom";

import { InMemoryCache, ApolloProvider, ApolloClient } from "@apollo/client";

function App() {
  // client オブジェクトの作成
  const client = new ApolloClient({
    cache: new InMemoryCache(),
    uri: "http://localhost:4010/graphql"
  });

  // 全体を ApolloProvider でラップする
  return (
    <ApolloProvider client={client}>
      <HashRouter>{/* 中身は変更不要 */}</HashRouter>
    </ApolloProvider>
  );
}

export default App;

ApolloClient はブラウザから GraphQL サービスへの接続を担うクラスです。上記のように GraphQL の endpoint URL はここに記載します。

cache に指定した InMemoryCache というクラスについては後述します。とりあえずはおまじないとして必要であると思っていてください。

ApolloProvider は React Context Provider をラップした Component です。この Component でラップすることで、このアプリケーションからはどの Component からでも useQueryuseMutation といった hooks 関数が利用できるようになります。

React Component から GraphQL Query を実行する

Chapter 1. と同じく、まずは商品一覧の取得を行いましょう。

src/components/Products.tsx の名前で新しくファイルを作成します。このファイルは最終的に商品一覧ページの React Component となります。

Component のことはさておき、まずは GraphQL クエリを書いていきましょう。

import { gql } from "@apollo/client";

const query = gql`
  query ProductsQuery {
    products {
      id
      name
    }
  }
`;

クエリの中身については最早説明不要でしょう。

gql という Tagged Template Function は Apollo Client を使う上でのおまじないようなものなので、深く気にしなくて大丈夫です。

つづいて、Component 部分を書いていきます。以下のように編集してみてください。

/* src/components/Products.tsx */

import { gql, useQuery } from "@apollo/client";

const query = gql`
  query ProductsQuery {
    products {
      id
      name
    }
  }
`;

export default function Products() {
  const { data, loading } = useQuery(query);
  if (loading || !data) return null;
  return (
    <>
      <ul>
        {data.products.map((product: { id: string; name: string }) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
    </>
  );
}

GraphQL の Query を実行するには useQuery というフック関数を利用します。

このフック関数はオブジェクトを返却します。下記は特に頻繁に使います。

key description
data クエリが正常に実行できた場合の取得結果
erros クエリのエラー情報
loading クエリが実行中の場合に true となる
refetch このクエリを再実行するための関数

さて、商品一覧ページが完成したので、 src/App.tsx を編集して作成した Products Component が表示されるようにしましょう。

/* src/App.tsx */

import Products from "./components/Products";

function App() {
  // 略
  return (
    <ApolloProvider client={client}>
      {/* 中略 */}
      <Route path="/products">
        <Products />
      </Route>
    </ApolloProvider>
  );
}

http://localhost:4000/#/products をブラウザで開き、商品の一覧が表示されたら成功です!

GraphQL Frontend の開発環境を整える

商品詳細画面の開発に入る前に、より便利に GraphQL Frontend を開発していくためのツールをいくつか導入していきます。

Apollo Client の Inspection

まずは 開発中に Apollo Client の内部の様子を Debug するための Chrome 拡張です。以下のリンクからインストールしてください。

https://chrome.google.com/webstore/detail/apollo-client-devtools/jdkknkkbebbapilgoeccciglkfbmbnfm

この拡張機能がインストールされると、Chrome Devtool で "Apollo" というタブが選択可能になります(もしタブが追加されない場合は、別タブで http://localhost:4000 を開き直すなどしてみてください)。

さらに Apollo 拡張機能の "CACHE" をクリックします。

このタブは Apollo Client に設定したキャッシュの内部を表示しています。

const client = new ApolloClient({
  cache: new InMemoryCache() // これ
});

<ApolloProvider> を設定した際に登場した cache もこのキャッシュのことです。

Apollo Client における「キャッシュ」は、Redux でいうところの state と同じような存在です。

非同期実行した GraphQL のクエリ実行結果のすべてがキャッシュに保存され、このキャッシュ上の値が useQuery などの API を通して React Component に渡っているのです。State 上の値が useSelector を通して React Component に渡される、というのととても似ていますよね?

もう少し詳細に Apollo Client キャッシュの中身を見ていきましょう。おそらく下記のようになっているはずです。

{
  "ROOT_QUERY": {
    "__typename": "Query",
    "products": [
      {
        "__ref": "Product:001"
      },
      {
        "__ref": "Product:002"
      },
      {
        "__ref": "Product:003"
      }
    ]
  },
  "Product:001": {
    "id": "001",
    "__typename": "Product",
    "name": "うまいバー",
    "price": 10
  },
  "Product:002": {
    "id": "002",
    "__typename": "Product",
    "name": "やっちゃんイカ",
    "price": 50
  },
  "Product:003": {
    "id": "003",
    "__typename": "Product",
    "name": "ブラックダンサー",
    "price": 30
  }
}

一見すると何もおかしな箇所は無いように見える?

そう思うのであれば、プレイグラウンドからもう一度同じクエリを実行してみてください。

query ProductsQuery {
  products {
    id
    name
  }
}

プレイグラウンドでの結果は下記のようになったはずです。

{
  "data": {
    "products": [
      {
        "id": "001",
        "name": "うまいバー"
      },
      {
        "id": "002",
        "name": "やっちゃんイカ"
      },
      {
        "id": "003",
        "name": "ブラックダンサー"
      }
    ]
  }
}

Apollo Client のキャッシュとプレイグラウンドの実行結果を見比べると、以下の違いが分かります。

  1. クエリに記載していない __typename というフィールドが生えている
  2. products の配列要素が "__ref": "Product:001" のように参照を表すような構造になっている

Apollo Client は GraphQL サービスからのレスポンスをキャッシュに格納する際に正規化を施します。

__typename というフィールドは GraphQL の仕様で定義された特殊な項目で、そのオブジェクトの Type 名を返します。Apollo Client では 「id, _id, key といった名前のフィールドと __typename の値を連結したもの」をキャッシュのキーとして正規化を行います。

GraphQL のクエリのエディタ補完

ところで、プレイグラウンドでは、Query や Mutation を編集する際に利用可能なフィールドやオブジェクト(正確には Selection と呼ぶ)が、その場で補完されて便利でしたよね。

一方で TSX ファイルの中での GraphQL Query はただの文字列であるため、エラーチェックや補完がされません。折角 GraphQL という静的に型が付いた言語を扱っているのに、その恩恵が開発時に得られないのはもったいないです。

const query = gql`
  query ProductsQuery {
    products {
      id
      namae # typo しても気づきにくい
    }
  }
`;

そこで、プレイグラウンドと同等のことができるように補助ツールを導入します。

まず準備として、プレイグラウンドから Server の Schema SDL ファイルを DL して、 schema.graphql という名前で frontend ディレクトリに配置します。

つづいて、以下のパッケージをインストールします。

$ npm i ts-graphql-plugin -D

TypeScript の設定ファイルに、ts-graphql-plugin の設定を追記します。これで補完やエラーチェックが有効になるはずです。

{
  "compilerOptions": {
    // 中略
    "plugins": [
      { "name": "ts-graphql-plugin", "schema": "schema.graphql", "tag": "gql" }
    ]
  }
}
const query = gql`
  query ProductsQuery {
    products {
      id
      namae # エディタ上にエラーが表示される
    }
  }
`;

VSC を利用していて補完やエラーチェックが動作しない場合、以下を確認してください。

  • frontend ディレクトリ直下を VSC で開いているかどうか(.vscode と同じ階層で開いてください)
  • Ctrl + Shift + P (mac OS の場合は Cmd + Shift + P ) から "Select TypeScript Version" を検索し、表示される TypeScript のオプションが "Use Workspace Version" となっているかどうか

クエリの実行結果と TypeScript の型

現状では、 useQuery で取得してきた data は TypeScript 上では any 型となってしまっています。

const { data, loading } = useQuery(query); // data の型が any になってしまう

これは Apollo Client 自体は、GraphQL Query のレスポンスとなる型を知らないためです。 useQuery のフック関数は、下記のようにジェネリクスとしてレスポンスの型を指定すると、対応して data にも型があたります。

cosnt { data } = useQuery<{ id: string }>(query) // data の型は { id: string } 型となる

ただ、GraphQL のクエリを編集するたびに、開発者自身が自分で { id: string } の部分を書いてしまうと問題が生じます:

  • typo などのバグの元
  • そもそも面倒くさい

そこで、TSX 上のクエリから、対応する data の型を自動で生成するようにします。

今回の workshop では先程インストールした ts-graphql-plugin の CLI を利用するようにします(クエリから型を生成するツールは他にも色々なパッケージが npm 上で提供されています。e.g. graphql-codegen, apollo-tooliing, etc... )。

$ npx ts-graphql-plugin typegen

これで __generated__ ディレクトリ以下にクエリに応じた TypeScript の型ファイルが生成されました。import して useQuery のジェネリクス(型パラメータ)にセットしましょう。

/* src/components/Products.tsx */

import {
  ProductsQuery,
  ProductsQueryVariables
} from "./__generated__/products-query";

// 中略
const { data, loading } = useQuery<ProductsQuery, ProductsQueryVariables>(
  query
); // dataは `query` に応じた結果の型となる

引数付きのクエリを実行する

次に商品の詳細ページを作っていきます。

まずは

  • 商品名
  • 説明
  • レビュー内容のリスト

を表示できるようにしましょう。GraphQL のクエリは以下のようになりますね。

query ProductDetailQuery($id: ID!) {
  product(id: $id) {
    id
    name
    description
    reviews {
      id
      commentBody
    }
  }
}

React 部分については、商品一覧の場合とほぼ同様に作ります。商品の ID を router から取得して Apollo Client に Variable として渡しています。

/* src/components/ProductDetail.tsx */

import { useParams } from "react-router-dom";
import { useQuery, gql } from "@apollo/client";

import {
  ProductDetailQuery,
  ProductDetailQueryVariables
} from "./__generated__/product-detail-query";

const query = gql`
  query ProductDetailQuery($id: ID!) {
    product(id: $id) {
      id
      name
      description
      reviews {
        id
        commentBody
      }
    }
  }
`;

export default function ProductDetail() {
  const { productId } = useParams<{ readonly productId: string }>();
  const { data, loading } = useQuery<
    ProductDetailQuery,
    ProductDetailQueryVariables
  >(query, {
    variables: {
      id: productId
    }
  });
  if (loading) return <div>loading...</div>;
  if (!data?.product) return <div>not found </div>;
  const { product } = data;
  return (
    <>
      <h1>{product.name}</h1>
      <p style={{ whiteSpace: "pre-wrap" }}>{product.description}</p>
      <div>
        <h2>レビュー</h2>
        {product.reviews.length ? (
          <ul>
            {product.reviews.map(r => (
              <li key={r.id}>{r.commentBody}</li>
            ))}
          </ul>
        ) : (
          <p>レビューはまだありません</p>
        )}
      </div>
    </>
  );
}

Component を実装したら、Routing を更新して正しく動作することを確認しましょう。

/* src/App.tsx */

import Products from "./components/Products";
import ProductDetail from "./components/ProductDetail"; // 追加

function App() {
  return (
    <HashRouter>
      <Switch>
        {/* route 定義を編集 */}
        <Route path="/products/:productId">
          <ProductDetail />
        </Route>

        <Route path="/products" exact>
          <Products />
        </Route>
      </Switch>
    </HashRouter>
  );
}

Mutation を実行する

この章の仕上げとして、商品詳細ページからレビューコメントを投稿できるようにしてみます。

useMutation という Apollo Client の フック関数を利用します。 useMutation は次のように利用します。

import { gql, useMutation } from "@apollo/client";

const mutation = gql`
  mutation MyMutation {
    # mutation の詳細
  }
`;

const [myMutation, { data, loading }] = useMutation(mutation);
// myMutation({ variables: { /* Mutation に渡す変数 */ } }) で実行する

フック関数は次の構造のタプルを返します。

  • 1 つめ: Mutation を実行するための関数
  • 2 つめ: Mutation が実行されたときの State

実際に商品詳細の画面に、レビューコメント投稿の form を追加して useMutation を使ってみましょう。

const mutation = gql`
  mutation AddReviewMutation($pid: ID!, $comment: String!) {
    addReview(
      productId: $pid
      addReviewInput: { commentBody: $comment, star: 0 }
    ) {
      id
    }
  }
`;

export default function ProductDetail() {
  const [myComment, setMyComment] = useState("");
  const [addReview, { loading: submitting }] = useMutation<
    AddReviewMutation,
    AddReviewMutationVariables
  >(mutation);

  return (
    <>
      {/* 中略 */}

      <form
        onSubmit={e => {
          e.preventDefault();
          addReview({
            variables: {
              pid: productId,
              comment: myComment
            }
          });
        }}
      >
        <div>
          <label>
            コメント <br />
            <textarea
              value={myComment}
              onChange={e => setMyComment(e.target.value)}
            />
          </label>
        </div>
        <button type="submit" disabled={submitting}>
          追加
        </button>
      </form>
    </>
  );
}

画面から実行して見ください。プレイグラウンド上で、選択した商品の reviews に投稿が追加されたことが確認できるはずです。

Mutation 実行後にクエリを再実行する

ここまでの実装では「追加」をタップしたときにレビューが投稿され、Server 上のデータが変わったことは確認できたものの、画面にはそれが反映されていない状態となってしまっているはずです。

Mutation 実行後に画面を更新する方法はいくつか存在しますが、いずれの方法の場合でも「Apollo Client のキャッシュを正しく更新する必要がある」が基本的な考え方です。

Apollo Client 拡張から、Mutation 実行直後のキャッシュを確認してみてください。

商品、すなわち Product Type に対応するキャッシュとして Product:002 などが表示されているはずですが、 addReview Mutation 実行直後は以下のように対応する reviews には追加したレビューが含まれない状態です。

{
  "Product:002": {
    "reviews": []
  }
}

もっとも簡単に Apollo Client のキャッシュを更新する方法として、ここでは「関係するクエリを再度実行する」という方法を用います。

useMutation の第二引数に update という関数を渡すと、当該の Mutation が完了した歳にその関数が Callback されます。今回はこのコールバック関数に useQuery で指定した商品詳細の取得クエリを再実行するようにしましょう。

// `useQuery` の戻り値から、Query 実行関数を取得しておく
const { data, loading, refetch } = useQuery<
  ProductDetailQuery,
  ProductDetailQueryVariables
>(query, {
  variables: {
    id: productId
  }
});

const [addReview, { loading: submitting }] = useMutation<
  AddReviewMutation,
  AddReviewMutationVariables
>(mutation, {
  // Mutation 実行後に動作する関数
  update(_, { data }) {
    if (!data?.addReview) return;
    setMyComment("");
    refetch();
  }
});

実際に画面からレビューを投稿し、レビュー一覧に反映されることを確認してみましょう。

(Advanced) Mutation の結果を利用してキャッシュを更新する

このパートは若干発展的な内容を扱っているため、読み飛ばしても構いません。

refetch は手軽にキャッシュの更新が行えますが、場合によっては非機能要件を満たせないことがあります。

  • 該当のクエリの再実行に大量のサーバーリソースを消費する(e.g. サーバー側で Slow SQL Query となってしまう)
  • ネットワークの結果を待たずに、ユーザーの画面に結果を反映させたい(Optimistic Update)
  • etc,,,

このような場合、クエリを再実行せずに Apollo Client のキャッシュを更新することも選択肢の1つです。

今回の例の場合、AddReviewMutation を実行したら、以下のようにデータが変更されることを開発者は知っています。

  • いま見ている商品詳細の reviews の末尾に自分が書いたレビューが追加される

そこで、 AddReviewMutation の結果を用いて、キャッシュを更新するようにしてみましょう。Mutation の中身を少し書き換えます。

const mutation = gql`
  mutation AddReviewMutation($pid: ID!, $comment: String!) {
    addReview(
      productId: $pid
      addReviewInput: { commentBody: $comment, star: 0 }
    ) {
      id
      commentBody # 追加した
      __typename
    }
  }
`;

次に、 useMutationupdate オプションを以下のように書き換えていきます。第一引数の cache から、readQuerywriteQuery を使うようにしました。

const [mutate, { loading: submitting }] = useMutation<
  AddReviewMutation,
  AddReviewMutationVariables
>(mutation, {
  // Mutation 実行後に動作する関数
  update: (cache, { data }) => {
    if (!data?.addReview) return;
    setMyComment("");
    // refetch() // もう使わない

    // Apollo Client のキャッシュから商品詳細の内容を取得
    const cachedProductDetail = cache.readQuery<
      ProductDetailQuery,
      ProductDetailQueryVariables
    >({
      query,
      variables: {
        id: productId
      }
    });

    if (!cachedProductDetail?.product) return;

    const { product } = cachedProductDetail;
    const createdReview = data.addReview;

    // Apollo Client のキャッシュに、変更した商品詳細データをセット
    cache.writeQuery<ProductDetailQuery, ProductDetailQueryVariables>({
      query,
      variables: {
        id: productId
      },
      data: {
        product: {
          ...product,
          // レビュー部分の末尾に Mutation の結果データを追加
          reviews: [...product.reviews, createdReview]
        }
      }
    });
  }
});

画面から実行してみましょう。 refetch を使った場合と同じ様に Apollo Client のキャッシュが変更される様子が拡張機能から確認できるはずです。

今回のような「変更するキャッシュの内容がわかっている」というケースにおいては、往々にして Mutation の引数だけからほぼキャッシュの更新内容が決定できることが多いです。

今回の場合、 commentBody はユーザー自身が <textarea> に書いた内容そのものです。このような場合、実際の Mutation の完了を待つ前にキャッシュを更新することで、ユーザーの体感的なパフォーマンスを向上できます。これを Optimistic Update と呼びます。

Apollo Client で Optimistic Update を実現する場合、 useMutationoptimisticResponse オプションを利用します。

const [mutate, { loading: submitting }] = useMutation<
  AddReviewMutation,
  AddReviewMutationVariables
>(mutation, {
  // Mutation 実行後に動作する関数
  update: (cache, { data }) => {
    // 変更不要
  },
  optimisticResponse: {
    addReview: {
      __typename: "Review",
      id: "__TEMP_REVIEW_ID__",
      commentBody
    }
  }
});

update オプションの実行内容は一切変更する必要はありません。 optimisticResponse を追加すると、update の処理は都合2回実行されます。

  1. mutate 実行直後(ユーザーが「追加」ボタンを押したすぐ後)。このときは optimisticResponseupdate の第2引数のデータとなる。
  2. サーバーで Mutation が完了し、結果がフロントエンドに返ってきたとき。このときは AddReviewMutation の結果が update の第2引数のデータとなる。

その他の GraphQL Client Library

この workshop では Apollo Client を紹介しました。JavaScript フロントエンドで利用可能な GraphQL ライブラリには Apollo Client 以外にも以下があります。

名前 特徴
Relay Facebook 社が作成した React と GraphQL を統合したフロントエンド向けフレームワーク。Relay QL Spec という追加仕様をサーバーサイドに要求することもあり敷居が高い
urql 軽量、カスタマイズ性の高い GraphQL クライアントライブラリ。React 以外にも Vue.js や Svelte 向けの実装も存在している
gqless React JSX から自動的に GraphQL Query を生成するアプローチのクライアントライブラリ

Chapter 1 へ Chapter 3 へ