FirebaseGoogle Cloud 

FirebaseのCloud Firestoreでページネーションを実装する方法

はいさい!ちゅらデータぬオースティンやいびーん!

概要

今日は、AngularとFirebaseを組み合わせてブログのWebアプリケーションを作ります。
その中で今回の記事で紹介したいのは、FirebaseのCloud Firestoreでページネーションを実装する方法です。
ページネーションだけお読みになる方は、セットアップの部分を飛ばしてください!
本記事で紹介されているソースコードはこちらのレポシトリー(https://github.com/tronicboy1/angular-firebase-blog)でご覧いただけます!

Cloud Firestoreとは

筆者は、Firebase Realtime Databaseを主に使って記事を書いてきましたが、実は、FirebaseのサービスにはRealtime Databaseの後継者がいます。
その高貴なる後継者は、Cloud Firestoreという名を授かっているのです。

なぜRealtime Databaseの後継者が必要なのか?

上記の疑問が読者の頭の中に浮かぶのではないでしょうか。
筆者も、Realtime Databaseでもの足りているのではとずっと思ってきました。
しかし。
そう、しかしです。Realtime Databaseでこの前、ページネーションを実装することがありました。
親戚のコーヒー農園のホームページのブログ(https://www.pace-coffee.com/blog/)を実装した時でしたが、Realtime Databaseでページネーションを実装することは非常に困難なのです。
どういう実装をしたかというと、最も最新の投稿を5件最初に取って、下にスクロールすると次の5件を取るというエンドレススクロール方式に実装だったのです。

しかし、RDBでいうORDER BY created_at DESCみたいなクエリをRealtime Databaseでやるのは非常に難しいことがわかりました。
というより、全体的に関係データベース(RDB)で行うようなクエリそのものはRealtime Databaseには向いていないのです。
Googleのお兄さん・お姉さんたちは、さすがですが、この弱点に早くも気づいてCloud Firestoreという、Realtime Databaseの後継者を開発したのです。

Cloud Firestoreのメリット、Realtime Databaseのメリット

後継者と言っているのですが、若干語弊がある言い方なのです。後継者だったらなぜRealtime Databaseは消えないのでしょうか?
それは、両者にメリットがあり、適切な用途があるからです。
Cloud Firestoreのメリットは、上記に説明したRealtime Databaseの弱点だったデータクエリがまともにできるようになったところが最も大きいように感じます。
かなり複雑なクエリまでも可能になります。

他にも以下のメリットがあります。

・複雑なクエリが可能
・何GB、何TBもの大量のデータをパフォーマンスよく読み込める
・データをコレクションというものに整理できる
・通信環境が不安定な状況に対応できる

それに対してRealtime Databaseのメリットは以下の通り

・小さいデータなら素早く読み書きができる
・データは簡単なJSONツリー
・99.999%の可用性(Firestore: 99.95%)
 引用: Firebase Docs(https://firebase.google.com/docs/database/rtdb-vs-firestore)
 ※複数のデータベースを作ることができる 有料プランの場合

どのサービスを使うかお悩みの時は、正式ドキュメント(https://firebase.google.com/docs/database/rtdb-vs-firestore)を頼りに決めたらいいと思います。とてもわかりやすい質問票を用意してくれています。

 

プロジェクトセットアップ

今回は、Firebaseの新規プロジェクトにCloud Firestoreのデータベースを作った前提で進めます。
Firebaseプロジェクトの新規作成は、Firebase Console(https://console.firebase.google.com/)からできます。
プラスのアイコンをクリックすればいいです。

フロントエンドのセットアップ

今回は、Angular(https://angular.io/)を使って構築していきます。

ゴマスリ時間ですが、筆者は、Googleのソフトウェアが大好きなのです。Googleが作るオープンソースソフトウェアは、なんだか、コンピューターサイエンスに忠実な、しっかりした設計をしているように感じます。Angularは特にフレームワークの機能と制限がとても充実していて管理しやすいと感じています。
ゴマスリはさておき、セットアップを始めましょう。
まずは今回のプロジェクトのダイレクトリを作ってAngularプロジェクトを新規で作りましょう。
そのために、AngularのCLI(https://angular.io/cli)をインストールする必要があります。

npm install -g @angular/cli

インストールしていない方は上記のコマンドでインストールしていただければと思います。
できたら、以下のコマンドで新規プロジェクトを作ります。

cd Documents/
ng new angular-firebase-blog

CLIで設定を問われますが、以下の通り、ルーターなしのCSSで設定してください。

するとしばらくnpmでパッケージをインストールしてくれますが、終わったら以下のコマンドを実行してDevサーバーを立ち上げましょう。

cd angular-firebase-blog/
ng serve

すると、http://localhost:4200/にアクセスすれば以下のような画面が表示されます。

ここまでくればAngularのセットアップは以上です。

Firebaseのプロジェクトをリンクさせる

次、Firebaseプロジェクトをローカルのプロジェクトにリンクさせます。
以下のコマンドを実行するためにはFirebase CLIが必要なので、npmでインストールしておきましょう。

npm install -g firebase-tools

それができたら、次にログインします。

firebase login

それでブラウザの画面が出てきてGoogleアカウントにログインするようなダイアログが表示されますが、それに従って上記の新規プロジェクトを作ったアカウントにログインしてください。

ログインした状態以下のコマンドを実行しましょう。これでローカルにプロジェクト情報をダウンロードすることができます。

firebase init

案内でどのようなサービスを使うのか聞かれますが、FirestoreとHostingを選択してください。

既存のプロジェクトの選択肢を選んで、今回作ったプロジェクトを使いましょう。

Firestoreの設定も問われますが、デフォルトの初期値でいいです。

Hostingの設定は以下の通りで、public directoryをAngularのビルドフォルダーに設定するところと、single-page appをyesにするところだけ気をつけてください。

そしたら試しに以下のコマンドを実行してデプロイしてみましょう!

ng build
firebase deploy

成功すれば以下のようなログが出力されます。

そして、https://austin-blog-965da.web.appのようなリンクも出ますが、これを開くと以下のような画面が出ます。

どこかでみたような画面ですね!これでちゃんとデプロイができることがわかりました。
※ここでデプロイしているものは一般のWebに公開されているのでご注意ください

Hostingだけでなく、ローカルにあるfirestore.rulesとfirestore.indexes.jsonをアップロードして反映させています。
後ほどローカルで編集したこれらのファイルをこうしてデプロイしたいと思いますがとりあえずFirebaseのローカルの設定はこれでOKです。
Firebase CLIが作ったファイルは基本的にgitにコミットしておいた方がいいです。

FirebaseのWebアプリケーション設定をコミットする

次に必要なのは、AngularのWebアプリケーションがCloud Firestoreと接続する時に必要になるFirebaseコンフィグをコミットする必要があります。

以下のコマンドを実行すれば、Webアプリケーションを登録することができます。

firebase apps:create web

CLIでアプリケーション名を問われますが、webでいいでしょう。

最後に、このコマンドを実行すればコンフィグを出力してくれると丁寧に教えてくれたので、実行してみましょう。

firebase apps:sdkconfig WEB 1:262418756090:web:ff1629670efe372c017443

実行するとほしい情報が出てきました。

これをコピーしてsrc/firebase/index.tsというところに以下のように入れておきましょう。

const config = {
  "projectId": "austin-blog-965da",
  "appId": "1:262418756090:web:ff1629670efe372c017443",
  "storageBucket": "austin-blog-965da.appspot.com",
  "locationId": "asia-east2",
  "apiKey": "AIzaSyBEMXKxQ-R7FnO3KVbnMZpSdluPFcp-lyY",
  "authDomain": "austin-blog-965da.firebaseapp.com",
  "messagingSenderId": "262418756090"
};

準備がまだ終わっていませんでした!AngularでFirebaseサービスを使うために、Firebaseのnpmパッケージ(https://firebase.google.com/docs/web/setup)をインストールする必要があります。
一旦、Devサーバーを止めて以下のコマンドを実行しておいてください。

npm install firebase
ng server

それから、もう一度src/firebase/index.tsに戻って以下のように修正します。

import { initializeApp } from 'firebase/app';

const config = {
  projectId: 'austin-blog-965da',
  appId: '1:262418756090:web:ff1629670efe372c017443',
  storageBucket: 'austin-blog-965da.appspot.com',
  locationId: 'asia-east2',
  apiKey: 'AIzaSyBEMXKxQ-R7FnO3KVbnMZpSdluPFcp-lyY',
  authDomain: 'austin-blog-965da.firebaseapp.com',
  messagingSenderId: '262418756090',
};

export const firebaseApp = initializeApp(config);

これができればとりあえずOK!

おまけ:TypeScriptのエイリアスを設定する

上記のsrc/firebaseのフォルダーからものをインポートする時、src/app/my-component/my-component.component.tsみたいなところで実際インポートすることになります。
その時にインポートのパスが../../../../../firebaseのような長いパスになるのですが、筆者は個人的にこういう相対的パスがとても嫌で避けたいのです。
だから、TypeScriptのインポートエイリアス機能を使ってややこしくなる前に解決したいです。

以下のようにtsconfig.jsonを修正すればAngularもちゃんと設定を見てくれます。

/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    /* ここの設定を追加します。 */
    "paths": {
      "@firebase/*": ["src/firebase/*"]
    },
    /* ここ以下は全部一緒 */
    "outDir": "./dist/out-tsc",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "sourceMap": true,
    "declaration": false,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "target": "es2020",
    "module": "es2020",
    "lib": ["es2020", "dom"]
  },
  "angularCompilerOptions": {
    "enableI18nLegacyMessageIdFormat": false,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}

Angularでブログの部品を作成する

セットアップにもなるのかもしれませんが、ブログの部品とAngularサービスを作るところもやっておきましょう。
まず、サービスを一つ作りましょう。

ng generate service posts

真っ先に、PostsServiceの基本的な準備をしておきましょう。
getPosts、getPostおよびwatchForNewPostsではとりあえずダミーデータを返すようにしましょう。
deletePostとupdatePostは今回使いませんが、Firestoreを使った充実したブログならいずれは必要ですのでプレースホルダーとして入れておきましょう。

import { Injectable } from '@angular/core';
import { delay, interval, mergeMap, Observable, of } from 'rxjs';

export type Post = {
  title: string;
  body: string;
  createdAt: number;
};

const DUMMY_POST: Post = {
  title: 'Austin Mayer',
  body: 'Programmer, full-stack.',
  createdAt: Date.now(),
};

@Injectable({
  providedIn: 'root',
})
export class PostsService {
  constructor() {}

  public getPosts(limit = 5): Observable<Post[]> {
    return of(new Array(limit).fill(DUMMY_POST)).pipe(delay(1000));
  }

  public getPost(key: string): Observable<Post> {
    return of(DUMMY_POST).pipe(delay(1000));
  }

  public watchForNewPosts(): Observable<Post> {
    return interval(5000).pipe(mergeMap(() => of(DUMMY_POST)));
  }

  public createPost(post: Post): Promise<void> {
    return Promise.resolve();
  }

  public updatePost(key: string, post: Post): Promise<void> {
    return Promise.resolve();
  }

  public deletePost(key: string, post: Post): Promise<void> {
    return Promise.resolve();
  }
}

あとでこれをFirestoreで実装します!
次に、投稿を表示するためのCSSとロジックが入る新規部品を二つ作りましょう。

ng generate component post-list
ng generate component post

post-listが投稿を取得するリスト親部品で、子部品のpostがそれを表示する想定です。

post部品

src/app/post/post.component.tsから手をつけていきます。

import { Component, Input, OnInit } from '@angular/core';
import { Post } from '../posts.service';

@Component({
  selector: 'app-post',
  templateUrl: './post.component.html',
  styleUrls: ['./post.component.css'],
})
export class PostComponent implements OnInit {
  @Input()
  public post?: Post;

  constructor() {}

  ngOnInit(): void {}
}

ここで@Inputのデコレーターを使って、このpostの部品に親部品からPostのデータが渡されることを知らせます。こうすると、Angularは親部品のテンプレートで<app-post [post]=”post”>と書いた時に、自動的にこのpostのデータを子部品に渡し、子部品を再レンダーさせます。

次、src/app/post/post.component.htmlでテンプレートを書きます。シンプルにしておきましょう。

<article *ngIf="this.post">
  <h1>{{ this.post.title | uppercase }}</h1>
  <p>{{ this.post.body }}</p>
  <p><small>{{ this.post.createdAt | date }}</small></p>
</article>

最後に、src/app/post/post.component.cssでちょこっとCSSを追加しておきましょう。

article {
  margin: 1rem auto;
  padding: 0 1rem;
  width: 90%;
  max-width: 800px;
  display: flex;
  flex-direction: column;
  border: 1px solid white;
  background-color: white;
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.091);
}

article p small {
  color: grey;
  font-size: 0.7

post-list部品

post部品が終わったので、投稿のデータをpostにレンダーするpost-listの親部品に着手しましょう。前述の文でも触れましたが、今回はエンドレススクロールを実装します。
エンドレススクロールとは、ページネーションおよび遅延読み込みの手法の一つで、「最初に一部しかデータを読み込まないが、リストの最後が近づいたら次のデータを読み込む」というものです。

これを実現するためにはIntersection Observer API(https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API)というブラウザ機能を使います。

しかし、このアプリケーションはAngularなので、リストの最後に来るHTMLElement、つまり<div>に直接アクセスするためには多少工夫が必要です。
その工夫とは、@angular/coreに含まれているViewChildのデコレーターを使うこと、そしてIntersectionObserverをRxJSのObservableに包むことです。
src/app/post-list/post-list.component.tsで実装してみましょう。

import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { map, mergeMap, Subscription } from 'rxjs';
import { Post, PostsService } from '../posts.service';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-post-list',
  templateUrl: './post-list.component.html',
  styleUrls: ['./post-list.component.css'],
})
export class PostListComponent implements OnInit {
  public posts: Post[] = [];
  @ViewChild('listEnd')
  private listEnd: ElementRef<HTMLElement> | undefined = undefined;
  private subscriptions: Subscription[] = [];

  constructor(private postsService: PostsService) {}

  ngOnInit(): void {
    const newPostsSubscription = this.postsService
      .watchForNewPosts()
      .subscribe((post) => {
        this.posts = [post, ...this.posts];
      });
    this.subscriptions.push(newPostsSubscription);
  }

  ngAfterViewInit(): void {
    if (!this.listEnd) throw Error('List end Div not found.');
    const intersectionObserverEntries$ = this.createPageNoObservable(
      this.listEnd.nativeElement
    );
    const posts$ = intersectionObserverEntries$.pipe(
      mergeMap(() => this.postsService.getPosts(5))
    );
    const postsSubscription = posts$.subscribe((newPosts) => {
      this.posts = [...this.posts, ...newPosts];
    });
    this.subscriptions.push(postsSubscription);
  }

  ngOnDestroy(): void {
    // MPAなどで部品が消えたら、IntersectionObserverのSubscription登録を解除しないといけない!
    this.subscriptions.forEach((subscription) => subscription.unsubscribe());
  }

  private createPageNoObservable(target: HTMLElement) {
    return new Observable<IntersectionObserverEntry>((observer) => {
      const options: IntersectionObserverInit = {
        root: null,
        rootMargin: '24px',
        threshold: 0.1,
      };
      const callback: IntersectionObserverCallback = ([entry]) => {
        if (entry.isIntersecting) {
          observer.next(entry);
        }
      };
      const intersectionObserver = new IntersectionObserver(callback, options);
      intersectionObserver.observe(target);

      return () => intersectionObserver.disconnect(); // unsubcsribeを実行した時にIntersectionObserverを消すため
    });
  }
}

ここは本投稿のお題とは脱線になるので、ロジックを紹介するだけに留めたいのですが、少しだけ説明します。

・ngOnInitでは、新しい投稿が入ったら、public postsの頭に入れるようにしています。
・private listEndをElementRef<HTMLElement> | undefinedと定義しているのはわざとです。Angularでは、ライフサイクルによってprivate listEndがまだ再定義されていない場合があり、その場合、undefinedが返ってきます。例えば、ngOnInitでprivate listEndを呼んだらundefinedになります。こういうエラーを避けるために、明示的にundefinedとして定義しておくべきです。
・22行でif構文でErrorをthrowしていますが、これは開発者のためです。もし@ViewChildの引数が間違っていても、AngularはErrorを吐かないのです。undefinedのままです。なので、明示的にErrorを出すと、開発者に優しい思いやりができます。
・createPageNoObservableでIntersectionObserverを使ってページ番号を配信します。
・ページ番号を配信するObservableにpipeを付けてmergeMapでPostsService.getPostsが返すObservable<Post[]>をマージします。
・ngOnDestroyでSubscription.unsubscribeが実行されるように、private subscriptionsの配列にpostsSubscriptionを入れます。

ここのロジックと一緒に、src/app/post-list/post-list.component.htmlのテンプレートも肉付けしておきましょう。

<main *ngFor="let post of this.posts">
  <app-post [post]="post"></app-post>
</main>
<div id="list-end" #listEnd></div>

これでpost-listもあちこーこーの出来上がりです!

new-post部品

新規投稿も簡単に作れるようにしたいのでフォーム用の部品を作ります。このnew-postの部品は、基本的に隠れていて**+**アイコンのボタンをクリックすればフォームが出てきて、出すのに成功したら隠れる、という動作にしたいです。

アイコンは、Google Fonts(https://fonts.google.com/icons)を利用します。

以下のコマンド実行して生成してもらいましょう。

ng generate component new-post

生成されたsrc/app/new-post/new-post.component.tsでEventListenerとクラス変数を二つずつ定義します。

import { Component, OnInit } from '@angular/core';
import { PostsService } from '../posts.service';

@Component({
  selector: 'app-new-post',
  templateUrl: './new-post.component.html',
  styleUrls: ['./new-post.component.css'],
})
export class NewPostComponent implements OnInit {
  public show = false;
  private loading = false;

  constructor(private postsService: PostsService) {}

  ngOnInit(): void {}

  public handleShowButtonClick: EventListener = () => {
    this.show = true;
  };

  public handleFormSubmission: EventListener = (event) => {
    event.preventDefault();
    if (this.loading) return;
    const form = event.currentTarget;
    if (!(form instanceof HTMLFormElement)) throw TypeError();
    const formData = new FormData(form);
    const title = formData.get('title')!.toString().trim();
    const body = formData.get('body')!.toString().trim();
    if (title.length > 0 && body.length > 0) {
      this.loading = true;
      this.postsService
        .createPost({ title, body, createdAt: Date.now() })
        .then(() => {
          form.reset();
          this.show = false;
        })
        .finally(() => (this.loading = false));
    }
  };
}

ここで定義したEventListenerをsrc/app/new-post/new-post.component.htmlで要素にバインドします。

ここでフォームも書きますが、

<button *ngIf="!this.show" type="button" (click)="this.handleShowButtonClick($event)">
  <svg xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0,0,48,48">
    <path d="M22.5 38V25.5H10v-3h12.5V10h3v12.5H38v3H25.5V38Z" />
  </svg>
</button>
<form *ngIf="this.show" (submit)="this.handleFormSubmission($event)">
  <label for="title">Title</label>
  <input type="text" id="title" name="title" maxlength="255" minlength="1" required>
  <label for="body">Body</label>
  <textarea name="body" id="body" minlength="1" maxlength="10000" required></textarea>
  <button type="submit"><svg xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0,0,48,48">
      <path
        d="M22.5 34h3v-8.5H34v-3h-8.5V14h-3v8.5H14v3h8.5ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z" />
    </svg></button>
</form>

CSSのスタイルも多少つけたいのでsrc/app/new-post/new-post.component.cssに以下のスタイルを入れます。

:host {
  display: flex;
  flex-direction: column;
  margin: 1rem 0;
  --radius: 4px;
  --primary-color: lightgray;
}

button {
  background-color: var(--primary-color);
  border: 1px solid var(--primary-color);
  border-radius: var(--radius);
  cursor: pointer;
}
button:has(svg) {
  display: flex;
  align-items: center;
  justify-content: center;
}

form {
  display: flex;
  flex-direction: column;
  width: 100%;
}
form button {
  margin-bottom: 1rem;
  height: 40px;
  width: 60%;
  margin: auto;
}

input,
textarea {
  margin-bottom: 1rem;
  border: 1px solid var(--primary-color);
  border-radius: var(--radius);
  padding: 0.25rem;
}

input {
  height: 32px;
}

textarea {
  min-height: 200px;
}

button svg {
  margin: auto;
  display: inline-flex;
  justify-content: center;
  align-items: center;
}

app-rootでpost-listとnew-postを代入する

後ちょっとで上記の作業の成果が見えてきます!src/app/app.component.htmlに入っているダミーテンプレートデータを全て消して、以下のように<app-post-list>の要素だけにすれば良いです。

<app-post-list></app-post-list>

これを保存すると、ダミーデータの魔法が始まるのです。

エンドレススクロール、いえ、エンドレスAUSTIN MAYERになっています!
new-postも入れてみましょう。

<app-new-post></app-new-post>
<app-post-list></app-post-list>

何もしないのですが、ロジックはOKです。

ここまで来たので、あとは本来の本投稿のお題である「FirebaseのCloud Firestoreでページネーションを実装する方法」に入っていきましょう!

Cloud Firestoreのルールを設定する

上記のダミーアプリケーションでどのようなデータを表示したいか、TypeScriptの型で表現しています。

type Post = {
  title: string;
  body: string;
  createdAt: number;
};

この型を守らせるルールをCloud Firestoreにも教えたいので、ローカルに引っ張ったfirestore.rulesを修正してデプロイしたいと思います。

Cloud Firestoreのルールについてはこちらの正式ドキュメント(https://firebase.google.com/docs/firestore/security/get-started)で基本を覚えることができます。
Cloud Firestoreのルールで筆者が特にいいと思っているのは、関数を定義することができるところです。
関数で認証の細かいルールのロジックをまとめることができるので、アプリケーションのサイズが増えてもルールのコードを整理することができます。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /posts/{post} {
      function postDataIsValid(data) {
        let titleIsCorrect = ("title" in data) && data.title.size() > 0 &&  data.title.size() < 255;
        let bodyIsCorrect = ("body" in data) && data.body.size() > 0 &&  data.body.size() < 10000;
        let createdAtIsCorrect = ("createdAt" in data) && data.createdAt > 0;
        return titleIsCorrect && bodyIsCorrect && createdAtIsCorrect;
      }

      allow read: if true;
      allow create: if postDataIsValid(request.resource.data);
    }
  }
}

今回、postDataIsValidという関数を作りましたが、このようにboolを返す関数を作ると便利です。
これを反映させるためにFirebase CLIで以下のコマンドを実行します。

firebase deploy --only firestore

FirebaseコンソールにアクセスしてCloud Firestore > ルールを開きましょう。先ほどデプロイしたルールがちゃんと入っていることが確認できます。

ルールの設定が反映されているのがわかったので、src/app/posts.service.tsに戻ってロジックを書きましょう!

新規投稿のFirestoreロジックを追加する

今、せっかく作ったCloud Firestoreのデータベースに、何も入っていないのです。まず、コンテンツを追加できるようにしておきましょう!
src/app/posts.service.tsに入ってテコを入れていきましょう。
最も最初にやらないといけないのは、このサービスにFirestoreデータベースのロジックで使われる変数を追加することです。

import { getFirestore } from 'firebase/firestore';
import type { Firestore } from 'firebase/firestore';
import { firebaseApp } from '@firebase/index';

...

@Injectable({
  providedIn: 'root',
})
export class PostsService {
  private db: Firestore;
  private collectionRoot = 'posts';

  constructor() {
    this.db = getFirestore(firebaseApp);
  }

...

}

このロジックは全ての関数で使われるのでクラス関数に入れておくといいです。
さて、createPostを以下のように書いてちゃんとFirestoreのpostsコレクションに入れてくれるようにしましょう!

...

import { getFirestore, collection, addDoc } from 'firebase/firestore';

...

export class PostsService {

...

  public createPost(post: Post) {
    const ref = collection(this.db, this.collectionRoot);
    return addDoc(ref, post);
  }

...

}

これを保存すれば、アプリケーションで試しに新しい投稿を作ってみましょう。

ちゃんとFirestoreに入ってくれていますね!

Firestoreからページごとの投稿を取得する方法

さて、本投稿の醍醐味の部分に差し掛かってきました。Cloud Firestoreで一体どうやってページネーションができるでしょうか。
ここまで来ての告白ですが、実は、こういう記事を書くといった筆者はこの記事を書くまでFirestoreを使ったことがなかったのです。
ましてや、Firestoreでページネーションを実装したことも、無論、ありません。
この筆者でも、できるくらい簡単なのではないかと期待してのここまでの記事です。
しかし、ささっと正式ドキュメントの一覧をに一通り目を通したら、ちょうどいい資料(https://firebase.google.com/docs/firestore/query-data/query-cursors)があったのです。
まず、firebase/firestoreのqueryとgetDocsを使って取得するロジックを書きましょう。src/app/posts.service.tsにもう一度入ってgetPostsを修正します。

import {
  getFirestore,
  collection,
  addDoc,
  getDocs,
  query,
} from 'firebase/firestore';

...

export class PostsService {

...

  public getPosts(pageNo: number, limit = 5): Observable<Post[]> {
    return new Observable((observer) => {
      const ref = collection(this.db, this.collectionRoot);
      const q = query(ref);
      getDocs(q)
        .then((result) => {
          const { docs } = result;
          if (!docs.length) return;
          const data = docs.map((doc) => doc.data()) as Post[];
          observer.next(data);
        })
        .catch((error) => observer.error(error))
        .finally(() => observer.complete());
    });
  }

...

}

これでとりあえず全ての記事が取れます。

これもエンドレスではあるのですが、欲しいのはページネーションなので、queryの引数にorderBy、startAfter、そしてlimitを追加します。
ここで重要な変更を加えます。
Firestoreのページネーションに必須なstartAfterの関数は、最後に返したドキュメントを引数として受け取ります。
筆者はページ番号を使うページネーションを想定していたのですが、ページ番号ではなく、最後に取得したドキュメントの情報を使って次のドキュメント(投稿データ)を取得するように変更しなければなりません 。
そのためには、PostsServiceで想定していたObservable<Post[]>型を返すのではなく、Observable<QueryDocumentSnapshot<DocumentData>[]>を返すべきことに気が付きました。
そしたら、post-listで最後のドキュメントのデータをキャッシュ化したらページネーションができるはずです!

まず、getPostsから修正しましょう。

import {
  getFirestore,
  collection,
  addDoc,
  getDocs,
  query,
  orderBy,
  limit as limitBy,
  startAfter,
} from 'firebase/firestore';
import type { QueryDocumentSnapshot, DocumentData } from 'firebase/firestore';

...

export class PostsService {

...

  public getPosts(
    lastDoc: QueryDocumentSnapshot | undefined,
    limit = 5
  ): Observable<QueryDocumentSnapshot<DocumentData>[]> {
    return new Observable((observer) => {
      const ref = collection(this.db, this.collectionRoot);
      const constraints = [orderBy('createdAt', 'desc'), limitBy(limit)];
      lastDoc && constraints.push(startAfter(lastDoc));
      const q = query(ref, ...constraints);
      getDocs(q)
        .then((result) => {
          const { docs } = result;
          observer.next(docs);
        })
        .catch((error) => observer.error(error))
        .finally(() => observer.complete());
    });
  }

...

}

続いて、src/app/post-list/post-list.component.tsも以下のように修正します。

import { Observable, map, mergeMap, Subscription } from 'rxjs';
import type { QueryDocumentSnapshot } from 'firebase/firestore';

...

export class PostListComponent implements OnInit {

...

  private lastDocument?: QueryDocumentSnapshot;

...

  ngAfterViewInit(): void {
    if (!this.listEnd) throw Error('List end Div not found.');
    const intersectionObserverEntries$ = this.createPageNoObservable(
      this.listEnd.nativeElement
    );
    const posts$ = intersectionObserverEntries$.pipe(
      mergeMap(() => this.postsService.getPosts(this.lastDocument, 5)),
      map((docs) => {
        if (docs.length) {
          this.lastDocument = docs[docs.length - 1];
        }
        return docs.map((doc) => doc.data() as Post);
      })
    );
    const postsSubscription = posts$.subscribe((newPosts) => {
      this.posts = [...this.posts, ...newPosts];
    });
    this.subscriptions.push(postsSubscription);
  }

...

}

保存してみると、完成です!

綺麗にやってくれていますね。Firebase熱を感じます。

新規投稿を自動的に追加するロジック

本投稿の本題は終わったのですが、最後に、新しい投稿が入ったらリストの頭に加えるロジックを完成させたいと思います。
こちらのロジックも若干上記のページネーションと似ているのです。
まず、src/app/posts.service.tsのwatchForNewPosts関数を修正します。上記のgetPostsと同様にObservable<Post[]>型ではなく、Observable<QueryDocumentSnapshot<DocumentData>[]>型を返すようにします。

import {
  getFirestore,
  collection,
  addDoc,
  getDocs,
  query,
  orderBy,
  limit as limitBy,
  startAfter,
  onSnapshot,
  startAt,
} from 'firebase/firestore';

...

type DocumentObservable = QueryDocumentSnapshot<DocumentData>[];
...

export class PostsService {

...

  public watchForNewPosts(): DocumentObservable {
    return new Observable((observer) => {
      let unsubscribe: ReturnType<typeof onSnapshot> = () => 0;
      const listenForNewestPosts = () => {
        const ref = collection(this.db, this.collectionRoot);
        const currentTime = Date.now();
        const q = query(ref, orderBy('createdAt'), startAt(currentTime));
        unsubscribe = onSnapshot(
          q,
          (snapshot) => {
            const { docs } = snapshot;
            if (!docs.length) return;
            observer.next(docs);
            unsubscribe();
            listenForNewestPosts(); // 回帰的なのです
          },
          (error) => observer.error(error),
          () => observer.complete()
        );
      };
      listenForNewestPosts();
      return () => unsubscribe();
    });
  }

...

}

読者の眉が上がるのが筆者に見えています。回帰的な処理がなんだか過剰に複雑そうな感じですよね。
Realtime Databaseだと、onChildAddedで簡単にできますが、Cloud Firestoreは残念ながらそう簡単には行かないのです。
現在のUNIX時刻をとって、それより大きい数字のcreatedAtを持つ投稿であればプッシュしてもらえるように仕込んでおかないといけないのですが、いつ最後に取得したのかを更新していかないと最初に定義した時間以降の投稿を毎度取得します。
場合によってはそれでいいのでしょうが、ページネーションの取得の仕方と相入れないので、できません。
従って、新しい投稿の配列が入ったら、現在の時刻を更新してonSnapshotを再起動するというような工夫が必要です。ただし、これは綺麗な解決だとは思っていません。パフォーマンスを考えたら、おそらく同じonSnapshotのリスナーを使い続けた方がいいでしょう。
ともかく、こちらのロジックができているので、src/app/post-list/post-list.component.tsで少し修正を加えます。

export class PostListComponent implements OnInit {

...

  ngOnInit(): void {
    const newPostsSubscription = this.postsService
      .watchForNewPosts()
      .subscribe((docs) => {
        const posts = docs.map(doc => doc.data() as Post);
        this.posts = [...posts, ...this.posts];
      });
    this.subscriptions.push(newPostsSubscription);
  }

...

}

これでうまくいくはずなので、早速試してみましょう。

案の定大丈夫でした!ChromeのDevツールでパフォーマンスをモニタリングもしましたが、これというメモリーリークは起きていなさそうなのでこれもOKです。

まとめ

ここまで、Angularを使ったFirebaseアプリケーションでCloud Firestoreでページネーションを実装する方法を紹介してきましたがいかがだったのでしょうか?
おまけに、最後に新規投稿をリストの頭に追加する方法も紹介しました。

面白かったこと

筆者は、ページネーションというと、OFFSETを計算するために、ページ番号が必要だろうと、取得件数も一定でないといけないだろうと当初考えていたのですが、Firestoreはそういう仕組みじゃないのだと知って面白かったです。
やりたければ、一回2件だけとっては、次10件とって、次6件とってと、limitは関係ないのです。

困ったこと

困ったのは、Realtime DatabaseのonChildAddedリスナーのような機能がFirestoreにないことでした。
こちらの工夫で、新しく追加したドキュメントのみ拾うように実装できたのですが、やはりRealtime Databaseのその機能がとても便利だなと思いました。

AngularとFirebaseの相性について

余談ですが、Angularを使ってFirebaseアプリケーションを作るのが初めてだったので感想も述べておきたいと思います。

全体的にFirebaseとAngularの相性もよくて特にAngularのServiceの考え方で、実際に情報を取得するロジックと、情報を表示するロジック、ページネーションのロジックを分けることが綺麗にできた気がします。
Angularはコードの整理がしやすい点から、大きくなるようなアプリケーションに向いていると感じました。
また、これはとても大事なことでここはVueと同じなのですが、Angularは決まったプロジェクト構成を強要することもとても魅力的だと思います。
Angularで独特なプロジェクト構成をするためにはかなりの労力がかかるしそうするメリットはないし、第三者のnpmパッケージを使う必要も特にないので、必然的にAngularのプロジェクトは似てくるのだと思います。
複数の案件・プロジェクトを抱えている企業の視点から考えると、プロジェクトが似ているから人員の移動が簡単にできるという大きなメリットがあります。

デメリットは、Reactのように求人への応募を増やしてくれない点です。
完全にAngularの絶賛にしかなっていないのですが、Firebaseも同じく用途が違ってもプロジェクト構成は同じなので、組織全体に全く同じ効果が得られるのです。

以上、本記事はここで終わりなのですが、ここまで読んでいただけたなら、ありがとう!みんなでFirebaseを使っていこう!最高のサービスです!

このページをシェアする: