5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

vue-composition-apiでFirestoreを扱うreact-firebase-hooks風のComposition Functionを作った

Last updated at Posted at 2020-09-03

2020/09/04加筆修正

こちらのコメントを @kahirokunn さんから頂いたので、微修正を加えました!
Converterについては、まだ手元で動作確認していないので、いい感じの代案が作れたらConverter無しバージョンも作ってみたいと思います。

普通にTwitterでシュッと意見を聞いてみただけなのに、Firestoreのリスナ解除していないことを見抜いてしまうあたり場数が違うなあと思いました(僕は手元で動かしてようやく気がついたのでw)。

概要

composition-apiを使ってFirebaseFirestoreからデータ/ローディング/エラーなどを読み取ることができるComposition Functionを作りました。

useCollection.tsと名付けており、useCollectionData<T>というメソッドをexportしています。

ソースコードはこちらのGistに上げていますが、本記事ではこの内容についてざっと解説していこうと思います。
https://gist.github.com/TeXmeijin/380e52febdae921f31afe382af1bb40f

useCollection活用の全体像

まずはこのComposition Functionはどういった仕様か、また、どのようにComponentで活用できるかを説明します。

useCollection.ts
export const useCollectionData: useCollectionData = <T extends { id: string }> (
  getQuery,
  option
) => {
  const {
    snapshot, loading, error,
  } = useCollection<T>(getQuery, option)

  const values = computed(
    () => {
      return (snapshot.value
        ? snapshot.value.docs.map(doc => {
          return {id: doc.id, ...doc.data()}
        })
        : []) as T[]
    }
  )

  return {
    values, loading, error,
  }
}

※本家のreact-firebase-hooksは一度きりの読み取りが可能なHooksも公開していますが、本記事では一旦リアルタイムアップデートを入手できるComposition Functionの作成についてのみ話します。

useCollectionData<T>を使うと、下記の型の戻り値が得られます。

type CollectionData<T> = {
  values: Ref<T[]>,
  loading: Ref<boolean>,
  error: Ref<Error | null>,
}

リアクティブに変更されるCollectionの値(values)と、読込中にTrueになるloading、読み込みエラー時にエラー内容が入るerrorが返ってきます。

これらは以下のuseMessagesのように具体的な型を指定したアプリケーションのユースケースに沿った別のcomposition functionを挟んだ上でComponentから呼び出して利用します。

useMessages.ts
// チャットメッセージのフック。部屋の`ID`を指定すると部屋のメッセージ一覧を取得できる
export const useMessages = (roomIdRef: Ref<string>, option = {
  limit: 20,
}) => {
  const messagesState = useCollectionData<Message>(
    () => firebase.firestore().collection('messages').where('roomId', '==', roomIdRef.value)
      .orderBy('updateTime', 'desc'),
    {
      dataConverter: messageFirestoreDataConverter,
    }
  )

  return {
    ...messagesState,
  }
}
Component.vue
  setup (props) {
    // @note FirebaseのInitializeは親コンポーネントまたはNuxt Pluginなどにて実行すること
    const messagesState = useMessages(computed(() => props.selectedRoomId))

    return {
      messagesState,
    }
  },

templateは下記のような感じです。

Component.vue
<template>
  <main>
    <section>
      <div v-if="messagesState.loading">
        loading...
      </div>
      <div v-else-if="messagesState.errors">
        {{ messagesState.errors.message }}
      </div>
      <div v-else>
        <ul>
          <li v-for="message in messagesState.values" :key="message.id">
            <span>{{ message.text }}</span>
            <span>{{ message.createTime }}</span>
          </li>
        </ul>
      </div>
    </section>
  </main>
</template>

ローディングやエラー表示もリアクティブに取得できるため、このようにシンプルな記述でリアクティブなデータ読み込みを実装できます。

ここで、setupの戻り値を下記のようにするとtemplate側でmessagesStateと書かなくて良い分書きやすいのでそちらで書いてもいいでしょう。

    return {
      ...messagesState
    }

useCollectionDataの概要

設計思想

設計思想は元祖であるreact-firebase-hooksの同名の関数を真似ています。
https://github.com/CSFrequency/react-firebase-hooks/blob/master/firestore/useCollection.ts

こちらのライブラリはもとよりReactから提供されているHooksを活用していたり、共通でローディングを扱うHooksとしてuseLoadingValueを実装していたりと、なかなか簡単に複製できそうにないと感じたため、一旦useCollectionDataと近しいものを再現する方針で取り組んでみました。

useCollectionDataの型は下記のように定義しました。

export type CollectionData<T> = {
  values: Ref<T[]>,
  loading: Ref<boolean>,
  error: Ref<Error | null>,
}

type useCollectionData = {
  <T extends { id: string }> (
    getQuery: () => firestore.Query,
    option?: {
      dataConverter?: FirestoreDataConverter<T>
    }
  ): CollectionData<T>
}

ジェネリック型TFirestoreから取り出してきたデータの型です。Firestoreの戻り値は基本的にそのままだと型安全ではないので、この段階で型が付けられるようにしています。

useCollectionDatagetQueryは関数で渡すようになっています。これはuseCollection内部で下記のようにcomputedでWrapすることでリアクティブにクエリの変更を受け付けられるようにするためです。

  const query = computed(() => getQuery())

というのも、チャットルームでメッセージを読み込む処理を開発する場合、部屋を何度も切り替えることが想定されるため、部屋を切り替えるたびに新しくデータを読み取ってほしいからです。そこで、検索クエリ内には検索パラメータが必ず含まれることを利用して、検索パラメータの変更に対して即時に再度クエリを投げてもらうよう設計してみました。

戻り値のCollectionDataについては前述の通りです。

オプションのdataConverterには、FirestoreDataConverterを指定できます。
Converterについての仕様は下記ドキュメントを御覧ください。
https://cloud.google.com/firestore/docs/manage-data/add-data?hl=ja#custom_objects

読み込み、書き込み時にFirestore上のデータとドメインオブジェクトで変換を掛けることができるハンドラのようなものです。個別のモジュールとして作っておいて適宜importして使うと良いと思います。

特にDateFirestore上のデータの型はTimestamp型として扱われているので、dayjs等で日付のフォーマットを掛けるために下記のように実装してConverterとしています。

messageFirestoreDataConverter.ts
export const messageFirestoreDataConverter: FirestoreDataConverter<Message> = {
  toFirestore (message: Message): DocumentData {
    return message
  },
  fromFirestore (snapshot: QueryDocumentSnapshot<FirebaseMessage>): Message {
    const { createTime, updateTime, ...contents } = snapshot.data()

    return {
      createTime: convertTimestampToDate(createTime),
      updateTime: convertTimestampToDate(updateTime),
      ...contents,
    }
  },
}

データの読み取り

データの読み取りとそれに関するステートは下記コードによって取得しています。

  const {
    snapshot, loading, error,
  } = useCollection<T>(query, option)

useCollectionDataやここで使われるuseCollectionでは徹底的にジェネリック型を使って、ドメインオブジェクトの型を取り回し続けるように組んでいます。

useCollectionは下記の通りです。

export const useCollection = <T> (
  getQuery: () => firestore.Query,
  option?: {
    dataConverter?: FirestoreDataConverter<T>
  }
) => {
  const snapshotState = reactive<SnapshotState>({
    snapshot: undefined,
    loading: true,
    error: null,
  })

  const query = computed(() => getQuery())

  let listener = () => {
  }

  const bindSnapshotHandler = () => {
    if (!query.value) return

    listener()
    snapshotState.loading = true

    const builtQuery = option?.dataConverter ? query.value.withConverter(option.dataConverter) : query.value

    listener = builtQuery.onSnapshot({
      next: val => {
        snapshotState.snapshot = val
        snapshotState.loading = false
      },
      error: err => {
        snapshotState.error = err
        snapshotState.loading = false
      },
    })
  }

  // マウント完了時か、クエリが変化したときにデータを読み込む
  watch(query, bindSnapshotHandler, {immediate: true})
  onUnmounted(() => {
    listener()
  })

  return {
    ...toRefs(snapshotState),
  }
}

前述のDataConverterを必要に応じて使いながら、watchsnapshotの取得を試みています。
Converterの型定義にジェネリック型が必要になるので、ここまでずっとジェネリック型Tを取り回し続けています。

今回はこのように、immediate: trueで即時実行するようにフックを組みましたが、実行するハンドラごと呼び出し元に返して、呼び出し元の裁量で実行タイミングを決められる設計もありだと思います。
今回の応用版として、useDocumentCreate.tsも作っているのですが、そちらはcreateというハンドラを呼び出し元に返しておき、チャットメッセージの送信ボタン押下のイベントハンドラでcallしています。

わからないこと

2020/09/20追記

下記のわからないことですが、クロージャを引数で受け取って、composition function内部でcomputedでWrapすればリアクティブになるので、そのほうが望ましいことに気が付きました。useSWRVがその方式を採用しています。


これでとりあえず動作するわけなのですが、ベストプラクティスかどうか分かっていない点としては、下記のようにComputedRefcomposition functionが受け取る点です。

  const readState = useCollectionData<Message>(
    computed(() => firebase.firestore().collection('messages').where('roomId', '==', roomIdRef.value)
      .orderBy('updateTime', 'desc')),
    {
      dataConverter: messageFirestoreDataConverter,
    }
  )

あるページ内でクエリが次々変化するケースにおいてのカスタムフックってこのように作る方針でいいんでしょうか。
普通にQuery型を渡してしまうと、Queryを更新したときに再度フックが走らないのでデータが更新されないわけです。
こうなるくらいなら、ハンドラを呼び出し元に返す設計に組み直して、呼び出し元でwatchなどを基点に再読み込み動かすほうがスッキリ来るような気もする。

まとめ

自信がない箇所もありますが、なんにせよ通信処理とエラー、ローディングがリアクティブに扱えるモジュールを切り出しやすいcomposition-apiは便利です。ReactHooksが羨ましいと思っていたのですがcomposition-apiのほうがリアクティブな値の型が明示されている点で直感的な気がしていて好みです。色々試行錯誤してきたいと思います。

今のところ、以下でツイートしているように、インフラ層と、DTO(というかドメインオブジェクトの型)に変換する層と、アプリケーション固有の仕様を表現する層にComposition Functionのレイヤーを分けて、ネストさせて扱うと見通しが良さそうに思っています。

よろしければTwitterのフォローお願いします!
https://twitter.com/Meijin_garden

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?