2020/09/04加筆修正
こちらのコメントを @kahirokunn さんから頂いたので、微修正を加えました!
Converterについては、まだ手元で動作確認していないので、いい感じの代案が作れたらConverter無しバージョンも作ってみたいと思います。
細かい所とかだとwatchはimmediateオプションあるとか、query変更時に前のsubscribe解除してないとかありますが、使い勝手は良さそうですね!
— { __typename: “kahirokunn” } (@kahirokunn) September 4, 2020
Converter関数渡すなら、自分で取得したデータをcomputedで変換すればいいので、Converterいらないかなぁとも思いました
普通にTwitterでシュッと意見を聞いてみただけなのに、Firestoreのリスナ解除していないことを見抜いてしまうあたり場数が違うなあと思いました(僕は手元で動かしてようやく気がついたのでw)。
概要
composition-api
を使ってFirebase
のFirestore
からデータ/ローディング/エラーなどを読み取ることができるComposition Function
を作りました。
useCollection.ts
と名付けており、useCollectionData<T>
というメソッドをexport
しています。
ソースコードはこちらのGist
に上げていますが、本記事ではこの内容についてざっと解説していこうと思います。
https://gist.github.com/TeXmeijin/380e52febdae921f31afe382af1bb40f
useCollection
活用の全体像
まずはこのComposition Function
はどういった仕様か、また、どのようにComponent
で活用できるかを説明します。
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
から呼び出して利用します。
// チャットメッセージのフック。部屋の`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,
}
}
setup (props) {
// @note FirebaseのInitializeは親コンポーネントまたはNuxt Pluginなどにて実行すること
const messagesState = useMessages(computed(() => props.selectedRoomId))
return {
messagesState,
}
},
template
は下記のような感じです。
<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>
}
ジェネリック型T
はFirestore
から取り出してきたデータの型です。Firestore
の戻り値は基本的にそのままだと型安全ではないので、この段階で型が付けられるようにしています。
useCollectionData
のgetQuery
は関数で渡すようになっています。これは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
して使うと良いと思います。
特にDate
はFirestore
上のデータの型はTimestamp
型として扱われているので、dayjs
等で日付のフォーマットを掛けるために下記のように実装してConverter
としています。
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
を必要に応じて使いながら、watch
でsnapshot
の取得を試みています。
Converter
の型定義にジェネリック型が必要になるので、ここまでずっとジェネリック型T
を取り回し続けています。
今回はこのように、immediate: true
で即時実行するようにフックを組みましたが、実行するハンドラごと呼び出し元に返して、呼び出し元の裁量で実行タイミングを決められる設計もありだと思います。
今回の応用版として、useDocumentCreate.ts
も作っているのですが、そちらはcreate
というハンドラを呼び出し元に返しておき、チャットメッセージの送信ボタン押下のイベントハンドラでcall
しています。
わからないこと
2020/09/20追記
下記のわからないことですが、クロージャを引数で受け取って、composition function内部でcomputedでWrapすればリアクティブになるので、そのほうが望ましいことに気が付きました。useSWRV
がその方式を採用しています。
これでとりあえず動作するわけなのですが、ベストプラクティスかどうか分かっていない点としては、下記のようにComputedRef
をcomposition function
が受け取る点です。
const readState = useCollectionData<Message>(
computed(() => firebase.firestore().collection('messages').where('roomId', '==', roomIdRef.value)
.orderBy('updateTime', 'desc')),
{
dataConverter: messageFirestoreDataConverter,
}
)
あるページ内でクエリが次々変化するケースにおいてのカスタムフックってこのように作る方針でいいんでしょうか。
普通にQuery
型を渡してしまうと、Query
を更新したときに再度フックが走らないのでデータが更新されないわけです。
こうなるくらいなら、ハンドラを呼び出し元に返す設計に組み直して、呼び出し元でwatch
などを基点に再読み込み動かすほうがスッキリ来るような気もする。
まとめ
自信がない箇所もありますが、なんにせよ通信処理とエラー、ローディングがリアクティブに扱えるモジュールを切り出しやすいcomposition-api
は便利です。React
のHooks
が羨ましいと思っていたのですがcomposition-api
のほうがリアクティブな値の型が明示されている点で直感的な気がしていて好みです。色々試行錯誤してきたいと思います。
今のところ、以下でツイートしているように、インフラ層と、DTO
(というかドメインオブジェクトの型)に変換する層と、アプリケーション固有の仕様を表現する層にComposition Function
のレイヤーを分けて、ネストさせて扱うと見通しが良さそうに思っています。
インフラ:useApi/useFirestoreCollectionなど
— 名人さん | マナリンクCTO (@Meijin_garden) September 2, 2020
DTO変換:useArticle/useMessageなど
アプリケーション:画面特有の、ここのイベントが動いたらここのデータを再読込するみたいな動きを実装する
よろしければTwitterのフォローお願いします!
https://twitter.com/Meijin_garden