RAKSUL TechBlog

ラクスルグループのエンジニアが技術トピックを発信するブログです

Vue.js v2 Composition API + Vue Apollo v4 で得られた知見

はじめに

はじめまして、2021 年に入社した新卒の宮﨑です。現在はラクスル事業本部フロントエンド開発部に所属しています。RAKSUL Advent Calendar 2021 5日目はフロントエンドに関する話題です!

弊社ではいくつかのサービスで GraphQL を使用しており、クライアントでは Vue Apollo v3 を使用しています。この Vue Apollo というライブラリには Vue.js v3 で導入された Composition API をサポートする v4 が開発されています。今回は Vue Apollo v4 を半年ほど使用してみて得られた知見、苦労した点を共有したいと思います。

技術構成

主な技術構成は以下の通りです。Nuxt.js v2 に @nuxtjs/composition-api というライブラリを利用して Composition API を使用可能にしています。

  • Nuxt.js v2.14
  • Vue.js v2.7
  • @nuxtjs/composition-api v0.30
  • @nuxtjs/apollo v4.0.1-rc.5
  • @vue/apollo-composable v4.0.0-alpha.12

Vue Apollo v4 について

弊社で以前から使用している Vue Apollo v3 は Vue.js の this コンテキストがある前提でのライブラリのため、 Composition API の setup 内でクエリを呼び出すには適していませんでした。そこで登場したのが Vue Apollo v4 (@vue/apollo-composable) です。 useQueryuseMutation などの Apollo Client (@apollo/client) API が setup や Composition 関数内で使用できるようになります。今回は Vue Apollo v4 に合わせて Nuxt.js での SSR 対応などを行う @nuxtjs/apollo も使用しています。

Composition API で Vue Apollo v4 を使うための設定

ライブラリをインストールするだけでは @nuxtjs/apollo と Vue Apollo v4 は上手く動作しません。公式ドキュメントには記載されていませんが、 こちらのissue をもとに解決できました。 @nuxtjs/apollo は Apollo Client に関する複雑な設定をしてくれるライブラリなのですが、 @nuxtjs/apollo で作成した Apollo Client を Vue Apollo v4 のクライアントとして登録する必要があります。

// <projectRoot>/plugins/apollo-client.ts

import { Context } from '@nuxt/types'
import {
  provide,
  onGlobalSetup,
  defineNuxtPlugin,
} from '@nuxtjs/composition-api'
import { DefaultApolloClient } from '@vue/apollo-composable/dist'

export default defineNuxtPlugin(({ app }: Context): void => {
  onGlobalSetup(() => {
    provide(DefaultApolloClient, app.apolloProvider?.defaultClient)
  })
})

Composition API との組み合わせ

Composition API と Vue Apollo v4 の組み合わせ方についてはネット上にもほとんど知見がなく手探りな状態でした。開発初期の頃は以下のような使い方をしていました。

買い物カートの情報を取得するクエリを例に考えてみましょう。クエリの引数として「あとで買う」オプションを取っています。

// <projectRoot>/apollo/queries/masterData.ts

import gql from 'graphql-tag'

export const getCart = gql`
  query getCart($buyLater: boolean) {
    cart(buyLater: $buyLater) {
      name
      updatedAt
      ...
    }
  }
`

続いて、このクエリを扱う Composition 関数を用意します。単純な要件の画面では 1 クエリ・1 Composition 関数の対応で実装しています。useQuery の返り値からレスポンス待ちかどうかを表す loading という値がありますが、これは ref でラップされた値なのでリアクティブに変化します。このように外部の TypeScript ファイル内でリアクティブな値を扱えるようになったのも Composition API のおかげですね。useQuery にクエリの引数を渡す手段はいくつかありますが、公式ドキュメントで詳細に説明されています。

// <projectRoot>/composables/useCart.ts

import { useQuery, useResult, Reactive } from '@vue/apollo-composable/dist'

interface Payload {
  buyLater: boolean
}

export default function useCart(payload: Reactive<Payload>) {
  const {
    loading,
    refetch,
    onResult,
  } = useQuery<CartResponse>(  // レスポンスの型は自動生成されたものを指定する
    cartQuery,  // GraphQL のクエリ文字列
    payload,    // `reactive` でラップされたオブジェクトをそのまま渡すことができる
  )

  // `ComputedRef` というリアクティブな値として結果を取り出せる
  const cart = useResult(result) 

  return { loading, refetch, onResult, cart }
}

setup 内でこの Composition 関数を実行します。

// <projectRoot>/pages/index.vue

import { defineComponent, reactive } from '@nuxtjs/composition-api'
import useCart from '<projectRoot>/composables/useCart'

export default defineComponent({
  setup() {
    const payload = reactive({
      buyLater: false,
    })

    const {
      loading,
      refetch,
      onResult,
      cart,
    } = useCart(payload)

    onResult(() => {
      // cartQuery のレスポンスが返ってきた際に実行する処理をここに書く
    })

    return { loading, refetch, cart }
  }
})

これで setup の中でクエリを呼び出すことができました。

useQuery と useLazyQuery の使い方

Vue Apollo v4 ではクエリを呼び出す関数として useQueryuseLazyQuery が用意されています。useQuerysetup が実行されるタイミングでクエリが呼び出されます ( setup の実行タイミングについては Vue.js v3 のドキュメントを参照してください) 。一方、useLazyQuery はクエリの遅延呼び出しを行うために用意されています。しかし、useLazyQuery は Vue Apollo の公式ドキュメントには記載がされていないので注意が必要です (記事執筆時点 2021/12/2)。使い方は Apollo Client のドキュメントに記載されており、API は基本的に同じです。

直列呼び出しで発生した問題と解決策

GraphQL は 1 度のリクエストでその画面に必要な情報を取得できるのが魅力です。しかし、1 度のリクエストで完結させようとしてレスポンスタイムが大幅に伸びてしまう、早く取れる情報から画面上に表示できた方が体験として良いなど、どうしても複数の GraphQL クエリを直列で呼びたくなる場面が多々あります。この要求に対して開発初期段階では以下のように対処していました。

Vue Apollo v4 ではクエリの引数としてリアクティブな値を渡すことができます。そして渡された値の変更を検知して、クエリを自動で呼び出すという機能があります。先ほど例として挙げた useCart において、payload.buyLater = true のように変更すると、getCart クエリが自動でリクエストされるイメージです。また、レスポンスは  useResult を利用することで ComputedRef というリアクティブな値として取り出すことができます。この仕組みを利用して、前のクエリ結果から次のクエリも自動で呼び出そうという狙いでした。

getCartクエリの結果として得られたリアクティブな値(ComputedRef)を次のクエリの引数として渡す

これはクエリ呼び出しが単純な画面で非常に便利です。しかし、以下のように複数の条件でクエリの直列呼び出しを変えるような要件では破綻することがわかりました。

  • あるモーダルを開く場合
    • getCart クエリ -> B クエリ
  • あるボタンを押した場合
    • getCart クエリ -> C クエリ
  • ページの初期表示の場合 ->
    • getCart クエリ -> D クエリ

getCartクエリの後に3つのクエリを直列で呼ぶ

この方法の問題点は、リアクティブな値を useQuery の引数として渡すと、その後に実行するクエリを選択したり、実行タイミングを制御するのが難しいということです。例えば、getCart  -> B クエリ だけを期待していたが、C クエリ も実行されてしまうようなケースです。そこで私たちは getCart の結果を B クエリ の useQuery へ渡す際に、リアクティブな変更を保留し、任意のタイミングでその変更を適用するような仕組みを自作するなどでなんとか対処していました。

しかし、これでは開発が進むにつれてカオスになり、どこかのタイミングで手をつけられなくなるのは明らかでした。そこで 1 週間ほど時間を確保して、改めて何が問題なのかを洗い出しました。

抱える問題点

  1. 直列で呼ばなければならないクエリが多い
  2. 得たいデータはユーザーアクションに依存しているが、クエリ呼び出しはユーザーアクションに関係なく直前で呼ばれるクエリに依存している

1 つ目の問題は開発メンバーの GraphQL 利用経験が浅かったこともあり、クエリ設計がどうしても REST のようになってしまったのが良くなかったと思います。GraphQL の思想に立ち返り、可能な限り 1 度のリクエストでデータを取れるように順次リゾルバーを修正し、現在も対応中です。

2 つ目の問題点は直列なクエリの呼び出しとユーザーアクションがうまく紐づいていないということです。そこで、多少コードが冗長になることを許容して、ユーザーアクションごとに useQuery や  useLazyQuery による直列呼び出しを書き、Composition 関数内に閉じ込めました。

クエリごとにComposition関数を使って閉じ込める

買い物カートのモーダルを開いた際に、商品と合計金額を 2 つのクエリで取得するサンプルを書いてみます。実装している中で気づいた細かな挙動などはコメントしています。

// <projectRoot>/composables/useCartModalOpen.ts
// import, interface などは省略

export default function useCartModalOpen(payload: Reactive<Payload>) {
  const cartQueryCalled = ref(false)
  const billingDetailsQueryCalled = ref(false)

  // load 関数や refetch 関数でクエリ引数を渡す場合、
  // useLazyQuery では undefined と指定できます。
  const {
    load: cartLoad,
    refetch: cartRefetch,
    result: cartResult,
    onResult: cartOnResult,
  } = useLazyQuery(getCart, undefined) 

  // 初回呼び出しは load 関数を、それ以降は refetch 関数を使用します。
  // load 関数がネットワークエラーになどにより失敗しても、
  // 2 度目以降は refetch 関数を使わないとリクエストが
  // 飛ばないので注意が必要です。
  const fetchCart = () => {
    const variables = {
      buyLater: false,
    }

    // load や refetch を実行するタイミングでクエリの引数を渡しています。
    if (cartQueryCalled.value) {
      cartRefetch(variables)
    } else {
      // getCart は GraphQL クエリです。
      cartLoad(getCart, variables)
    }
    cartQueryCalled.value = true
  }

  const cart = useResult(cartResult)

  // getBillingDetails は GraphQL クエリです
  const {
    load,
    refetch,
    result,
    onResult,
  } = useLazyQuery(getBillingDetails, undefined)

  const fetchBillingDetails = () => {
    const variables = {
      cart: cart.value,
    }

    if (billingDetailsQueryCalled.value) {
      refetch(variables)
    } else {
      load(getCart, variables)
    }
    billingDetailsQueryCalled.value = true
  }

  const billingDetails = useResult(result)

  cartOnResult(() => {
    // getCart クエリ後に次の billingDetails クエリを呼びます
    fetchBillingDetails()
  })

  return {
    cartModalOpenFetch: fetchCart,
    onResult,
    cart,
    billingDetails,
  }
}

これによって GraphQL クエリの直列呼び出しという複雑なコードが Composition 関数の中に閉じ込められ、この Composition 関数を使う側は内部でいくつのクエリが呼び出されているかを意識する必要がなくなります。また、クエリの設計を改善する際はこの Composition 関数内での直列呼び出しを無くせるようにリゾルバーを修正していけば良いということです。

今後の改善ポイント

今後はクエリ設計を見直して直列な呼び出しを減らしていくとともに、キャッシュの設定もしていきたいです。Apollo Client のキャッシュ機構に関しては私自身まだ理解が浅い部分なので、高速化していけるよう知見を貯めていきたいと思います。

最後に

以上が Vue Apollo v4 を使って得られた知見と苦労した話でした。

良かった点は Composition API との併用により、

  • 再利用可能なクエリ呼び出しを書ける
  • 煩雑になりやすい GraphQL クエリの呼び出し部分をラップできる
  • リアクティブな値をうまく使えば状態変化に合わせた自動クエリ呼び出しができる

気を付けたい点としては、

  • セットアップ方法はネット上に知見があまり無いので注意
  • GraphQL クエリを直列に呼ぶ必要がないように可能な限り設計段階で考慮しよう

特に GraphQL の設計は画面仕様への依存が強く、開発初期段階で完璧な設計を考えるのを困難だと感じています。最後に述べたような Composition 関数を利用して GraphQL クエリの直列呼び出しをラップし、徐々にクエリ設計を改善していく方法は現実的なのかもしれないと思いました。

ラクスルではエンジニアを募集中です!

ラクスルのアドベントカレンダー全編はこちらから