使用 Angular 和 Firebase 建構網頁應用程式

透過集合功能整理內容 你可以依據偏好儲存及分類內容。

1. 簡介

上次更新日期:2020-09-11

建構項目

在本程式碼研究室中,我們將和 Angular 和 Firebase 共同建立網頁包板,我們的最終應用程式具有三個類別的工作:待處理工作、處理中及完成。我們可以建立、刪除工作,以及透過拖曳的方式將工作移到某個類別。

我們會使用 Angular 開發使用者介面,並將 Firestore 設為永久儲存區。程式碼研究室結束時,我們會使用 Angular CLI 將應用程式部署至 Firebase 託管。

b23bd3732d0206b.png

您將會瞭解的內容

  • 如何使用 Angular 材料和 CDK。
  • 如何將 Firebase 整合新增至您的 Angular 應用程式。
  • 如何將持續性資料保存在 Firestore 中。
  • 如何使用 Angular CLI 將應用程式部署到 Firebase 託管。

軟硬體需求

本程式碼研究室假設您擁有 Google 帳戶,且對 Angular 和 Angular CLI 有基本瞭解。

立即開始!

2. 建立新專案

首先,請建立新的 Angular 工作區:

ng new kanban-fire
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS

這個步驟可能需要幾分鐘的時間。Angular CLI 會建立專案結構並安裝所有依附元件。安裝程序完成後,請前往 kanban-fire 目錄,然後啟動 Angular CLI's 開發伺服器:

ng serve

開啟 http://localhost:4200,您應該會看到如下的結果:

5ede7bc5b1109bf3.png

在編輯器中開啟「src/app/app.component.html」並刪除其所有內容。當您返回 http://localhost:4200 時,應該會看到空白網頁。

3. 新增 Material 和 CDK

Angular 導入了符合 Material Design 規範的使用者介面元件,屬於 @angular/material 套件的一部分。@angular/material 的其中一個依附元件是元件開發套件 (CDK)。CDK 提供基元,例如 a11y 公用程式、拖放以及重疊。我們以 CDK 格式發布 @angular/cdk 套件。

如要在應用程式執行過程中新增教材,請按照下列步驟操作:

ng add @angular/material

這項指令會要求您選取主題、是否要使用通用內容字型樣式,以及您想設定 Angular Material 的瀏覽器動畫。選擇「Indigo/Pink」,即可得到與本程式碼研究室相同的結果,並以「是」回答答案,回答最後兩個問題。

ng add 指令會安裝 @angular/material、其相依關係,並在 AppModule 中匯入 BrowserAnimationsModule。在下一個步驟中,我們可以開始使用本單元提供的元件!

首先,讓我們在 AppComponent 中加入工具列和圖示。開啟 app.component.html 並新增下列標記:

src/app/app.component.html

<mat-toolbar color="primary">
  <mat-icon>local_fire_department</mat-icon>
  <span>Kanban Fire</span>
</mat-toolbar>

我們在這裡新增的工具列是採用材料設計主題的主要顏色,當中的工具則是使用「Kanban Fire」標籤旁邊的「local_fire_depeartment」圖示。如果現在查看主控台,你會看到 Angular 出現幾項錯誤。如要修正這個問題,請務必將下列匯入項目新增至 AppModule

src/app/app.module.ts

...
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatToolbarModule,
    MatIconModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

由於我們使用 Angular 材料工具列和圖示,因此必須匯入 AppModule 中對應的模組。

畫面上現在會顯示下列資訊:

a39cf8f8428a03bc.png

只要使用 4 行 HTML 和兩筆匯入功能,就能避免系統誤判!

4. 將工作視覺化

下一步是讓我們建立元件,以將 Kanban 白板中的工作以視覺化方式呈現。

前往 src/app 目錄並執行下列 CLI 指令:

ng generate component task

這個指令會產生 TaskComponent 並將其宣告加入 AppModule。在 task 目錄中,建立名為 task.ts 的檔案。我們會使用這個檔案來定義 Kanban 白板的工作介面。每項工作都有一個選用的 idtitledescription 欄位,這些欄位都包含所有類型字串:

src/app/task/task.ts

export interface Task {
  id?: string;
  title: string;
  description: string;
}

現在更新task.component.ts。我們希望 TaskComponent 接受為 Task 類型的物件,而它希望能夠發出「edit」輸出內容。

src/app/task/task.component.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from './task';

@Component({
  selector: 'app-task',
  templateUrl: './task.component.html',
  styleUrls: ['./task.component.css']
})
export class TaskComponent {
  @Input() task: Task | null = null;
  @Output() edit = new EventEmitter<Task>();
}

編輯TaskComponent的範本!開啟 task.component.html,並使用下列 HTML 取代其內容:

src/app/task/task.component.html

<mat-card class="item" *ngIf="task" (dblclick)="edit.emit(task)">
  <h2>{{ task.title }}</h2>
  <p>
    {{ task.description }}
  </p>
</mat-card>

請注意,現在主控台中發生錯誤:

'mat-card' is not a known element:
1. If 'mat-card' is an Angular component, then verify that it is part of this module.
2. If 'mat-card' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.ng

在上述範本中,我們使用的是 @angular/material 中的 mat-card 元件,但我們並未在應用程式中匯入相應的模組。如要修正以上錯誤,我們必須在 AppModule 中匯入 MatCardModule

src/app/app.module.ts

...
import { MatCardModule } from '@angular/material/card';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatCardModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

接下來,我們將在 AppComponent 建立幾項工作,並使用 TaskComponent 以視覺化方式呈現這些工作!

AppComponent 中定義名為 todo 的陣列,並在其中加入兩個工作:

src/app/app.component.ts

...
import { Task } from './task/task';

@Component(...)
export class AppComponent {
  todo: Task[] = [
    {
      title: 'Buy milk',
      description: 'Go to the store and buy milk'
    },
    {
      title: 'Create a Kanban app',
      description: 'Using Firebase and Angular create a Kanban app!'
    }
  ];
}

現在,在 app.component.html 底部新增下列 *ngFor 指令:

src/app/app.component.html

<app-task *ngFor="let task of todo" [task]="task"></app-task>

開啟瀏覽器後,你應該會看到下列資訊:

d96fccd13c63ceb1.png

5. 實作拖曳操作的功能

現在就開始體驗這個樂趣了!我們為其中三種不同的工作建立三個泳道,並使用 Angular CDK 來導入拖曳功能。

app.component.html 中,使用頂端的 *ngFor 指令移除 app-task 元件,並替換為:

src/app/app.component.html

<div class="container-wrapper">
  <div class="container">
    <h2>Backlog</h2>

    <mat-card
      cdkDropList
      id="todo"
      #todoList="cdkDropList"
      [cdkDropListData]="todo"
      [cdkDropListConnectedTo]="[doneList, inProgressList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
      <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>

  <div class="container">
    <h2>In progress</h2>

    <mat-card
      cdkDropList
      id="inProgress"
      #inProgressList="cdkDropList"
      [cdkDropListData]="inProgress"
      [cdkDropListConnectedTo]="[todoList, doneList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="inProgress.length === 0">Empty list</p>
      <app-task (edit)="editTask('inProgress', $event)" *ngFor="let task of inProgress" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>

  <div class="container">
    <h2>Done</h2>

    <mat-card
      cdkDropList
      id="done"
      #doneList="cdkDropList"
      [cdkDropListData]="done"
      [cdkDropListConnectedTo]="[todoList, inProgressList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="done.length === 0">Empty list</p>
      <app-task (edit)="editTask('done', $event)" *ngFor="let task of done" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>
</div>

這裡有許多內容。我們來逐步瞭解這個程式碼片段的個別部分。這是範本的頂層結構:

src/app/app.component.html

...
<div class="container-wrapper">
  <div class="container">
    <h2>Backlog</h2>
    ...
  </div>

  <div class="container">
    <h2>In progress</h2>
    ...
  </div>

  <div class="container">
    <h2>Done</h2>
    ...
  </div>
</div>

在這裡,我們會建立一個 div 來包裝這 3 條泳道,類別名稱為「container-wrapper」。此外,每條泳道都有類別名稱「container」和 h2 標記內的名稱。

現在,我們來看看第一條泳道的結構:

src/app/app.component.html

...
    <div class="container">
      <h2>Backlog</h2>

      <mat-card
        cdkDropList
        id="todo"
        #todoList="cdkDropList"
        [cdkDropListData]="todo"
        [cdkDropListConnectedTo]="[doneList, inProgressList]"
        (cdkDropListDropped)="drop($event)"
        class="list"
      >
        <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
        <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
      </mat-card>
    </div>
...

首先,我們將泳道定義為 mat-card,使用 cdkDropList 指令。由於此元件提供了各種樣式,因此我們使用 mat-card。稍後將使用 cdkDropList 將元素拖放到元素內部。我們也設定了以下兩種輸入內容:

  • cdkDropListData - 下拉式清單,讓我們指定資料陣列
  • cdkDropListConnectedTo - 參照目前 cdkDropList 所連線的其他 cdkDropList。設定這個輸入選項,即可指定哪些項目可移入項目

此外,我們想使用 cdkDropListDropped 輸出來處理放棄事件。當 cdkDropList 發出此輸出內容後,我們叫用 AppComponent 中宣告的 drop 方法,並將目前的事件做為引數傳遞。

請注意,我們也會指定一個 id 做為這個容器的 ID,以及一個 class 名稱,以便設定樣式。現在,讓我們來深入瞭解 mat-card 的內容。我們提供的兩個元素如下:

  • 這個段落是用來在「todo」清單中沒有任何項目時顯示「空白清單」的文字
  • app-task 元件。請注意,這裡處理的 edit 輸出內容,是透過呼叫具清單名稱和 $event 物件之呼叫的 editTask 方法。這樣可讓我們從正確的清單中替換已編輯的工作。接下來,我們會比照上述做法,反覆執行 todo 清單,並傳送 task 的輸入內容。但這次也會加入 cdkDrag 指令。可拖曳個別工作。

如要完成所有工作,我們必須更新 app.module.ts,並納入匯入至 DragDropModule

src/app/app.module.ts

...
import { DragDropModule } from '@angular/cdk/drag-drop';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    DragDropModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

我們也需要宣告 inProgressdone 陣列,以及 editTaskdrop 方法:

src/app/app.component.ts

...
import { CdkDragDrop, transferArrayItem } from '@angular/cdk/drag-drop';

@Component(...)
export class AppComponent {
  todo: Task[] = [...];
  inProgress: Task[] = [];
  done: Task[] = [];

  editTask(list: string, task: Task): void {}

  drop(event: CdkDragDrop<Task[]|null>): void {
    if (event.previousContainer === event.container) {
      return;
    }
    if (!event.container.data || !event.previousContainer.data) {
      return;
    }
    transferArrayItem(
      event.previousContainer.data,
      event.container.data,
      event.previousIndex,
      event.currentIndex
    );
  }
}

請注意,在 drop 方法中,我們首先會檢查與工作來源相同的清單。如果是這種情況,我們會立即傳回。否則,我們會將目前的工作轉移到目的地的泳道。

結果應為:

460f86bcd10454cf.png

現在您應該已經能夠在兩份清單之間轉移項目了!

6. 建立新工作

現在,讓我們來實作建立新工作的功能。為此,讓我們更新 AppComponent 的範本:

src/app/app.component.html

<mat-toolbar color="primary">
...
</mat-toolbar>

<div class="content-wrapper">
  <button (click)="newTask()" mat-button>
    <mat-icon>add</mat-icon> Add Task
  </button>

  <div class="container-wrapper">
    <div class="container">
      ...
    </div>
</div>

系統會在 container-wrapper 周圍建立頂層 div 元素,並加入一個帶有「add」字樣的按鈕。標籤圖示旁邊會標示「新增工作」。我們需要額外的包裝函式,將按鈕放在泳道清單的最上方。我們會在之後使用 Flexbox 彼此放置。由於這個按鈕使用了實體元件元件,因此必須在 AppModule 中匯入相應的模組:

src/app/app.module.ts

...
import { MatButtonModule } from '@angular/material/button';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatButtonModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

現在,讓我們實作在 AppComponent 中新增工作的功能。我們會使用教材對話方塊。在對話方塊中,您會看到一個表單,其中有兩個欄位分別是 title 和 description。當使用者按一下「Add Task」(新增工作) 按鈕時,系統會開啟對話方塊,而使用者提交表單時,我們會將新建立的工作加入 todo 清單。

讓我們看看「AppComponent」中有關這項功能的高階導入方式:

src/app/app.component.ts

...
import { MatDialog } from '@angular/material/dialog';

@Component(...)
export class AppComponent {
  ...

  constructor(private dialog: MatDialog) {}

  newTask(): void {
    const dialogRef = this.dialog.open(TaskDialogComponent, {
      width: '270px',
      data: {
        task: {},
      },
    });
    dialogRef
      .afterClosed()
      .subscribe((result: TaskDialogResult|undefined) => {
        if (!result) {
          return;
        }
        this.todo.push(result.task);
      });
  }
}

我們宣告了會插入 MatDialog 類別的建構函式。在 newTask 內,我們:

  • 使用 TaskDialogComponent 開啟新的對話方塊,系統稍後會定義它。
  • 指定對話方塊的寬度為 270px.
  • 將空白的工作當做資料傳送至對話方塊。我們將在 TaskDialogComponent內取得此資料物件的參照。
  • 我們訂閱了關閉事件,並將 result 物件的工作加入 todo 陣列。

如要確保這項功能正常運作,請先在 AppModule 中匯入 MatDialogModule

src/app/app.module.ts

...
import { MatDialogModule } from '@angular/material/dialog';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatDialogModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

現在,請建立 TaskDialogComponent。前往 src/app 目錄並執行下列指令:

ng generate component task-dialog

如要實作這項功能,請先開啟 src/app/task-dialog/task-dialog.component.html,並將其內容替換成:

src/app/task-dialog/task-dialog.component.html

<mat-form-field>
  <mat-label>Title</mat-label>
  <input matInput cdkFocusInitial [(ngModel)]="data.task.title" />
</mat-form-field>

<mat-form-field>
  <mat-label>Description</mat-label>
  <textarea matInput [(ngModel)]="data.task.description"></textarea>
</mat-form-field>

<div mat-dialog-actions>
  <button mat-button [mat-dialog-close]="{ task: data.task }">OK</button>
  <button mat-button (click)="cancel()">Cancel</button>
</div>

在上述範本中,我們建立了一份表單,其中包含 titledescription 這兩個欄位。我們使用 cdkFocusInput 指令,在使用者開啟對話方塊時,自動聚焦 title 輸入內容。

請注意,範本中如何參照元件的 data 屬性。這相當於我們傳送給 AppComponentdialogopen 方法。如要在使用者變更對應欄位的內容時更新工作的標題和說明,我們採用雙向資料繫結與 ngModel

當使用者按一下 [確定] 按鈕時,我們會自動傳回結果「{ task: data.task }」,這是使用上方範本中的表單欄位來修改的工作。

現在,請實作元件的控制器:

src/app/task-dialog/task-dialog.component.ts

import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Task } from '../task/task';

@Component({
  selector: 'app-task-dialog',
  templateUrl: './task-dialog.component.html',
  styleUrls: ['./task-dialog.component.css'],
})
export class TaskDialogComponent {
  private backupTask: Partial<Task> = { ...this.data.task };

  constructor(
    public dialogRef: MatDialogRef<TaskDialogComponent>,
    @Inject(MAT_DIALOG_DATA) public data: TaskDialogData
  ) {}

  cancel(): void {
    this.data.task.title = this.backupTask.title;
    this.data.task.description = this.backupTask.description;
    this.dialogRef.close(this.data);
  }
}

TaskDialogComponent 中,我們為對話方塊插入參考資料,以便關閉此對話方塊,也會插入與 MAT_DIALOG_DATA 憑證相關的提供者值。這是我們在上述 AppComponent 中傳送到開放式方法的資料物件。此外,我們也宣告私人屬性「backupTask」,也就是我們與資料物件一併傳送的工作副本。

當使用者按下取消按鈕時,我們還原了可能的 this.data.task 屬性,並關閉對話方塊,結果會透過 this.data 傳送結果。

我們參照了兩種類型,但尚未宣告:TaskDialogDataTaskDialogResultsrc/app/task-dialog/task-dialog.component.ts 內將下列聲明新增至檔案底部:

src/app/task-dialog/task-dialog.component.ts

...
export interface TaskDialogData {
  task: Partial<Task>;
  enableDelete: boolean;
}

export interface TaskDialogResult {
  task: Task;
  delete?: boolean;
}

在我們完成這項功能前,我們還有最後一步,就是在 AppModule 中匯入幾個模組!

src/app/app.module.ts

...
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatInputModule,
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

按下 [新增工作] 按鈕後,您應該會看到下列使用者介面:

33bcb987fade2a87.png

7. 改善應用程式的樣式

為了讓應用程式更具吸引力,我們藉由微調樣式,改善了版面配置。我們想要將泳道彼此相鄰。我們也希望對 [新增工作] 按鈕和空白清單標籤進行一些微幅調整。

開啟 src/app/app.component.css,並在頁面底部新增下列樣式:

src/app/app.component.css

mat-toolbar {
  margin-bottom: 20px;
}

mat-toolbar > span {
  margin-left: 10px;
}

.content-wrapper {
  max-width: 1400px;
  margin: auto;
}

.container-wrapper {
  display: flex;
  justify-content: space-around;
}

.container {
  width: 400px;
  margin: 0 25px 25px 0;
}

.list {
  border: solid 1px #ccc;
  min-height: 60px;
  border-radius: 4px;
}

app-new-task {
  margin-bottom: 30px;
}

.empty-label {
  font-size: 2em;
  padding-top: 10px;
  text-align: center;
  opacity: 0.2;
}

在上方程式碼片段中,我們調整了工具列及其標籤的版面配置。同時,我們也將內容寬度設為 1400px,並將邊界設為 auto,以確保水平對齊。接下來,使用 flexbox 將泳道彼此相鄰,最後調整工作與空白清單的呈現方式。

應用程式重新載入後,您應該會看到下列使用者介面:

69225f0b1aa5cb50.png

雖然我們大幅改善了應用程式的樣式,但是在四處移動工作時,我們還是會遇到一些問題:

f9aae712027624af.png

當我們開始拖曳「買牛奶」工作時,會發現兩份工作相同的資訊卡,分別是「拖曳」和「泳道」中的一張資訊卡。Angular CDK 向我們提供 CSS 類別名稱,方便我們修正這個問題。

src/app/app.component.css 底部新增下列樣式覆寫值:

src/app/app.component.css

.cdk-drag-animating {
  transition: transform 250ms;
}

.cdk-drag-placeholder {
  opacity: 0;
}

當我們拖曳一個元素時,Angular CDK 會拖放該元素,然後將它插入與放置原始位置的位置。為確保此元素不會顯示,我們在 cdk-drag-placeholder 類別中設定不透明度屬性,並將 CDK 新增至預留位置。

此外,當我們捨棄元素時,CDK 會新增 cdk-drag-animating 類別。為了定義流暢動畫,而非直接貼齊元素,我們將定義轉換的時間長度為 250ms

此外,我們也想稍微調整工作的樣式。在 task.component.css 中,將主機元素顯示設定設為 block 並設定一些邊界:

src/app/task/task.component.css

:host {
  display: block;
}

.item {
  margin-bottom: 10px;
  cursor: pointer;
}

8. 編輯及刪除現有工作

為編輯和移除現有工作,我們會重複使用多數已執行的功能!當使用者按兩下工作時,系統會開啟 TaskDialogComponent,並在表單的兩個欄位中填入該工作的 titledescription

我們也在 TaskDialogComponent 中加入了刪除按鈕。使用者按一下按鈕後,我們會傳送刪除指示,而該指令會在 AppComponent後結束。

在「TaskDialogComponent」中,我們只需要在範本中做出一項變更:

src/app/task-dialog/task-dialog.component.html

<mat-form-field>
 ...
</mat-form-field>

<div mat-dialog-actions>
  ...
  <button
    *ngIf="data.enableDelete"
    mat-fab
    color="primary"
    aria-label="Delete"
    [mat-dialog-close]="{ task: data.task, delete: true }">
    <mat-icon>delete</mat-icon>
  </button>
</div>

這個按鈕會顯示 [刪除教材] 圖示。使用者按一下它之後,系統會關閉對話方塊,並傳送物件常值 { task: data.task, delete: true } 做為結果。另請注意,我們採用 mat-fab 將按鈕設為圓形,並將顏色設為主要顏色,而且只有在對話方塊資料刪除後才會顯示。

「編輯及刪除」功能的其他實作功能已列於 AppComponent。將 editTask 方法替換為下列內容:

src/app/app.component.ts

@Component({ ... })
export class AppComponent {
  ...
  editTask(list: 'done' | 'todo' | 'inProgress', task: Task): void {
    const dialogRef = this.dialog.open(TaskDialogComponent, {
      width: '270px',
      data: {
        task,
        enableDelete: true,
      },
    });
    dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
      if (!result) {
        return;
      }
      const dataList = this[list];
      const taskIndex = dataList.indexOf(task);
      if (result.delete) {
        dataList.splice(taskIndex, 1);
      } else {
        dataList[taskIndex] = task;
      }
    });
  }
  ...
}

我們來看看 editTask 方法的引數:

  • 'done' | 'todo' | 'inProgress', 類型的清單,該字串為字串常值聯集類型,具有與個別泳道相關的屬性對應的值。
  • 我們正在編輯目前的工作。

在方法的主體中,我們首先開啟了 TaskDialogComponent 的例項。它會在其 data 中傳遞物件常值,以指定您要編輯的工作,並且將 enableDelete 屬性設為 true,以在表單中啟用編輯按鈕。

從對話方塊取得結果時,我們會處理兩個情況:

  • delete 標記設為 true (也就是使用者按下刪除按鈕時) 時,我們會將工作從對應的清單中移除。
  • 或者,我們只使用從對話方塊結果取得的工作,取代指定索引上的工作。

9. 建立新的 Firebase 專案

現在,請建立新的 Firebase 專案!

10. 將 Firebase 新增至專案

在這個部分,我們會將專案與 Firebase 整合!Firebase 團隊提供 @angular/fire 套件,可整合這兩項技術。如要為應用程式新增 Firebase 支援,請開啟工作區的根目錄並執行:

ng add @angular/fire

這個指令會安裝 @angular/fire 套件並詢問幾個問題。在終端機中,您應該會看到類似下方的內容:

9ba88c0d52d18d0.png

在此同時,安裝會開啟瀏覽器視窗,方便您使用 Firebase 帳戶進行驗證。最後,系統會要求您選擇 Firebase 專案,並在磁碟上建立一些檔案。

接下來,我們需要建立 Firestore 資料庫!在「Cloud Firestore」之下,按一下 [建立資料庫]。

1e4a08b5a2462956.png

完成後,以測試模式建立資料庫:

ac1181b2c32049f9.png

最後,選取區域:

34bb94cc542a0597.png

現在,您只需要將 Firebase 設定加入環境即可。您可以在 Firebase 主控台中找到專案設定。

  • 按一下「專案總覽」旁邊的「齒輪」圖示。
  • 選擇 [專案設定]。

c8253a20031de8a9.png

在「你的應用程式」下方,選取「網頁應用程式」:

428a1abcd0f90b23.png

接著,註冊應用程式,並確認您已啟用「Firebase 託管」

586e44cb27dd8f39.png

按一下 [註冊應用程式] 後,您可以將設定複製到 src/environments/environment.ts

e30f142d79cecf8f.png

最後,您的設定檔應該會如下所示:

src/environments/environment.ts

export const environment = {
  production: false,
  firebase: {
    apiKey: '<your-key>',
    authDomain: '<your-project-authdomain>',
    databaseURL: '<your-database-URL>',
    projectId: '<your-project-id>',
    storageBucket: '<your-storage-bucket>',
    messagingSenderId: '<your-messaging-sender-id>'
  }
};

11. 將資料移至 Firestore

現在 Firebase 已經設定好 Firebase SDK,因此我們使用 @angular/fire 將資料存放在 Firestore 中!首先,請匯入 AppModule 中所需的模組:

src/app/app.module.ts

...
import { environment } from 'src/environments/environment';
import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';

@NgModule({
  declarations: [AppComponent, TaskDialogComponent, TaskComponent],
  imports: [
    ...
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

由於我們會使用 Firestore,因此必須在 AppComponent 的建構函式中插入 AngularFirestore

src/app/app.component.ts

...
import { AngularFirestore } from '@angular/fire/firestore';

@Component({...})
export class AppComponent {
  ...
  constructor(private dialog: MatDialog, private store: AngularFirestore) {}
  ...
}

接著,我們更新了泳道陣列的初始化方式:

src/app/app.component.ts

...

@Component({...})
export class AppComponent {
  todo = this.store.collection('todo').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  inProgress = this.store.collection('inProgress').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  done = this.store.collection('done').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  ...
}

我們在這裡使用 AngularFirestore 直接從資料庫取得集合內容。請注意,valueChanges 會傳回 observable (而非陣列),我們也指定此集合中文件 ID 欄位的名稱應設為 id,使其與 Task 介面中使用的名稱相符。「valueChanges」傳回的可觀測項目會在每次變更時發出一系列工作。

由於我們是以可觀測項目 (而非陣列) 處理工作,因此必須更新新增、移除和編輯工作的方式,以及移動游泳通道之間的功能。我們不使用 Firebase 的記憶體內陣列,而是透過 Firebase SDK 更新資料庫中的資料。

首先,我們來看看重新調整後的外觀。將 src/app/app.component.ts 中的 drop 方法替換為:

src/app/app.component.ts

drop(event: CdkDragDrop<Task[]>): void {
  if (event.previousContainer === event.container) {
    return;
  }
  const item = event.previousContainer.data[event.previousIndex];
  this.store.firestore.runTransaction(() => {
    const promise = Promise.all([
      this.store.collection(event.previousContainer.id).doc(item.id).delete(),
      this.store.collection(event.container.id).add(item),
    ]);
    return promise;
  });
  transferArrayItem(
    event.previousContainer.data,
    event.container.data,
    event.previousIndex,
    event.currentIndex
  );
}

上方程式碼片段,以方框特別標出新程式碼。如果要將工作從目前的泳道移到目標位置,我們將從第一個集合中移除該工作,然後將它新增到第二個集合。由於我們執行了兩種看起來很像的作業 (也就是將作業設為不可部分完成),因此系統會在 Firestore 交易中執行這些作業。

接著,請將 editTask 方法更新為使用 Firestore!在關閉對話方塊處理常式內,我們必須變更下列幾行程式碼:

src/app/app.component.ts

...
dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
  if (!result) {
    return;
  }
  if (result.delete) {
    this.store.collection(list).doc(task.id).delete();
  } else {
    this.store.collection(list).doc(task.id).update(task);
  }
});
...

我們會存取透過 Firestore SDK 操控的工作對應的目標文件,然後將其刪除或更新。

最後,我們需要更新建立新工作的方法。將 this.todo.push('task') 替換為:this.store.collection('todo').add(result.task)

請注意,我們的集合並非陣列,而是可觀測的。為了呈現圖表,我們必須先更新 AppComponent 的範本。只要將 todoinProgressdone 屬性的所有存取權分別換成 todo | asyncinProgress | asyncdone | async 即可。

非同步管道會自動訂閱與集合相關聯的可觀測器。當可觀測器發出新值時,Angular 會自動執行變更偵測並處理已發送的陣列。

舉例來說,我們來看看我們在 todo 泳道需要做哪些調整:

src/app/app.component.html

<mat-card
  cdkDropList
  id="todo"
  #todoList="cdkDropList"
  [cdkDropListData]="todo | async"
  [cdkDropListConnectedTo]="[doneList, inProgressList]"
  (cdkDropListDropped)="drop($event)"
  class="list">
  <p class="empty-label" *ngIf="(todo | async)?.length === 0">Empty list</p>
  <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo | async" cdkDrag [task]="task"></app-task>
</mat-card>

我們把資料傳送到 cdkDropList 指令時,就會套用非同步管道。在 *ngIf 指令中,它也相同,但請注意,我們也在選用 length 屬性時,也使用選用鏈結 (又稱為 Angular 中的安全運算子運算子),這樣即使 todo | async 不是 nullundefined,我們也不會發生執行階段錯誤。

現在,當您在使用者介面中建立新工作並開啟 Firestore 時,便會看到類似下方的內容:

dd7ee20c0a10ebe2.png

12. 改善最佳化更新

目前,我們正在在應用程式中執行最佳更新。我們在 Firestore 中擁有可靠的資料來源,但同時擁有本機的本機副本;當任何與集合相關的觀測器發出時,我們就會取得各種工作。使用者動作修改狀態時,我們會先更新本機值,再將變更套用至 Firestore。

當我們將工作從某個泳道移到另一條泳道時,系統會叫用 transferArrayItem,,以在該陣列中代表各泳道中的任務。Firebase SDK 會將這些陣列視為不可變動的,因此當 Angular 下次執行變更偵測時,我們會取得這些陣列的新實例,以便在轉移工作之前轉譯先前的狀態。

同時,我們將觸發 Firestore 更新,而 Firebase SDK 會使用正確的值觸發更新,因此使用者介面在短短幾秒內就會變成正確的狀態。如此一來,我們剛完成的工作就從第一份清單跳到下一個清單。您可以在以下的 GIF 中看見這個問題:

70b946eebfa6f316.gif

每個應用程式採用的正確解決方式都不盡相同,但不論在什麼情況下,我們必須確保資料的一致性,直到資料更新為止。

我們可以利用 BehaviorSubject 來包裝從 valueChanges 收到的原始觀測器。在引擎蓋下方,BehaviorSubject 保存一個可變動的陣列,以維持 transferArrayItem 的更新。

如要實作修正,只需更新 AppComponent 即可:

src/app/app.component.ts

...
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { BehaviorSubject } from 'rxjs';


const getObservable = (collection: AngularFirestoreCollection<Task>) => {
  const subject = new BehaviorSubject<Task[]>([]);
  collection.valueChanges({ idField: 'id' }).subscribe((val: Task[]) => {
    subject.next(val);
  });
  return subject;
};

@Component(...)
export class AppComponent {
  todo = getObservable(this.store.collection('todo')) as Observable<Task[]>;
  inProgress = getObservable(this.store.collection('inProgress')) as Observable<Task[]>;
  done = getObservable(this.store.collection('done')) as Observable<Task[]>;
...
}

我們在上述程式碼片段中所做的一切,就是建立 BehaviorSubject,以便在每次與集合相關的可觀察項目變更時傳送一個值。

一切正常,因為 BehaviorSubject 會在變更偵測叫用時重複使用陣列,而且只有在從 Firestore 取得新值時才會更新。

13. 部署應用程式

只要執行應用程式即可執行:

ng deploy

這個指令將:

  1. 利用實際工作環境設定建構您的應用程式,並套用編譯時間最佳化。
  2. 將應用程式部署至 Firebase 託管。
  3. 輸出網址即可預覽結果。

14. 恭喜

恭喜!您成功透過 Angular 和 Firebase 成功建立了 11 個 Kanan 遊戲板!

您建立了使用者介面,其中有三個欄代表不同工作的狀態。使用 Angular CDK 時,您已實作各欄的工作拖曳功能。接著,您可以使用 Angular 材料建構一份表單來建立新工作及編輯現有工作。接下來,您已瞭解如何使用 @angular/fire,並將所有應用程式狀態移至 Firestore。最後,您已將應用程式部署至 Firebase 託管。

後續步驟

請記住,我們是使用測試設定來部署應用程式。在部署應用程式至正式版之前,請確認您已設定正確的權限。如需詳細步驟,請按這裡

目前,我們不會保留特定游泳歷程中個別工作的順序。如要實作這項功能,您可以使用工作文件中的訂單欄位,並根據該欄位進行排序。

此外,我們只為單一使用者建立 kanban 板,也就是說,所有開啟應用程式的使用者都有一個 Kanan 板。如要為應用程式的不同使用者分別導入 Jamboard,請變更資料庫的結構。請參閱這篇文章,瞭解 Firestore 的最佳做法。