Nuxtとfirestoreで更に読み込む実装をする

こんにちは。[virapture株式会社](https://virapture.com)の[もぐめっと](https://mogmet.com)です。

![mogmet.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/84d16a84-3727-9994-cf02-7ea649d6bd17.jpeg)
学生時代に酔いつぶれた写真が出てきました。若さを感じますね。

本日はNuxtとfirestoreを使った実践的な内容として、一覧から必要な数の要素を取得した後に、さらに続きも取得して表示するといった方法を紹介しようと思います。

完成図はこんな感じになります。

![moreload.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/3eb11129-6adc-e0f2-e164-7ea4a7cde856.gif)


実装イメージはこんな感じ。
![moreload-image.001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/8f15f0b6-c7d4-8f52-c444-0f14f1838f8d.png)

ざっくり紹介するとこんな感じになります。

- RoomRepository: firestoreからデータを取得します
- RoomService: repositoryから取得されたデータを保持し、pageからのリクエストを受けてrepositoryとのやり取りを行います
- Page: RoomServiceにリクエストを投げて帰ってきた結果を表示します


実際のソースコードもおいておきますので、ソースから見たい方はこちらにどうぞ。

https://github.com/mogmet/firestore-infinity-load

ではソースコードとともに実装を紹介します。

## RoomRepositoryの実装

まずRoomの実体になるentityを作っておきます。

```~/models/entity/room.ts
import firebase from 'firebase'

export default class Room {
  // eslint-disable-next-line no-useless-constructor
  constructor(
    readonly id: string,
    readonly roomName: string,
    readonly createdAt: firebase.firestore.Timestamp
    ) {
  }
}
```

repositoryで使う返却値を用意します。

```~/models/repository/collectionResponse.ts
import firebase from 'firebase'
import DocumentSnapshot = firebase.firestore.DocumentSnapshot

export interface CollectionResponse<T> {
  results: T[]
  nextSnapshot: DocumentSnapshot | null
}
```

前準備ができたところでrepositoryの紹介です。

```~/models/repository/roomRepository.ts
import Room from '~/models/entity/room'
import firebase, { firestore } from '~/plugins/firebase'
import CollectionReference = firebase.firestore.CollectionReference
import DocumentSnapshot = firebase.firestore.DocumentSnapshot
import { CollectionResponse } from '~/models/repository/collectionResponse'

export default class RoomRepository {
  readonly limit = 2
  private roomsCollection: CollectionReference<Room>

  constructor() {
    this.roomsCollection = firestore.collection('rooms').withConverter<Room>({
      fromFirestore(snapshot: firebase.firestore.QueryDocumentSnapshot, _: firebase.firestore.SnapshotOptions): Room {
        const data = snapshot.data({ serverTimestamps: 'estimate' })
        return new Room(snapshot.id, data.roomName as string, data.createdAt)
      },
      toFirestore(modelObject: Room): firebase.firestore.DocumentData {
        const data = Object.assign({ ...modelObject })
        return data
      }
    })
  }

  /**
   * firestoreからroomの一覧を取得する
   * @param limit
   * @param startAtSnapshot
   */
  async fetchRooms(
    {
      limit = this.limit,
      startAtSnapshot = undefined
    }: {
      limit?: number
      startAtSnapshot?: DocumentSnapshot
    } = {}): Promise<CollectionResponse<Room>> {
    let query = this.roomsCollection.limit(limit + 1).orderBy('createdAt', 'desc')
    if (startAtSnapshot) {
      query = query.startAt(startAtSnapshot)
    }
    const snapshots = await query.get()
    const snapshotLength = snapshots.docs.length
    const hasMore = snapshotLength === limit + 1
    const rooms = snapshots.docs.map(document => {
      return document.data()
    })
    if (hasMore) {
      rooms.pop()
    }
    const lastSnapshot = hasMore ? snapshots.docs[snapshotLength - 1] : null
    return {
      results: rooms,
      nextSnapshot: lastSnapshot
    }
  }
}
```

limitとsnapshotを引数とし、指定の位置、数でfirestoreからRoomを取得しています。

味噌なのがlimit + 1をロードしている箇所で、+1をロードすることで次に読み込むデータがあるかどうかを判定できるようにしています。
ロードしたリストがlimit + 1なければもう次に読み込むものはないと判定します。

実際に使う側に返すのはlimitの分だけしか返さないので、余剰に+1リードコストをかけています。

## RoomServiceの実装

```~/models/service/roomService.ts
import firebase from 'firebase'
import RoomRepository from '~/models/repository/roomRepository'
import Room from '~/models/entity/room'
import DocumentSnapshot = firebase.firestore.DocumentSnapshot

export default class RoomService {
  private repository: RoomRepository
  private _rooms: Room[] = []
  private nextSnapshot: DocumentSnapshot | null = null

  /**
   * 現在のroom一覧
   */
  get rooms(): Room[] {
    return this._rooms
  }

  /**
   * nextSnapshotがなければ読み込むものはもうない
   */
  get canMoreLoad(): boolean {
    return !!this.nextSnapshot
  }

  constructor() {
    this.repository = new RoomRepository()
  }

  /**
   * roomの一覧を取得する。
   */
  async fetchRooms(): Promise<void> {
    const response = await this.repository.fetchRooms()
    this.nextSnapshot = response.nextSnapshot
    this._rooms = response.results
  }

  /**
   * 追加でroom一覧を読み込む
   */
  async fetchMoreRooms(): Promise<void> {
    if (!this.nextSnapshot) {
      return
    }
    const response = await this.repository
      .fetchRooms({
        startAtSnapshot: this.nextSnapshot
      })
    this.nextSnapshot = response.nextSnapshot
    this._rooms = [...this._rooms, ...response.results]
  }
}
```

repositoryからは一覧と、次の参照先について返してくれるのでそれを保持しながら、リクエストを処理する作りになっています。
nextSnapshotがない = もう読み込むものがない
といった判定をcanMoreLoadでは使っています。

## Pageの実装

ロジックができたので実際のPageで表示処理します。

```pages/index.vue
<template>
  <div>
    お部屋一覧
    <ul>
      <li v-for='room in rooms' :key='room.id'>
        {{ room.roomName }}
      </li>
      <li v-if='canMoreLoad'><button @click='onClickMoreLoad'>更に読み込む</button></li>
    </ul>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import RoomService from '~/models/service/roomService'
import Room from '~/models/entity/room'
export type DataType = {
  roomService: RoomService
}
export default Vue.extend({
  data(): DataType {
    return {
      roomService: new RoomService()
    }
  },
  computed: {
    rooms(): Room[] { return this.roomService.rooms },
    canMoreLoad(): boolean { return this.roomService.canMoreLoad }
  },
  mounted() {
    this.roomService.fetchRooms()
  },
  methods: {
    onClickMoreLoad(): void {
      this.roomService.fetchMoreRooms()
    }
  }
})
</script>
```

続きがあれば更に読み込むボタンを表示し、押されると続きが読み込まれて表示されるような作りになっています。

## まとめ

limit+1読み込むことで一覧でよくありがちな続きを読む処理を実装することができました！
実装の仕方自体はTypescript以外でも似たように実装すればいろんな言語で使えるはずです。
また、スクロールしたときにイベント発火すれば無限スクロールといった実装もできるようになります。

ただ、シンプルな実装にしているので、より込み入ったことをしたい場合（アイテムが削除されたらちゃんと同期をとったり、リアルタイムで一覧を表示したりなど）は手を加える必要がありそうです。

この辺の記事とかも合わせて参考にしたらより込み入った事もできそうです。

https://qiita.com/mildsummer/items/25c196632090b08065df


最後に、[ワンナイト人狼オンライン](https://online.1nite-jinro.com/link/blog)というゲームを作ってます！よかったら遊んでね！

https://online.1nite-jinro.com/link/blog

他にも[Camecon](https://camecon.me/)や[Offcha](https://offcha.com/)といったサービスも作ってるのでよかったら使ってね！

https://camecon.me/

https://offcha.com/

また、チームビルディングや技術顧問、Firebaseの設計やアドバイスといったお話も受け付けてますので御用の方は弊社までお問い合わせください。

https://virapture.com/