Angular実践入門チュートリアル [4] Firebase編
Angular実践入門チュートリアル のFirebase編です。
ここまでで、ある程度見た目の美しいTodoアプリが出来上がりましたが、肝心のTodoタスクのデータがどこにも永続化されていないという致命的な問題があります。
というわけで、このチュートリアルの締め括りとして、Firebase
# 1. Firebaseプロジェクトを作成する
まずは以下の手順でFirebaseプロジェクトを作成しましょう。
- https://console.firebase.google.com/
で プロジェクトを追加
をクリック - プロジェクト名に
angular-todo
と入力して続行
このプロジェクトでGoogleアナリティクスを有効にする
は今回はOFFにしてプロジェクトを作成
- 1分ほど待って、
新しいプロジェクトの準備ができました
と表示されたら、続行
以下のような画面まで来たらプロジェクト作成完了です。
# 2. Firestoreのデータベースを作成する
以下の手順で、Firebaseプロジェクト内にFirestoreのデータベースを作成しましょう。
- Firebaseプロジェクトページ(上記スクリーンショットの画面)の左サイドメニューから
Database
をクリック データベースの作成
をクリックテストモードで開始
を選択して次へ
- Cloud Firestoreのロケーションで
asia-northeast1
(東京リージョン)を選択して完了
- 1分ほど待つ
以下のような画面になったらデータベースの準備は完了です。
※注意
テストモード
でデータベースを作ると、作成から30日間は誰でも読み書きが可能な権限設定になります。重要な秘匿情報や個人情報などを保存してしまわないように注意してください。
# 3. アプリからFirestoreを利用するための準備をする
データベースをアプリから利用するための準備が必要です。以下の手順を実施してください。
- Firebaseプロジェクトページの左サイドメニューから
プロジェクトの概要
をクリック </>
アイコン(Webアプリ)のボタンをクリック
- アプリのニックネームに
angular-todo
と入力し、このアプリのFirebase Hostingも設定します
には チェックせずにアプリを登録
をクリック - 表示されたコードスニペットのうち以下の部分をどこかにコピーしておいた上で、
コンソールに進む
をクリックapiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", authDomain: "angular-todo-xxxxx.firebaseapp.com", databaseURL: "https://angular-todo-xxxxx.firebaseio.com", projectId: "angular-todo-xxxxx", storageBucket: "angular-todo-xxxxx.appspot.com", messagingSenderId: "xxxxxxxxxxxxx", appId: "1:xxxxxxxxxxxxx:web:xxxxxxxxxxxxxxxxxxxxxx", measurementId: "G-xxxxxxxxxx"
このコードスニペットは後ほどAngularアプリからFirestoreに接続するために必要になります。
このコードスニペットは、 左サイドメニューの歯車アイコン > プロジェクトを設定 > 全般
の画面でいつでも見られます👌
# 4. アプリからFirestoreを利用できるようにする
AngularアプリでFirebaseを利用するには、angularfire
こちらのドキュメント
まず前提としてFirebaseのSDK本体が必要です。
npm i -S firebase
## または
yarn add firebase
続いてangularfireをインストールします。
ng add @angular/fire
途中 ? Please select a project:
と聞かれるので angular-todo
を選択してください。
インストールが完了したら、Firebaseプロジェクトと接続するための情報を環境設定ファイルに記述します。
src/environments/environments.ts
を開いて、以下のように environment.firebase
に先ほどコピーしておいたコードスニペットをそのままセットしてください。
export const environment = {
- production: false
+ production: false,
+ firebase: {
+ apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ authDomain: "angular-todo-xxxxx.firebaseapp.com",
+ databaseURL: "https://angular-todo-xxxxx.firebaseio.com",
+ projectId: "angular-todo-xxxxx",
+ storageBucket: "angular-todo-xxxxx.appspot.com",
+ messagingSenderId: "xxxxxxxxxxxxx",
+ appId: "1:xxxxxxxxxxxxx:web:xxxxxxxxxxxxxxxxxxxxxx",
+ measurementId: "G-xxxxxxxxxx",
+ },
};
最後に、 src/app/app.module.ts
に以下のように AngularFireModule
と AngularFirestoreModule
を追加します。
+ import { AngularFireModule } from '@angular/fire';
+ import { AngularFirestoreModule } from '@angular/fire/firestore';
+ import { environment } from '../environments/environment';
registerLocaleData(en);
@NgModule({
declarations: [
// 略
],
imports: [
// 略
+ AngularFireModule.initializeApp(environment.firebase),
+ AngularFirestoreModule,
],
// 略
})
export class AppModule { }
# 5. 実際にアプリからFirestoreにデータを登録してみる
これで準備は完了です!
それでは早速、実際にFirestoreにデータを登録してみましょう💪
src/app/task-list/task-list.component.ts
に以下のようなコードを追記します。
import { Component, OnInit } from '@angular/core';
import { Task } from '../../models/task';
+ import { AngularFirestore } from '@angular/fire/firestore';
@Component({
selector: 'app-task-list',
templateUrl: './task-list.component.html',
styleUrls: ['./task-list.component.scss']
})
export class TaskListComponent implements OnInit {
- constructor() { }
+ constructor(
+ private firestore: AngularFirestore,
+ ) { }
tasks: Task[] = [
{title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
{title: '可燃ゴミを出す', done: true, deadline: new Date('2020-01-02')},
{title: '銀行に行く', done: false, deadline: new Date('2020-01-03')},
];
ngOnInit(): void {
}
addTask(task: Task): void {
this.tasks.push(task);
+ this.firestore.collection('tasks').add(task);
}
}
angularfireが持っている AngularFirestore
というサービスを、 TaskListComponent
にインジェクトして利用しています。
Angularでは、コンストラクタの引数に型注釈を書くだけでサービスのインジェクトができます。
AngularFirestore
サービスの collection()
メソッドで 'tasks'
という名前のコレクションを指定し、 add()
メソッドでそこに task
オブジェクトをドキュメントとして追加しています。
この状態で実際に画面から適当なタスクを追加してみて、FirestorのWeb UIをリロードしてみてください。
こんなふうにデータが登録されているはずです👍
# 5. Firestoreから読み込んだデータを表示する
Firestoreにデータを保存できるようになったので、ダミーデータではなく実際のFirestore上のデータを画面に表示するようにしましょう。
src/app/task-list/task-list.component.ts
に手を加えていきます。
まずはタスクリストの初期データをダミーデータではなく空の配列に変更しましょう。
- tasks: Task[] = [
- {title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
- {title: '可燃ゴミを出す', done: true, deadline: new Date('2020-01-02')},
- {title: '銀行に行く', done: false, deadline: new Date('2020-01-03')},
- ];
+ tasks: Task[] = [];
そして、 ngOnInit()
メソッド内でFirestoreからデータを取得して this.tasks
を上書きします。
ngOnInit(): void {
+ this.firestore.collection('tasks').valueChanges().subscribe(tasks => {
+ this.tasks = tasks as Task[];
+ });
}
ngOninit()
はAngularコンポーネントの ライフサイクル・フック
this.firestore.collection('tasks')
に生えている valueChanges()
Observable
subscribe()
することで、変更が検知される度に特定の処理を実行することができます。
Angularにおいて、非同期処理は基本的に
Promise
ではなくrxjsのObservable
を使って処理します。このチュートリアルではObservable
の詳しい使い方については説明しませんが、本格的にAngularを使っていくなら避けては通れない存在なので、ぜひ学んでみてください💪
少し難しいですが、まとめると、 ngOnInit()
内で valueChanges()
メソッドが流してくれるストリームを subscribe()
することで、コンポーネントが初期化されて以降ずっとFirestore上の tasks
コレクションの変更を検知し続けて、変更がある度に this.tasks
を更新する処理が実行されるようにしている、というわけですね。
なお、Firestorから取得されるデータは、ローカルで定義した Task
インターフェースと完全に一致はしていないため、ひとまず as Task[]
と 型アサーション
それから、Firestoreにタスクが追加されれば自動で変更が検知されて this.tasks
が丸ごと更新されるようになったので、 addTask()
メソッド内でオンメモリの this.tasks
に新しいタスクをpushする処理はもはや不要ですね。削除してしまいましょう✋
addTask(task: Task): void {
- this.tasks.push(task);
this.firestore.collection('tasks').add(task);
}
さて、ひとまずこの段階で一旦動かしてみてください。実際に動かしてみるとコンソールに以下のようなエラーが出力されます😓
ERROR TypeError: task.deadline.getTime is not a function
task.deadline.getTime
という関数がないと言われていますね。
どうやらFirestorから取得した task
の deadline
プロパティの中身が Date
オブジェクトではないために getTime()
メソッドを持っていないのが原因のようです。
実際には datetime
プロパティには firebase.firestore.Timestamp
このオブジェクトは toDate()
Date
型に変換することができます。
なので、例えば以下のようにコードを修正することで、とりあえず動かすことが可能です。
- this.firestore.collection('tasks').valueChanges().subscribe(tasks => {
+ this.firestore.collection('tasks').valueChanges().subscribe((tasks: any) => {
- this.tasks = tasks as Task[];
+ this.tasks = tasks.map(task => {
+ task.deadline = task.deadline ? task.deadline.toDate() : null;
+ return task;
+ }) as Task[];
});
これで、特にエラーが発生することもなく正常にFirestore上のデータを表示することができたかと思います👍
# 6. メモリリークを解消する
さて、実は今の実装には重大なバグがあります😱
ngOnInit()
で valueChanges()
を subscribe()
する処理を書きましたが、これの意味は
少し難しいですが、まとめると、
ngOnInit()
内でvalueChanges()
メソッドが流してくれるストリームをsubscribe()
することで、コンポーネントが初期化されて以降ずっとFirestore上のtasks
コレクションの変更を検知し続けて、変更がある度にthis.tasks
を更新する処理が実行されるようにしている、というわけですね。
ということでしたよね。実は今のままだと、コンポーネントが破棄されたあともメモリ上にリスナー関数が残り続けてしまい、メモリリーク
より詳しく理解したい方は Observableのライフサイクル - Angular After Tutorial
などをご参照ください。ただ、書かれている内容が今の時点で読むには少し難しいと思うので、もう少し慣れてきてから理解を深めるでも全然大丈夫です👍
メモリリークが発生しないようにするには、 コンポーネントを破棄するタイミングで、 subscribe()
による購読を解除する という処理を追記する必要があります。
具体的には以下のようにコードを修正します。
- import { Component, OnInit } from '@angular/core';
+ import { Component, OnDestroy, OnInit } from '@angular/core';
import { Task } from '../../models/task';
import { AngularFirestore } from '@angular/fire/firestore';
+ import { Subscription } from 'rxjs';
@Component({
selector: 'app-task-list',
templateUrl: './task-list.component.html',
styleUrls: ['./task-list.component.scss']
})
- export class TaskListComponent implements OnInit {
+ export class TaskListComponent implements OnInit, OnDestroy {
constructor(
private firestore: AngularFirestore,
) { }
tasks: Task[] = [];
+ subscription: Subscription;
+
ngOnInit(): void {
- this.firestore.collection('tasks').valueChanges().subscribe((tasks: any[]) => {
+ this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: any[]) => {
this.tasks = tasks.map(task => {
task.deadline = task.deadline ? task.deadline.toDate() : null;
return task;
}) as Task[];
});
}
+ ngOnDestroy(): void {
+ this.subscription.unsubscribe();
+ }
+
addTask(task: Task): void {
this.firestore.collection('tasks').add(task);
}
}
- クラスの宣言を変更し、今まで
OnInit
インターフェースだけを実装していたところを、加えてOnDestroy
インターフェースも実装するように- これにより、コンポーネントが破棄される直前に呼ばれるライフサイクル・メソッド
ngOnDestroy()
を使えるようになる
- これにより、コンポーネントが破棄される直前に呼ばれるライフサイクル・メソッド
valueChanges().subscribe()
の戻り値(Subscription
型)をクラス変数に保存しておいて、ngOnDestroy()
内でunsubscribe()
を実行することで、購読を解除するように
ということをしました。
まだ Observable
に慣れていないので、多分「分かったような分からないような」という感覚だと思います😅今の段階では、「 subscribe()
したまま unsubscribe()
しないコードを書くとメモリリークの原因になりうる」という事実だけを頭の片隅で覚えておけば十分です!💪
# 7. Firestoreから読み込んだデータにも型を持たせる
ちょっと難しかったですが、一応これでメモリリークもなく動くものが作れました👍
ただ、この辺りのコードが
this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: any[]) => {
this.tasks = tasks.map(task => {
task.deadline = task.deadline ? task.deadline.toDate() : null;
return task;
}) as Task[];
});
any
as
のオンパレードでちっとも型の力を活かせていませんね🤔
やはりTypeScriptにおいて any
や as
はできるだけ使うべきではないので、もう少し型安全なコードに直してみましょう💪
まず、Firestoreドキュメントとして取得したデータをきちんと型を付けて扱えるように、 src/models/task.ts
に以下のようなコードを追加します。
+ import * as firebase from 'firebase';
+ import Timestamp = firebase.firestore.Timestamp;
+
export interface Task {
title: string;
done: boolean;
deadline?: Date;
}
+
+ export interface TaskDocument {
+ title: string;
+ done: boolean;
+ deadline?: Timestamp;
+ }
+
+ export function fromDocument(doc: TaskDocument): Task {
+ return {
+ title: doc.title,
+ done: doc.done,
+ deadline: doc.deadline ? doc.deadline.toDate() : null,
+ };
+ }
Firestorドキュメントとしてのタスクを TaskDocument
インターフェースとして定義し、さらに TaskDocument
型のデータを Task
型に変換するユーティリティを fromDocument
関数として定義しました。
これらを使って、 ngOnInit()
の中身を以下のように変更できます。
import { Component, OnDestroy, OnInit } from '@angular/core';
- import { Task } from '../../models/task';
+ import { fromDocument, Task, TaskDocument } from '../../models/task';
import { AngularFirestore } from '@angular/fire/firestore';
import { Subscription } from 'rxjs';
// ...
ngOnInit(): void {
- this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: any[]) => {
- this.tasks = tasks.map(task => {
- task.deadline = task.deadline ? task.deadline.toDate() : null;
- return task;
- }) as Task[];
- });
+ this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: TaskDocument[]) => {
+ this.tasks = tasks.map(fromDocument);
+ });
}
型安全かつとてもシンプルで可読性の高いコードになりましたね👍
# 8. チェックボックスのクリックでFirestore上のデータを更新するように
Firestoreの読み書きができるようになりましたが、現状では既存のタスクの完了状態を変更することができませんので、これに対応していきたいと思います。
# Firestoreからのデータ読み込み時にドキュメントIDも取得する
まず、既存のFirestoreドキュメントを更新するためには、ドキュメントIDを知る必要があります。まずはFirestoreからデータを読み込んだときにドキュメントIDも一緒に取得するように修正していきましょう。
まずは Task
型と TaskDocument
型を修正して、 id
を持てるようにします。
export interface Task {
+ id?: string;
title: string;
done: boolean;
deadline?: Date;
}
export interface TaskDocument {
+ id: string;
title: string;
done: boolean;
deadline?: Timestamp;
}
export function fromDocument(doc: TaskDocument): Task {
return {
+ id: doc.id,
title: doc.title,
done: doc.done,
deadline: doc.deadline ? doc.deadline.toDate() : null,
};
}
次に、valueChanges()
メソッドに引数を渡してドキュメントIDも取得してくれるようにします。
引数として {idField: '何というキー名として取得したいか'}
を渡すことで、任意のキー名でドキュメントIDを取得できます。(参考
ngOnInit(): void {
- this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: TaskDocument[]) => {
+ this.subscription = this.firestore.collection('tasks').valueChanges({idField: 'id'}).subscribe((tasks: TaskDocument[]) => {
this.tasks = tasks.map(fromDocument);
});
}
これで、Firestoreから読み込んだタスクが id
を持った状態になりました。このままの状態でタスクの追加操作をしてしまうと、 id
というプロパティを持ったオブジェクトとして保存されてしまうので(実害はありませんが)、 addTask()
の処理を少し修正して、 id
プロパティは取り除いた状態で保存するようにしておきましょう。
addTask(task: Task): void {
- this.firestore.collection('tasks').add(task);
+ const clone = Object.assign({}, task);
+ delete clone.id;
+
+ this.firestore.collection('tasks').add(clone);
}
# チェックボックスがクリックされたらFirestore上のデータを更新する
「チェックボックスがクリックされたこと」を知っているのは TaskListItemComponent
ですが、Firestoreを操作する処理は TaskListComponent
に集約しておきたいので、 TaskFormComponent
から TaskListComponent
へイベント経由でタスクを渡したときと同じように、クリックされたタスクを TaskListComponent
に渡すような実装にしてみましょう✋
src/app/task-list/task-list.component.ts
addTask(task: Task): void {
const clone = Object.assign({}, task);
delete clone.id;
this.firestore.collection('tasks').add(clone);
}
+
+ updateTask(task: Task): void {
+ const clone = Object.assign({}, task);
+ delete clone.id;
+
+ this.firestore.collection('tasks').doc(task.id).update(clone);
+ }
src/app/task-list-item/task-list-item.component.ts
- import { Component, Input, OnInit } from '@angular/core';
+ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Task } from '../../models/task';
// ...
@Input() task: Task;
+ @Output() updateTask = new EventEmitter<Task>();
// ...
onToggleDone(task: Task): void {
this.updateTask.emit(task);
}
src/app/task-list-item/task-list-item.component.html
<div class="left">
- <label nz-checkbox [(ngModel)]="task.done" class="{{ task.done ? 'done' : '' }}">
+ <label nz-checkbox [(ngModel)]="task.done" class="{{ task.done ? 'done' : '' }}" (click)="onToggleDone(task)">
{{ task.title }}
</label>
</div>
(click)="onToggleDone(task)"
と書きましたが、厳密には(ngModelChange)="onToggleDone(task)"
とすべきです。ここでは説明は割愛します。なぜそうすべきなのかぜひ考えてみてください👍
src/app/task-list/task-list.component.html
<nz-list-item *ngFor="let task of tasks">
- <app-task-list-item [task]="task"></app-task-list-item>
+ <app-task-list-item [task]="task" (updateTask)="updateTask($event)"></app-task-list-item>
</nz-list-item>
# 9. タスクリストが作成日時順で並ぶようにする
いよいよ大詰めです。
現時点でタスクの読み込み・追加・編集ともFirestoreをデータストアとして実行できるようになりましたが、実際に操作してみると違和感を覚えたはずです。
そう、 タスクリストの並び順がバラバラ ですね。
Firestoreから取得したリストをそのまま表示しているので、Firestoreが気まぐれに並べた順番(おそらくドキュメントID昇順)で表示されてしまっているのです。
リストをオンメモリで保持していたときと同様に、タスクの作成日時昇順で並ぶように修正してみましょう💪
src/models/task.ts
export interface Task {
id?: string;
title: string;
done: boolean;
deadline?: Date;
+ createdAt: Date;
}
export interface TaskDocument {
id: string;
title: string;
done: boolean;
deadline?: Timestamp;
+ createdAt: Timestamp;
}
export function fromDocument(doc: TaskDocument): Task {
return {
id: doc.id,
title: doc.title,
done: doc.done,
deadline: doc.deadline ? doc.deadline.toDate() : null,
+ createdAt: doc.createdAt.toDate(),
};
}
src/app/task-list/task-list.component.ts
ngOnInit(): void {
this.subscription = this.firestore.collection('tasks').valueChanges({idField: 'id'}).subscribe((tasks: TaskDocument[]) => {
- this.tasks = tasks.map(fromDocument);
+ this.tasks = tasks.map(fromDocument).sort((a: Task, b: Task) => a.createdAt.getTime() - b.createdAt.getTime());
});
}
src/app/task-form/task-form.component.ts
submit(): void {
this.addTask.emit({
title: this.newTask.title,
done: false,
deadline: this.newTask.deadline ? new Date(this.newTask.deadline) : null,
+ createdAt: new Date(),
});
this.newTask = {
title: '',
deadline: null,
};
}
型に createdAt
プロパティを追加して、タスク作成時に現在日時を入れて保存するようにした上で、Firestoreから読み込んだタスクリストを sort()
createdAt
昇順でソートしているだけです👍
createdAt
なしのタスクがすでにFirestore上に保存されている場合は、一度それらをFirestore上から削除した上で動作確認してみてください。
現状の動作はこんな感じです。画面をリロードしてもちゃんと状態が保持されていますね🙌
# 10. 追加課題
ここまでできたあなたに、最後に追加課題です!
各タスクに削除ボタンを追加し、クリックすると画面からもFirestore上からもタスクが削除されるようにしてみましょう。
チェックボックスのクリックでタスクを更新したときと同じ流れでできるはずです💪
Firestoreからドキュメントを削除する方法は以下のとおりです。
this.firestore.collection('tasks').doc(task.id).delete();
動作例はこんな感じです。
頑張ってみてください!
筆者の回答例が こちら
にあります。ぜひ、まずは自力で頑張ってみた上で、答え合わせをしてみてください。
# お疲れさまでした!
- 【1】導入編
- 【2】基礎編
- 【3】UIフレームワーク編
- 【4】Firebase編
このチュートリアルが役に立ったと思っていただけた方は、こちらのトップページ をSNS等でシェアしていただけるととても嬉しいです!😆