使用 Angular 和 Firebase 构建一个 Web 应用

1. 简介

上次更新时间:2020 年 9 月 11 日

构建内容

在此 Codelab 中,我们将使用 Angular 和 Firebase 构建一个 Web 看板。最终应用将包含以下三个类别的任务:积压任务、处理中任务和已完成任务。该应用将支持创建和删除任务,以及通过拖放操作在不同类别之间移动任务。

我们将使用 Angular 开发界面,并使用 Firestore 作为持久性存储。最后,我们使用 Angular CLI 将应用部署到 Firebase Hosting。

b23bd3732d0206b.png

学习内容

  • 如何使用 Angular Material 和 CDK。
  • 如何将 Firebase 集成添加到 Angular 应用中。
  • 如何在 Firestore 中持久化数据。
  • 如何在 Angular CLI 中通过一条命令将应用部署到 Firebase Hosting。

所需条件

此 Codelab 假定您拥有 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 的开发服务器:

ng serve

打开 http://localhost:4200,您会看到如下所示的输出:

5ede7bc5b1109bf3.png

在编辑器中,打开 src/app/app.component.html 并删除其中的所有内容。返回到 http://localhost:4200 中,您应当会看到一个空白页面。

3. 添加 Material 和 CDK

Angular 的 @angular/material 软件包中随附了符合 Material Design 的界面组件实现。@angular/material 的一个依赖项是组件开发工具包 (CDK)。CDK 提供各种基元,例如无障碍实用程序、拖放功能以及叠加。CDK 发布在 @angular/cdk 软件包中。

如要将 Material 添加到您的应用中,请运行以下命令:

ng add @angular/material

此命令会要求您选择一个主题,询问您是否要使用全局 Material 排版样式,以及是否要为 Angular Material 设置浏览器动画。选择“Indigo/Pink”以实现与此 Codelab 相同的结果,最后两个问题请选择“Yes”。

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>

此处添加的工具栏使用了 Material Design 主题的主色,其内部依次包含 local_fire_depeartment 图标和“Kanban Fire”标签。现在查看控制台,您会看到 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 Material 工具栏和图标,因此需要在 AppModule 中导入相应的模块。

现在,您应会在屏幕上看到以下画面:

a39cf8f8428a03bc.png

只使用了 4 行 HTML 代码和两个导入项,效果还不错!

4. 直观呈现任务

下一步,创建可在看板中直观呈现任务的组件。

转到 src/app 目录并运行以下 CLI 命令:

ng generate component task

此命令会生成 TaskComponent 并将其声明添加到 AppModule 中。在 task 目录中,创建一个名为 task.ts 的文件。我们将使用此文件定义看板中的任务界面。所有任务都包含可选的 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 来封装所有三个泳道,其类名为“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 输出来处理 drop 事件。当 cdkDropList 发出此输出后,随即将调用 AppComponent 中声明的 drop 方法,并将当前事件作为参数传递给该方法。

请注意,我们还指定了一个 id 作为此容器的标识符,并在 class 中指定了样式名称。现在,我们来看一看 mat-card 的内容子项。其中包含两个元素:

  • 第一个元素是一段代码,用于在 todo 列表中没有任何内容时显示“Empty list”文本
  • 第二个元素是 app-task 组件。请注意,此处使用列表名称和 $event 对象调用了 editTask 方法来处理最初声明的 edit 输出。这将有助于我们从正确的列表中替换修改后的任务。接下来,对 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 Task”标签旁边添加了一个使用“add”Material 图标的按钮。我们使用另外一个封装容器将按钮放置在泳道列表的顶部。我们将在稍后使用 Flexbox 让它们彼此相邻放置。由于此按钮使用 Material 按钮组件,因此我们需要在 AppModule 中导入相应的模块:

src/app/app.module.ts

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

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

然后,在 AppComponent 中实现添加任务的功能。我们将使用 Material 对话框。该对话框中的表单包含以下两个字段: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.
  • 使用空白 task 对象作为数据传递给此对话框。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 属性。这与 AppComponent 中传递至 dialogopen 方法的 data 相同。为了在用户更改任务标题和描述时更新相应字段,我们通过 ngModel 实现了双向数据绑定。

当用户点击 OK 按钮时,系统会自动返回结果 { 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 中传递给 open 方法的数据对象。我们还声明了私有属性 backupTask,这是与数据对象一起传递的任务副本。

当用户点击“Cancel”按钮时,系统会恢复 this.data.task 可能已更改的属性,然后关闭对话框,并传递 this.data 作为结果。

我们引用但尚未声明的两种类型包括:TaskDialogDataTaskDialogResult。在 src/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 { }

现在,点击“Add Task”按钮,您应当会看到以下界面:

33bcb987fade2a87.png

7. 改进应用的样式

为了改善应用的视觉效果,我们将微调其样式,让布局更加合理。我们希望各个泳道彼此相邻。我们还将对“Add Task”按钮和空白列表标签进行一些细微的调整。

打开 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

当拖动“Buy milk”任务时,页面上会显示同一任务的两张卡片:一张正在拖动中,另一张在泳道中。我们可以使用 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 中,将 host 元素的 display 设置为 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>

此按钮显示“删除”Material 图标。当用户点击该图标时,系统会关闭对话框,并传递对象字面量 { 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 添加到项目中

在此部分中,我们将集成 Angular 项目与 Firebase 项目!Firebase 团队提供了 @angular/fire 软件包,其中提供了这两种技术的集成。如要在应用中添加 Firebase 支持,请打开工作区的根目录并运行以下命令:

ng add @angular/fire

此命令会安装 @angular/fire 软件包并询问几个问题。在终端中,您应当会看到如下所示的输出内容:

9ba88c0d52d18d0.png

同时,安装命令会打开一个浏览器窗口,您需要在其中使用 Firebase 帐号进行身份验证。最后,您将根据提示选择一个 Firebase 项目。您的磁盘上还会创建一些文件。

接下来,我们需要创建一个 Firestore 数据库!在“Cloud Firestore”下,点击“Create Database”。

1e4a08b5a2462956.png

然后,在测试模式下创建数据库:

ac1181b2c32049f9.png

最后,选择一个区域:

34bb94cc542a0597.png

接下来是最后一步,将 Firebase 配置添加到您的环境中。您可以在 Firebase 控制台中找到项目配置。

  • 点击“Project Overview”旁边的齿轮图标。
  • 选择“Project Settings”。

c8253a20031de8a9.png

在“Your apps”下,选择一个“Web 应用”:

428a1abcd0f90b23.png

接下来,注册应用并确保启用“Firebase Hosting”

586e44cb27dd8f39.png

点击“Register app”后,您可以将配置复制到 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 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 会返回可观察对象而非数组,另外,我们将该集合中文档的 ID 字段命名为 id,以匹配我们在 Task 界面中使用的名称。valueChanges 返回的可观察对象会在每次发生更改时发出任务集合。

由于我们使用的是可观察对象而非数组,因此需要更新添加、删除和修改任务的方式,以及在各泳道之间移动任务的功能。我们将使用 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 Hosting。
  3. 输出网址,以便查看结果。

14. 恭喜

恭喜,您已成功使用 Angular 和 Firebase 构建了一个看板!

您创建了一个包含三列的界面,每一列分别代表不同任务的状态。您使用 Angular CDK 实现了在各列之间拖放任务的功能。然后,使用 Angular Material 构建了一个表单,用于创建新任务和修改现有任务。接下来,您学习了如何使用 @angular/fire 并将所有应用状态移至 Firestore。最后,您将应用部署到了 Firebase Hosting。

后续操作

请谨记,我们使用了测试配置来部署应用。在将应用部署到生产环境之前,请确保设置了正确的权限。您可以点击此处了解如何执行此操作。

目前,我们并未在泳道中保留各项任务的顺序。如要实现此功能,您可以使用任务文档中的 order 字段并根据该字段进行排序。

此外,我们仅针对单个用户构建了看板。这意味着打开该应用的所有用户都将操作同一个看板。要为应用的不同用户实现不同的看板,您需要更改数据库结构。点击此处了解 Firestore 的最佳实践。