コンポーネントとGraphQLクエリの管理にFragment Colocactionを導入したら素晴らしかった件

はじめまして、NewsPicks Web Product Unitのじゆんきち(@junkisai)です。

弊チームでは、ここ1年間くらいWeb 版のNewsPicksを新しい基盤に置き換えるプロジェクト(以降、リアーキプロジェクト)を進めています。 新 Web 基盤のフロントエンドはReact (Next.js)+TypeScriptを採用しており、バックエンドとの通信にはGraphQL(Apollo) を採用しています。

今回はFragment Colocationという考え方を導入したら、これまで抱えていた技術的な課題が解消され、開発スピードが向上したお話をしようと思います。

導入前の課題

リアーキプロジェクト開始当初から使用していたGraphQLですが、プロダクトの規模が大きくなるにつれ、いくつか課題が浮き彫りになってきました。

課題1. BFFとの通信回数が多い

弊プロダクトではフロントエンドとBFFとの間でも型安全な開発が行えるように、GraphQL Code Generator を導入しています。 このツールが自動生成してくれるのは型定義だけではなく、QueryやMutationを呼び出せるhooksも含まれています。 自動生成されたhooksは、通信まわりをすっきり記述できるというメリットがあり、弊プロダクトでは下記のようなルールでコンポーネントを構成して、コンポーネント内で利用していました。

SomeComponent/
├── templates.tsx     ロジックをもたずレイアウトに責務をもつ
└── container.tsx     ロジック(ex. BFFにリクエストするhooksを呼ぶ)に責務をもち、templates.tsxをラップする
import { useSampleQuery } from 'graphql/generated'

export const SampleComponent: React.FC = () => {
  const { loading, data } = useSampleQuery()
  
  if (loading) {
    return <Loading />
  }

  return <Component />
}

しかし、ページを読み込む際にレンダリングするコンポーネントの数だけQueryを叩くことになるので、扱うコンポーネント数が多くなるにつれて通信回数が増えてしまうという問題がでてきました。

課題2. クエリを書く際に必要な項目が漏れていたり、不要な項目が残ったままになる

弊プロダクトでは、コンポーネントがどんなデータを必要としているかを、ページのクエリとして記述するという形を取っていました。このため、各コンポーネントの知識をページがもつ必要があり、コンポーネントの追加や変更があった際、同時にページのクエリも変更しなければなりませんでした。

具体例として、Newsスキーマの項目を使用するNews_A, News_Bというコンポーネントをあるページに表示する際の実装をあげてみます。

// News_AコンポーネントではNewsスキーマのimageUrlとtitleがほしい
export const News_A: React.FC = () => {  
  return <Component imageUrl={imageUrl} title={title}  />
}
// News_BコンポーネントではNewsスキーマのtitleとcreatedAtがほしい
export const News_B: React.FC = () => {
  return <Component title={title} createdAt={createdAt} />
}

News_A, News_Bコンポーネントを表示するページのクエリは下記のようになり、Newsスキーマのデータがほしいコンポーネントが増えれば増えるほど、クエリの管理が複雑になり、仕様変更によって使用しなくなった項目が残ったままになったり、項目の記述漏れが発生してしまいます。

query SamplePage {
  news {
    title      ← これは News_AにもNews_Bにも依存
    imageUrl   ← これは News_A依存
    createdAt  ← これは News_B依存
  }
}

課題3. ドメイン知識をもったコンポーネントの振り分けルールが曖昧

弊プロダクトでは、コンポーネントをcomponents/ディレクトリ下で管理しており、下記のようなルールでコンポーネントを振り分けています。 domain/下は更に各ドメイン知識ごとにディレクトリが分けられているといった状態です。

components/
├── base/       外からのスタイリングを許可するコンポーネント
├── ui/         ドメイン知識をもたないレイアウト系のコンポーネント
└── domain/     ドメイン知識をもつコンポーネント
    ├── user/
    ├── news/
    └── movie/

しかし、上記のような振り分けルールで管理していくと、「ドメイン知識」という定義が明確ではなく、チームメンバーごとに解釈が異なってしまい、ui/ 層 と domain/ 層の線引きが曖昧になってしまいました。(ex. 「いいねボタン」のコンポーネントをlikeドメインとして捉えるべきか否か、動画の番組ラベルをmovieドメインとして扱うかprogramドメインとして扱うか etc.)

そこでFragment Colocation

「Fragment Colocation」とは、コンポーネントが必要なデータをGraphQLのFragmentで記述し、コンポーネントと同じ場所で宣言する考え方です。 つまり、各コンポーネントに自分をレンダリングするために必要な項目をFragmentとしてまとめ、ページコンポーネントでレンダリングするコンポーネントのFragmentを取りまとめて、単一のクエリを投げるという形を取ります。(具体的な実装フローは後半に記載してあるので、そちらをご参照ください)

Fragment Colocation 導入時のルール決め

Fragment Colocationの考え方をベースに、コンポーネントのディレクトリやファイル構成、クエリの管理ルールなどを再考しました。

components/下のディレクトリ振り分けルール

まず、components/下のディレクトリを下記のような振り分けルールに更新し、domain/下もGraphQLスキーマベースにディレクトリを切っていくというルールに変更しました。

components/
├── base/       外からのスタイリングを許可するコンポーネント
├── ui/         レイアウト系のコンポーネント
└── domain/     BFFから取得したデータを流しこむコンポーネント
    ├── user/     Userスキーマの項目を使用するコンポーネント
    ├── news/     Newsスキーマの項目を使用するコンポーネント
    └── movie/    Movieスキーマの項目を使用するコンポーネント

GraphQLファイルの置き場所

ページとQuery、コンポーネントとFragmentがそれぞれ1対1に対応するため、下記のようにページとコンポーネントそれぞれの近くに配置することにしました。

pages
├── index.tsx
├── index.graphqls      トップページで使用するFragment群を集約するQuery
└── users
    ├── index.tsx
    └── index.graphqls  ユーザー一覧ページで使用するFragment群を集約するQuery
components/domain
└── user
    └── UserCard
        ├── container.tsx
        ├── templates.tsx
        └── fragment.graphqls  UserCardコンポーネントで必要な項目をFragmentで記述

具体的な実装フロー

ユーザー一覧ページ(/users)にUserCardというコンポーネントを表示する場合の実装フローを紹介します。 まずはコンポーネントと対になるFragmentの定義をfragment.graphqlに記述します。

framgment UserCard on User {
  name
  imageUrl
}

これをGraphQL Code Generatorにかけると下記のような型定義が生成されます。

export type UserCardFragment = {
  __typename?: 'User'
  name: string
  imageUrl: string
}

生成された型定義をUserCardコンポーネントのpropsとして使用して、コンポーネントを組み上げていきます。

export type Props = {
  fragment: UserCardFragment
}

export const UserCard: React.FC<Props> = ({ fragment }) => {
  const { name, imageUrl } = fragment
    
  return <img src={imageUrl} alt={name} />
}

次にコンポーネントを呼び出したいページのクエリを更新します。

query UsersPage {
  news {
    ...NewsLink
    ...NewsThumbnail
  }
  users {
    ...UserCard
  }
}

最後に該当のコンポーネントまでデータを流し込んで完了です。

const UsersPage: NextPage = ({ usersPage }) => {
  return (
    <>
      <NewsLink fragment={usersPage.news} />
      <NewsThumbnail fragment={usersPage.news} />
      <UserCard fragment={usersPage.users} />
    </>
  )
}

UsersPage.getInitialProps = async (
  context: NextPageContext,
): Promise<IndexPageProps> => {
  const apiClient = initializeApollo(null, context)
  
  const { data: usersPage } =
    await apiClient.query<UsersPageQuery>({
      query: usersPageDocument
    })
  
  return {
    usersPage,
  }
}

開発にどんな影響をもたらしたか

先にあげた課題をFragment Colocationが解決してくれました。

  • 課題1. BFFとの通信回数が多い
    • → 各ページのレンダリングに必要な項目をFragmentとしてまとめ、単一のクエリを投げるようになったので、1回の通信で済むようになった
  • 課題2. クエリを書く際に必要な項目が漏れていたり、不要な項目が残ったままになる
    • → コンポーネントに必要な項目をコンポーネントファイルと隣接したFragment定義ファイルで管理しているため、ページとコンポーネントの依存関係が弱まり、型安全に項目の漏れや不備を防げるようになった
  • 課題3. ドメイン知識をもったコンポーネントの振り分けルールが曖昧
    • domain/下のディレクトリ分けをGraphQLスキーマベースにしたことで、メンバー間で解釈が異ならないルールになった

また、BFFでスキーマ定義さえ先にしてしまえば、コンポーネントの実装からBFFとのつなぎこみまでノンストップで進めることができるといったメリットも得られ、弊チームの開発スピードも導入前に比べ、向上したのではないかと思います。

おわりに

今回は、GraphQLの活用とコンポーネントの設計まわりをFragment Colocationという考え方を取り入れて改善した話を紹介させていただきました。

我々Web Product UnitはNewsPicksにおけるWebの価値を届けるべく、より良い技術を模索し、プロダクトを成長させ続けています。 Webフロントエンドエンジニアも積極採用中ですので、少しでも興味のある方は以下からエントリーをお願いいたします!

corp.newspicks.com

参考記事

Page top