Angular5-和-Firebase-全栈开发实用指南-全-

Angular5 和 Firebase 全栈开发实用指南(全)

原文:zh.annas-archive.org/md5/f113aa7bff00c98a8b38bd392d37f2d0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

使用 Angular 5 和 Firebase 进行实战全栈开发将为你提供开发 Web 应用所需的实际知识。在这本书中,我们介绍了编写完整社交媒体应用所需的所有主要工具和技术。本书以这样的方式编写,你将从头开始构建应用,随着你在书中的进展,你将了解 Web 开发的主要概念。作为我们开发过程的一部分,本书严格遵守常见的软件原则和编码标准。我还介绍了对 Angular 组件、服务和管道进行单元测试。这些软件开发实践使我们成为更好的开发者。

作为本书的一部分,我们将使用 Angular 作为前端和 Firebase 作为后端,从开发到生产构建 Web 应用。Firebase 完全可扩展且实时,提供了开发丰富、协作应用所需的所有工具。使用 Firebase,可以轻松构建和原型化任何商业应用,而无需创建复杂的后端服务。

你将使用 Angular 框架,通过 HTML 和 CSS 构建客户端应用程序。Angular 提供了高级功能,可以将客户端代码模块化到 HTML、CSS、组件和服务中。作为我们开发过程的一部分,我们还将集成其他常用库,如 RxJS。

因此,请期待一段精彩的旅程。

本书面向对象

本书面向那些对 Angular 框架有一定了解的 JavaScript 开发者,他们希望使用 Angular 和 Firebase 开始开发实时应用程序。对于任何希望将业务或想法在线化的小型初创公司来说,本书非常实用,因为它提供了快速开发应用程序的实际技巧。本书也非常适合那些希望在不进行大量投资的情况下构建完整 Web 应用的大学生。如果你正在寻找一种更实用、理论性较少的方法来学习主要 Web 应用概念,那么这本书适合你。

本书涵盖内容

第一章,组织你的 Angular 项目结构,让你了解 Angular 项目结构。你将使用 Angular CLI 命令创建 Angular 项目,并了解 Angular 项目中的所有重要库。

第二章,创建注册组件,介绍了如何启用 Firebase 身份验证并创建注册组件、模板和服务。你还将了解 AngularFire2 库。

第三章,创建登录组件,教你如何创建登录组件和模板。你还将执行重置密码操作。

第四章,组件之间的路由和导航,帮助您为模块启用路由并创建导航栏以在组件之间导航。

第五章, 创建用户个人资料页面,专注于 RxJs 库并在组件模块之间传递数据。您还将创建带有编辑操作的个人信息页面。

第六章,创建用户好友列表,教您如何创建带有分页功能的好友列表页面。您还将创建好友服务和自定义 Angular 日期管道。

第七章, 探索 Firebase 存储,讨论 Firebase 存储并为您的应用配置存储。您还将从 Firebase 存储上传和下载图片到您的应用中。

第八章,创建聊天组件,帮助您创建带有子组件的聊天模块。我们还学习了更多关于 SCSS 变量和 mixin 概念。

第九章, 将聊天组件与 Firebase 数据库连接,涵盖了如何将您的聊天组件与 Firebase 数据库集成。您还将学习使用路由参数传递数据。

第十章, 对我们的应用进行单元测试,教您关于 Angular 测试的知识。您将为我们的组件、服务和管道编写单元测试用例,并了解代码覆盖率。

第十一章, 调试技术,涵盖了调试技术的不同方面。作为本章的一部分,我们将涵盖 Angular、Web、TypeScript、CSS 和网络调试。

第十二章, Firebase 安全和托管,教您关于 Firebase 安全并解释如何为用户和聊天消息添加安全规则。您还将学习如何创建多个环境并托管我们的好友应用。

第十三章,使用 Firebase 扩展我们的应用,涵盖了 Firebase 云消息。您还将了解 Google Analytics 和广告。

第十四章, 将我们的应用转换为 PWA,涵盖了 PWA 功能并展示了如何使您的应用符合 PWA 规范。您还将了解服务工作者。

要充分利用这本书

要充分利用这本书,您应该有一些使用 HTML 和 CSS 进行 JavaScript 开发的经验。随着您通过章节的进展,将提供所有重要工具、编辑器或框架的链接,这些是开发 Angular 应用所需的。

下载示例代码文件

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Full-Stack-Development-with-Angular-5-and-Firebase。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们!

使用的约定

在本书中使用了多种文本约定。

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块按照以下方式设置:

public addUser(user: User): void {
    this.fireDb.object(`${USERS_CHILD}/${user.uid}`).set(user);
}

任何命令行输入或输出都按照以下方式编写:

$ cd <your directory>\friends\src\app

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“点击认证。”

警告或重要说明看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,我们将非常感激您能提供位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packtpub.com

第一章:组织您的 Angular 项目结构

在本章中,我们将使用 @angular/cli 命令创建项目结构。我们将使用 npmNode 包管理器)下载所有必要的库。然后,我们将启动项目,查看在浏览器上运行的第一个 Angular 应用程序。我们将介绍开发高质量 Angular 应用程序所需的所有重要文件。

npm 是 JavaScript 的包管理器。它还帮助安装开发应用程序所需的包。

我们还将运行设置测试框架的过程,并为我们的社交应用的一些重要组件编写测试用例。我们的目标是通过对测试、开发和数据分析的开发,创建一个生产就绪的应用程序。

最后,我们将介绍编码风格的推荐指南,因为这是开发中最被忽视的部分。我们觉得在团队开发任何应用程序时,这是必需的,以确保在整个开发团队中遵循通用术语和术语。

本章将涵盖以下主题:

  • 创建项目大纲

  • 项目结构

  • 设置 Firebase

  • Angular 术语

  • 应用程序项目结构

  • 常见原则

  • 编码标准指南

创建项目大纲

Angular CLI命令行界面)使创建项目变得容易。它还通过简单的命令帮助创建组件、路由、服务和管道。我们将使用 Angular CLI 创建一个示例项目大纲。这提供了开始构建您的应用程序所需的所有必要文件。

我们需要以下四个步骤来运行我们的第一个 Angular 应用程序,而无需编写任何代码,并获取我们的第一个欢迎页面。我们还需要 npm 来安装重要的库;您可以从 nodejs.org/en/download/ 下载 npm:

  1. 让我们使用 npm 安装 Angular CLI:
$npm install -g @angular/cli

请注意,-g 标志全局安装 angular CLI,以便在任何项目中都可以访问。

  1. 使用 ng new 命令创建 friends 项目的结构。由于我们使用 SASS 创建样式表,因此我们还将提供 --style=sass 选项,这将配置我们的应用程序中的 SASS。请查看以下命令:
$ng new friends --style=sass

不要忘记在最后给出您的项目名称,否则它将创建一个默认的项目名称。

  1. 进入新创建的 friends 文件夹并执行 npm install。这安装了构建我们的应用程序所需的所有包,并创建了 node_modules 文件夹。请查看以下命令:
$npm install
  1. 最后,使用 npm start 部署新创建的 friends 项目,并查看在浏览器中运行的第一个 Angular 应用程序。请参考以下命令。默认端口是 4200,您可以在浏览器中输入 http://localhost:4200 来查看您的示例应用程序:
$npm start                            

恭喜您完成了您的第一个 Angular 应用程序!

项目结构

下一步是将新创建的项目映射到一个编辑器。我们使用 WebStorm 作为我们的编辑器,这是一个付费版本。您可以使用免费的 Visual Studio Code 或 Sublime 作为您的编辑器,您可以从以下 URL 下载它们:

我们将项目映射到我们的 WebStorm 编辑器,并使用 npm install 安装依赖库,它创建了一个 node_modules 文件夹。这个文件夹包含所有依赖库。在编辑器中的项目结构如下截图所示:

package.json 概述

package.json 文件指定了运行应用的起始包。我们也可以在此文件中添加包,随着应用的演变。

package.json 文件包含以下两组依赖:

  • dependencies**: **"dependencies" 中的包包含运行 Angular 应用所需的所有基本库:
    "dependencies": {
        "@angular/animations": "⁵.2.0",
        "@angular/common": "⁵.2.0",
        "@angular/compiler": "⁵.2.0",
        "@angular/core": "⁵.2.0",
        "@angular/forms": "⁵.2.0",
        "@angular/http": "⁵.2.0",
        "@angular/platform-browser": "⁵.2.0",
        "@angular/platform-browser-dynamic": "⁵.2.0",
        "@angular/router": "⁵.2.0",
        "core-js": "².4.1",
        "rxjs": "⁵.5.6",
        "zone.js": "⁰.8.19"
    },

依赖包括以下库。以下列表中我们只解释了重要的库:

    • @angular/common: 它提供了常用功能,如管道、服务和指令。

    • @angular/compiler: 它理解模板,并将它们转换为浏览器可以理解的格式,以便我们的应用可以运行和渲染。我们不直接与此库交互。

    • @angular/core: 它提供了所有常用元数据,例如组件、指令、依赖注入和组件生命周期钩子。

    • @angular/forms: 它提供了一个基本的输入布局。这用于登录、注册或反馈。

    • @angular/http: 它是一个 Angular 服务,提供了一个用于 HTTP REST 调用的实用函数。

    • @angular/platform-browser: 它提供了用于生产构建启动应用的 bootstrapStatic() 方法。

    • @angular/platform-browser-dynamic: 它主要在开发过程中的启动阶段使用。

    • @angular/router: 它提供了一个组件路由器,用于组件之间的导航。

    • core-js: 它将 ES2015(ES6)的基本功能修补到全局上下文 window 中。

    • rxjs: 它是一个用于使用观察者进行响应式编程的库,有助于编写 HTTP 的异步代码。

    • zone.js: 它提供了一个在异步任务之间持续存在的执行上下文。

  • devDependencies: devDependencies 中的包主要在开发过程中需要。您可以使用以下 npm 命令在生产构建中排除 devDependencies

$npm install my-application --production

设置 Firebase

在我们的项目中,我们使用 Firebase 作为后端服务,一个移动和网页应用平台。它提供了一整套集成到一个平台的产品,使得开发过程更加快速,主要部分由平台处理。其产品主要围绕两个主要主题:

  • 开发和测试你的应用:这些套件提供了开发可扩展应用所需的所有服务

  • 增长并吸引你的受众:这对于我们应用的增长是必需的

你可以参考以下链接获取有关 Firebase 的更多信息:firebase.google.com/

设置 Firebase 账户

创建新 Firebase 项目的第一步是创建你的新 Google 账户或使用当前账户。

现在,打开 Firebase 门户,按照以下四个步骤开始你的 Firebase 项目:

  1. 点击右上角的“GO TO CONSOLE”。

  2. 点击带有加号(+)的“添加项目”。

  3. 在弹出的窗口中,输入项目名称和国家/地区。项目 ID 是可选字段,它将采用默认值。

  4. 点击 CREATE PROJECT。

以下截图显示了朋友项目:

最后,你在 Firebase 的欢迎页面上获取配置详情,然后点击将 Firebase 添加到你的 web 应用。你可以将此配置复制到 environments.ts 中,如下所示:

export const environment = {
    production: false,
    firebase: {
        apiKey: 'XXXX',
        authDomain: 'friends-4d4fa.firebaseapp.com',
        databaseURL: 'https://friends-4d4fa.firebaseio.com',
        projectId: 'friends-4d4fa',
        storageBucket: '',
        messagingSenderId: '321535044959'
    }
};

Angular 术语

在本节中,我们将讨论 Angular 中的重要术语。你可能对其中大部分都很熟悉,但这将帮助你巩固知识:

  • 模块:Angular 通过模块支持模块化。所有 Angular 项目至少有一个名为 AppModule 的模块。当我们构建大型应用时,我们可以将应用划分为具有共同相关能力的多个功能模块。我们可以使用 @NgModule 注解创建模块。

  • 组件:组件是一个控制器,它具有视图和逻辑来管理视图事件和组件之间的导航。它通过各种数据绑定技术与视图交互。你可以使用以下 CLI 命令生成 component

$ng g component <component-name>
  • 模板:模板代表网页的视图,它使用 HTML 标签创建。它还包含许多自定义标签,如 Angular 指令以及原生 HTML 标签。

  • 元数据:它为任何类分配行为。这是位于类之上的元数据,它告诉 Angular 类的行为。例如,组件是通过 @Component 注解创建的。

  • 数据绑定:这是一个模板与组件交互的过程。数据通过各种数据绑定技术来回传递。Angular 支持以下三种类型的数据绑定:

    • 插值:在这个绑定中,我们使用两个大括号来访问组件成员的属性值。例如,如果我们有一个组件中的类成员属性 name,那么我们可以在模板中定义 {{name}} 来访问名称值。

    • 属性绑定:这种数据绑定技术有助于将值从父组件传递到子组件。例如,如果我们有一个父组件中的 name 作为类成员属性,以及子组件中的 userName,那么我们可以使用 [userName] = "name" 将父组件的值分配给子组件。

    • 事件绑定:这种事件驱动的数据绑定有助于将值从模板传递到组件。例如,我们显示列表中的名称;当用户点击列表项时,我们使用事件绑定 (click)="clickName(name)" 传递点击值。

  • 指令:它是注入到模板中的行为,它修改了 DOM(文档对象模型)的渲染方式。基本上有两种类型的指令:

    • 结构:它通过添加、删除或替换 DOM 元素来改变 DOM 布局。这类指令的示例有 ngForngIf

    • 属性:它改变现有 DOM 元素的外观和行为。ngModel 指令是属性指令的示例,它通过响应事件来改变现有元素的行为。

  • 服务:它是一个可用的实体,被任何 Angular 组件消费,有助于将视图逻辑与业务逻辑分离。我们通常在服务中为特定模块编写 HTTP 特定的调用,因为它有助于代码的可读性和可维护性。流行的服务示例包括日志服务或数据服务。您可以使用以下 CLI 命令创建 service

$ng g service <service-name>
  • 管道:它是 Angular 中最简单但最有用的功能之一。它提供了一种编写可跨应用程序重用实用功能的方法。Angular 提供了内置管道,如日期和货币。您可以使用以下 CLI 命令创建 pipe
$ng g pipe <pipe-name>

应用程序的项目结构

当我们浏览我们的示例朋友应用程序时,我们会遇到 src 文件夹,它包含所有具有视图、业务逻辑和导航功能的应用程序核心文件。开发者的大部分时间都花在这个文件夹中:

图片

我们组织文件夹时遵循的主要思想是功能模块。每个类似的功能都被组织到功能模块中。为了更好地理解它,我们将查看我们朋友应用程序中的一个认证功能示例,以及本书后续章节中的一些文件引用。

在我们的应用程序中,所有与认证相关的功能,如登录和注册,都被组合到一个名为 authentication 的模块中。这种相同的模式将应用于我们应用程序的所有功能。这使得我们的应用程序更加模块化和可测试。我们以一个认证功能模块为例,在以下章节中对其进行了更详细的解释。

我们的认证功能模块如下截图所示;认证功能具有登录和注册功能,所有组件都在模块中声明——它还有一个用于内部导航的自己的路由模块:

应用模块

应用模块是我们整个项目的根模块。所有 Angular 项目至少有一个应用模块,这是强制性的,它用于启动项目以启动应用程序。应用模块使用@NgModule装饰器声明。NgModule注解中的元数据如下:

  • imports:在imports标签中,我们声明所有依赖的功能模块。以下代码示例中,我们声明了BrowserModuleAuthenticationModuleAppRouting模块。

  • declarations:在declarations标签中,我们声明此根模块的所有组件。以下示例中,我们在AppModule中声明了AppComponent

  • providers:在providers标签中,我们声明所有服务或管道。以下示例中,我们声明了AngularFireAuthAngularFireDatabase

  • bootstrap:在bootstrap标签中,我们声明根组件,这在index.html中是必需的。

以下示例app.module.ts是使用ng new命令创建的,如下所示:

    import {BrowserModule} from '@angular/platform-browser';
    import {NgModule} from '@angular/core';
    import {AppComponent} from './app.component';

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

应用路由

根路由模块用于在不同的功能模块之间导航。我们将创建一个包含主应用程序导航流程的路由。以下示例包含路由的样本,展示了在应用程序中常见组件之间的导航,例如AboutComponentPageNotFoundComponent。路径中的通配符'**'指定任何错误的 URL 都将重定向到页面未找到组件。应用路由使用@NgModule装饰器创建,并使用RouterModule配置路由。

以下示例app.routing.ts如下:

import {RouterModule, Routes} from '@angular/router';
import {NgModule} from '@angular/core';
import {PageNotFoundComponent} from './notfound/not-found.component';
import {AboutComponent} from './about/about.component';

export const ROUTES: Routes = [
   {path: 'app-about', component: AboutComponent, pathMatch: 'full'},
   {path: '**', component: PageNotFoundComponent},
];

@NgModule({
   imports: [
      RouterModule.forRoot(
         ROUTES
      )],
   exports: [
      RouterModule
   ]
})
export class AppRouting {
}

认证模块

authentication模块是一个功能模块,它包含登录和注册功能集。所有与认证相关的功能都包含在这个文件夹中。我们在声明元数据中包含了LoginComponentSignupComponent

import {NgModule} from '@angular/core';
import {AuthenticationRouting} from './authentication.routing';
import {LoginComponent} from './login/login.component';
import {SignupComponent} from './signup/signup.component';
import {FormsModule} from '@angular/forms';
import {AuthenticationService} from '../services/authentication.service';

/**
 * Authentication Module
 */
@NgModule({
   imports: [
      FormsModule,
      AuthenticationRouting
   ],
   declarations: [
      LoginComponent,
      SignupComponent
   ],
   providers: [
      AuthenticationService
   ]
})
export class AuthenticationModule {
}

认证路由

此路由有助于在子组件内进行导航,因此我们为登录和注册组件创建路由。随着我们在后续章节中开始实现登录和注册组件,这些路由将逐步发展。

以下示例authentication.routing.ts如下:

import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {LoginComponent} from './login/login.component';
import {SignupComponent} from './signup/signup.component';

export const ROUTES: Routes = [
   {path: 'app-friends-login', component: LoginComponent},
   {path: 'app-friends-signup', component: SignupComponent}
];

/**
 * Authentication Routing Module
 */
@NgModule({
   imports: [
      RouterModule.forChild(ROUTES)
   ],
   exports: [
      RouterModule
   ]
})
export class AuthenticationRouting {
}

登录组件

组件是 Angular 应用程序中 UI(用户界面)的基本构建块。@Component装饰器用于将类标记为 Angular 组件。以下示例展示了使用@Component定义的登录组件,它包含SCSSSassy CSS)和模板文件:

import {Component} from '@angular/core';

@Component({
  selector: 'friends-login',
  styleUrls: ['./login.component.scss'],
  templateUrl: './login.component.html'
})
export class LoginComponent {
}

登录组件 HTML

登录组件 HTML 是一个视图,包含登录表单数据。以下示例展示了包含两个标签的简单 HTML;Angular 中的模板遵循与 HTML 相同的语法:

<h3>Login Component</h3>
<p>This is the Login component!</p>

登录组件 scss

在我们的项目中,我们使用 SASS语法优美的样式表),这是一个预处理器,将我们的 SCSS 文件转换为 CSS 文件。这有助于以简单和可读的格式组织我们的 CSS 元素。它提供了许多功能,如变量、嵌套、混合和继承,这些功能有助于以可重用的方式编写复杂的 CSS。我们将在创建功能模块时介绍这些功能。以下 SCSS 文件是一个简单的示例,其中黑色变量被外部化到变量 :black 中:此变量可以与其他 CSS 元素一起使用:

$black: #000;

h1.title {
    color: $black;
}

登录组件规范

我们使用 Jasmine 测试框架来编写我们的单元测试用例。从开发初期开始编写测试用例是一个非常好的习惯。测试用例应该是我们开发过程的一部分。不幸的是,我们通常在生产后或在生产过程中看到大量问题时才编写测试用例。我们知道在开发初期编写测试用例需要时间,但它在后期可以节省大量时间。以前,软件行业交付新产品的基础是 市场时间,但现在焦点已经转移到 有质量的市场时间。因此,当编写测试用例成为开发的一部分时,我们就可以更快地编写测试用例,并按时交付高质量的产品。我们希望这解释了在开发过程中这一过程的重要性。

Chapter 10, *Unit Testing Our Application*: 
describe('LoginComponent tests', () => {
 });

当我们的测试用例准备就绪后,我们可以使用 npm 执行测试用例:

$npm test

认证服务

在 Angular 中,将 UI 逻辑与业务逻辑分离的最佳方式是使用服务。它提供了一个执行特定逻辑的类。在我们的项目中,我们通过服务中的 HTTP 与 Firebase 数据库交互,以便将重负载转移到服务上。在认证服务中,我们执行登录、注册和注销等操作。

当我们在一个定义良好的类中明确分离责任时,我们遵循 SRP单一责任原则)。SRP 在下一节中将有更详细的解释。我们在服务上使用 Injectable 装饰器,以便 Angular 框架可以将此服务注入到任何组件或服务中,我们不需要创建对象。这种模式被称为依赖注入。

以下是一个 authentication.service.ts 的示例:

import { Injectable } from '@angular/core';

/**
 * Authentication service
 *
 */
@Injectable()
export class AuthenticationService {
}

日期管道

管道有助于在 Angular 应用程序中编写实用函数。

我们将通过扩展 PipeTransform 并重写超类中的 transform 方法来创建一个管道,以提供我们的功能。我们的应用程序的日期管道被装饰了管道元数据。我们将使用 name 标签为我们的管道提供一个名称,该标签用于在模板中转换数据。

以下是一个 friendsdate.pipe.ts 的示例:

import {Pipe, PipeTransform} from '@angular/core';

/**
 * It is used to format the date
 */
@Pipe({
   name: 'friendsdate'
})
export class FriendsDatePipe implements PipeTransform {

   transform(dateInMillis: string) {
   }
}

以下是在模板中使用管道的方式:

<div>{{<timeInMillis>: friendsdate}} </div>

常见原则

原则帮助我们以更好的方式设计我们的应用程序,并且这些原则并不特定于任何编程语言或平台。在本节中,我们将介绍 Angular 应用程序中最常用的几个原则:

  • 单一职责原则:此原则指出,一个类应该只有一个改变的理由,并且责任应该封装在类中。我们将此原则应用于所有我们的组件、服务或路由器。这有助于代码的可维护性和可测试性。例如,组件负责管理与视图相关的逻辑。其他复杂的服务器逻辑委托给服务。我们主要在管道中实现实用函数。

  • 单文件:将组件或服务定义在每个文件中是一种良好的实践。这使得我们的代码更容易阅读、维护和跨模块导出。

  • 小函数:始终编写具有明确目的的小函数是很好的。这确实使使用您代码的其他开发者的生活变得更简单,因为新开发者可以轻松阅读和理解代码。

  • LIFT 原则:始终遵循提升原则,以便您可以快速定位代码,识别代码,保持结构扁平,并尝试保持简洁。这确保了应用程序中结构的一致性。这极大地提高了开发者的效率。

  • 按功能划分文件夹:在应用程序的开发过程中,我们遵循了这一原则,并将所有功能分组到功能模块中。

  • 共享功能:我们创建一个共享文件夹来声明所有可重用组件、指令和管道,并且这些在其他文件夹之间共享。创建SharedModules以便在整个应用程序中引用。

编程标准指南

编程标准是我们编写应用程序时遵循的共同模式。这使得我们的代码在应用程序中保持一致。当多个开发者共同开发同一应用程序时,这非常有用。以下是一些编程标准:

  • 命名约定:对于我们的文件名、类名等,保持一致的命名约定非常重要。从可维护性和可读性的角度来看,这是必不可少的。这也有助于更快地理解代码,并使调试变得更容易。

    • 文件名:在 Angular 中,建议的文件名模式为 feature.type.ts。例如,认证组件文件的名称为authentication.component.ts

    • 类名:类名遵循驼峰命名法,并从文件名中获取名称。例如,认证组件文件的类名为AuthenticationComponent

    • 方法和属性名称:我们遵循驼峰命名法来命名类方法和属性。我们不使用下划线作为私有变量的前缀。

    • 选择器名称:组件中的选择器名称是连字符分隔的,并且所有字符都是小写。我们的登录组件的名称如下所示:

      selector: 'app-friends-login'
      

      管道名称:管道以其功能命名。例如,货币管道的名称是 'currency',如下所示:

      name: 'currency'
      
  • 常量:常量是使用 const 关键字声明的最终变量,其值只赋值一次。常量变量以大写形式声明,如下所示:

    export const USER_DETAILS_CHILD = 'user-details';
    
  • 成员序列:所有成员变量首先声明,然后是方法。私有成员变量以公共成员变量命名。

摘要

在本章中,我们使用 Angular CLI 命令创建了我们的第一个 Angular 应用程序。我们浏览了 Angular 应用程序中的重要文件。我们学习了常见的 Angular 术语,然后了解了我们的应用程序项目结构和内部结构。最后,我们涵盖了常见的原则和编码指南,这些我们将作为我们开发过程的一部分来遵循。

在下一章中,我们将创建我们的第一个注册页面。我们将应用本章中的许多概念来创建我们的注册组件。

第二章:创建注册组件

在本章中,我们将开始我们的应用程序开发之旅。我们将构建一个注册页面。在这个过程中,我们还将探索 Firebase 的不同功能。我们将查看如何在控制台中启用 Firebase 身份验证,这是与身份验证功能模块交互所必需的。Firebase 支持许多身份验证机制,但在这个项目中,我们将启用电子邮件/密码身份验证。然后,我们将继续构建注册表单模板。我们将在注册组件中添加一个功能。在这个过程中,我们还将处理错误场景。这将使我们的应用程序更加健壮,减少错误的可能性。

在本章中,我们将涵盖以下主题:

  • 在 Firebase 中启用身份验证

  • AngularFire2 简介

  • 创建身份验证模块

  • 创建服务

  • 定义领域模型

  • 创建注册模板

  • 错误处理

  • 创建自定义警报对话框

  • 创建注册组件

在 Firebase 中启用身份验证

在我们的应用程序开发中,第一步是在 Firebase 门户中启用身份验证。我们已经在上一章中创建了我们的 Firebase 项目。按照以下步骤启用 Firebase 身份验证:

  1. 在 Firebase 控制台中打开你朋友的工程。

  2. 在左侧面板上展开 “开发”。

  3. 点击 “身份验证”。

  4. 点击右侧面板上的 “登录方法” 选项卡。

  5. 点击 “电子邮件/密码” 项并启用它。

Firebase 中启用的电子邮件/密码身份验证将如下截图所示:

这就是我们启用 “电子邮件/密码” 身份验证模式所需做的所有事情。Firebase 还支持其他身份验证模式。在这本书中,我们将仅实现电子邮件/密码身份验证。你可以作为练习与其他身份验证一起工作。

针对不同用户集需要不同的身份验证模式。了解其他身份验证模式是很好的。其他支持的模式如下:

  • 电话:这是一种简单的身份验证形式,不需要从用户那里获取太多信息。只需用户的手机号码就足以进行用户身份验证。这种模式在移动应用程序中变得很受欢迎。

  • Google:启用 Google 身份验证是很好的,因为大多数目标用户都有 Google 账户。在这种模式下,身份验证是通过 Google 凭据进行的。

  • Facebook:这与 Google 身份验证类似。唯一的不同之处在于,身份验证是通过 Facebook 凭据而不是 Google 凭据进行的。

  • Twitter:这与前面的身份验证类似,但身份验证是通过你的 Twitter 账户进行的。

  • GitHub:这对开发者社区非常有帮助。他们有 GitHub 账户,你不需要提供任何个人账户信息。

  • 匿名:这种认证对于用户不想注册的应用程序很有用。这主要用在电子商务应用程序上,用户可以使用这种认证浏览产品。

在所有前面的认证中,Firebase 生成一个唯一的 ID,称为用户 UID,用户使用 UID、标识符等在 Firebase 认证中注册。相同的 UID 也用于在 Firebase 数据库中注册用户,包含更多信息,如电子邮件、UID、姓名和电话号码。

介绍 angularfire2

angularfire2 是 Angular 和 Firebase 的官方库。我们将在我们的应用程序中使用这个库。这个库提供了以下功能:

  • 它利用了 RxJS、Angular 和 Firebase 的力量

  • 它提供了与 Firebase 数据库和认证交互的 API

  • 它实时同步数据

  • 它提供了与 AngularFirestore 交互的 API

更多详情,请参阅 github.com/angular/angularfire2

创建认证模块

在本节中,我们将使用 Angular CLI 命令创建我们的第一个模块。按照以下步骤创建认证模块:

  1. 导航到您的项目 src 文件夹并执行模块 CLI 命令,如下所示:
$ cd <your directory>\friends\src\app
$ ng g module authentication --routing`

前面的命令创建了 authentication.module.tsauthentication.routing.ts 文件的骨架。您可以根据 Angular CLI 命令创建其他组件。

  1. 在应用程序模块中添加认证模块,并配置 Angular 和 Firebase 相关组件,如下所示;查看 app.module.ts 文件:
import {NgModule} from '@angular/core';
import {AppComponent} from './app.component';
import {FormsModule} from '@angular/forms';
import {BrowserModule} from '@angular/platform-browser';

import {AuthenticationModule} from './authentication/authentication.module';
import {AngularFireModule} from 'angularfire2';
import {environment} from './environments/environment';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {AngularFireAuth} from 'angularfire2/auth';
import {AngularFireDatabase} from 'angularfire2/database';
import {CommonModule} from '@angular/common';
import {RouterModule} from '@angular/router';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    CommonModule,
    BrowserModule,
    FormsModule,
    AngularFireModule.initializeApp(environment.firebase),
    BrowserAnimationsModule,
    RouterModule.forRoot([]),
    AuthenticationModule
  ],
  providers: [
    AngularFireAuth,
    AngularFireDatabase,
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
}
  1. 在应用程序组件模板中添加占位符,如下所示的 app.component.html 文件:
<router-outlet></router-outlet>

创建服务

Angular 应用程序中的服务包含核心业务逻辑。作为注册组件的一部分,我们创建了两个服务:

  • 认证服务

  • 用户服务

认证服务

我们在前一章中介绍了认证服务,所以我们将向认证服务中添加更多方法。在创建 Angular 服务时,请记住以下步骤。

  1. 编写服务:AngularFire2 有一个 AngularFireAuth 类。这个类提供了对 firebase.auth.Auth 的访问,它有 createUserWithEmailAndPassword API 用于在 Firebase 中注册。
import {Injectable} from '@angular/core';
import {AngularFireAuth} from 'angularfire2/auth';

/**
 * Authentication service
 *
 */
@Injectable()
export class AuthenticationService {

  /**
   * Constructor
   *
   * @param {AngularFireAuth} angularFireAuth provides the 
     functionality related to authentication
   */
  constructor(private angularFireAuth: AngularFireAuth) {
  }

  public signup(email: string, password: string): Promise<any> {
    return 
    this.angularFireAuth.auth.createUserWithEmailAndPassword(email, 
    password);
  }
}
  1. 注册服务:在使用 API 之前,我们需要在认证模块中包含此服务。查看以下详细信息。
providers tag to include the AuthenticationService.
@NgModule({
    imports: [
       ...    
    ],
    declarations: [
        ...
    ],
    providers: [
        AuthenticationService
    ]
})
...
  1. 注入和使用服务:一旦服务注册,我们将在构造函数中声明它。实例将由 Angular 框架注入。最后,注册组件使用 AuthenticationServicesignup() API 对 Firebase 进行用户认证。

以下示例显示了在注册组件中声明 AuthenticationService

constructor(private authService:  AuthenticationService) {}

用户服务

除了将新用户注册到 Firebase 身份验证之外,我们还需要在 Firebase 数据库中存储更多关于用户的信息,例如手机、电子邮件等。

用户服务用于在 Firebase 数据库中输入用户信息。我们使用angularfire2中的AngularFireDatabase将用户信息设置到 Firebase 数据库中。此类在UserService类的构造函数中注入:

constructor(private fireDb: AngularFireDatabase) {}

AngularFireDatabase提供了一个对象方法,它接受 Firebase 数据库中数据的路径;这返回AngularFireObject以将数据设置到 Firebase:

public addUser(user: User): void {
    this.fireDb.object(`${USERS_CHILD}/${user.uid}`).set(user);
}

到目前为止,完整的user.service.ts文件如下所示:

import {Injectable} from '@angular/core';
import {AngularFireDatabase} from 'angularfire2/database';
import {User} from './user';
import 'firebase/storage';
import {USERS_CHILD} from './database-constants';

/**
 * User service
 *
 */
@Injectable()
export class UserService {

  /**
   * Constructor
   *
   * @param {AngularFireDatabase} fireDb provides the functionality for 
     Firebase Database
   */
  constructor(private fireDb: AngularFireDatabase) {
  }

  public addUser(user: User): void {
    this.fireDb.object(`${USERS_CHILD}/${user.uid}`).set(user);
  }
}

如前所述,我们需要在authentication.module.ts中注册该服务:

@NgModule({
    imports: [
       ...    
    ],
    declarations: [
        ...
    ],
    providers: [
        AuthenticationService,
        UserService
    ]
})

定义域模型

对象模型包含有关我们应用程序关键域的信息。这有助于以更可读和结构化的方式存储我们的非结构化数据。在我们的应用程序中,我们将介绍许多对象模型来存储我们的域信息。

当我们注册新用户时,我们将用户详细信息存储在 Firebase 数据库中,并创建了一个具有与用户相关的属性的用户模型来存储用户数据。所有属性都声明为具有类型的成员变量,因为 TypeScript 支持变量的类型,如下面的user.ts所示:

export class User {

   email: string;

   name: string;

   mobile: string;

   uid: string;

   friendcount: number;

   image: string;
}

TypeScript 是 JavaScript 的类型版本,并编译成 JavaScript。在 TypeScript 中编程更容易、更快。您可以在www.typescriptlang.org/docs/home.html了解更多信息。

创建注册模板

注册模板表示网页视图。它提供了用于输入用户输入的表单元素。它还处理模板中的用户错误。在本节中,我们将介绍模板中使用的所有标签。模板中使用的错误处理将在下一节中介绍。

FormModule

我们使用FormModule中的<form>来创建注册模板。ngForm包含注册表单数据。这是检索用户填写数据所必需的。我们将此表单数据传递给注册组件中的onSignup()方法。此用户填写的数据可以通过例如signupFormData.value.email来检索,如下所示:

<form name="form" (ngSubmit)="onSignup(signupFormData)" #signupFormData='ngForm'></form>

Bootstrap 元素

我们使用 bootstrap 元素来设计我们的注册表单。我们在index.html文件中包含了 bootstrap 和其他依赖库,如 tether 和 jQuery,如下所示:

<!DOCTYPE html>
<html>
<head>
  <meta charset=UTF-8>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script src="img/tether.min.js"></script>
  <link rel="stylesheet" 
  href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/
  css/bootstrap.min.css">
  <script src="img/
  jquery.min.js"></script>
  <script src="img/
  js/bootstrap.min.js"></script>
  <title>Friends - A Social App</title>
  <base href="/">
</head>
<body>
<app-friends>
  Loading...
</app-friends>
</body>
</html>

Bootstrap 是一个用于开发 HTML、CSS 和 JavaScript 的开源工具包。有关更多详细信息,请参阅getbootstrap.com/docs/4.0/getting-started/introduction/

使用的元素如下:

  • 网格样式:我们使用 bootstrap 网格样式将表单对齐到中间,如下所示:
<div class="col-md-6 col-md-offset-3"></div>
  • 警告:此元素用于在用户对元素执行任何操作时提供上下文消息,如下所示:
<div class="alert alert-danger"></div>

更多关于 Bootstrap 的详细信息,请参阅getbootstrap.com/docs/4.0/components/alerts/

Angular 数据绑定

如前一章所述,Angular 支持多种数据绑定方式。在这种情况下,我们将支持单向绑定使用 (ngModel)='name'

现在看看完整的 signup.component.html 文件:

<div class="col-md-6 col-md-offset-3">
    <h2>Signup</h2>
 <app-error-alert *ngIf="showError" [errorMessage]="errorMessage">  
 </app-error-alert>
    <form name="form" (ngSubmit)="onSignup(signupFormData)" 
     #signupFormData='ngForm'>
        <div class="form-group">
            <label for="name">Name</label>
            <input type="text" class="form-control" name="name" 
             (ngModel)="name" #name="ngModel" required id="name"/>
            <div [hidden]="name.valid || name.pristine"
                 class="alert alert-danger">
                Name is required
            </div>
        </div>
        <div class="form-group">
            <label for="email">Email</label>
            <input type="text" class="form-control" name="email" 
             (ngModel)="email" #email="ngModel"
                   required
                   pattern="^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*
                   (\.\w{2,3})+$"
                   id="email"/>
            <div [hidden]="email.valid || email.pristine"
                 class="alert alert-danger">
                <div [hidden]="!email.hasError('required')">Email is 
                 required</div>
                <div [hidden]="!email.hasError('pattern')">Email format 
                 should be
                    <small><b>codingchum@gmail.com</b></small>
                </div>
            </div>
        </div>
        <div class="form-group">
            <label for="password">Password</label>
            <input type="password" class="form-control" name="password" 
            (ngModel)="password" #password="ngModel" required 
             id="password"/>
            <div [hidden]="password.valid || password.pristine"
                 class="alert alert-danger">
                Password is required
            </div>
        </div>
        <div class="form-group">
            <label for="name">Retype Password</label>
            <input type="password" class="form-control" 
             id="confirmPassword"
                   required
                   passwordEqual="password"
                   (ngModel)="confirmPassword" name="confirmPassword"
                   #confirmPassword="ngModel">
            <div [hidden]="confirmPassword.valid || 
            confirmPassword.pristine"
                 class="alert alert-danger">
                Passwords did not match
            </div>
        </div>
        <div class="form-group">
            <label for="mobile">Mobile</label>
            <input type="text" class="form-control" name="mobile" 
             (ngModel)="mobile" #mobile="ngModel"
                   required
                   pattern="[0-9]*"
                   minlength="10"
                   maxlength="10"
                   id="mobile"/>
            <div [hidden]="mobile.valid || mobile.pristine"
                 class="alert alert-danger">
                <div [hidden]="!mobile.hasError('minlength')">Mobile 
                 should be 10 digit</div>
                <div [hidden]="!mobile.hasError('required')">Mobile is 
                 required</div>
                <div [hidden]="!mobile.hasError('pattern')">Mobile  
                 number should be only numbers</div>
            </div>
        </div>
        <div class="form-group">
            <button type="submit" class="btn btn-success" 
            [disabled]="!signupFormData.form.valid">SIGNUP</button>
            <a [routerLink]="['/app-friends-login']" class="btn btn-
            link">CANCEL</a>
        </div>
    </form>
</div>

错误处理

错误处理是创建良好应用程序的重要步骤。它使我们的产品更加健壮和抗错误。

我们使用 Angular 验证器来验证用户输入的准确性和完整性。对于用户输入,我们可以使用一个常见的内置验证器或创建我们自己的自定义验证器。

请注意,这些验证器是内置在 HTML 中,而不是 Angular 本身。

以下是一些使用的内置验证器:

  • 必需:这使得输入字段成为必填项。

  • 最小长度:这定义了用户输入的下限。例如,我们可以将手机号码的最小长度限制为 10 位数字。

  • 最大长度。这定义了用户输入的上限。例如,我们可以将手机号码的最大长度限制为 10 位数字。

  • 模式:我们可以为用户输入创建一个模式。例如,手机号码只接受数字作为输入。

内置验证器可以在注册模板中使用,如下所示:

<div class="form-group">
    <label for="mobile">Mobile</label>
    <input type="text" class="form-control" name="mobile" 
     [(ngModel)]="model.mobile" #mobile="ngModel"
           required
           pattern="[0-9]*"
           minlength="10"
           maxlength="10"
           id="mobile"/>
    <div [hidden]="mobile.valid || mobile.pristine"
         class="alert alert-danger">
        <div [hidden]="!mobile.hasError('minlength')">Mobile should be 
         10 digit</div>
        <div [hidden]="!mobile.hasError('required')">Mobile is 
         required</div>
        <div [hidden]="!mobile.hasError('pattern')">Mobile number 
         should be only numbers</div>
    </div>
</div>

自定义验证器是我们应用程序用例的定制验证器。在我们的应用程序中,我们将获取一个密码并让用户重新输入密码以进行确认,为了验证这两个密码,我们将创建一个自定义密码验证器。

要创建我们的验证器,我们将从我们的表单模块扩展 Validator,这提供了一个 validate 方法来编写我们的自定义实现:

import {Directive, forwardRef, Attribute} from '@angular/core';
import {Validator, AbstractControl, NG_VALIDATORS} from '@angular/forms';

@Directive({
    selector: '[passwordEqual][formControlName],[passwordEqual]
    [formControl],[passwordEqual][ngModel]',
    providers: [
        {provide: NG_VALIDATORS, useExisting: forwardRef(() => 
         PasswordEqualValidator), multi: true}
    ]
})
export class PasswordEqualValidator implements Validator {
    constructor(@Attribute('passwordEqual') public passwordEqual: 
    string) {
    }

    validate(control: AbstractControl): { [key: string]: any } {
        let retypePassword = control.value;

        let originalPassword = control.root.get(this.passwordEqual);

        // original & retype password is egual
        return (originalPassword && retypePassword !== 
        originalPassword.value)
            ? {passwordEqual: false} : null;
    }
}

我们将验证器包含在我们的模块中,我们的authentication.module.ts看起来如下:

@NgModule({
    imports: [
       ...    
    ],
    declarations: [
        PasswordEqualValidator
    ],
    providers: [
        ...
    ]
})
...

最后,我们在注册模板中使用PasswordEqualValidatorpasswordEqual选择器来确认密码,如下面的代码所示:

<input type="password" class="form-control" id="confirmPassword"
       required
       passwordEqual="password"
       [(ngModel)]="model.confirmPassword" name="confirmPassword"
       #confirmPassword="ngModel">

Firebase 错误

一旦用户提供了正确的输入并点击了 SIGNUP 按钮,我们就可以调用 signup() 方法,并将用户导向其个人资料页面。如果任何用户输入有误,Firebase API 会将错误抛给应用程序,我们需要在我们的应用程序中处理它。

createUserWithEmailAndPassword of AngularFireAuth 会抛出以下错误:

  • auth/email-already-in-use:当用户在注册时提供了一个已使用的电子邮件地址时,会抛出此错误。

  • auth/invalid-email:当电子邮件地址无效时,会抛出此错误。

  • auth/operation-not-allowed:当我们为用户创建注册时,如果未启用 Firebase 身份验证,则会抛出此错误。大多数情况下,这发生在开发期间。

  • auth/weak-password:当提供的密码较弱时,会抛出此错误。

当我们调用注册 API 时,auth 返回 Promise<any>。这个类有 thencatch 方法用于成功和失败场景。在 catch 块中,我们读取错误信息并在警报对话框中显示错误。Firebase 错误信息有一个可读的错误消息,所以我们不需要将错误代码与消息映射。我们还可以创建自定义的警报对话框并显示错误消息。在下一节中,我们将创建自定义错误警报并将其集成到注册模板中。

onSignup(signupFormData): void {   this.authService.signup(signupFormData.value.email, signupFormData.value.password).then((userInfo) => {
        ...
    }).catch((error) => {
        this.showError = true;
        this.errorMessage = error.message;
    });
}

创建自定义警报对话框

在本节中,我们将创建一个警报对话框组件。这个组件用于显示错误消息。它是一个可重用的组件,这意味着它可以在应用程序的任何地方使用。我们需要遵循以下步骤来创建和配置我们独立的警报对话框:

  • 创建组件:这与创建任何其他组件相同。我们提供了 @Input errorMessage:any 绑定以从其他集成组件接收错误消息。此消息将在注册页面上显示。

这里是完整的 error-alert.component.ts:

import {Component, Input} from '@angular/core';

@Component({
   selector: 'app-error-alert',
   templateUrl: './error-alert.component.html',
   styleUrls: ['./error-alert.component.scss']
})
export class ErrorAlertComponent {

   @Input() errorMessage: any;

}
  • 创建模板:我们使用了来自 bootstrap 组件的警报。花括号中的 errorMessage 变量接受文本并在注册页面上显示错误消息。

这里是完整的 error-alert.component.html:

<div class="error-alert-container">
   <div class="alert alert-danger fade show" role="alert">
      {{errorMessage}}
   </div>
</div>
  • 创建样式表:我们设置了 topbottom 边距以正确对齐警报。

这里是完整的 error-alert.component.scss:

.error-alert-container {
    margin-top: 24px;
    margin-bottom: 8px;
}
  • 在注册模板中配置:一旦我们的错误警报准备就绪,我们就可以将其与其他组件集成。在本章中,我们将错误警报与我们的注册组件集成。这包含 *ngIf 指令以启用错误警报,并将错误消息绑定到显示来自注册组件的文本。

这里是包含错误警报的修改后的 signup.component.html 文件,如下所示:

<div class="col-md-6 col-md-offset-3">
    <h2>Signup</h2>
   <app-error-alert *ngIf="showError" [errorMessage]="errorMessage">  
   </app-error-alert>
 ...
</div>
  • 分配错误消息:我们创建了两个成员变量:errorMessageshowError。这些变量在 onSignup() 方法中启用,当发生错误时。errorMessageerror.message 分配。

这里是包含错误信息的完整 signup.component.ts:

export class SignupComponent {

   errorMessage: string;

   showError: boolean;

   onSignup(signupFormData): void {    
   this.authService.signup(signupFormData.value.email, 
   signupFormData.value.password).then((userInfo) => {
         // Register the new user
         ...
      }).catch((error) => {
         this.showError = true;
         this.errorMessage = error.message;
      });
   }
}

在新用户注册过程中,如果您仅输入一个字符的密码,注册页面中的错误如下所示:

创建注册组件

注册组件是一个控制器,它用于响应用户的操作,例如注册或取消。它注入以下两个服务:

  • 身份验证服务:它提供与身份验证相关的功能,例如登录、注册和注销

  • 用户服务:它与 Firebase 数据库交互以存储额外的用户信息,例如手机号码和姓名。

注册组件的构造函数接受身份验证和用户服务,如下所示:

constructor(private authService: AuthenticationService, private userService: UserService) {
}

当用户点击“注册”按钮时,将调用onSignup方法。它接受表单数据作为参数,其中包含用户输入的信息。将检索电子邮件和密码,并将它们传递给身份验证服务的signup方法。在成功注册后,我们将从表单数据中检索其他信息,并将其存储在用户域模型中。最后,我们将这个新创建的User对象传递给用户服务,并在 Firebase 数据库中注册:

在成功注册后,用户信息包含 UID。这是特定用户的唯一标识符。它用作在 Firebase 数据库中存储数据的指示器。这也用于从数据库中检索用户信息。

onSignup(signupFormData): void {
   this.authService.signup(signupFormData.value.email, 
   signupFormData.value.password).then((userInfo) => {
      // Register the new user
      const user: User = new User(signupFormData.value.email,
         signupFormData.value.name, signupFormData.value.mobile, 
         userInfo.uid, 0, '');
      this.writeNewUser(user);
   }).catch((error) => {
      this.showError = true;
      this.errorMessage = error.message;
   });
}

private writeNewUser(user: User): void {
   this.userService.addUser(user);
}

在成功注册后,Firebase 身份验证将具有以下条目:

Firebase 数据库将具有以下条目:

这是到目前为止完整的signup.component.ts文件:

import {Component} from '@angular/core';
import {User} from '../../services/user';
import {AuthenticationService} from '../../services/authentication.service';
import {UserService} from '../../services/user.service';

@Component({
   selector: 'app-friends-signup',
   styleUrls: ['signup.component.scss'],
   templateUrl: 'signup.component.html'
})
export class SignupComponent {

   errorMessage: string;

   showError: boolean;

   constructor(private authService: AuthenticationService,
            private userService: UserService) {
   }

   onSignup(signupFormData): void {
      this.authService.signup(signupFormData.value.email, 
      signupFormData.value.password).then((userInfo) => {
         // Register the new user
         const user: User = new User(signupFormData.value.email,
            signupFormData.value.name, signupFormData.value.mobile, 
            userInfo.uid, 0, '');
         this.writeNewUser(user);
      }).catch((error) => {
         this.showError = true;
         this.errorMessage = error.message;
      });
   }

   private writeNewUser(user: User): void {
      this.userService.addUser(user);
   }

最后,按照以下方式在身份验证路由模块中注册signupComponent

import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {SignupComponent} from './signup/signup.component';

export const ROUTES: Routes = [
  {path: 'app-friends-signup', component: SignupComponent}
];

/**
 * Authentication Routing Module
 */
@NgModule({
  imports: [RouterModule.forChild(ROUTES)],
  exports: [RouterModule]
})
export class AuthenticationRouting {
}

现在,使用http://localhost:4200/app-friends-signup在浏览器中查看您的组件,组件如下所示:

摘要

最后,我们构建我们的注册组件。本章的关键学习点是可注入的服务。我们创建了身份验证和用户服务。这些服务使用 Angular fire 库与 Firebase 身份验证和数据库服务进行交互。这些新服务通过依赖注入添加到注册组件中。该组件使用这些服务注册新用户并将他们添加到 Firebase 数据库中。我们内置并自定义了消息警报。

在下一章中,我们将创建一个登录组件并登录新注册的用户账户。在成功登录后,用户将被导向用户个人资料页面。

第三章:创建登录组件

在本章中,我们将构建我们的第二个组件。我们将创建一个登录页面,该页面将与注册页面类似。我们还将向服务中添加更多功能。我们将使用电子邮件/密码身份验证来登录用户。用户详情已在注册过程中添加到 Firebase 数据库中。我们将从 Firebase 检索用户详情并将它们传递到用户个人资料页面。我们还将处理常见的错误场景,因为这将加强这一概念。

本章将涵盖以下主题:

  • 将登录功能添加到现有服务中

  • 重复使用域模型

  • 创建登录模板

  • 登录错误处理

  • 创建登录组件

  • 重置密码

将登录功能添加到现有服务中

在上一章中,我们使用了电子邮件/密码身份验证并将我们的用户添加到 Firebase 数据库中。我们获得了将数据推送到 Firebase 的基本知识。在本节中,我们将登录用户并从 Firebase 检索用户详情。我们将在身份验证和用户服务中添加登录功能。

身份验证服务

在注册过程中,用户注册到 Firebase。AngularFireAuthsignInWithEmailAndPassword方法来登录用户。此方法返回firebase.Promise<any>。此类有thencatch方法来处理成功和失败场景。成功时,我们将用户重定向到用户个人资料页面,并在失败时显示错误消息。

考虑以下方法:

  • 登录:此方法验证用户并在登录成功时传递用户信息,如下所示:
public login(email: string, password: string): Promise<any> {
  return this.angularFireAuth.auth.signInWithEmailAndPassword(email, password);
}
  • 重置AngularFireAuth提供了一个重置密码的 API。Firebase 提供了密码重置的基础设施,例如密码电子邮件通知。我们只需在身份验证服务中调用resetPasswordAPI,如下所示:
public resetPassword(email: string): Promise<any> {
  return this.angularFireAuth.auth.sendPasswordResetEmail(email);
}

现在这是完整的authentication.service.ts

import {Injectable} from '@angular/core';
import {AngularFireAuth} from 'angularfire2/auth';

/**
 * Authentication service
 *
 */
@Injectable()
export class AuthenticationService {

  /**
   * Constructor
   *
   * @param {AngularFireAuth} angularFireAuth provides the 
     functionality related to authentication
   */
  constructor(private angularFireAuth: AngularFireAuth) {
  }

  public signup(email: string, password: string): Promise<any> {
    return 
    this.angularFireAuth.auth.createUserWithEmailAndPassword(email, 
    password);
  }

  public login(email: string, password: string): Promise<any> {
    return this.angularFireAuth.auth.signInWithEmailAndPassword(email, 
    password);
  }

  public resetPassword(email: string): Promise<any> {
    return this.angularFireAuth.auth.sendPasswordResetEmail(email);
  }

}

用户服务

在这里,我们介绍如何通过执行读取操作从 Firebase 检索值。AngularFire2AngularFireDatabase类,该类提供了以下两种方法从 Firebase 读取数据:

  • object:此方法检索 JSON 对象。它返回AngularFireObject<T>,该对象提供了valueChanges方法以返回 Observable。例如,如果我们想从 Firebase 获取用户对象,则使用此方法。

  • list:此方法检索 JSON 对象的数组。它返回AngularFireList<T>,该对象提供了valueChanges方法以返回包含对象的 Observable。例如,如果我们想获取在我们应用程序中注册的所有用户,则此方法非常有用。

一旦用户输入正确的凭据,我们就使用AngularFireDatabaseobject方法检索用户详情。

AngularFireDatabase类中的方法列表如下:

export declare class AngularFireDatabase {
    app: FirebaseApp;
    database: database.Database;
    constructor(app: FirebaseApp);
    list<T>(pathOrRef: PathReference, queryFn?: QueryFn): 
    AngularFireList<T>;
    object<T>(pathOrRef: PathReference): AngularFireObject<T>;
    createPushId(): string | null;
}

在我们的应用程序中,我们主要使用AngularFireDatabaselistobject方法。这些方法接受pathOrRef参数。list方法接受一个额外的QueryFn参数。这些参数的目的是如下:

  • pathOrRef:此参数接受 Firebase 数据库中数据的路径。如下例所示,要访问用户数据,我们提供直到用户uid的路径。
public getUser(uid: string): Observable<User> {
   return this.fireDb.object<User>(`${USERS_CHILD}/${uid}`).valueChanges();
}

例如,如果我们想检索用户 ID 为qu3bXn9tTJR7j4PBp9LzBGKxHAe2的用户信息,那么在这种情况下路径是/users/qu3bXn9tTJR7j4PBp9LzBGKxHAe2

图片

  • QueryFn:这是list方法中的可选参数,根据过滤条件过滤列表。例如,如果我们想获取前三个注册的用户,那么我们使用limitToFirst查询。

现在是完整的user.service.ts

import {Injectable} from '@angular/core';
import {AngularFireDatabase} from 'angularfire2/database';
import {User} from './user';
import 'firebase/storage';
import {USERS_CHILD} from './database-constants';
import {Observable} from 'rxjs/Observable';

/**
 * User service
 *
 */
@Injectable()
export class UserService {

  /**
   * Constructor
   *
   * @param {AngularFireDatabase} fireDb provides the functionality for 
     Firebase Database
   */
  constructor(private fireDb: AngularFireDatabase) {
  }

  public addUser(user: User): void {
    this.fireDb.object(`${USERS_CHILD}/${user.uid}`).set(user);
  }

  public getUser(uid: string): Observable<User> {
    return this.fireDb.object<User>
    (`${USERS_CHILD}/${uid}`).valueChanges();
  }
}

重新使用域模型

一旦用户成功登录,我们将从我们的 Firebase 数据库中检索用户对象。在成功登录后,我们获取用户的字符串uid,并使用这个uid从 Firebase 数据库中用户节点检索用户详细信息。正如前文所述,我们以以下 JSON 格式获取数据:

{
  "email": "uttamagarwal.slg@gmail.com",
  "mobile": "9972022242",
  "name": "Uttam Agarwal",
  "uid": "qu3bXn9tTJR7j4PBp9LzBGKxHAe2"
}

此 JSON 对象需要映射到用户对象。当我们使用AngularFireDatabase从 Firebase 检索 JSON 对象时,我们在尖括号中提供类型对象<User>,这会将 JSON 映射到用户对象:

public getUser(uid: string): Observable<User> {
  return this.fireDb.object<User>(`${USERS_CHILD}/${uid}`).valueChanges();
}

构造函数接受分配给其成员变量的所有参数,如下所示;以下是完整的user.ts

export class User {

   email: string;

   name: string;

   mobile: string;

   uid: string;

   constructor(email: string,
            name: string,
            mobile: string,
            uid: string) {
      this.email = email;
      this.name = name;
      this.mobile = mobile;
      this.uid = uid;
   }
}

创建登录模板

登录模板是视图,这部分与注册模板相似。我们重用了注册中的电子邮件和密码元素。它包含以下三个部分:

  1. 输入表单:这是一个文本框,接受用户输入的值

  2. 提交操作:它触发带有登录表单数据的onLogin()方法到组件

  3. 导航:这部分将在第四章中详细说明,即组件之间的路由和导航,因此在这里我不会涉及这部分内容。

以下为完整的login.component.html

<div class="col-md-6 col-md-offset-3 container">
    <h2>Login</h2>
 <app-error-alert *ngIf="showError" [errorMessage]="errorMessage"></app-error-alert>
    <form name="form" (ngSubmit)="onLogin(loginFormData)" 
    #loginFormData='ngForm'>
        <div class="form-group">
            <label for="email">Email</label>
            <input type="text" class="form-control" name="email" 
            (ngModel)="email" #email="ngModel"
                   required
                   pattern="^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*
                           (\.\w{2,3})+$"
                   id="email"/>
                 <div [hidden]="email.valid || email.pristine"
                 class="alert alert-danger">
                <div [hidden]="!email.hasError('required')">Email is 
                 required</div>
                <div [hidden]="!email.hasError('pattern')">Email format 
                 should be
                    <small><b>codingchum@gmail.com</b></small>
                </div>
            </div>
        </div>
        <div class="form-group">
            <label for="password">Password</label>
            <input type="password" class="form-control" name="password" 
             (ngModel)="password" #password="ngModel"
                   required id="password"/>
            <div [hidden]="password.valid || password.pristine"
                 class="alert alert-danger">
                Password is required
            </div>
        </div>
        <div class="form-group">
            <button type="submit" id="login-btn" class="btn btn-
            success" [disabled]="!loginFormData.form.valid">
            LOGIN</button>
            <button routerLink="/app-friends-signup" data-tag="signup-
             tag" routerLinkActive="active" 
             class="btn btn-success">
                SIGNUP
            </button>
        </div>
    </form>
</div>

登录错误处理

如前一章所述,我们将处理用户输入和服务器错误。在登录组件中,用户输入错误与注册组件中的相同。在这个组件中,我们使用了相同的内置 Angular 错误。此错误消息有助于用户输入正确信息。

Firebase 错误

当用户登录到朋友的应用程序时,登录 Firebase API 会抛出错误。我们使用AngularFireAuthsignInWithEmailAndPassword()方法来注册用户。此 API 会抛出以下错误:

  • auth/invalid-email:正如其名所示,当用户提供一个无效的电子邮件地址时,会发生此错误。

  • auth/user-disabled:当注册用户账户在 Firebase 中被禁用时,将发生此错误。当注册用户不遵守应用程序的条款和条件时,需要此功能。然后,我们可以向用户显示有意义的消息。

您可以通过以下三个步骤禁用用户账户:

  1. 前往 Firebase。

  2. 在左侧面板转到 DEVELOP|认证。

  3. 在右侧面板上突出显示用户。点击溢出图标,然后选择禁用账户选项。

查看以下 Firebase 中的禁用账户选项:

图片

  • auth/user-not-found:当用户未在我们的应用程序中注册时,将发生此错误。在这种情况下,我们可以将用户直接导向注册页面。

  • auth/wrong-password:当密码不正确时,将发生此错误。在这种情况下,用户有两个选择:提供正确的密码或重置密码。

认证服务中的登录方法返回Promise<any>。我们在catch块中处理错误。

Promise 是任何异步操作的结果。在成功或失败操作之后,我们使用 Promise 对象检索存储的数据。

然后,我们将重用第二章中创建的“创建注册组件”错误警报,并显示错误:

onLogin(loginFormData): void {
   this.authService.login(loginFormData.value.email, 
   loginFormData.value.password).then((userInfo) => {
      ...
   }).catch((error) => {
      this.errorMessage = error.message;
      this.showError = true;
   });
}

当用户输入错误密码时,将显示以下自定义错误消息:

图片

创建登录组件

登录组件处理用户与 UI 的交互。它注入了三个服务以执行各种操作:

  • 认证服务:它提供登录 API 以供注册用户登录。

  • 用户服务:它提供了一种从 Firebase 数据库中检索用户详细信息的方法。

  • 路由器:此服务用于在应用程序的不同页面之间进行路由。在登录组件中,我们将使用此服务将用户路由到注册页面。这将在下一章中详细介绍。

服务被注入到登录组件的constructor中,如下所示:

constructor(
    private userService: UserService,
    private router: Router,
    private authService: AuthenticationService
) {}

登录组件也处理用户点击事件。当用户点击登录按钮时,将调用onLogin方法。此方法接受登录表单数据作为参数,包含用户输入的数据。我们通过loginFormData.value.emailloginFormData.value.password检索电子邮件和密码。这些数据被传递给认证服务进行登录。登录成功后,我们获取用户的uid,然后使用此uid从我们的 Firebase 数据库中检索用户详细信息。我们还在用户服务中缓存这些用户详细信息,以便在其他页面中将来参考:

onLogin(loginFormData): void {
   this.authService.login(loginFormData.value.email, 
   loginFormData.value.password).then((userInfo) => {
      // Login user
      const uid: string = userInfo.uid;
      this.getUserInfo(uid);
   }).catch((error) => {
      ...
   });
}

private getUserInfo(uid: string) {
   this.userService.getUser(uid).subscribe(snapshot => {
      this.user = snapshot;
   });
}

最后,我们看到了如何以注册用户身份登录。现在,唯一缺少的部分是密码恢复,我们将在下一节中介绍。

现在的完整login.component.ts如下所示:

import {Component} from '@angular/core';
import {User} from '../../services/user';
import {Router} from '@angular/router';
import {AuthenticationService} from '../../services/authentication.service';
import {UserService} from '../../services/user.service';
import {AngularFireAuth} from 'angularfire2/auth';

@Component({
  selector: 'app-friends-login',
  styleUrls: ['login.component.scss'],
  templateUrl: 'login.component.html',
})
export class LoginComponent {

  errorMessage: string;

  showError: boolean;

  private user: User;

  constructor(private userService: UserService,
              private router: Router,
              private authService: AuthenticationService,
              private angularFireAuth: AngularFireAuth) {
    this.angularFireAuth.auth.onAuthStateChanged(user => {
      if (user) {
        this.getUserInfo(user.uid);
      }
    });
  }

  onLogin(loginFormData): void {
    this.authService.login(loginFormData.value.email, 
    loginFormData.value.password).then((user) => {
      // Login user
      const uid: string = user.uid;
      this.getUserInfo(uid);
    }).catch((error) => {
      this.errorMessage = error.message;
      this.showError = true;
    });
  }

  private getUserInfo(uid: string) {
    this.userService.getUser(uid).subscribe(snapshot => {
      this.user = snapshot;
    });
  }
}

最后,我们在认证路由模块中注册LoginComponent

{path: 'app-friends-login', component: LoginComponent}

因此,将 URL http://localhost:4200/app-friends-login 粘贴到浏览器中,我们的登录组件看起来如下:

图片 00017

重置密码

在我们的应用程序中提供密码恢复选项是个好主意,这个过程提高了我们应用程序的可用性。令人兴奋的是,Firebase 提供了执行此操作所需的所有基础设施。我们将逐步在我们的应用程序中添加此功能。

添加模态模板

密码重置动作的第一步是获取用户电子邮件地址,我们将在此场景中使用模态框。模态框是一个出现在当前页面视图之上的弹出窗口/对话框。我们将使用模态框来显示弹出窗口以获取用户的电子邮件地址。

在登录 html 中添加模态模板:我们已经修改了 login.component.html 文件以添加重置密码按钮,如下面的代码所示:

<div id="password_reset" class="modal fade" role="dialog">
    <div class="modal-dialog modal-sm">
        <form name="form" (ngSubmit)="onReset(resetFormData)" 
         #resetFormData='ngForm'>
            <div class="modal-content">
                <div class="modal-header">
                  <h4 class="modal-title">Forgot Password?</h4>
                  <button type="button" class="close" data-
                   dismiss="modal">&times;</button>
                </div>
                <div class="modal-body">
                    <p>Please enter your registered email to sent you  
                      the password reset instructions.</p>

                    <div class="form-group">
                        <label for="reset_email">Email</label>
                        <input type="text" class="form-control" 
                         name="email" (ngModel)="email"              
                         #email="ngModel"
                               required
                               pattern="^\w+([\.-]?\w+)*@\w+([\.-]?
                               \w+)*(\.\w{2,3})+$"
                               id="reset_email"/>
                    </div>
                </div>
                <div class="modal-footer form-group">
                    <button type="submit" class="btn btn-default" 
                     [disabled]="!resetFormData.form.valid"
                    >Reset
                    </button>
                    <button type="button" class="btn btn-default" data-
                     dismiss="modal">Close</button>
                </div>
            </div>
        </form>
    </div>
</div>

当用户点击重置密码时,以下模态框会出现:

图片 00018

到目前为止,这是完整的 login.component.html

<div class="col-md-6 col-md-offset-3 container">
    <h2>Login</h2>
 <app-error-alert *ngIf="showError" [errorMessage]="errorMessage"></app-error-alert>
    <form name="form" (ngSubmit)="onLogin(loginFormData)" 
     #loginFormData='ngForm'>
        <div class="form-group">
            <label for="email">Email</label>
            <input type="text" class="form-control" name="email" 
             (ngModel)="email" #email="ngModel"
                   required
                   pattern="^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*
                   (\.\w{2,3})+$"
                   id="email"/>
            <div [hidden]="email.valid || email.pristine"
                 class="alert alert-danger">
                <div [hidden]="!email.hasError('required')">Email is 
                 required</div>
                <div [hidden]="!email.hasError('pattern')">Email format 
                should be
                    <small><b>codingchum@gmail.com</b></small>
                </div>
            </div>
        </div>
        <div class="form-group">
            <label for="password">Password</label>
            <input type="password" class="form-control" name="password" 
             (ngModel)="password" #password="ngModel"
                   required id="password"/>
            <div [hidden]="password.valid || password.pristine"
                 class="alert alert-danger">
                Password is required
            </div>
        </div>
        <div class="form-group">
            <button type="submit" id="login-btn" class="btn btn-
             success" [disabled]="!loginFormData.form.valid">
             LOGIN</button>
            <button routerLink="/app-friends-signup" data-tag="signup-
             tag" routerLinkActive="active" class="btn btn-success">
                SIGNUP
            </button>
        </div>
    </form>
    <button type="button" data-tag="password-reset-tag" class="btn btn-
     info" data-toggle="modal" data-target="#password_reset">
     Reset Password
    </button>
</div>
<div id="password_reset" class="modal fade" role="dialog">
    <div class="modal-dialog modal-sm">
        <form name="form" (ngSubmit)="onReset(resetFormData)" 
         #resetFormData='ngForm'>
            <div class="modal-content">
                <div class="modal-header">
                  <h4 class="modal-title">Forgot Password?</h4>
                  <button type="button" class="close" data-
                   dismiss="modal">&times;</button>
                </div>
                <div class="modal-body">
                    <p>Please enter your registered email to sent you 
                      the password reset instructions.</p>

                    <div class="form-group">
                        <label for="reset_email">Email</label>
                        <input type="text" class="form-control" 
                         name="email" (ngModel)="email" 
                         #email="ngModel"
                               required
                               pattern="^\w+([\.-]?\w+)*@\w+([\.-]?
                               \w+)*(\.\w{2,3})+$"
                               id="reset_email"/>
                    </div>
                </div>
                <div class="modal-footer form-group">
                    <button type="submit" class="btn btn-default" 
                     [disabled]="!resetFormData.form.valid"
                    >Reset
                    </button>
                    <button type="button" class="btn btn-default" data-
                     dismiss="modal">Close</button>
                </div>
            </div>
        </form>
    </div>
</div>

添加 onReset() 功能

下一步是在登录组件中添加重置方法。登录组件中的 onReset() 方法重置密码并将重置指令发送到注册的电子邮件。此电子邮件包含重置链接;当我们点击链接时,它会在另一个浏览器标签页中打开一个警报对话框以提供新密码。

我们在登录组件中添加了 onReset() 方法,如下所示:

onReset(resetFormData): void {
  this.authService.resetPassword(resetFormData.value.email).then(() =>   
  {
    alert('Reset instruction sent to your mail');
  }).catch((error) => {
    this.errorMessage = error.message;
    this.showError = true;
  });
}

在 Firebase 中编辑密码重置模板

Firebase 提供了更改电子邮件模板的选项。我们可以自定义电子邮件正文。在此应用程序中,我们使用默认模板,尽管您可以通过此过程进行更改:

  1. 前往 Firebase 身份验证

  2. 在右侧面板上,点击 TEMPLATES 选项卡

  3. 点击左侧面板上的密码重置选项

  4. 点击铅笔图标进行编辑

考虑以下 Firebase 中的密码重置模板:

图片 00019

摘要

在本章中,我们实现了登录组件并增强了身份验证和用户服务。登录模板和组件看起来与登录模板和组件相似。然后我们在应用程序中实现了密码重置功能。Firebase 为实现重置密码功能提供了必要的元素。我们还实现了我们应用程序中的第一个模态框。

在下一章中,我们将介绍不同组件之间的导航流程。我们还将添加 Angular 守卫以根据守卫条件限制或启用对组件的导航。

第四章:组件之间的路由和导航

在本章中,我们将涵盖 Angular 应用程序中的导航。我们将为我们的应用程序组件和认证实现路由器。我们还将涵盖组件视图的路由出口,并为认证创建一个路由模块。这将使我们的认证功能完全独立于其他模块。认证模块中的组件导航将由子路由模块处理。我们还将讨论 Angular 守卫,它将根据组件的条件限制导航。这增强了我们应用程序的安全性。最后,我们将深入探讨 Firebase 会话生命周期,并将用户重定向到登录或我的个人资料页面。

在本章中,我们将涵盖以下主题:

  • 在应用程序组件中启用路由

  • 为认证创建一个路由模块

  • 添加路由链接

  • 使用认证守卫

  • Firebase 会话生命周期

  • 到目前为止,我们的项目结构如下:

在应用程序组件中启用路由

在本节中,我们启用路由并创建应用程序的主要导航栏。启用路由的步骤如下:

  • 添加基本参考 URL:我们需要在index.html中添加一个基本元素,以告诉 Angular 路由器如何组合导航 URL。

我们在index.html的头部标签中添加了href

<base href="/">
  • 创建主要导航栏:大多数 Web 应用程序在页面顶部都有一个导航栏,用于在应用程序的不同页面之间导航。我们使用 Bootstrap 的nav bar组件为我们的朋友应用程序添加了一个主要导航栏。
  1. 第一步是在app.component.html中包含nav标签,如下所示:
<nav class="navbar navbar-expand-lg navbar-light bg-color"></nav>
  1. 第二步是使用ul标签创建项目列表:
<ul class="navbar-nav"></ul>
  1. 第三步是使用li标签创建项目。在我们的应用程序中,我们必须根据用户登录条件激活标签页,例如,当用户登录时,将显示用户个人资料标签页。我们遵循以下条件来激活导航栏中的标签页:

    • 用户未登录:我们只激活了关于和登录标签页。用户可以通过点击 SIGNUP 按钮导航到注册页面。

    • 用户已登录:我们停用了登录页面并激活了用户个人资料页面。

为了实现这些场景,我们将访问认证服务对象,检查用户登录状态,并激活标签页。我们使用ngIf指令进行条件检查:

<li class="nav-item" *ngIf="authenticationService?.isAuthenticated()"><a class="nav-link" routerLink="/app-friends-userprofile" routerLinkActive="active">My Profile</a></li>

模板中AuthenticationServiceauthenticationService?中的问号确保对象不为空。该对象在app.component.ts中定义,如下所示:

authenticationService: AuthenticationService;

constructor(private authService: AuthenticationService) {
   this.authenticationService = authService;
}

到目前为止的完整app.component.html如下所示:

<h1 class="title">Friends - A Social App</h1>
<div class="nav-container">
<nav class="navbar navbar-expand-lg navbar-light bg-color">
  <div class="collapse navbar-collapse" id="navbarNav">
    <ul class="navbar-nav">
      <li class="nav-item"
      *ngIf="authenticationService?.isAuthenticated()"><a 
      class="nav-link" routerLink="/app-friends-userprofile" 
      routerLinkActive="active">My Profile</a></li>
      <li class="nav-item" 
     *ngIf="authenticationService?.isAuthenticated()">
      <a class="nav-link" routerLink="/app-friends-userfriends" 
      routerLinkActive="active">Friends</a></li>
      <li class="nav-item" ><a class="nav-link" routerLink="/app-
      friends-about" routerLinkActive="active">About</a></li>
      <li class="nav-item" active 
     *ngIf="!authenticationService?.isAuthenticated()">
      <a class="nav-link" routerLink="/app-friends-login" 
      routerLinkActive="active">Login</a></li>
    </ul>
    <div class="form-container">
    <form class="form-inline my-2 my-lg-0">
      <input class="form-control mr-sm-2" type="text" 
      placeholder="Search friends..." aria-label="Search">
      <button class="btn btn-success my-2 my-sm-0" 
      type="submit">Search</button>
    </form>
    </div>
  </div>
</nav>
</div>
<router-outlet></router-outlet>
  • 创建页面未找到和关于组件PageNotFoundComponent组件用于显示错误 URL 的视图,而AboutComponent用于显示有关网站的信息。

这两个组件看起来很相似,都有一个带有 h2 标签的消息标题。在这些组件中,我们在组件注释中定义了模板和样式表。这是创建组件的一种简单方法。

以下为完整的 page-not-found.component.ts:

import {Component} from '@angular/core';

@Component({
  selector: 'app-friends-page-not-found',
  template: '<h2>Page not found</h2>'
})
export class PageNotFoundComponent {}

以下为完整的 about.component.ts

import {Component} from '@angular/core';

@Component({
  selector: 'app-friends-about',
  template: '<h2>Friends is a social app</h2>'
})
export class AboutComponent {}
  • 为应用组件创建路由:我们为主应用模块创建关于和页面未找到组件的路由。

我们使用以下路径为两个组件创建路由;双星号(**)是一个通配符,因此当用户提供任何错误的 URL 时,路由器将导航到“页面未找到”组件:

export const ROUTES: Routes = [
  {path: 'app-friends-about', component: AboutComponent, 
   pathMatch: 'full'},
  {path: '**', redirectTo: 'app-friends-page-not-found'},
];

我们在 Angular 的 RouterModule 中注入应用模块的路由;现在完整的 app.routing.ts 如下所示:

import {RouterModule, Routes} from '@angular/router';
import {NgModule} from '@angular/core';
import {PageNotFoundComponent} from './notfound/page-not-found.component';
import {AboutComponent} from './about/about.component';

export const ROUTES: Routes = [
  {path: 'app-friends-about', component: AboutComponent,
   pathMatch: 'full'},
  {path: '**', redirectTo: 'app-friends-page-not-found'},
];

@NgModule({
  imports: [
    RouterModule.forRoot(
      ROUTES
    )],
  exports: [
    RouterModule
  ]
})
export class AppRouting {
}

最后,我们将应用路由模块集成到我们的主应用模块中。我们在导入标签中添加 AppRouting,如下所示:

@NgModule({
    declarations: [
        AppComponent,
        PageNotFoundComponent,
        AboutComponent
    ],
    imports: [
        ...
        AuthenticationModule,
        AppRouting
    ],
    bootstrap: [AppComponent]
})
export class AppModule {}

当用户未登录时,导航栏将显示“关于”和“登录”标签页:

当用户登录时,导航栏将显示“我的资料”、“朋友”和“关于”标签页:

创建认证的路由模块

如前所述,我们将为每个功能模块构建单独的路由。在认证方面,我们有两个组件:

  • 登录组件

  • 注册组件

为了定义路由模块,我们需要创建导航路由常量:

export const ROUTES: Routes = [
   {path: 'app-friends-login', component: LoginComponent},
   {path: 'app-friends-signup', component: SignupComponent}
];

由于这些路由是主应用组件的子组件,我们将路由注入到子路由模块中:

RouterModule.forChild( ROUTES )

现在是完整的 authentication.routing.ts

import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {LoginComponent} from './login/login.component';
import {SignupComponent} from './signup/signup.component';

export const ROUTES: Routes = [
   {path: 'app-friends-login', component: LoginComponent},
   {path: 'app-friends-signup', component: SignupComponent}
];

/**
 * Authentication Routing Module
 */
@NgModule({
   imports: [
      RouterModule.forChild(ROUTES)
   ],
   exports: [
      RouterModule
   ]
})
export class AuthenticationRouting {}

创建路由模块后,我们将 AuthenticationRouting 模块包含在主认证模块中;这使得我们的认证模块独立于主应用模块。

下面是一个示例 authentication.module.ts

import { NgModule } from '@angular/core';
import { AuthenticationRouting } from './authentication.routing';

/**
 * Authentication Module
 */
@NgModule({
    imports: [
        ...
        AuthenticationRouting
    ],
    declarations: [
        ...
    ],
    providers: [
        ...
    ]
})
export class AuthenticationModule {
}

探索更多路由技术

在本节中,我们将探索我们应用程序中的两种导航方法:

  • 静态路由:在静态路由中,我们将在 HTML 模板中提供导航。Angular 路由器提供了一个指令来执行导航操作。我们将包含 routerLink 指令,并将路由注入到子路由模块中:导航路径。如以下代码所示,当您点击“注册”按钮时,Angular 框架使用 Router 导航到注册组件:
<button routerLink="/app-friends-signup" data-tag="signup-tag" routerLinkActive="active" class="btn btn-success">
SIGNUP
</button>
  • 动态路由:在动态路由中,我们使用 Angular 框架的 Router 组件。该实例在构造函数中使用依赖注入:
constructor(
    ...
    private router: Router,
){}

路由器提供了 navigateByUrl() 方法来导航到不同的组件;在以下场景中,登录成功后,我们导航到用户资料页面:

private navigateToUserProfile() {
  this.router.navigateByUrl('/app-friends-userprofile');
}

现在我们已经为我们的应用程序添加了导航;因此,在下一节中,我们将添加基于条件的导航守卫。

添加认证守卫

守卫是 Angular 中一个非常有用的功能,用于保护路由。它们提供了用于限制我们应用程序中资源的安全功能,这样用户就不能在没有适当权限的情况下消费资源。

在 Angular 中有不同类型的守卫:

  • CanActivate:这个用于决定路由是否可以被激活

  • CanActivateChild:这个用于决定子路由是否可以被激活

  • CanDeactivate:这个用于决定路由是否可以被停用

  • CanLoad:这个用于决定模块是否可以懒加载

我们将查看 CanActivate 守卫在认证中的示例。我们将在用户认证成功后允许用户访问用户资料和好友页面。这意味着用户在没有认证的情况下将不允许访问 http://localhost:4200/app-friends-userprofile,并将被重定向到登录页面。激活守卫涉及的步骤如下:

  1. 守卫条件:我们需要为激活守卫提供条件。在这个场景中,我们正在检查当前用户的状态。这个条件也用于应用程序组件中,根据条件显示各种标签页:
public isAuthenticated(): boolean {
    let user = this.angularFireAuth.auth.currentUser;
    return user ? true : false;
}

现在的完整 authentication.service.ts 如下所示:

import {Injectable} from '@angular/core';
import {AngularFireAuth} from 'angularfire2/auth';

/**
 * Authentication service
 *
 */
@Injectable()
export class AuthenticationService {

  /**
   * Constructor
   *
   * @param {AngularFireAuth} angularFireAuth provides the  
     functionality related to authentication
   */
  constructor(private angularFireAuth: AngularFireAuth) {
  }

  public signup(email: string, password: string): Promise<any> {
    return 
    this.angularFireAuth.auth.createUserWithEmailAndPassword(email, 
    password);
  }

  public login(email: string, password: string): Promise<any> {
    return 
    this.angularFireAuth.auth.signInWithEmailAndPassword(email, 
    password);
  }

  public resetPassword(email: string): Promise<any> {
    return this.angularFireAuth.auth.sendPasswordResetEmail(email);
  }

  public isAuthenticated(): boolean {
    const user = this.angularFireAuth.auth.currentUser;
    return user ? true : false;
  }

  public signout() {
    return this.angularFireAuth.auth.signOut();
  }
}
  1. 守卫实现:我们通过扩展 CanActivate 接口并重写 canActivate 方法来实现守卫。在这个方法中,当用户认证无效时,我们将导航到登录页面,这有助于根据守卫条件将路由导航到登录页面。

现在的完整 authentication.guard.ts 如下所示:

import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from '@angular/router';
import {AuthenticationService} from './authentication.service';

@Injectable()
export class AuthenticationGuard implements CanActivate {

  constructor(private authService: AuthenticationService,
              private router: Router) {
  }

  canActivate(route: ActivatedRouteSnapshot, state: 
  RouterStateSnapshot): boolean {
    const isLoggedIn: boolean = this.authService.isAuthenticated();
    if (!isLoggedIn) {
      this.router.navigateByUrl('/app-friends-login');
    }
    return isLoggedIn;
  }
}
  1. 向用户模块添加守卫:这个守卫是在用户模块中添加的。用户模块将在下一章中更详细地介绍。在这里,我们配置了用户路由模块中的守卫,以限制用户访问用户资料和好友页面:
import {AuthenticationGuard} from '../services/authentication.guard';

/**
 * User Module
 */
@NgModule({
    imports: [
        ...
    ],
    declarations: [
        ...
    ],
    providers: [
        AuthenticationGuard
    ]
})
export class UserModule {
}
  1. 添加守卫以保护组件:如以下代码所示,我们可以将这个守卫添加到任何需要此条件检查的组件中。我们将守卫添加到用户资料和用户好友列表组件中。这意味着这些页面受到非法访问的保护。

这是完整的 user-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import {UserProfileComponent} from './user-profile/user-profile.component';
import {AuthenticationGuard} from '../services/authentication.guard';

const ROUTES: Routes = [
  {path: '', redirectTo: '/app-friends-userprofile', pathMatch: 
  'full' , canActivate: [AuthenticationGuard]},
  {path: 'app-friends-userprofile', component: UserProfileComponent 
   , canActivate: [AuthenticationGuard]}

];

@NgModule({
  imports: [
    RouterModule.forChild(ROUTES)
  ],
  exports: [
    RouterModule
  ],
  providers: [
    AuthenticationGuard
  ]
})
export class UserRoutingModule { }
  1. 测试守卫:您可以通过将用户资料 URL (http://localhost:4200/app-friends-userprofile) 粘贴到浏览器中来测试这个守卫,用户将被重定向到登录页面进行认证。

Firebase 会话生命周期

Firebase 会持久化用户状态,因此即使用户刷新或重新启动页面,用户也始终处于登录状态,页面将被重定向到主页而不是登录页面。我们将涵盖 Firebase 会话生命周期的两种导航场景:

  • 用户令牌存在:在这种情况下,用户令牌仍然有效,用户将被重定向到用户资料页面。

AngularFireAuth.auth 提供了 onAuthStateChanged 方法来了解用户状态信息。我们订阅此方法,检查我们的用户,并将他们重定向到个人资料页面。

下面是 login.component.ts 的示例:

import {Component} from '@angular/core';
import {User} from '../../services/user';
import {Router} from '@angular/router';
import {AuthenticationService} from '../../services/authentication.service';
import {UserService} from '../../services/user.service';
import {AngularFireAuth} from 'angularfire2/auth';

@Component({
  selector: 'app-friends-login',
  styleUrls: ['login.component.scss'],
  templateUrl: 'login.component.html',
})
export class LoginComponent {

  ...

  private user: User;

  constructor(private userService: UserService,
              private router: Router,
              private authService: AuthenticationService,
              private angularFireAuth: AngularFireAuth) {
    this.angularFireAuth.auth.onAuthStateChanged(user => {
      if (user) {
        this.getUserInfo(user.uid);
      }
    });
  }

  private navigateToUserProfile() {
    this.router.navigateByUrl('/app-friends-userprofile');
  }

  private getUserInfo(uid: string) {
    this.userService.getUser(uid).subscribe(snapshot => {
      this.user = snapshot;
      this.navigateToUserProfile();
    });
  }
}
  • 用户令牌过期:在这种情况下,用户令牌过期,用户被重定向到登录页面。通常,用户令牌在以下条件下过期:

    • 用户清除浏览历史:用户令牌可以通过清除浏览器历史记录而过期。这将清除令牌,并将用户重定向到登录页面。

    • 用户更改密码:当用户更改密码时,用户令牌过期,并将他们重定向到登录页面。此场景将在下一章中介绍。

    • 用户注销:当用户注销时,用户令牌过期,并将他们重定向到登录页面。我们将在本节中介绍此场景。

我们已在用户资料页面实现了注销功能。用户资料组件将在下一章中更详细地介绍。在本节中,我们仅添加注销功能。

我们在用户资料模板中创建了一个按钮。当用户点击 LOGOUT 按钮时,用户会话将被清除,并将用户重定向到登录页面。

下面是 user-profile.component.html 的示例:

<div class="user-profile">
    <div class="user-profile-btn">
        <button type="button" (click)='onLogout()' class="btn btn-
         info">LOGOUT</button>
    </div>
</div>

当用户点击 LOGOUT 按钮时,UserProfileComponentonLogout() 方法被调用,我们在认证服务中调用 signout()

下面是目前 user-profile.component.ts 的示例:

import {Component, OnInit} from '@angular/core';
import {AuthenticationService} from '../../services/authentication.service';
import {Router} from '@angular/router';

@Component({
  selector: 'app-friends-userprofile',
  styleUrls: ['user-profile.component.scss'],
  templateUrl: 'user-profile.component.html'
})
export class UserProfileComponent {

  constructor(private authService: AuthenticationService,
              private router: Router) {
  }

  onLogout(): void {
    this.authService.signout().then(() => {
      this.navigateToLogin();
    });
  }

  navigateToLogin() {
    this.router.navigateByUrl('/app-friends-login');
  }
}

认证服务在 authentication.service.ts 中具有注销功能:

public signout(): Promise<any> {
    return this.angularFireAuth.auth.signOut();
}

目前我们的项目结构如下:

因此,我们来到了第一个认证模块的结尾。我们的认证模块将如下所示:

图片

摘要

在本章中,我们学习了页面视图之间的导航。我们在基础模块中启用了导航。我们使用路由链接指令创建我们的主要导航栏。我们看到了如何在模板中访问组件变量,并基于条件启用了导航项。我们构建了第一个守卫来限制用户导航到我们的用户资料页面,以便只有经过身份验证的用户才能查看页面。最后,我们介绍了 Firebase 会话生命周期,并基于用户令牌实现了导航。最后,我们探讨了认证模块的项目结构。

在下一章中,我们将构建更复杂的模块,并探索 Firebase 和 Angular 的更多功能。

第五章:创建用户个人资料页面

在本章中,我们将编写一个用户个人资料组件。我们将介绍 RxJSReactiveX),这是一个流行的异步编程库。在本节中,我们将使用 RxJS 的 Observable 将认证模块的组件传递到用户模块的组件。我们将使用这个传递的用户模型来填充用户个人资料组件。我们将编辑用户数据并更新 Firebase 认证和数据库。作为编辑的一部分,我们将实现一个可重用的编辑组件,该组件使用 bootstrap 模态来获取用户输入。最后,我们将看到当密码更改时 Firebase 会话令牌的生命周期。

在本章中,我们将涵盖以下主题:

  • RxJS 简介

  • 在模块组件之间传递数据

  • SASS 简介

  • 创建用户个人资料组件

  • 增强更新操作的服务

  • 创建一个编辑对话框组件

  • 更新操作的 Firebase 会话

RxJS 简介

RxJS 是一个流行的异步和基于事件的编程库。在本节中,我们将仅介绍这个库的基础知识,以便您理解使用这个库的真实原因。更多详情,您可以参考 RxJS 的官方站点 reactivex.io/rxjs/

这里是该库的一些关键术语:

  • 可观察的: 这是一个可以消费的值或事件的集合。例如,可观察的可以是一个数字数组的集合:
let observable = Rx.Observable.from([1, 2, 3]);
  • 订阅: 要从 可观察的 中读取数据,我们需要订阅,然后通过观察者传递事件或值:
let subscription = observable.subscribe(x => console.log(x));
  • subject: 这是 可观察的 的扩展,用于向多个观察者广播事件或值。这个例子将在我们应用程序的使用案例中介绍。这是对 RxJS 库的非常基础的理解。

在模块组件之间传递数据

我们在前一章中完成了我们的认证模块。在本节中,我们将介绍在模块组件之间传递数据,这是我们应用程序开发的一个重要部分。在实现一个网络应用程序时,我们总是面临如何从一个组件模块传递数据到另一个组件模块的问题。我们的初步想法是将模型存储在一个公共应用程序类中,例如单例类,然后在其他组件中检索它。Angular 提供了许多传递数据的方法,我们已经在 Angular 绑定中提到了这一点。当我们在同一模块中具有父子关系的组件时,这种方法很有用。

单例类是一种软件设计模式,它限制类的实例化只能有一个对象。这个单一的对象对应用程序的所有组件都是可用的。

我们在 service 类中使用 RxJS 库的 subject 来将数据传递到不同模块的组件。这种设计有助于创建一个独立的模块。

执行以下步骤以将认证模块的数据传递到用户模块:

  1. 存储用户模型:第一步是使用 service 类中的 subject 存储数据。我们在用户服务中存储用户模型。我们使用 BehaviorSubject,它是 subject 类的扩展,用于存储用户模型。

RxJS 的行为主题向订阅者发出最新的数据。

null, and this will be populated with the latest user model: 
private subject: BehaviorSubject<User> = new BehaviorSubject(null);

我们在 subject 中保存用户模型。我们从登录和注册组件调用此方法:

public saveUser(user: User){
    this.subject.next(user);
}
  1. 在服务类中创建方法:我们可以在 UserService 类中创建 getSavedUser()。此方法返回 subject,调用者需要订阅或使用 getValue() 方法来检索保存的 User 对象:
public getSavedUser(): BehaviorSubject<User>{
    return this.subject;
}
  1. 在组件类中检索用户模型:我们可以使用 getValue() 方法从 subject 中检索值。您也可以订阅并检索用户模型:
ngOnInit() {
    this.user = this.userService.getSavedUser().getValue();
}

目前为止,这是完整的 user.service.ts

import {Injectable} from '@angular/core';
import {AngularFireDatabase} from 'angularfire2/database';
import {User} from './user';
import {USERS_CHILD} from './database-constants';
import {Observable} from 'rxjs/Observable';
import {BehaviorSubject} from 'rxjs/BehaviorSubject';

/**
 * User service
 *
 */
@Injectable()
export class UserService {

  private subject: BehaviorSubject<User> = new 
  BehaviorSubject(null);

  /**
   * Constructor
   *
   * @param {AngularFireDatabase} fireDb provides the functionality 
   for Firebase Database
   */
   constructor(private fireDb: AngularFireDatabase) {
  }

  public addUser(user: User): void {
    this.fireDb.object(`${USERS_CHILD}/${user.uid}`).set(user);
    this.saveUser(user);
  }

  public getUser(uid: string): Observable<User> {
    return this.fireDb.object<User>
   (`${USERS_CHILD}/${uid}`).valueChanges();
  }

  public saveUser(user: User) {
    this.subject.next(user);
  }

  public getSavedUser(): BehaviorSubject<User> {
    return this.subject;
  }

  public updateEmail(user: User, newEmail: string): void {
    this.fireDb.object(`${USERS_CHILD}/'${user.uid}`).update({email: 
    newEmail});
    this.saveUser(user);
  }

  public updateMobile(user: User, mobile: string): void {

  this.fireDb.object(`${USERS_CHILD}/'${user.uid}`).update({mobile: 
  mobile});
    this.saveUser(user);
  }

  public updateName(user: User, name: string): void {
    this.fireDb.object(`${USERS_CHILD}/'${user.uid}`).update({name: 
    name});
    this.saveUser(user);
  }

}

SASS 简介

SASS(系统化出色的样式表) 是一个 CSS 预处理器,它为现有的 CSS 添加了更多功能。它有助于添加变量、嵌套规则、混入、继承等。这个特性有助于以更系统化的方式组织我们的样式表。

SASS 提供了两种风味:

  • SASS

  • SCSS

SASS 是两种语法中较老的一种,而 SCSS 是更常用的一种。在这本书中,我们使用了 SCSS 格式。一些受支持的功能如下:

  • 部分(Partial):部分是一个可重用的 CSS 元素,可以包含在其他 SCSS 文件中。这有助于将我们的 CSS 模块化成更小的可重用元素,跨越 SCSS 文件。一个部分的文件名包含一个前导下划线,这样编译器就知道这是一个部分文件,不会转换为 CSS 文件,例如,_shared.scss。我们可以使用 @import 指令将部分文件导入到其他组件中。

以下是一个在其他 SCSS 文件中包含部分的示例,如下所示:

@import "../../shared/shared";
.chat-message-main-container {
}
  • 扩展:这类似于高级编程语言中的继承。我们将编写 CSS 属性在公共类选择器中,然后在其他类选择器中扩展它。

以下是一个 @extend 的示例:

.user-profile{
   margin-top: 10px;
}

.user-profile-name{
   @extend .user-profile;
   border-color: green;
}
  • 混入(Mixin):这用于将可以在整个应用程序中使用的声明分组。这类似于类中的方法签名:
@mixin message-pointer($rotate , $skew) {
    transform: rotate($rotate) skew($skew);
    -moz-transform: rotate($rotate) skew($skew);
    -ms-transform: rotate($rotate) skew($skew);
    -o-transform: rotate($rotate) skew($skew);
    -webkit-transform: rotate($rotate) skew($skew);
}

创建用户配置文件组件

在本节中,我们将创建一个用户配置文件组件。在成功登录或注册后,用户将被导向其配置文件页面。此组件显示用户信息,如姓名和电子邮件,并提供编辑功能以更改用户信息。以下创建用户配置文件组件的步骤:

  1. 创建用户配置文件模板:第一步是创建用户配置文件页面的模板。在这个模板中,我们将显示姓名、电子邮件、手机和密码。这些信息中的每一项都有一个编辑按钮。

首先,我们创建一个包含所有用户信息元素的 div 容器。我们使用 *ngIf 来检查用户数据:

<div class="user-profile" *ngIf="user">
</div>

第二步,我们在 div 中为每个用户信息创建 div,包括 labeluser.nameEdit 按钮:

<div class="user-profile-name">
    <label>Name: </label>
    <div class="user-profile-name-value">{{user?.name}}</div>
    <button (click)="onNameChange()" type="button" class="btn btn-
    default btn-sm user-profile-name-btn">
    Edit
    </button>
</div>

下面是完整的 user-profile.component.html

<div class="user-profile" *ngIf="user">
    <div class="person-icon">
        <img [src]="profileImage" style="max-width: 100%; max-height:  
         100%;">
    </div>
    <div class="user-profile-name">
        <label>Name: </label>
        <div class="user-profile-name-value">{{user?.name}}</div>
        <button (click)="onNameChange()" data-toggle="modal" data-
         target="#editModal" type="button"
         class="btn btn-default btn-sm user-profile-name-btn">
            Edit
        </button>
    </div>
    <div class="user-profile-email">
        <label>Email: </label>
        <div class="user-profile-email-value">{{user?.email}}</div>
        <button (click)="onEmailChange()" data-toggle="modal" data-
        target="#editModal" type="button"
        class="btn btn-default btn-sm">
            Edit
        </button>
    </div>
    <div class="user-profile-mobile">
        <label>Mobile: </label>
        <div class="user-profile-mobile-value">{{user?.mobile}}</div>
        <button (click)="onMobileChange()" data-toggle="modal" data- 
         target="#editModal" type="button"
         class="btn btn-default btn-sm user-profile-mobile-btn">
            Edit
        </button>
    </div>

    <div class="user-profile-password">
        <label>Password: </label>
        <div class="user-profile-password-value">****</div>
        <button (click)="onPasswordChange()" data-toggle="modal" data-
        target="#editModal" type="button"
        class="btn btn-default btn-sm user-profile-password-btn">
            Edit
        </button>
    </div>
    <div class="user-profile-btn">
        <button type="button" (click)='onLogout()' class="btn btn-
        info">LOGOUT</button>
    </div>
</div>

使用样式表对用户信息进行对齐。我们使用 SCSS 的嵌套来从容器到子选择器的类选择器:

.user-profile{
    width: 50%;
    margin-left: 24px;
    margin-top: 10px;
    .user-profile-name{
        text-align: left;
        margin-top: 10px;
        .user-profile-name-value{
            display: inline-block;
            margin-left: 10px;
        }
        .user-profile-name-btn{
            margin-left: 100px;
        }
    }
}

下面的 user-profile.component.scss 代码是完整的:

.user-profile{
    width: 50%;
    margin-left: 24px;
    margin-top: 10px;
    .user-profile-name{
        text-align: left;
        margin-top: 10px;
        .user-profile-name-value{
            display: inline-block;
            margin-left: 10px;
        }
        .user-profile-name-btn{
            margin-left: 100px;
        }
    }
    .user-profile-email{
        text-align: left;
        margin-top: 20px;
        .user-profile-email-value{
            display: inline-block;
            margin-left: 10px;
        }
    }
    .user-profile-mobile{
        text-align: left;
        margin-top: 20px;
        .user-profile-mobile-value{
            display: inline-block;
            margin-left: 10px;
        }
        .user-profile-mobile-btn{
            margin-left: 110px;
        }
    }
    .user-profile-password{
        text-align: left;
        margin-top: 20px;
        .user-profile-password-value{
            display: inline-block;
            margin-left: 10px;
        }
        .user-profile-password-btn{
            margin-left: 154px;
        }
    }
    .user-profile-btn{
        margin-top: 20px;
    }
}
  1. 创建用户资料组件: 我们将在组件中定义检索用户模型和处理事件的逻辑。

第一步是从 service 类中检索用户模型。我们实现 onInit 接口并重写 ngOnInit 来检索用户模型,如下所示:

export class UserProfileComponent implements OnInit {
    private user: User;
    constructor(private authService: AuthenticationService,
                private userService: UserService,
                private router: Router) {
    }
    ngOnInit() {
        this.user = this.userService.getSavedUser().getValue();
    }
}

OnInit 是一个生命周期钩子接口,由 Angular 框架管理。它有一个 ngOnInit() 方法,当组件和指令完全初始化时会被调用。

下面的 user-profile.component.ts 是目前的完整版本:

import {Component, OnInit, ViewChild} from '@angular/core';
import {AuthenticationService} from '../../services/authentication.service';
import {Router} from '@angular/router';
import {User} from '../../services/user';
import {UserService} from '../../services/user.service';
import {EditDialogComponent} from '../../edit-dialog/edit-dialog.component';
import {EditType} from '../../edit-dialog/edit-details';

@Component({
    selector: 'app-friends-userprofile',
    styleUrls: ['user-profile.component.scss'],
    templateUrl: 'user-profile.component.html'
})
export class UserProfileComponent implements OnInit {

    profileImage: any = '../../../assets/images/person_edit.png';

    user: User;

    @ViewChild(EditDialogComponent) editDialog: EditDialogComponent;

    constructor(private authService: AuthenticationService,
                private userService: UserService,
                private router: Router) {
    }

    ngOnInit() {
        this.user = this.userService.getSavedUser().getValue();
    }

    onLogout(): void {
        this.authService.signout().then(() => {
            this.navigateToLogin();
        });
    }

    navigateToLogin() {
        this.router.navigateByUrl('/app-friends-login');
    }
}

我们的用户资料页面视图应该是这样的:

增强更新操作的服务

在本节中,我们将增强现有的服务以提供用户信息的更新。作为此练习的一部分,我们将讨论如何更新 Firebase 身份验证和数据库。

我们将更新以下用户信息:

  • 用户名: 此数据存储在 Firebase 数据库中,因此我们添加新的 update API 来执行此操作。我们在用户服务中添加了 updateName() 方法,并在 Firebase 中更新存储的用户数据:
public updateName(user: User, name: string): void {
    this.fireDb.object(`${USERS_CHILD}/'${user.uid}`).update({name: 
    name});
    this.saveUser(user);
}
  • 用户电子邮件: 此数据存储在 Firebase 身份验证和数据库中,因此我们需要在两个地方更新它。

我们需要在我们的身份验证服务中添加一个 changeEmail() 方法:

public changeEmail(email: string): Promise<any> {
    return this.angularFireAuth.auth.currentUser.updateEmail(email);
}

一旦在身份验证服务中完成此操作,我们就可以使用用户服务在 Firebase 数据库中更新新的电子邮件:

public updateEmail(user: User, newEmail: string): void {
    this.fireDb.object(`${USERS_CHILD}/'${user.uid}`).update({email: 
    newEmail});
    this.saveUser(user);
}

现在编辑移动密码与前面的代码相同,你可以遵循以下代码。authentication.service.tsuser.service.ts 的更新版本如下:

import {Injectable} from '@angular/core';
import {AngularFireAuth} from 'angularfire2/auth';

/**
 * Authentication service
 *
 */
@Injectable()
export class AuthenticationService {

    /**
     * Constructor
     *
     * @param {AngularFireAuth} angularFireAuth provides the 
       functionality related to authentication
     */
    constructor(private angularFireAuth: AngularFireAuth) {
    }

    public signup(email: string, password: string): Promise<any> {
        return 
        this.angularFireAuth.auth.createUserWithEmailAndPassword(
        email, password);
    }

    public login(email: string, password: string): Promise<any> {
        return this.angularFireAuth.auth.signInWithEmailAndPassword(
        email, password);
    }

    public resetPassword(email: string): Promise<any> {
        return 
        this.angularFireAuth.auth.sendPasswordResetEmail(email);
    }

    public isAuthenticated(): boolean {
        const user = this.angularFireAuth.auth.currentUser;
        return user ? true : false;
    }

    public signout(): Promise<any>{
        return this.angularFireAuth.auth.signOut();
    }

    public changeEmail(email: string): Promise<any> {
        return 
        this.angularFireAuth.auth.currentUser.updateEmail(email);
    }

    public changePassword(password: string): Promise<any> {
        return 
        this.angularFireAuth.auth.currentUser.updatePassword
        (password);
    }
}

下面的 user.service.ts 文件是更新后的版本:

import {Injectable} from '@angular/core';
import {AngularFireDatabase} from 'angularfire2/database';
import {User} from './user';
import {USERS_CHILD} from './database-constants';
import {Observable} from 'rxjs/Observable';
import {BehaviorSubject} from 'rxjs/BehaviorSubject';

/**
 * User service
 *
 */
@Injectable()
export class UserService {

  private subject: BehaviorSubject<User> = new BehaviorSubject(null);

  /**
   * Constructor
   *
   * @param {AngularFireDatabase} fireDb provides the functionality 
     for Firebase Database
   */
  constructor(private fireDb: AngularFireDatabase) {
  }

  public addUser(user: User): void {
    this.fireDb.object(`${USERS_CHILD}/${user.uid}`).set(user);
    this.saveUser(user);
  }

  public getUser(uid: string): Observable<User> {
    return this.fireDb.object<User>
   (`${USERS_CHILD}/${uid}`).valueChanges();
  }

  public saveUser(user: User) {
    this.subject.next(user);
  }

  public getSavedUser(): BehaviorSubject<User> {
    return this.subject;
  }

  public updateEmail(user: User, newEmail: string): void {
    this.fireDb.object(`${USERS_CHILD}/'${user.uid}`).update({email:  
    newEmail});
    this.saveUser(user);
  }

  public updateMobile(user: User, mobile: string): void {

  this.fireDb.object(`${USERS_CHILD}/'${user.uid}`).update({mobile:   
   mobile});
    this.saveUser(user);
  }

  public updateName(user: User, name: string): void {
    this.fireDb.object(`${USERS_CHILD}/'${user.uid}`).update({name: 
    name});
    this.saveUser(user);
  }

}

创建编辑对话框组件

编辑对话框组件用于获取用户输入以更新 Firebase 中的用户信息。此组件被重复使用以获取所有其他用户详细信息的信息,如姓名、电子邮件、手机和密码。此组件包含在用户资料组件中,当用户点击编辑按钮时,编辑对话框会显示出来。

创建一个编辑 dialog 组件的步骤如下:

  1. 创建编辑对话框模板: 第一步是创建编辑对话框的模板。此模板包含标题、文本标题和一个输入框。

我们使用 Bootstrap 模态框创建一个编辑对话框。它有一个输入框来接收用户输入。

第一步是创建一个带有isVisible条件的div容器,并且当用户点击“编辑”按钮时,这个变量会动态变化:

<div *ngIf="isVisible" class="modal fade show in danger" id="editModal" role="dialog" />

我们使用表单元素来获取用户输入,它有一个submit按钮,如下所示:

<div class="modal-dialog">
    <form name="form" (ngSubmit)="onSubmit(editFormData)" 
     #editFormData='ngForm'>
</form>
</div>

由于前面的模板也用于不同的编辑目的,我们需要动态更改标题、标题等文本。我们可以使用单向 Angular 绑定来分配变量:

<p>This will change your {{bodyTitle}}</p>

以下是完全的edit-dialog.component.html文件:

<div *ngIf="isVisible" class="modal fade in" id="editModal" role="dialog">
    <div class="modal-dialog">
        <form name="form" (ngSubmit)="onSubmit(editFormData)" 
        #editFormData='ngForm'>
            <div class="modal-content">
                <div class="modal-header">
                    <button type="button" class="close" data-
                    dismiss="modal">&times;</button>
                    <h4 class="modal-title">{{titleMessage}}</h4>
                </div>
                <div class="modal-body">
                    <p>This will change your {{bodyTitle}}</p>

                    <div class="form-group">
                        <label for="editDetail">{{bodyLabel}}
                        </label>
                        <input type="text" class="form-control" 
                        name="editValue" (ngModel)="editValue"
                        id="editDetail"/>
                    </div>
                </div>
                <div class="modal-footer form-group">
                    <button type="submit" class="btn btn-default" 
                   [disabled]="!editFormData.form.valid">
                    Edit
                    </button>
                    <button type="button" class="btn btn-default" 
                    data-dismiss="modal" 
                   (click)="hide()">Close</button>
                </div>
            </div>
        </form>
    </div>
</div>
  1. 创建编辑对话框组件:当用户点击“编辑”按钮时,此组件从用户资料组件接收一个变量输入。它接收用户在对话框中的输入,并将其传递给EditDetails类以更新信息。

我们将使用构造函数模式从UserProfileComponent获取输入变量。

构造函数模式是一种用于创建复杂对象的创建型模式。这基本上是在类中的构造函数接受许多参数时使用。这减少了构造函数的复杂性。

在我们的情况下,我们需要参数来动态更改标题、label。我们将为每个变量输入创建多个方法——例如,对于标题,我们将创建一个setTitle()方法并返回this,即类的实例。这有助于在单行中链接方法调用:

public setTitle(title: string): EditDialogComponent {
    this.titleMessage = title;
    return this;
}

我们将需要使用showhide方法切换isVisible变量,如下所示:

public show() {
    this.isVisible = true;
}

public hide() {
    this.isVisible = false;
}

现在这是完整的edit-dialog.component.ts文件:

import {Component, ViewChild} from '@angular/core';
import {AuthenticationService} from '../services/authentication.service';
import {UserService} from '../services/user.service';
import {User} from '../services/user';
import {EditDetails, EditType} from './edit-details';

@Component({
   selector: 'app-edit-dialog',
   templateUrl: './edit-dialog.component.html',
})
export class EditDialogComponent {
   isVisible: boolean;

   titleMessage: string;

   bodyTitle: string;

   bodyLabel: string;

   editType: EditType;

   editDetails: EditDetails;

   constructor(private authService: AuthenticationService,
            private userService: UserService) {
      this.editDetails = new EditDetails(authService, userService);
   }

   public setTitle(title: string): EditDialogComponent {
      this.titleMessage = title;
      return this;
   }

   public setBodyTitle(bodyTitle: string): EditDialogComponent {
      this.bodyTitle = bodyTitle;
      return this;
   }

   public setBodyLabel(bodyLabel: string): EditDialogComponent {
      this.bodyLabel = bodyLabel;
      return this;
   }

   public setEditType(editType: EditType): EditDialogComponent {
      this.editType = editType;
      return this;
   }

   public show() {
      this.isVisible = true;
   }

   public hide() {
      this.isVisible = false;
   }

   private onSubmit(editFormData): void {
      this.editDetails.edit(this.editType, 
      editFormData.value.editValue);
   }
}
  1. 创建更新操作:当用户提供了更新所需的新数据并点击submit时,将调用onSubmit()方法。对于每个更新操作,我们将调用EditDetails类的edit()方法。

现在这是完整的edit-details.ts文件:

import {AuthenticationService} from '../services/authentication.service';
import {UserService} from '../services/user.service';
import {User} from '../services/user';

export enum EditType {
   NAME,
   EMAIL,
   MOBILE,
   PASSWORD
}

export class EditDetails {

   constructor(private authService: AuthenticationService,
            private userService: UserService) {
   }

   public edit(editType: EditType, value: string) {
      switch (editType) {
         case EditType.NAME:
            this.editName(value);
            break;

         case EditType.EMAIL:
            this.editEmail(value);
            break;

         case EditType.MOBILE:
            this.editMobile(value);
            break;

         case EditType.PASSWORD:
            this.editPassword(value);
            break;
      }
   }

   private editName(name: string) {
      const user: User = this.userService.getSavedUser().getValue();
      user.name = name;
      this.userService.updateName(user, name);
      alert('Name changed successfully');
   }

   private editEmail(newEmail: string) {
      this.authService.changeEmail(newEmail).then(() => {
         const user: User =   
         this.userService.getSavedUser().getValue();
         user.email = newEmail;
         this.userService.updateEmail(user, newEmail);
         alert('Email changed successfully');
      }).catch(function (error) {
         const errorMessage = error.message;
         alert(errorMessage);
      });
   }

   private editMobile(mobile: string) {
      const user: User = this.userService.getSavedUser().getValue();
      user.mobile = mobile;
      this.userService.updateMobile(user, mobile);
      alert('Mobile changed successfully');
   }

   private editPassword(value: string) {
      const newPassword: string = value;
      this.authService.changePassword(newPassword).then(() => {
         alert('Password changed successfully');
      }).catch(function (error) {
         const errorMessage = error.message;
         alert(errorMessage);
      });
   }
}
  1. 使用编辑对话框组件:最后,我们将在用户资料组件中使用编辑对话框组件。第一步是将此组件包含在user-profile.component.html中,如下所示:
<div class="user-profile" *ngIf="user">
...
</div>
<app-edit-dialog></app-edit-dialog>

第二步是在user-profile.component.ts中初始化编辑对话框组件,如下所示:

export class UserProfileComponent implements OnInit {
   @ViewChild(EditDialogComponent) editDialog: EditDialogComponent;
...
}

当用户点击任何“编辑”按钮时,我们需要初始化变量并调用show()方法:

onNameChange() {
   this.editDialog.setTitle('Do you want to edit name?')
      .setBodyTitle('name')
      .setBodyLabel('Enter new name')
      .setEditType(EditType.NAME)
      .show();
}

以下是在user-profile.component.ts中其他更新操作的其他方法:

onEmailChange() {
   this.editDialog.setTitle('Do you want to edit email?')
      .setBodyTitle('email')
      .setBodyLabel('Enter new email')
      .setEditType(EditType.EMAIL)
      .show();
}

onMobileChange() {
   this.editDialog.setTitle('Do you want to edit mobile?')
      .setBodyTitle('mobile')
      .setBodyLabel('Enter new mobile')
      .setEditType(EditType.MOBILE)
      .show();
}

onPasswordChange() {
   this.editDialog.setTitle('Do you want to edit password?')
      .setBodyTitle('password')
      .setBodyLabel('Enter new password')
      .setEditType(EditType.PASSWORD)
      .show();
}

最后,我们在用户模块中配置编辑组件,如下所示:

import {NgModule} from '@angular/core';
import {EditDialogComponent} from '../edit-dialog/edit-dialog.component';

/**
 * User Module
 */
@NgModule({
    imports: [
        ...
    ],
    declarations: [
        ...
        EditDialogComponent
    ]
})
export class UserModule {
}

现在,当用户点击“编辑”按钮时,以下编辑对话框将出现:

更新操作的 Firebase 会话

当用户编辑他们的电子邮件和密码时,Firebase 会要求用户重新登录。当我们在AngularFireAuth中调用updatePassword()方法时,Firebase 会抛出错误,这个错误是为了安全原因而添加的。

此操作敏感,需要最近一次的认证。在重试此请求之前请重新登录。

前面的消息显示在我们的应用程序的警告对话框中,要编辑电子邮件或密码,我们需要立即注销并刷新会话,然后执行操作。

提升用户体验的最佳方式是让用户通过弹出窗口注销并刷新令牌。我们不会将此行为作为本书的一部分进行实现,所以你可以将其作为练习。

摘要

恭喜你完成本章!这是最先进的章节之一。我们涵盖了与编程范式相关的重要概念。我们讨论了从一个模块组件向另一个模块组件传递数据。作为其中的一部分,我们使用最少的依赖开发了两个独立的模块。我们介绍了 RxJS 库。我们开发了一个编辑组件并将其包含在用户资料组件中。最后,我们介绍了 Firebase 的安全功能,在编辑敏感信息(如电子邮件或密码)时,该功能将使会话过期。

在下一章中,我们将增强我们的朋友应用,添加用户的“朋友”功能。我们还将检索朋友列表并在列表中显示它们。我们将添加分页功能以便导航到朋友列表。

第六章:创建用户的友情列表

在本章中,我们将转向 Angular 和 Firebase 的更高级功能。我们将使用 Firebase 列表检索我们的用户友情列表。我们将使用 Bootstrap 提供的卡片组件显示友情列表。我们将使用 Firebase 过滤器实现分页概念。最后,我们将讨论 Angular 管道。

在本章中,我们将涵盖以下主题:

  • 创建用户的友情模板

  • 创建朋友的服务

  • 创建朋友组件

  • 创建我们的第一个日期管道

创建用户的友情模板

在本节中,我们将介绍一个稍微复杂一些的模板,使用 Bootstrap 卡片组件。我们将检索定义大小的朋友列表,并在卡片项中显示用户的友情列表。我们将调用 Firebase API 获取三个项目,并使用 *ngFor 指令循环朋友的列表。

卡片是一个灵活且可扩展的容器。它有显示标题、页脚、标题等选项。我们将使用以下属性:

  • card-img-top:用于在顶部显示朋友的图片。

  • card-title:用于显示朋友的姓名。

  • card-text:用于显示他们的电子邮件和电话号码。

  • card-footer:用于使用自定义管道显示日期。我们将在本章的后续部分实现自定义管道。

<div *ngFor="let friend of friends" class="card">
    <img class="card-img-top"  src={{friend.image}} 
     alt="Card image cap">
    <div class="card-block">
        <h4 class="card-title">{{friend.name}}</h4>
        <p class="card-text">{{friend.email}} | {{friend.mobile}}</p>
    </div>
    <div class="card-footer">
        <small class="text-muted">Friends from {{friend.time | 
         friendsdate}}</small>
    </div>
</div>

在我们显示第一页后,我们需要左右图标来滚动到下一页和上一页。这些图标将根据列表中的总项目数显示,并且 isLeftVisible 将从 component 类设置:

<div *ngIf="isLeftVisible" (click)="onLeft()" class="left"></div>

以下是完整的 user-friends.component.html 文件:

<div class="main_container">
    <div *ngIf="friends" class="content_container">
        <div *ngIf="isLeftVisible" (click)="onLeft()" class="left">
            <img src="img/left.png">
        </div>
        <div class="card-deck list">
            <div *ngFor="let friend of friends" class="card">
                <img class="card-img-top"  src={{friend.image}} 
                 alt="Card image cap">
                <div class="card-block">
                    <h4 class="card-title">{{friend.name}}</h4>
                    <p class="card-text">{{friend.email}} | 
                     {{friend.mobile}}</p>
                </div>
                <div class="card-footer">
                    <small class="text-muted">Friends from 
                     {{friend.time | friendsdate}}</small>
                </div>
            </div>
        </div>
        <div *ngIf="isRightVisible" (click)="onRight()" class="right">
            <img src="img/right.png">
        </div>
    </div>
    <div *ngIf="!friends || friends.length === 0" 
     class="no_info_container">
        <h1>No friends in your list</h1>
    </div>
</div>

我们将类选择器分配给元素以应用样式。在朋友列表页面中,我们使用 display:inline 水平对齐元素。同时,左图标、卡片列表和右图标依次显示,因此我们使用 float: left

以下是到目前为止完整的 user-friends.component.scss 文件:

.main_container {
    margin-top: 10px;
    margin-left: 80px;
    .content_container {
        display: inline;
        .list {
            float: left;
            .card-img-top {
                height: 180px;
                width: 260px;
                background-image: 
                url('../../../assets/images/person.png');
            }
        }

        .left {
            float: left;
            margin-top: 140px;
        }

        .right {
            float: left;
            margin-top: 140px;
        }
    }
}

创建朋友的服务

我们将在朋友的组件部分引入一个额外的服务。这个服务将从 Firebase 获取朋友的详细信息。在本节中,我们将涵盖以下主题:

  • 在我们的数据库中创建 Firebase 节点

  • 实现 Friend

  • 实现朋友的服务

在我们的数据库中创建 Firebase 节点

现在,我们已经如下一张图所示在 Firebase 中预先填充了朋友的详细信息。我们引入了一个名为 user-details 的单独节点。这将存储所有用户信息,我们不需要查询用户节点以获取更多信息,因为这会增加查询性能。

以下是对 Firebase 此实例的一些关键观察:

  • 我们尚未实现添加朋友的功能;因此,我们将手动添加朋友的信息。

  • 我们使用 UID 关系来列出用户的好友。在这种情况下,UID qu3bXn9tTJR7j4PBp9LzBGKxHAe2 是用户 ID,而另一个 UID—8wcVXYmEDQdqbaJ12BPmpsCmBMB2—是当好友注册应用时生成的朋友 ID。

  • 在 Firebase 中,我们重复很多数据。这是在 NoSQL 数据库中组织数据时的常见模式,因为它避免了多次对数据库的访问。尽管这增加了写入时间,但有助于我们的应用在读取数据时进行扩展。它防止了大型查询减慢我们的数据库速度,以及读取时间较长的嵌套节点。

Firebase 数据库中的好友节点如下:

实现好友模型类

我们将实现 Friend 模型类,以映射从 Firebase 中获取的朋友 JSON 对象的数组。这个类与 User 模型类类似,将责任分离到单独的类中是一种良好的实践。

这个类有 nameemailmobileuidtimeimage 属性。时间属性用于显示友谊的持续时间,并以毫秒为单位存储。我们需要使用 Angular 管道将毫秒时间转换为可读的日期格式。

以下为完整的 friend.ts 文件:

export class Friend {

   name: string;

   mobile: string;

   email: string;

   uid: string;

   time: string;

   image: string;

   constructor(name: string,
            mobile: string,
            email: string,
            uid: string,
            time: string,
            image: string) {
      this.name = name;
      this.mobile = mobile;
      this.email = email;
      this.uid = uid;
      this.time = time;
      this.image = image;

   }

}

实现好友服务

作为此服务的一部分,我们需要检索好友列表。AngularFireDatabase 提供了一个列表 API 来检索好友列表。此服务包含以下三个方法,以提供完整的分页功能:

  • 获取第一页: getFirstPage() 方法接受 uidpageSize 作为参数。这些参数用于从 Firebase 检索第一页 pageSize 数据。我们在查询函数的第二个参数中传递 pageSize
getFirstPage(uid: string, pageSize: number): Observable<Friend[]> 
{
  return this.fireDb.list<Friend> 
  (`${USER_DETAILS_CHILD}/${FRIENDS_CHILD}/${uid}`,
    ref => ref.limitToFirst(pageSize)
  ).valueChanges();
}
  • 获取下一页: loadNextPage() 接受 uidfriendKeypageSize 参数。uidfriendKey 用于设置查询。这意味着它们从最后检索的 friendKey 数据中检索下一个 pageSize 数据:
loadNextPage(uid: string, friendKey: string, pageSize: number): Observable<Friend[]> {
  return this.fireDb.list<Friend>
  (`${USER_DETAILS_CHILD}/${FRIENDS_CHILD}/${uid}`,
    ref => ref.orderByKey().startAt(friendKey)
           .limitToFirst(pageSize + 1)
  ).valueChanges();
}
  • 获取上一页: loadPreviousPage() 方法接受 uidfriendKeypageSize。后两个参数用于从起始 friendKey 元素检索前一个 pageSize 数据:
loadPreviousPage(uid: string, friendKey: string, pageSize: number): Observable<Friend[]> {
  return this.fireDb.list<Friend>
  (`${USER_DETAILS_CHILD}/${FRIENDS_CHILD}/${uid}`,
    ref => ref.orderByKey().startAt(friendKey)
           .limitToLast(pageSize + 1)
  ).valueChanges();
}

这是完整的 friends.service.ts

import {Injectable} from '@angular/core';
import {AngularFireDatabase} from 'angularfire2/database';
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import {Friend} from './friend';
import {FRIENDS_CHILD, USER_DETAILS_CHILD} from './database-constants';

/**
 * Friends service
 *
 */
@Injectable()
export class FriendsService {

    /**
     * Constructor
     *
     * @param {AngularFireDatabase} fireDb provides 
       the functionality related to authentication
     */
    constructor(private fireDb: AngularFireDatabase) {
    }

    getFirstPage(uid: string, pageSize: number): Observable<Friend[]> 
{
        return this.fireDb.list<Friend>
        (`${USER_DETAILS_CHILD}/${FRIENDS_CHILD}/${uid}`,
            ref => ref.limitToFirst(pageSize)
        ).valueChanges();
    }

    loadNextPage(uid: string, friendKey: string, pageSize: number): 
    Observable<Friend[]> {
        return this.fireDb.list<Friend>
        (`${USER_DETAILS_CHILD}/${FRIENDS_CHILD}/${uid}`,
            ref => 
            ref.orderByKey().startAt(friendKey)
            .limitToFirst(pageSize + 1)
        ).valueChanges();
    }

    loadPreviousPage(uid: string, friendKey: string, pageSize: number): 
    Observable<Friend[]> {
        return this.fireDb.list<Friend>
       (`${USER_DETAILS_CHILD}/${FRIENDS_CHILD}/${uid}`,
            ref => 
            ref.orderByKey().startAt(friendKey)
            .limitToLast(pageSize + 1)
        ).valueChanges();
    }
}

创建好友组件

这是我们的好友页面的主要控制器。在这个组件中,我们需要管理导航和我们的下一页和上一页图标的可见性。在本节中,我们将涵盖以下两个主要内容:

  • 显示下一页和上一页

  • 图标的可见性

为了显示下一页和上一页,我们已创建了显示朋友信息的 API。我们已经扩展了 OnInit 接口,并在 ngOnInit 上调用 getFirstPage(),使用 uidpageSize 作为过滤参数,如下所示:

ngOnInit() {
    this.user = this.userService.getSavedUser().getValue();
    this.friendService.getFirstPage(this.user.getUid() , this.pageSize)
        .subscribe(friends => {
            this.friends = friends;
            ...
        });
}

ngOnInit() 方法在页面加载时运行。

因此,我们将使用朋友服务中的 API 检索下一页和前一页,如下所示;唯一的区别是我们还将传递朋友uid,这样我们就可以从最后检索的项目开始检索下一页的大小数据:

next() {
    this.friendService.loadNextPage(this.user.getUid() ,
        this.friends[this.friends.length - 1].getUid(),
        this.pageSize
    ).subscribe(friends => {
        this.friends = friends;
        ...
    });

}

现在,我们将继续到下一部分。我们需要处理下一个和上一个图标,为此我们需要朋友的总数。在我们之前的讨论中,我们得到了大小为pageSize。为了解决这个问题,我们必须在我们的 Firebase 用户节点中创建friendcount。每次我们添加一个朋友,我们就增加计数。我们在User类中添加了这个属性;其他所有部分保持不变:

private friendcount: number

然后,在ngOnInit中,我们将检索总项目数,如下所示:

ngOnInit() {
        this.user = this.userService.getSavedUser().getValue();
        this.totalCount = this.user.getFriendcount();
        this.friendService.getFirstPage(this.user.getUid() , 
        this.pageSize)
            .subscribe(friends => {
                ...
                let count: number = this.friends.length;
                this.currentCount = count;
                this.leftArrowVisible();
                this.rightArrowVisible();
            });
    }

接下来,我们将当前计数初始化为检索的项目,然后根据总数和当前计数调用可见性:

leftArrowVisible(): void{
    this.isLeftVisible = this.currentCount > this.pageSize;
}

rightArrowVisible(): void{
    this.isRightVisible = this.totalCount > this.currentCount;
}

这是完整的user-friends.component.ts文件:

import {Component, OnInit} from '@angular/core';
import {FriendsService} from '../../services/friends.service';
import {Friend} from '../../services/friend';
import {UserService} from '../../services/user.service';
import {User} from '../../services/user';
import 'firebase/storage';
import {Router} from '@angular/router';

@Component({
  selector: 'app-friends-userfriends',
  styleUrls: ['user-friends.component.scss'],
  templateUrl: 'user-friends.component.html'
})
export class UserFriendsComponent implements OnInit {

  friends: Friend[];

  totalCount: number;

  pageSize = 3;

  currentCount = 0;

  previousCount = 0;

  isLeftVisible = false;

  isRightVisible = true;

  user: User;

  constructor(private friendService: FriendsService,
              private userService: UserService) {
  }

  ngOnInit() {
    this.user = this.userService.getSavedUser().getValue();
    this.totalCount = this.user.friendcount;
    this.friendService.getFirstPage(this.user.uid, this.pageSize)
      .subscribe(friends => {
        this.friends = friends;
        const count: number = this.friends.length;
        this.currentCount = count;
        this.leftArrowVisible();
        this.rightArrowVisible();
      });
  }

  onLeft(): void {
    this.previous();
  }

  onRight(): void {
    this.next();
  }

  next() {
    this.friendService.loadNextPage(this.user.uid,
      this.friends[this.friends.length - 1].uid,
      this.pageSize
    ).subscribe(friends => {
      this.friends = friends;
      const count: number = this.friends.length;
      this.previousCount = count - 1;
      this.currentCount += this.previousCount;
      this.leftArrowVisible();
      this.rightArrowVisible();
    });

  }

  previous() {
    this.friendService.loadPreviousPage(this.user.uid,
      this.friends[0].uid,
      this.pageSize
    ).subscribe(friends => {
      this.friends = friends;
      const count: number = this.friends.length;
      this.currentCount -= this.previousCount;
      this.leftArrowVisible();
      this.rightArrowVisible();
    });

  }

  leftArrowVisible(): void {
    this.isLeftVisible = this.currentCount > this.pageSize;
  }

  rightArrowVisible(): void {
    this.isRightVisible = this.totalCount > this.currentCount;
  }

}

用户的友谊页面显示三个有导航功能的朋友:

图片

创建我们的第一个日期管道

管道接受输入作为其数据,并将其转换为所需的输出。它用于将数据转换为可用的形式。

我们使用管道将时间转换为人类友好的日期格式。要创建管道,我们实现PipeTransform接口并重写transform方法。在这个方法中,我们获取以毫秒为单位的日期,并使用 moment 库将时间转换为特定的日期格式。我们提供了选择器名称,该名称用于 HTML 标签中的输入数据:

import * as moment from 'moment';
import {Pipe, PipeTransform} from '@angular/core';

/**
 * It is used to format the date
 */
@Pipe({
  name: 'friendsdate'
})
export class FriendsDatePipe implements PipeTransform {
  transform(dateInMillis: string) {
    if (dateInMillis === '0' || dateInMillis === '-1') {
      return 'Invalid Date';
    }
    return moment(dateInMillis, 'x').format('MM/DD/YY');
  }
}

Moment 是一个用于格式化、操作或解析日期的 JavaScript 库。

创建管道后,我们在user模块中添加它:

@NgModule({
    imports: [
        ...
    ],
    declarations: [
        ...
        FriendsDatePipe
    ]
})
export class UserModule {
}

最后,我们将friendsdate管道添加到模板中从friend对象中的time值,如下所示:

<div class="card-footer">
    <small class="text-muted">Friends from {{friend.getTime() | friendsdate}}</small>
</div>

摘要

在本章中,我们涵盖了大量的重要概念。我们介绍了现在大多数应用中使用的卡片组件。我们用样式装饰了我们的视图,并创建了一个新的服务。我们讨论了 Firebase 列表,然后提供了过滤选项。这为我们朋友的列表实现了分页。最后,我们讨论了 Angular 管道,我们使用它将时间转换为人类友好的日期格式。

在下一章中,我们将介绍 Firebase 存储,并学习如何存储个人资料图片以及如何检索它。

第七章:探索 Firebase 存储

在本章中,我们将继续探索 Firebase 的其他功能。如今,图像、音频和视频已成为任何网站开发的必要组成部分。考虑到这一点,Firebase 引入了存储功能。

我们将查看如何使用 Firebase 存储 API 上传个人资料图片。我们将使用 Firebase 门户上传一些随机图片,并使用 API 下载上传的图片以显示在我们的朋友列表中。然后,我们将查看如何在 Firebase 存储中删除文件。最后,我们将介绍错误处理。

在本章中,我们将涵盖以下主题:

  • 介绍 Firebase 存储

  • 配置 Firebase 存储

  • 上传个人资料图片

  • 下载朋友的照片

  • 删除个人资料图片

  • Firebase 存储中的错误处理

介绍 Firebase 存储

Firebase 存储为应用程序开发者提供了在存储中存储各种内容类型的灵活性。它存储图像、视频和音频。内容存储在 Google Cloud 存储桶中,并且可以从 Firebase 和 Google Cloud 访问。

Firebase 存储与 Firebase 身份验证集成,并提供了强大的安全性。我们还可以应用声明式安全模型来控制对内容访问的控制。我们将在稍后的部分中更详细地研究这一点。

Firebase 存储提供了以下关键特性:

  • 缩放:它由 Google Cloud Storage 支持,可以扩展到 PB 级别的容量。您可以在cloud.google.com/storage/了解更多关于 Google Cloud Storage 的信息。

  • 安全性:每个上传的文件都可以使用存储安全规则进行保护。

Firebase 存储的默认安全规则如下:

service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if request.auth!=null;
    }
  }
}
  • 网络:Firebase 存储在文件的上传和下载过程中自动处理网络问题。

配置 Firebase 存储

要在我们的应用程序中配置 Firebase 存储,我们需要存储桶 URL。这可以在标题下的“存储”选项卡中的“文件”标签内找到,如下面的截图所示;在我们的案例中,应用程序朋友的存储桶 URL 为gs://friends.4d4fa.appspot.com

这是修改后的environment.ts文件,其中包含存储桶 URL:

export const environment = {
    production: false,
    firebase: {
        apiKey: 'XXXX',
        authDomain: 'friends-4d4fa.firebaseapp.com',
        databaseURL: 'https://friends-4d4fa.firebaseio.com',
        projectId: 'friends-4d4fa',
        storageBucket: 'friends-4d4fa.appspot.com',
        messagingSenderId: '321535044959'
    }
};

上传个人资料图片

在本节中,我们将介绍如何将文件上传到 Firebase 存储。在用户资料页面上,我们将在页面顶部添加用户个人资料图片。

上传和显示用户个人资料图片的步骤如下:

  1. 在用户资料模板中添加 HTML 属性:我们提供了一个input标签,用于从文件选择器中获取用户选择的图片。通常,在具有文件类型的input标签中,我们有一个按钮来选择文件;然而,在这种情况下,我们需要用户点击默认图片。我们使用内置样式隐藏了input标签中的按钮,如下所示:
<div class="person-icon">
  <img [src]="profileImage" style="max-width: 100%; max-height: 
   100%;">
  <input (change)="onPersonEdit($event)" required accept=".jpg" 
   type="file" style="opacity: 0.0; position: absolute; top:120px; 
   left: 30px; bottom: 0; right:0; width: 200px; height:200px;" />
</div>
  1. 在样式表中添加默认图片:最初,我们通过在 user-profile.component.ts 文件中声明默认图片路径来显示默认图片,如下所示:
export class UserProfileComponent implements OnInit {

    profileImage: any = '../../../assets/images/person_edit.png';
 ...
}

以下是对 user-profile.component.html 文件进行的修改:

<div class="user-profile" *ngIf="user">
    <div class="person-icon">
      <img [src]="profileImage" style="max-width: 100%; max-height: 
       100%;">
      <input (change)="onPersonEdit($event)" required accept=".jpg"  
        type="file" style="opacity: 0.0; position: absolute;  
        top:120px; left: 30px; bottom: 0; right:0; width: 200px; 
        height:200px;" />
    </div>
...
</div>
<app-edit-dialog></app-edit-dialog>

下面是修改后的 user-profile.component.scss 文件:

.user-profile{
    width: 50%;
    margin-left: 24px;
    margin-top: 10px;
    .person-icon{
        width: 200px;
        height: 200px;
    }
    ...
}
  1. 在用户个人资料组件中处理点击事件:我们将实现 onPersonEdit() 方法,它接受 event 作为其参数。如以下代码所示,我们需要从事件对象中检索选定的文件并将它们传递给 UserService
onPersonEdit(event) {
   const selectedFiles: FileList = event.target.files;
   const file = selectedFiles.item(0);
   this.userService.addProfileImage(this.user, file);
}
  1. 在用户服务中添加方法:在 user.service.ts 中,我们在构造函数中初始化 Firebase 存储实例,如下所示:
@Injectable()
export class UserService {

   private fbStorage: any;

   private basePath = '/profile';

   /**
    * Constructor
    *
    * @param {AngularFireDatabase} fireDb provides the functionality 
      related to authentication
    */
   constructor(private fireDb: AngularFireDatabase) {
      this.fbStorage = fireDb.app.storage();
   }
  ...
}

现在我们实现用户服务中的 addProfileImage() 方法。

  1. 首先,我们为 Firebase 存储中的图片存储创建路径:
`${this.basePath}/${file.name}`
  1. 其次,我们调用 Firebase 存储引用的 put() 方法,如下所示:
this.fbStorage.ref(`${this.basePath}/${file.name}`).put(file)

在成功上传后,我们在 Firebase 的用户节点中保存下载 URL 并刷新缓存的用户对象:

public addProfileImage(user: User, file: File) {
  this.fbStorage.ref(`${this.basePath}/${file.name}`).
  put(file).then(
    snapshot => {
      const imageUrl: string = snapshot.downloadURL;
      this.fireDb.object(`${USERS_CHILD}/${user.uid}`).
      update({image: imageUrl});
      user.image = imageUrl;
      this.saveUser(user);

    }).catch((error) => {
      ...
  });
}
  1. 刷新用户个人资料图片:在成功上传后,我们需要更新我们用户个人资料页面中的图片。我们在用户个人资料组件中订阅 user 可观察对象并更新个人资料图片,如下所示:
ngOnInit() {
    this.user = this.userService.getSavedUser().getValue();
    this.userService.getSavedUser().subscribe(
        (user) => {
            if (this.user.image) {
                this.profileImage = this.user.image;
            }
        }
    );
}

在刷新成功后,用户个人资料页面将如下所示:

下载好友图片

在用户个人资料页面,我们在 Firebase 存储中上传了个人资料图片。我们还在 Firebase 的用户节点中存储了一个可下载的 URL,好友可以通过 UID 访问。在获取好友列表后,我们必须调用另一个 Firebase API 从我们的用户节点获取可下载的 URL。以下是可以下载的 URL:

https://firebasestorage.googleapis.com/v0/b/friends-4d4fa.appspot.com/o/profile%2Fclaire.jpg?alt=media&token=e00012af-c71c-48eb-92bc-4a0c9f989cbd

在 HTML 中,图片通过 <img> 标签定义。src 属性指定了图片的 URL 地址,如下所示:

<img src="img/url">

user-friends.component.html 文件中,我们添加了带有可下载 URL 的默认图片:

<img class="card-img-top"  src={{friend.image}} alt="Card image cap">

user-friends.component.scss 文件中,我们使用了 background-image 并添加了 widthheight 以确保图片能够适应卡片布局,如下所示:

.main_container {
    margin-top: 10px;
    margin-left: 80px;
    .content_container {
        display: inline;
        .list {
            float: left;
            .card-img-top {
                height: 180px;
                width: 260px;
                background-image: 
                url('../../../assets/images/person.png');
            }
        }

        .left {
            float: left;
            margin-top: 140px;
        }

        .right {
            float: left;
            margin-top: 140px;
        }
    }
}

Firebase 提供了一个 API,可以通过 Firebase 存储获取可下载的 URL:

public getDownloadURL(user: User, file: File) {    this.fbStorage.child('images/claire.jpg').getDownloadURL().then((url) => {
        // assign to the img src
    }).catch((error) => {
        // Handle any errors
    });
}

删除个人资料图片

Firebase 存储提供了一个 API 用于从 Firebase 删除文件。delete 操作与其他 Firebase 存储 API 类似。我们尚未在我们的应用程序中实现此用例;然而,您可能在您的应用程序中需要此概念:

public deleteFile() {
    this.fbStorage.child('images/claire.jpg').delete().then(function()  
{
        // File deleted successfully
    }).catch((error) => {
        // Handle any errors
    });
}

处理 Firebase 存储中的错误

Firebase 存储根据不同的条件抛出错误,如下所示:

  • storage/unknown:这可能是因为任何未知错误。这与 switch...case 语句中的默认条件类似。

  • storage/object_not_found:当文件/图片引用在 Firebase 存储位置不可用时发生。

  • storage/bucket_not_found:当 Firebase 存储桶未配置时,此错误发生。

  • storage/project_not_found: 当 Firebase 项目未配置 Firebase 存储时,此错误发生。

  • storage/quota_exceeded: 当免费套餐计划到期并被要求升级到付费计划时,此错误发生。

  • storage/unauthenticated: 当用户未认证但仍然能够访问 Firebase 存储中的文件和图片时,此错误发生。

  • storage/unauthorized: 当未经授权的用户访问 Firebase 存储中的文件/图片时,此错误发生。

  • storage/retry_limit_exceeded: 当由于网络缓慢或无网络而导致用户超过重试限制时,此错误发生。

  • storage/invalid_checksum: 当客户端的校验和与服务器的不匹配时,此错误发生。

  • storage/canceled: 当用户干预上传或下载操作时,此错误发生。

  • storage/invalid_event_name: 当提供给 Firebase 存储 API 的无效事件名称时,此错误发生。正确的事件是 runningprogresspause

  • storage/invalid-argument: Firebase 存储 put() 方法接受文件、BlobUInt8 作为参数。当我们传递错误参数时,此错误发生。

当我们在应用程序中遇到错误时,我们实现 Promise 的then()方法,检索错误消息并在警告对话框中显示它。

这里是user.service.ts类中修改后的addProfileImage()方法:

public addProfileImage(user: User, file: File) {
  this.fbStorage.ref(`${this.basePath}/${file.name}`).put(file).then(
    snapshot => {
      ...

    }).catch((error) => {
    const errorMessage = error.message;
    alert(errorMessage);
  });
}

摘要

在本章中,我们讨论了 Firebase 存储。我们将个人资料图片上传到 Firebase 存储,并将可下载的 URL 存储在我们的数据库中的用户节点中。我们在 HTML 的img标签中显示了图片,因为这有助于从 Firebase 存储中下载图片。我们介绍了 Firebase 安全,用户需要正确认证才能访问 Firebase 存储中的图片/文件。最后,我们讨论了 Firebase 存储的错误处理。

在下一章中,我们将介绍我们应用程序更有趣和令人兴奋的部分。我们将创建一个聊天应用程序,并介绍 Firebase 如何支持实时更新。

第八章:创建聊天组件

在本章中,我们将在现有应用中创建我们的聊天应用,并查看使用 Firebase 数据库的实时消息更新。我们将在本章和下一章中解释聊天功能。

由于我们已经在上一章创建了组件,因此在本章中我们将设计一个涉及多个组件的更复杂组件。根据一般规则,我们将将其创建为一个模块,因此主组件将是聊天组件,它将包含消息列表组件、表单组件和消息组件。在实现聊天功能时,我们将探索更多数据绑定方式。在本章中,我们将编写更复杂的 SCSS。我们相信,如果您正确地遵循本章,大多数 Angular 内容将更加清晰,您将能够自己构建更复杂的组件。

在本章中,我们将涵盖以下主题:

  • 创建聊天模块

  • 创建颜色变量

  • 创建聊天组件

  • 创建聊天消息列表组件

  • 创建消息视图的 mixin

  • 创建聊天消息组件

  • 创建聊天消息表单组件

创建聊天模块

创建模块的第一步是定义路由并将它们包含在聊天模块中。在聊天路由模块中,我们创建聊天路由并在 RouterModules 中配置它们。

以下是目前完整的chat-routing.module.ts文件:

import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {ChatComponent} from './chat.component';

export const ROUTES: Routes = [
  {path: 'app-friends-chat/:id', component: ChatComponent}
];

/**
 * Chat Routing Module
 */
@NgModule({
  imports: [
    RouterModule.forChild(ROUTES)
  ],
  exports: [
    RouterModule
  ]
})
export class ChatRoutingModule { }

聊天模块包含所有组件、模块和服务的声明。在聊天功能中,我们有以下四个组件:

  • 聊天组件:这是主组件,它封装了消息列表和消息表单组件。

  • 聊天消息列表组件:这是一个消息列表,显示列表中的消息。它调用消息组件来填充文本框中的消息。

  • 聊天消息表单组件:这是一个表单,它接受用户输入的消息并将其添加到 Firebase 数据库中。

  • 聊天消息组件:此组件显示用户输入的消息和发布消息的日期。

以下是目前完整的chat.module.ts文件:

import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {BrowserModule} from '@angular/platform-browser';
import {UserService} from '../services/user.service';
import {ChatMessageComponent} from './chat-message/chat-message.component';
import {ChatMessageListComponent} from './chat-message-list/chat-message-list.component';
import {ChatMessageFormComponent} from './chat-message-form/chat-message-form.component';
import {ChatComponent} from './chat.component';
import {ChatRoutingModule} from './chat-routing.module';

/**
 * Chat Module
 */
@NgModule({
    imports: [
        CommonModule,
        BrowserModule,
        FormsModule,
        ChatRoutingModule
    ],
    declarations: [
        ChatMessageComponent,
        ChatMessageListComponent,
        ChatMessageFormComponent,
        ChatComponent
    ],
    providers: [
        UserService
    ]
})
export class ChatModule {
}

最后,我们将聊天模块包含到应用模块中,如下所示;以下是目前的修改后的app.module.ts文件:

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

目前,我们的聊天模块是主应用模块的一部分。现在,我们将实现这些组件。

创建颜色变量

在本节中,我们讨论 SCSS 中的变量支持。在 CSS 中,我们需要为每个属性声明颜色代码,我们没有在另一个 CSS 属性中重用相同颜色代码的机制:

#messages {
    background-color: #F2F2F2 !important;
}

在我们的应用中,我们使用变量和部分来在整个应用中重用相同的颜色。我们使用变量在颜色文件中声明所有颜色,如下所示。此文件在 SCSS 中被称为部分,通常以下划线声明。

以下是目前完整的_colors.scss文件:

$mercury_solid:  #e5e5e5;
$sushi: #8BC34A;
$concrete_solid: #F2F2F2;
$iron: #E1E2E3;
$pickled_bluewood: #2d384a;

首先,我们将部分导入到另一个 SCSS 文件中,然后使用变量来访问颜色。在以下示例中,我们使用$concrete_solid变量来重复使用该颜色:

@import "../../shared/colors";

.chat-message-list-main-container {

    #messages {
        background-color: $concrete_solid !important;
    }
}

SCSS 变量帮助我们集中管理所有颜色在一个文件中,这样,当我们更改一个文件中的颜色组合时,这将在我们的整个应用程序中反映出来。

创建聊天组件

聊天组件是主要容器,它包含消息列表组件和消息表单组件。

它使用 Bootstrap 组件创建消息列表列视图。

div class="chat-main-container">
    <div class="main_container">
        <div class="col-md-8 col-md-offset-2">
            ...
        </div>
    </div>
</div>

聊天模板封装了聊天消息列表和聊天消息表单引用。

以下是目前的完整chat.component.html

div class="chat-main-container">
    <div class="main_container">
        <div class="col-md-8 col-md-offset-2">
            <app-chat-message-list [friendUid]="uid">
            </app-chat-message-list>
            <app-chat-message-form [friendUid]="uid">
            </app-chat-message-form>
        </div>
    </div>
</div>

我们使用margin-topmargin-left将主容器对齐到页面中间,如下所示,以下是目前的完整chat.component.scss

.chat-main-container {
    margin-top: 10px;
    margin-left: 80px;

    p {
        font-size: 10px;
    }

}

聊天组件声明了模板、样式表和选择器。以下是目前的完整chat.component.ts

import {Component} from '@angular/core';

@Component({
    selector: 'app-friends-chat',
    styleUrls: ['chat.component.scss'],
    templateUrl: 'chat.component.html',
})
export class ChatComponent {
}

聊天组件为其他子组件提供布局。

创建聊天消息列表组件

聊天消息列表组件以列表布局显示消息文本。它调用消息组件来填充消息文本的数据和时间。

首先,我们在容器div中创建列表,并使用#scrollContainer标签标记消息列表div,因为这有助于在收到新消息时将列表滚动到聊天窗口的底部。我们使用@ViewChild注解在组件中读取此标签:

<div class="chat-message-list-main-container">
    <div #scrollContainer class="message-list-container" 
     id="messages">
    ...    
    </div>
</div>

最后,我们包括了聊天消息选择器并循环消息。以下是目前完整的chat-message-list.component.html

<div class="chat-message-list-main-container">
    <div #scrollContainer class="message-list-container" id="messages">
        <app-chat-message *ngFor="let message of messages;" 
         [message]="message">
        </app-chat-message>
    </div>
</div>

在以下 HTML div标签中,我们在模板中包含两个选择器——我们添加了一个类选择器和 ID 选择器:

<div #scrollContainer class="message-list-container" id="messages"></div>

我们使用 SCSS 文件中选择器的名称后跟哈希来读取 ID 选择器以设置background-color

#messages {
    background-color: $concrete_solid !important;
}

我们使用box-shadowborder-radius属性为列表容器提供提升的外观:

.message-list-container {
        ...
        box-shadow: inset 0 3px 6px rgba(0, 0, 0, .05);
        border-radius: 8px;
    }

以下是目前的完整chat-message-list.component.scss

.chat-message-list-main-container {

    #messages {
        background-color: #F2F2F2 !important;
    }

    .message-list-container {
        position: relative;
        padding: 15px 15px 15px;
        border-color: #e5e5e5 #eee #eee;
        border-style: solid;
        border-width: 1px 0;
        background-color: #E1E2E3;
        box-shadow: inset 0 3px 6px rgba(0, 0, 0, .05);
        height: 60vh;
        overflow-y: scroll;
        background-color: #2d384a !important;
        border-radius: 8px;
    }

    p {
        font-size: 10px;
    }

}

在聊天消息列表组件中,我们使用@ViewChild读取滚动容器,如下所示:

@ViewChild('scrollContainer') private scrollContainer: ElementRef

然后,我们在组件中实现AfterViewChecked来处理底部滚动。

生命周期方法在组件视图在变更检测期间被检查时被调用。

interface AfterViewChecked{
    ngAfterViewChecked: void
}

我们覆盖了生命周期方法,并在每次收到新消息后,将消息列表滚动到最后一条消息的底部。我们还使用ChangeDetectorRef类检测组件变化。这是必需的,因为我们需要强制 Angular 检查组件的变化,因为滚动事件在 Angular 的作用域之外运行:

ngAfterViewChecked() {
        this.scrollToBottom();
        this.cdRef.detectChanges();
    }

    scrollToBottom(): void {
        try {
            this.scrollContainer.nativeElement.scrollTop =   
            this.scrollContainer.nativeElement.scrollHeight;
        } catch(err) {
            console.log("Error");
        }
    }

以下是目前的完整chat-message-list.component.ts

import {AfterViewChecked, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core';

@Component({
    selector: 'app-chat-message-list',
    styleUrls: ['chat-message-list.component.scss'],
    templateUrl: 'chat-message-list.component.html'
})
export class ChatMessageListComponent implements OnInit , AfterViewChecked{
    @ViewChild('scrollContainer') private scrollContainer: ElementRef;

    constructor(private messageService: MessagingService,
                private userService: UserService,
                private cdRef: ChangeDetectorRef) {
    }

    ngAfterViewChecked() {
        this.scrollToBottom();
        this.cdRef.detectChanges();
    }

    scrollToBottom(): void {
        try {
            this.scrollContainer.nativeElement.scrollTop = 
            this.scrollContainer.nativeElement.scrollHeight;
        } catch(err) {
            console.log("Error");
        }
    }

}

创建消息视图的 mixin

在本节中,我们将介绍 SCSS 混合。这个特性提供了将 CSS 属性分组的能力,我们可以在我们的应用程序中重用这个混合。就像类方法一样,我们也可以提供参数来使混合更加灵活。

我们将在我们的应用程序中使用这个混合来为聊天功能添加消息指针。我们将通过在方法名前加上 @mixin 关键字来声明混合,并添加如 $rotate$skew 这样的参数。

我们在 _shared.scss 中为我们的聊天消息创建了混合:

@mixin message-pointer($rotate , $skew) {
    transform: rotate($rotate) skew($skew);
    -moz-transform: rotate($rotate) skew($skew);
    -ms-transform: rotate($rotate) skew($skew);
    -o-transform: rotate($rotate) skew($skew);
    -webkit-transform: rotate($rotate) skew($skew);
}

我们在这个消息 SCSS 中使用这个混合。首先,我们需要在我们的消息文件中导入共享 SCSS 文件,然后我们使用 @include 来调用混合并传递参数。

以下为示例 chat-message.component.scss 文件:

@import "../../shared/shared";

.chat-message-main-container {

    .message-bubble::before {
        ...
        @include message-pointer(29deg , -35deg);
        ...
    }

}

创建聊天消息组件

消息组件是消息文本容器。它显示消息和时间。典型的聊天有一个气泡视图布局。我们为我们的聊天功能设计了此视图。我们声明以下三个类变量,我们在 SCSS 文件中使用它们:

  • message-bubble:这个选择器为消息气泡视图布局

  • class.sender:这会将发送者的所有消息对齐到容器的左侧

  • class.receiver:这会将接收者的所有消息对齐到容器的右侧

现在的完整 chat-message.component.html 文件如下:

<div class="chat-message-main-container">
    <div class="message-bubble" [class.receiver]="isReceiver(message)" 
    [class.sender]="isSender(message)">
        <p>{{ message.message }}</p>
        <div class="timestamp">
            {{ message.timestamp | date:"MM/dd/yy hh:mm a" }}
        </div>
    </div>
</div>

我们使用类选择器并为消息框提供样式。这包括以下两个主要部分:

  • 消息框:这为视图提供了阴影效果

  • 消息指针:这为消息框提供了一个指针

现在的完整 chat-message.component.scss 文件如下:

@import "../../shared/shared";

.chat-message-main-container {

    .message-bubble {
        background-color: #ffffff;
        border-radius: 5px;
        box-shadow: 0 0 6px #B2B2B2;
        display: inline-block;
        padding: 10px 18px;
        position: relative;
        vertical-align: top;
        width: 400px;
    }

    .message-bubble::before {
        background-color: #ffffff;
        content: "\00a0";
        display: block;
        height: 16px;
        position: absolute;
        top: 11px;
        @include message-pointer(29deg , -35deg);
        width: 20px;
    }

    .sender {
        display: inherit;
        margin: 5px 45px 5px 20px;
    }

    .sender::before {
        box-shadow: -2px 2px 2px 0 rgba(178, 178, 178, .4);
        left: -9px;
    }

    .receiver {
        display: inherit;
        margin: 5px 20px 5px 170px;
    }

    .receiver::before {
        box-shadow: 2px -2px 2px 0 rgba(178, 178, 178, .4);
        right: -9px;
    }

}

消息框看起来如下:

最后,我们在组件中编写事件方法。我们从我们的服务中检索保存的用户 UID 并编写逻辑来识别接收者和发送者。

现在的完整 chat-message.component.ts 文件如下:

import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core';
import {UserService} from '../../services/user.service';
import {Message} from '../../services/message';

@Component({
  selector: 'app-chat-message',
  styleUrls: ['chat-message.component.scss'],
  templateUrl: 'chat-message.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChatMessageComponent implements OnInit {

  @Input() message: Message;

  uid: string;

  constructor(private userService: UserService) {
  }

  ngOnInit() {
    this.uid = this.userService.getSavedUser().getValue().uid;
  }

  isReceiver(message: Message) {
    return this.uid === message.receiverUid;
  }

  isSender(message: Message) {
    return this.uid === message.senderUid;
  }

}

创建聊天消息表单组件

在聊天消息表单组件中,我们实现消息表单以将消息发送到 Firebase 并用新消息更新列表。

对于这些操作,我们需要以下两个元素:

  • 带有文本区域的输入:输入文本允许用户输入他们的消息。我们在输入文本中使用 (key.enter) 来处理键盘的 Enter 键,这会调用 sendMessage() 方法。

  • 发送按钮:这会调用 sendMessage() 方法并更新 Firebase 数据库。

现在的完整 chat-message-form.component.html 文件如下:

<div class="chat-message-form-main-container">
    <div class="chat-message-form-container">
        <input type="textarea" placeholder="Type a message" 
         class="message-text" [(ngModel)]="newMessage" 
         (keyup.enter)="sendMessage()">
        <button (click)="sendMessage()" 
         class="btn btn-outline-success my-2 my-sm-0" 
         type="submit">SEND</button>
    </div>
</div>

我们使用 chat-message-form-container 类选择器来设置边框的 border-radiusmessage-text 来设置输入文本相关的属性。

现在的完整 chat-message-form.component.scss 文件如下:

@import "../../shared/colors";

.chat-message-form-main-container {

    .chat-message-form-container {
        padding: 9px 50px;
        margin-bottom: 14px;
        background-color: #f7f7f9;
        border: 1px solid #e1e1e8;
        border-radius: 4px;

        .message-text {
            display: block;
            padding: 9.5px;
            margin: 0 0 10px;
            font-size: 13px;
            line-height: 1.42857143;
            color: #333;
            word-break: break-all;
            word-wrap: break-word;
            background-color: #ffffff;
            border: 1px solid $sushi;
            border-radius: 4px;
            width: 100%;
        }
    }
}

在聊天消息表单组件中,我们从用户服务中保存的用户对象中检索 UID。

以下为现在的完整 chat-message-form.component.ts 文件:

import { Component, OnInit, Input } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import {MessagingService} from '../../services/messaging.service';
import {Message} from '../../services/message';
import {UserService} from '../../services/user.service';

@Component({
  selector: 'chat-message-form',
    styleUrls: ['chat-message-form.component.scss'],
    templateUrl: 'chat-message-form.component.html'
})
export class ChatMessageFormComponent implements OnInit {

  uid: string;

  newMessage: string;

  constructor(private messageService: MessagingService,
              private userService: UserService) { }

  ngOnInit() {
      this.uid = this.userService.getSavedUser().getValue().getUid();
  }

   sendMessage() {
  }

}

最后,我们的聊天功能将如下所示:

摘要

在本章中,我们使用多个组件设计了一个更复杂的 UI 组件。我们实现了聊天模块并将其集成到主应用程序中。我们涵盖了新的 SCSS 功能,如变量、部分和混入。这真正帮助我们模块化我们的代码,并展示了如何在 SCSS 中实现可重用性。我们将一个大聊天组件分解成更小的组件,然后集成这些小组件。

在下一章中,我们将把我们的组件与服务集成。我们将为我们的聊天应用程序设计 Firebase 数据库。然后,我们将订阅实时数据库并获取即时更新。

第九章:将聊天组件与 Firebase 数据库连接

在本章中,我们将集成我们的聊天组件与新的消息服务。我们将讨论一种新的方法,使用路由参数将我们的数据传递到聊天模块。一旦从用户朋友列表组件传递了朋友的 UID,然后我们将这个朋友的 UID 传递给不同的聊天组件,因为消息列表和消息表单组件需要这些数据。我们还将为我们的聊天应用设计数据库,因为良好的设计可以避免数据重复。一旦数据库准备就绪,我们将从消息服务查询数据,并将消息服务与聊天组件集成。

在本章中,我们将涵盖以下主题:

  • 使用路由参数传递数据

  • 将朋友数据传递到不同的聊天组件

  • 设计聊天应用的 Firebase 数据库

  • 创建消息服务

  • 将服务集成到聊天组件中

使用路由参数传递数据

当用户点击“聊天”按钮时,我们使用路由参数将朋友 UID 传递给聊天组件。

在第五章,“创建用户个人资料页面”中,我们使用 RxJS 库中的BehaviorSubject传递用户数据。在本节中,我们将使用路由链接的参数来传递朋友的 UID。我们执行以下三个步骤来使用路由参数传递数据:

  1. 添加路由参数 ID

  2. 将路由链接到参数

  3. 读取参数

添加路由参数 ID

在前面列表中提到的第一步,我们需要将朋友的 UID 参数添加到路由链接中。我们在此处将 ID 参数添加到路径元素中,如下所示:

export const ROUTES: Routes = [
    { path: 'app-friends-chat/:id', component: ChatComponent }
];

当用户点击“聊天”按钮时,此 ID 将作为http://localhost:4200/friends-chat/8wcVXYmEDQdqbaJ12BPmpsCmBMB2添加到 URL 中。

将路由链接到参数

在前面列表中提到的第二步,我们需要将朋友的 UID 链接到路由链接。以下是有两种方法来实现这一点:

  • 路由链接指令:我们可以直接使用routerLink指令来链接参数 ID,如下所示:
<div *ngFor="let friend of friends" class="card" [routerLink]="['/app-friends-chat' , friend.getUid()]"></div>
  • 程序化使用路由:当用户点击“聊天”按钮时,我们将 UID 传递给方法参数,并使用路由传递数据。

我们在我们的用户朋友列表中添加一个聊天按钮。我们修改用户朋友的模板,如下所示:

<div *ngFor="let friend of friends" class="card">
    ...
        <button (click)="onChat(friend.uid)" class="btn btn-outline-
         success my-2 my-sm-0" type="submit">Chat</button>
    </div>
    ...
</div>

当用户点击“聊天”按钮时,将 UID 作为参数调用onChat()方法。最后,我们调用router方法将id作为路由参数传递:

onChat(id: string): void {
    this.router.navigate(['/app-friends-chat' , id]);
}

读取参数

我们使用ActivatedRoute来读取参数 ID。该组件提供了一组参数来读取 ID。我们订阅路由参数,并将订阅对象存储在成员变量中,并在 Angular 的生命周期ngOnDestroy()方法中取消订阅。

OnDestroy 是一个 Angular 生命周期钩子接口。它有一个 ngOnDestroy() 方法,当组件被销毁时调用,用于清理逻辑。

以下是到目前为止的完整 chat.component.ts 文件:

import {Component} from '@angular/core';
import {ActivatedRoute} from '@angular/router';

@Component({
    selector: 'friends-chat',
    styleUrls: ['chat.component.scss'],
    templateUrl: 'chat.component.html',
})
export class ChatComponent {

    uid: string;

    private sub: any;

    constructor(private route: ActivatedRoute) {

    }

    ngOnInit() {
        this.sub = this.route.params.subscribe(params => {
            this.uid = params['id'];
        });
    }

    ngOnDestroy() {
        this.sub.unsubscribe();
    }
}

我们将在下一节中介绍如何将 UID 数据传递给其他聊天组件。

将朋友数据传递给不同的聊天组件

一旦我们在聊天模块中有了朋友的 UID,我们可以使用 Angular 数据绑定将朋友的 UID 传递给我们的消息列表和消息表单组件。我们执行以下两个步骤来将数据传递给两个聊天组件:

  1. 声明输入变量:我们使用 Angular 的 @Input 注解在两个聊天组件中声明输入变量。以下代码片段显示了消息列表和消息表单组件的更改。
chat-message-list.component.ts:
export class ChatMessageListComponent implements OnInit , AfterViewChecked{
    @Input() friendUid: string;

}
chat-message-form.component.ts file:
export class ChatMessageFormComponent implements OnInit {
  @Input() friendUid: string;
  1. 将数据绑定到输入:我们可以将输入变量 friendUid 绑定到从用户模块传递来的 uid
<div class="col-md-8 col-md-offset-2">
    <app-chat-message-list [friendUid]="uid">
    </app-chat-message-list>
    <app-chat-message-form [friendUid]="uid">
    </app-chat-message-form>
</div>

我们将使用这个朋友的 UID 来读取或更新 Firebase 数据库。

设计用于聊天的 Firebase 数据库

设计 Firebase 数据库是编写聊天功能中最关键的部分。

聊天涉及两个人之间的交流。它有一个发送者和接收者,两人都可以看到相同的文本消息。我们必须将两个用户与相同的消息关联起来,这主要涉及两个步骤:

  1. 创建新的聊天 ID:第一步是为两个人关联唯一的消息 ID。我们创建一个唯一的消息 ID,并使用他们的 UID 将两个用户关联起来。如图所示,我们将 "-KvMW57CNfA40GJNDF-F" 密钥与两个 UID 关联起来。

当用户点击他们朋友页面上的聊天按钮时,我们将检查密钥并创建新的 ID,如下所示:

const key = this.fireDb.createPushId();

AngularFireDatabase 有一个 createPushId() 方法来创建唯一的 ID。

这里是来自 messaging.service.ts 的示例代码:

freshlyCreateChatIDEntry(uid: string, friendUid: string): string {
  const key = this.fireDb.createPushId();
  this.fireDb.object(`${USER_DETAILS_CHILD}/${CHAT_MESSAGES_CHILD}/${uid}/${friendUid}`).set({key: key});
  this.fireDb.object(`${USER_DETAILS_CHILD}/${CHAT_MESSAGES_CHILD}/${friendUid}/${uid}`).set({key: key});
  return key;
}
  1. 将消息与密钥关联:一旦我们为用户创建了密钥,我们就在 Firebase 数据库中创建新的节点,将此密钥作为节点关联,并将消息推送到此节点,以便两个用户都能访问。在数据库模式中,我们将创建具有 UIDs 的接收者和发送者节点,以便我们知道谁发送了消息,并根据此条件在聊天窗口中排列消息。

我们将密钥存储在服务的成员变量中,以便我们使用这个密钥向我们的 Firebase 数据库推送一条新消息。

以下是从 messaging.service.ts 的示例代码:

createNewMessage(newMessage: Message) {
  const messageKey = this.fireDb.createPushId();
  this.fireDb.object(`${MESSAGE_DETAILS_CHILD}/${this.key}/
  ${messageKey}`).set(newMessage).catch(error => {
    console.log(error);
  });
}

创建消息服务

创建消息服务的第一步是定义数据模型。我们创建一个消息模型,具有属性;消息模型由以下四个属性组成:

  • 消息:这包含一个字符串类型的消息。

  • 发送者 UID:发送者 UID 用于知道特定消息的发送者身份,并编写逻辑在聊天窗口的左侧面板上显示文本消息。

  • 接收者 UID:接收者 UID 用于识别特定消息的接收者,并编写逻辑在聊天窗口的右侧面板上显示文本消息。

  • 时间戳:此属性用于显示发送消息的日期和时间。

目前为止的message.ts文件如下:

export class Message {

   message: string;

   senderUid: string;

   receiverUid: string;

   timestamp: number;

   constructor(message: string,
            senderUid: string,
            receiverUid: string,
            timestamp: number) {
      this.message = message;
      this.senderUid = senderUid;
      this.receiverUid = receiverUid;
      this.timestamp = timestamp;
   }

}

作为聊天功能的一部分,我们声明一个常量以了解 Firebase 数据库的节点,如下所示。

目前为止的database-constants.ts文件如下:

export const USER_DETAILS_CHILD = 'user-details';
export const CHAT_MESSAGES_CHILD = "chat_messages";
export const MESSAGE_DETAILS_CHILD = "message_details";

我们消息功能的核心部分是服务。此服务负责创建聊天 ID、推送新消息和获取消息。作为此应用程序的一部分,我们引入了以下四个 API:

  • isMessagePresent():当用户点击聊天按钮时,我们检查是否存在消息键,并将键存储在此服务的成员变量中。我们使用此键将任何新消息推送到 Firebase 数据库。

  • freshlyCreateChatIdEntry():当用户开始与朋友进行新的沟通时,我们调用此 API 创建键并将其存储在 Firebase 数据库中。

  • getMessages():此 API 用于订阅对话中的所有消息,并且当收到新消息时,我们也会收到更新。

  • createNewMessage():当用户点击发送按钮时,我们调用此 API 将新消息存储在 Firebase 数据库中。新消息包括消息文本、发送者 UID、接收者 UID 和时间戳。

目前为止的messaging.service.ts文件如下:

import {Injectable} from '@angular/core';
import {AngularFireDatabase} from 'angularfire2/database';
import {CHAT_MESSAGES_CHILD, MESSAGE_DETAILS_CHILD, USER_DETAILS_CHILD} from './database-constants';
import {FirebaseApp} from 'angularfire2';
import 'firebase/storage';
import {Observable} from 'rxjs/Observable';
import {Message} from './message';

/**
 * Messaging service
 *
 */
@Injectable()
export class MessagingService {

  key: string;

  /**
   * Constructor
   *
   * @param {AngularFireDatabase} fireDb provides the functionality 
     related to authentication
   */
  constructor(private fireDb: AngularFireDatabase) {
  }

  isMessagePresent(uid: string, friendUid: string): Observable<any> {
    return  
    this.fireDb.object(`${USER_DETAILS_CHILD}/${CHAT_MESSAGES_CHILD}/
    ${uid}/${friendUid}`).valueChanges();
  }

  createNewMessage(newMessage: Message) {
    const messageKey = this.fireDb.createPushId();
    this.fireDb.object(`${MESSAGE_DETAILS_CHILD}/${this.key}/
    ${messageKey}`).set(newMessage).catch(error => {
      console.log(error);
    });
  }

  freshlyCreateChatIDEntry(uid: string, friendUid: string): string {
    const key = this.fireDb.createPushId();
    this.fireDb.object(`${USER_DETAILS_CHILD}/
    ${CHAT_MESSAGES_CHILD}/${uid}/${friendUid}`).set({key: key});
    this.fireDb.object(`${USER_DETAILS_CHILD}/
    ${CHAT_MESSAGES_CHILD}/${friendUid}/${uid}`).set({key: key});
    return key;
  }

  getMessages(key: string): Observable<Message[]> {
    return this.fireDb.list<Message> 
    (`${MESSAGE_DETAILS_CHILD}/${key}`).valueChanges();
  }

  setKey(key: string) {
    this.key = key;
  }
}

在下一节中,我们将此服务集成到我们的聊天组件中。

将我们的服务集成到聊天组件中

最后,我们将消息服务集成到组件中。我们将涵盖以下三个用例:

  • 检查新聊天的消息键:当用户与朋友开始对话时,我们调用消息服务的isMessagePresent() API。对于新的对话,此聊天键将不存在,我们需要创建新的键并使用相同的键进行后续沟通:
ngOnInit() {
    this.user = this.userService.getSavedUser().getValue();
    this.messageService.isMessagePresent(this.user.getUid(),  
    this.friendUid).subscribe(snapshot => {
        let snapshotValue = snapshot.val();
        let friend: Friend;
        if (snapshotValue == null) {
            console.log("Message is empty");
            this.key = 
            this.messageService.freshlyCreateChatIDEntry
            (this.user.getUid(), this.friendUid);
        } else {
            this.key = snapshotValue.key;
        }
        this.messageService.setKey(this.key);
        this.subscribeMessages();
    });
}
  • 订阅消息列表:我们将调用getMessages()方法来获取消息列表的可观察对象。然后我们将订阅这个可观察对象以获取消息列表,并将其分配给messages成员变量:
subscribeMessages() {
    this.messageService.getMessages(this.key)
        .subscribe(
            messages => {
                this.messages = messages;
            });

}

目前为止的chat-message-list.component.ts文件如下:

import {AfterViewChecked, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core';
import {MessagingService} from '../../services/messaging.service';
import {Message} from '../../services/message';
import {UserService} from '../../services/user.service';
import {User} from '../../services/user';

@Component({
   selector: 'app-chat-message-list',
   styleUrls: ['chat-message-list.component.scss'],
   templateUrl: 'chat-message-list.component.html'
})
export class ChatMessageListComponent implements OnInit, AfterViewChecked {
   @Input() friendUid: string;
   private user: User;
   messages: Message[];
   key: string;
   @ViewChild('scrollContainer') private scrollContainer: 
   ElementRef;

   constructor(private messageService: MessagingService,
            private userService: UserService,
            private cdRef: ChangeDetectorRef) {
   }

   ngOnInit() {
      this.user = this.userService.getSavedUser().getValue();
      this.messageService.isMessagePresent(this.user.uid , 
      this.friendUid).subscribe(snapshot => {
         if (snapshot == null) {
            console.log('Message is empty');
            this.key = 
            this.messageService.freshlyCreateChatIDEntry
            (this.user.uid, this.friendUid);
         } else {
            this.key = snapshot.key;
         }
         this.messageService.setKey(this.key);
         this.subscribeMessages();
      });
   }

   ngAfterViewChecked() {
      this.scrollToBottom();
      this.cdRef.detectChanges();
   }

   scrollToBottom(): void {
      try {
         this.scrollContainer.nativeElement.scrollTop = 
         this.scrollContainer.nativeElement.scrollHeight;
      } catch (err) {
         console.log('Error');
      }
   }

   subscribeMessages() {
      this.messageService.getMessages(this.key)
         .subscribe(
            messages => {
               this.messages = messages;
            });

   }

}
  • 向 Firebase 数据库发送消息:当用户输入消息并点击发送按钮时,我们创建新的消息对象,并在消息服务中调用createNewMessage()方法,这将负责将消息发送到 Firebase 数据库。
sendMessage() {
   const message: Message = new Message(this.newMessage, 
   this.uid, this.friendUid, Date.now());
   this.messageService.createNewMessage(message);
}

目前为止的chat-message-form.component.ts文件如下:

import {Component, Input, OnInit} from '@angular/core';
import {MessagingService} from '../../services/messaging.service';
import {Message} from '../../services/message';
import {UserService} from '../../services/user.service';

@Component({
   selector: 'app-chat-message-form',
   styleUrls: ['chat-message-form.component.scss'],
   templateUrl: 'chat-message-form.component.html'
})
export class ChatMessageFormComponent implements OnInit {
   @Input() friendUid: string;

   uid: string;

   newMessage: string;

   constructor(private messageService: MessagingService,
            private userService: UserService) {
   }

   ngOnInit() {
      this.uid = this.userService.getSavedUser().getValue().uid;
   }

   sendMessage() {
      const message: Message = new Message(this.newMessage, 
      this.uid, this.friendUid, Date.now());
      this.messageService.createNewMessage(message);
   }

}

最后,我们为我们的朋友应用程序创建了一个完全功能的聊天功能。

摘要

现在我们已经到达了朋友应用中聊天功能的结尾。在这一章中,我们涵盖了路由参数,并使用它将朋友的 UID 传递给我们的聊天模块。我们通过使用 @Input 绑定将这个 UID 传递给不同的聊天组件。然后,我们讨论了如何为我们的聊天功能设计 Firebase 数据库。我们在服务中使用了 Firebase 数据库 API,并创建了四个 API 来在我们的聊天功能中执行不同的操作。最后,我们将这个服务与聊天组件集成。

在下一章中,我们将讨论 Angular 中的单元测试。我们还将讨论 Jasmine 框架,并使用这个框架来对我们的应用程序进行单元测试。

第十章:单元测试我们的应用程序

在本章中,我们将详细讲解 Angular 测试。我们将从 Angular 测试的基本介绍开始,了解用于单元测试的工具和技术。我们将使用 Angular 测试框架为我们的登录组件编写单元测试,并配置一个依赖模块。我们还将对用户服务进行单元测试。作为测试的一部分,我们将为依赖服务或组件创建存根,以便我们只关注正在测试的类。我们将使用 Angular 框架对组件和服务进行单元测试,以便初始化依赖模块。我们将单独测试 Angular 管道,以便直接使用 new 关键字初始化管道。最后,我们将查看代码覆盖率。

在本章中,我们将涵盖以下主题:

  • Angular 测试简介

  • 单元测试 Angular 组件

  • 单元测试 Angular 服务

  • 单元测试 Angular 管道

  • 代码覆盖率简介

Angular 测试简介

单元测试是软件开发生命周期的重要组成部分。单元测试的好处如下:

  • 它有助于使我们的实现与设计保持一致

  • 它有助于保护我们的应用程序免受回归的影响

  • 如果我们有良好的测试用例,重构会变得更容易

Angular 提供了各种工具和技术来测试我们的应用程序。作为单元测试的一部分,我们将使用以下技术:

  • Jasmine:它为我们编写单元测试提供了基本框架。它附带一个 HTML 测试运行器,并在浏览器上运行。

  • Angular 测试框架:它与 Angular 框架一起提供,有助于为正在测试的 Angular 代码创建测试环境。它还为我们提供了访问 DOM 元素的能力。

  • Karma:我们使用 Karma 工具运行我们的应用程序。我们使用以下命令运行单元测试:

$ng test

我们可以编写两种类型的 Angular 测试:

  • 使用 Angular 测试框架进行单元测试:我们将使用 Angular 测试框架为我们的组件和服务编写单元测试。这将创建一个测试环境,并为我们提供访问 Angular 框架各个元素的能力。

  • 隔离单元测试:我们可以编写不依赖 Angular 的独立单元测试。这种单元测试对于测试服务和管道非常有用。在本章中,我们将以这种方式测试我们的日期管道。

值得注意的是,当你能够做到的时候,始终最好坚持使用隔离单元测试,并尽可能少地编写集成和端到端测试,因为隔离单元测试最容易维护。

单元测试 Angular 组件

在本节中,我们将编写第一个针对登录组件的 Angular 测试。编写测试用例涉及的步骤如下:

  1. 识别测试的类:编写单元测试用例的第一步是识别依赖项。登录组件的constructor显示了所有依赖项,并且依赖于UserServiceRouterAuthenticationServiceAngularFireAuth
constructor(private userService: UserService,
         private router: Router,
         private authService: AuthenticationService,
         private angularFireAuth: AngularFireAuth)
  1. 创建所有模拟或存根类:一旦我们识别了所有依赖项,我们需要消除这些外部依赖项,并专注于测试的组件类。因此,我们创建模拟或存根类来消除这些依赖项。

在登录组件中,我们使用用户服务通过getUser()方法检索用户信息,因此我们创建了一个具有getUser()方法的UserServiceStub,该方法返回封装在Observable对象中的模拟用户;我们为模拟用户创建了一个包含用户详情的测试数据类,如下所示:

class UserServiceStub {

   getUser(): Observable<User> {
      return Observable.of(mockUserJSON);
   }

}

这是user-test-data.ts文件的示例:

export const mockUserJSON = {
   email: 'user@gmail.com',
   friendcount: 0,
   image: '',
   mobile: '9999999999',
   name: 'User',
   uid: 'XXXX'
};

登录组件中我们使用认证服务进行登录和重置密码,因此我们创建了一个具有空的login()resetPassword()方法的AuthenticationServiceStub类:

class AuthenticationServiceStub {

   login(email: string, password: string) {}

   resetPassword(email: string) {}
}

AngularFireAuth类是 Angular 的 Fire 库的一部分。这个类负责我们应用中的认证,并包含一个auth对象,因此我们也为auth类创建了一个存根:

class AngularFireAuthStub {
   readonly auth: AuthStub = new AuthStub();,
}

这是AuthStub类:

class AuthStub {

   onAuthStateChanged() {
      return Observable.of({uid: '1234'});
   }
}

最后,我们使用路由器导航到应用中的页面。这个提供者是 Angular 框架的一部分。我们也为这个类创建了一个存根:

class RouterStub {
   navigateByUrl(url: string) {
      return url;
   }
}
  1. 创建测试套件:一旦我们消除了外部依赖项,我们可以在 Jasmine 框架的describe()方法中创建测试套件。此方法接受测试套件的description和用于 Jasmine 框架的specDefinitions函数来调用 spec 的内部套件:
describe(description: string, specDefinitions: () => void)
  1. 创建测试环境:我们为要测试的组件创建 Angular 测试环境。Angular 提供了一个TestBed类来创建测试环境;它初始化依赖模块、提供者、服务和组件。我们在beforeEach()方法中调用TestBed.configureTestingModule()方法,以便在每次测试用例执行之前配置模块:
beforeEach(async(() => {

  TestBed.configureTestingModule({
    declarations: [
      LoginComponent,
      ErrorAlertComponent
    ],
    imports: [
      CommonModule,
      BrowserModule,
      FormsModule
    ],
    providers: [
      {provide: UserService, useClass: UserServiceStub},
      {provide: Router, useClass: RouterStub},
      {provide: AuthenticationService, useValue: mockAuthService},
      {provide: AngularFireAuth, useClass: AngularFireAuthStub}
    ]
  }).compileComponents();
}));
  1. 初始化测试对象:一旦我们配置了模块,我们可以使用TestBed.createComponent()创建一个登录组件固定装置,并初始化登录组件和调试元素:
beforeEach(() => {
   fixture = TestBed.createComponent(LoginComponent);
   component = fixture.componentInstance;
   de = fixture.debugElement;
   fixture.detectChanges();
});
  1. 编写第一个测试用例:最后的步骤是编写测试用例。我们的第一个测试用例是检查登录组件是否已实例化。我们将在it()方法中编写测试用例,并使用expect()来验证实例:
it('Should instantiate LoginComponent', async(() => {
   expect(component instanceof LoginComponent).toBe(true,
      'LoginComponent not created');
}));
  1. 销毁创建的实例:在每个测试用例之后,我们在afterEach()方法中清除实例,如下所示:
afterEach(async(() => {
   fixture.detectChanges();
   fixture.whenStable().then(() => fixture.destroy());
}));
  1. 运行测试:最后,我们使用以下命令运行我们的第一个测试用例:
$ng test

在其成功运行后,它将在浏览器中打开,并显示状态为 1 个 spec 成功,0 个失败:

图片

  1. 添加更多单元测试:在下一个测试用例中,我们检查当用户输入他们的电子邮件和密码并点击登录按钮时,我们的服务中的 login 方法是否被调用。首先,我们初始化 DOM 元素,例如电子邮件输入文本、密码输入文本和登录按钮。接下来,我们初始化电子邮件和密码的默认值,并 spyOn 服务中的登录方法,以便在用户点击登录按钮时调用此模拟方法。点击登录按钮后,我们调用 detectChanges() 通知 DOM 刷新元素。最后,我们验证 login() 方法应该被调用:
it('Should call login', async(() => {
   const loginButton = de.query(By.css('#login-btn'));
   expect(loginButton).not.toBeNull('Login button not found');

   spyOn(mockAuthService, 'login').and.callThrough();
   de.query(By.css('#email')).nativeElement.value = 
   'user@gmail.com';
   de.query(By.css('#password')).nativeElement.value = 'password';
   fixture.detectChanges();

   // Click on Login button 
   loginButton.nativeElement.click();
   fixture.detectChanges();
   expect(mockAuthService.login).toHaveBeenCalled();
}));

现在是 login.component.spec.ts 文件:

import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {LoginComponent} from './login.component';
import {Router} from '@angular/router';
import {UserService} from '../../services/user.service';
import {Observable} from 'rxjs/Observable';
import {User} from '../../services/user';
import {mockUserJSON} from '../../test-data/user-test-data';
import {AuthenticationService} from '../../services/authentication.service';
import {AngularFireAuth} from 'angularfire2/auth';
import {CommonModule} from '@angular/common';
import {BrowserModule, By} from '@angular/platform-browser';
import {FormsModule} from '@angular/forms';
import {DebugElement} from '@angular/core';
import {ErrorAlertComponent} from '../../shared/error-alert/error-alert.component';

class RouterStub {
  navigateByUrl(url: string) {
    return url;
  }
}

class UserServiceStub {

  getUser(): Observable<User> {
    return Observable.of(mockUserJSON);
  }

}

class AuthenticationServiceStub {

  login(email: string, password: string) {
  }

  resetPassword(email: string) {
  }
}

class AngularFireAuthStub {
  readonly auth: AuthStub = new AuthStub();
}

class AuthStub {

  onAuthStateChanged() {
    return Observable.of({uid: '1234'});
  }
}

describe('LoginComponent with tests', () => {

  let fixture: ComponentFixture<LoginComponent>;
  let component: LoginComponent;
  let de: DebugElement;
  const mockAuthService: AuthenticationServiceStub = new 
  AuthenticationServiceStub();

  beforeEach(async(() => {

    TestBed.configureTestingModule({
      declarations: [
        LoginComponent,
        ErrorAlertComponent
      ],
      imports: [
        CommonModule,
        BrowserModule,
        FormsModule
      ],
      providers: [
        {provide: UserService, useClass: UserServiceStub},
        {provide: Router, useClass: RouterStub},
        {provide: AuthenticationService, useValue: mockAuthService},
        {provide: AngularFireAuth, useClass: AngularFireAuthStub}
      ]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    de = fixture.debugElement;
    fixture.detectChanges();
  });

  afterEach(async(() => {
    fixture.detectChanges();
    fixture.whenStable().then(() => fixture.destroy());
  }));

  it('Should instantiate LoginComponent', async(() => {
    expect(component instanceof LoginComponent).toBe(true,
      'LoginComponent not created');
  }));

  it('Should call login', async(() => {
    const loginButton = de.query(By.css('#login-btn'));
    expect(loginButton).not.toBeNull('Login button not found');

    spyOn(mockAuthService, 'login').and.callThrough();
    de.query(By.css('#email')).nativeElement.value = 'user@gmail.com';
    de.query(By.css('#password')).nativeElement.value = 'password';
    fixture.detectChanges();

    // Login button is enabled
    expect(loginButton.nativeElement.disabled).toBe(false);
    loginButton.nativeElement.click();
    fixture.detectChanges();
    expect(mockAuthService.login).toHaveBeenCalled();
  }));

});

单元测试 Angular 服务

在本节中,我们单元测试 Angular 服务,并为我们的用户服务编写测试用例。单元测试服务的步骤与我们的组件相同。

编写单元测试用例的第一步是分析依赖组件,因为我们看到用户服务依赖于 AngularFireDatabase 并初始化 Firebase 存储对象:

constructor(private fireDb: AngularFireDatabase)

因此,我们为这个依赖对象创建了一个模拟,例如 AngularFireDatabaseStub,它包含其他依赖模拟,如 AngularFireAppStubAngularFireObjectStub 对象引用以及 object() 方法:

class AngularFireDatabaseStub {

   app: AngularFireAppStub = new AngularFireAppStub;

   angularFireObject: AngularFireObjectStub;

   constructor(angularFireObject: AngularFireObjectStub) {
      this.angularFireObject = angularFireObject;
   }

   object(pathOrRef: PathReference): AngularFireObjectStub {
      return this.angularFireObject;
   }
}

以下是一个带有空模拟方法的 AngularFireAppStub 模拟类:

class AngularFireAppStub {

   storage() {}
}

以下是一个空的模拟方法的 AngularFireObjectStub 模拟类:

class AngularFireObjectStub {

   set() {}

   valueChanges() {}

   update() {}

}

下一步是使用 TestBed 初始化测试环境,并通过 TestBed.get() 获取用户服务对象:

const angularFireObject: AngularFireObjectStub = new AngularFireObjectStub();
const mockAngularFireDatabase: AngularFireDatabaseStub = new AngularFireDatabaseStub(angularFireObject);
let userService: UserService;

beforeEach(() => {
   TestBed.configureTestingModule({
      providers: [
         {provide: AngularFireDatabase, useValue: 
          mockAngularFireDatabase},
         {provide: UserService, useClass: UserService}
      ]
   });
   userService = TestBed.get(UserService);
});

现在,我们将开始编写我们的用户服务的测试用例。我们将涵盖以下用户服务的测试用例:

  • 第一个测试用例是将用户添加到 Firebase 数据库。我们将向 Angular 的 fire 对象的 set 方法添加 spyOn 并使用模拟用户调用添加用户方法;然后我们期望 Angular fire 对象的 set 方法被调用:
it('Add user', () => {
   spyOn(angularFireObject, 'set');
   userService.addUser(mockUserJSON);
   expect(angularFireObject.set).toHaveBeenCalled();
});

下一个测试用例是从 Firebase 数据库接收我们的用户。我们添加 spyOn Angular 的 fire 对象的值变化方法,它返回一个模拟用户。然后我们调用 getUser 方法,订阅 Observable 对象,然后验证方法调用,并使用预期值测试我们的模拟用户的内容:

it('getUser return valid user', () => {
   spyOn(angularFireObject, 
   'valueChanges').and.returnValue(Observable.of(mockUserJSON));
   userService.getUser(mockUserJSON.uid).subscribe((user) => {
      expect(angularFireObject.valueChanges).toHaveBeenCalled();
      expect(user.uid).toBe(mockUserJSON.uid);
      expect(user.name).toBe(mockUserJSON.name);
      expect(user.mobile).toBe(mockUserJSON.mobile);
      expect(user.email).toBe(mockUserJSON.email);
   });

});
  1. 下一个测试用例是将用户保存到 member 变量中。在这个测试用例中,我们将一个模拟用户保存到一个 Observable 中,然后使用 get 方法检索用户,并验证模拟用户的所有属性:
it('saveUser saves user in Subject', () => {
   userService.saveUser(mockUserJSON);
   userService.getSavedUser().subscribe((user) => {
      expect(user.uid).toBe(mockUserJSON.uid);
      expect(user.name).toBe(mockUserJSON.name);
      expect(user.mobile).toBe(mockUserJSON.mobile);
      expect(user.email).toBe(mockUserJSON.email);
   });

});
  1. 下一个测试用例是更新 Firebase 数据库中的电子邮件,并更新用户服务类中的缓存用户对象;我们 spyOn Angular 的 fire 对象的 update 方法,传递一个新电子邮件以更新 email 方法,这将更新 Firebase 数据库和缓存用户对象,测试 Firebase 数据库调用,从 get 方法检索用户,并验证模拟用户的所有属性:
it('updateEmail update the email', () => {
   spyOn(angularFireObject, 'update');
   userService.saveUser(mockUserJSON);
   mockUserJSON.email = 'user1@gmail.com';
   userService.updateEmail(mockUserJSON , mockUserJSON.email);
   userService.getSavedUser().subscribe((user) => {
      expect(angularFireObject.update).toHaveBeenCalled();
      expect(user.email).toBe(mockUserJSON.email);
   });

});

现在是 user.service.spec.ts 文件:

import {UserService} from './user.service';
import {AngularFireDatabase, PathReference} from 'angularfire2/database';
import {FirebaseApp} from 'angularfire2';
import {mockUserJSON} from '../test-data/user-test-data';
import {AngularFireAuth} from 'angularfire2/auth';
import {TestBed} from '@angular/core/testing';
import {Observable} from 'rxjs/Observable';
import {User} from './user';

class AngularFireDatabaseStub {

   app: AngularFireAppStub = new AngularFireAppStub;

   angularFireObject: AngularFireObjectStub;

   constructor(angularFireObject: AngularFireObjectStub) {
      this.angularFireObject = angularFireObject;
   }

   object(pathOrRef: PathReference): AngularFireObjectStub {
      return this.angularFireObject;
   }
}

class AngularFireAppStub {

   storage() {}
}

class AngularFireObjectStub {

   set() {}

   valueChanges() {}

   update() {}

}

describe('User service test suites', () => {

   const angularFireObject: AngularFireObjectStub = new 
   AngularFireObjectStub();
   const mockAngularFireDatabase: AngularFireDatabaseStub = new 
   AngularFireDatabaseStub(angularFireObject);
   let userService: UserService;

   beforeEach(() => {
      TestBed.configureTestingModule({
         providers: [
            {provide: AngularFireDatabase, useValue: 
             mockAngularFireDatabase},
            {provide: UserService, useClass: UserService}
         ]
      });
      userService = TestBed.get(UserService);
   });

   it('Add user', () => {
      spyOn(angularFireObject, 'set');
      userService.addUser(mockUserJSON);
      expect(angularFireObject.set).toHaveBeenCalled();
   });

   it('getUser return valid user', () => {
      spyOn(angularFireObject, 
      'valueChanges').and.returnValue(Observable.of(mockUserJSON));
      userService.getUser(mockUserJSON.uid).subscribe((user) => {
         expect(angularFireObject.valueChanges).toHaveBeenCalled();
         expect(user.uid).toBe(mockUserJSON.uid);
         expect(user.name).toBe(mockUserJSON.name);
         expect(user.mobile).toBe(mockUserJSON.mobile);
         expect(user.email).toBe(mockUserJSON.email);
      });

   });

   it('saveUser saves user in Subject', () => {
      userService.saveUser(mockUserJSON);
      userService.getSavedUser().subscribe((user) => {
         expect(user.uid).toBe(mockUserJSON.uid);
         expect(user.name).toBe(mockUserJSON.name);
         expect(user.mobile).toBe(mockUserJSON.mobile);
         expect(user.email).toBe(mockUserJSON.email);
      });

   });

   it('updateEmail update the email', () => {
      spyOn(angularFireObject, 'update');
      userService.saveUser(mockUserJSON);
      mockUserJSON.email = 'user1@gmail.com';
      userService.updateEmail(mockUserJSON , mockUserJSON.email);
      userService.getSavedUser().subscribe((user) => {
         expect(angularFireObject.update).toHaveBeenCalled();
         expect(user.email).toBe(mockUserJSON.email);
      });

   });

   it('updateMobile update the mobile', () => {
      spyOn(angularFireObject, 'update');
      userService.saveUser(mockUserJSON);
      mockUserJSON.mobile = '88888888';
      userService.updateEmail(mockUserJSON , mockUserJSON.mobile);
      userService.getSavedUser().subscribe((user) => {
         expect(angularFireObject.update).toHaveBeenCalled();
         expect(user.mobile).toBe(mockUserJSON.mobile);
      });
   });
});

单元测试 Angular 管道

Angular 管道单元测试是独立于 Angular 测试环境测试类的示例。在这个例子中,我们测试了我们的朋友日期管道类,并在测试类中创建了对象:

const pipe = new FriendsDatePipe();

在此对象上,我们将编写以下两个测试用例:

  1. 首先,测试绿色字段场景,即我们传递一个有效的日期(以毫秒为单位),并测试转换后的人类可读日期格式

  2. 第二,测试边缘情况场景,即我们传递一个无效日期-1,并期望返回一个字符串值"Invalid Date"

import {FriendsDatePipe} from './friendsdate.pipe';

describe('friendsdatepipe', () => {

   const pipe = new FriendsDatePipe();

   it('Transform dateInMillis to MM/DD/YY', () => {
      expect(pipe.transform('1506854340801')).toBe('10/01/17');
   });

   it('Transform invalid date', () => {
      expect(pipe.transform('-1')).toBe('Invalid Date');
   });

});

代码覆盖率

应用程序的代码覆盖率反映了我们代码的整体覆盖率。这为我们提供了代码行和函数覆盖率的概述,以便我们可以编写更多的测试用例来覆盖代码的其他部分。

我们可以在package.json中启用代码覆盖率,如下所示:

"scripts": {
  "ng": "ng",
  "start": "ng serve",
  "build": "ng build",
  "test": "ng test --sourcemaps false",
  "coverage": "ng test --sourcemaps false --watch=false 
   --code-coverage",
  "lint": "ng lint",
  "e2e": "ng e2e"
}

我们将执行以下命令,该命令运行测试用例,并创建一个coverage文件夹,其中包含index.html以显示覆盖率统计信息:

$ng test --codeCoverage

当我们打开index.html时,它显示了一个包含覆盖率概览的美丽表格:

图片

摘要

在本章中,我们介绍了单元测试,并讨论了 Angular 单元测试中的各种术语和术语。我们为我们的登录组件实现了单元测试,覆盖了TestBed,并配置了我们的模块。我们为我们的服务编写了单元测试,为外部依赖类创建了存根,并在我们的模块中注入了这些存根类。我们还为 Angular 管道编写了隔离的测试用例。最后,我们讨论了代码覆盖率,并为我们规格运行了代码覆盖率。

在下一章中,我们将讨论调试技术。这将帮助我们更快地解决问题和调试。

第十一章:调试技巧

调试被认为是软件开发最重要的部分。这个过程提高了效率并减少了开发时间。在本章中,我们将讨论不同的方法,并涵盖调试应用程序所需的所有方面。我们将从使用 Chrome 的开发者工具进行 Angular 和 HTML 调试开始,并简要介绍 Augury 用于调试 Angular 组件。我们将继续进行 TypeScript 调试,因为这有助于检查我们的 TypeScript 代码中的错误。我们将涵盖 CSS,这样我们就可以从工具本身设计许多样式元素。最后,我们将查看使用 Postman 和开发者工具的网络 API 调用。作为本章的一部分,我们将介绍 Chrome 的浏览器工具。

在本章中,我们将介绍以下主题:

  • Angular 调试

  • 调试网络应用程序

  • TypeScript 调试

  • CSS 调试

  • 调试网络

Angular 调试

在本节中,我们将从 Chrome 的 Augury 开始。这个工具以非常棒的方式展示了各种 Angular 组件及其依赖关系。它为我们提供了对应用程序以及不同构建块之间关系的洞察。

安装 Augury

安装 Augury 的最佳方式是使用 Chrome 的网络商店。另外,您也可以从 Augury 网站安装它。按照以下步骤安装 Augury:

  1. 在您的 Chrome 浏览器中打开 chrome://apps/

  2. 在右下角点击“网络商店”。

  3. 在网络商店页面,将 augury 输入搜索框并按 Enter 键,Augury 将出现在右侧面板上,如下所示;在我的情况下,这显示为 ADDED,但对于新安装,将出现一个“添加到 Chrome”按钮,您需要点击:

最后,当 Augury 安装完成后,一个带有黄色背景的黑色月亮将出现在 Chrome 浏览器右上角,如下所示:

使用 Augury 的功能

Augury 提供了良好的功能,有助于我们一眼预览数据和组件。在本章中,我们将介绍以下功能:

  • 组件树

  • 路由树

  • NgModules

组件树

当您启动 Augury 控制台时,您可以看到的第一个视图是组件树。以下截图显示了我们的朋友应用程序的用户配置文件视图:

上述组件树显示了 AppComponentUserProfileComponent 的层次视图。在右侧面板上,我们有“属性”选项卡,其中包含以下内容:

  • 查看源代码:这显示了组件的实际源代码

  • 变更检测:这显示了我们在组件中是否使用了变更检测

  • 状态:这显示了类的所有实例成员变量

  • 依赖关系:这显示了组件与其他组件或提供者的依赖关系

另一个视图是注入器图,它显示了特定组件或提供者被注入的位置:

路由树

Augury 为应用程序提供路由信息。这有助于查看应用程序中使用的所有路由。此选项位于组件树选项旁边,如图所示:

图片

NgModule

NgModule 是 Augury 中添加的另一个有用功能。它位于路由树旁边。它提供了有关特定应用程序模块中配置的所有导入、导出、提供者、声明和提供 InDeclarations 的信息,如下所示:

图片

调试 Web 应用程序

在本节中,我们将介绍 Chrome 开发者工具。这些工具为我们提供了分析应用程序的许多功能。我们将介绍以下主题:

  • HTML DOM

  • 布局预览

HTML DOM

您可以通过在浏览器页面上右键单击鼠标并选择“检查”或按F12来打开 Chrome 开发者工具。Chrome 开发者工具将以多个标签页打开。

您可以点击“元素”标签页,它显示以<html>标签为根的 DOM 元素。您可以通过每个元素上的右箭头图标进一步展开它,如下所示:

图片

上述预览有助于调试 HTML 元素。

布局预览

布局预览是一个很好的功能,可以预览与实际浏览器视图相同的布局。此工具提供双向视图,您可以使用鼠标箭头键在浏览器中的网页上悬停,并显示您的开发者工具中的实际 HTML 元素。您还可以在开发者工具中悬停在 HTML 元素上,并在浏览器中查看高亮视图,如图所示的后继截图。当您悬停在浏览器上时,您将看到实际使用的.user-profile样式的<div>标签,并且当您点击该元素时,开发者工具中的元素将展开。

图片

此布局预览有助于调试我们的实时应用程序。

调试 TypeScript

这又是调试实时 TypeScript 代码的一个重要方面。Chrome 工具提供了良好的机制来调试代码。在本节中,我们将介绍 Chrome 开发者工具中“源”标签页的以下功能:

  • 查看和搜索源文件

  • 设置断点和监视实时值

  • 在控制台窗口中添加代码

查看和搜索源文件

我们可以在开发者工具的“源”标签页中看到我们的 TypeScript 文件。所有文件都显示在左侧面板下的webpack://文件夹中。文件夹将如下所示:

图片

您也可以使用 Ctrl + P 命令搜索文件。源代码将显示在中间面板中:

图片

设置断点和监视实时值

实时调试是调试中最有趣的部分,并且对于调试遗留代码非常有帮助,因为它通过在代码中设置断点来帮助了解代码流。

您可以通过单击代码行中的数字来启用断点,如下所示:

图片

当你刷新页面时,应用程序会停在断点行。你可以使用右上角的面板遍历代码,该面板有跳过、进入、退出和恢复应用程序的命令。你还可以使用“观察”面板右下角的加号图标添加观察值;如下所示,我们在右侧的“观察”面板上添加了一个用户对象:

图片

这是一个快速调试问题或了解应用程序流程的方法。

在控制台窗口中添加代码

开发者工具提供了一种在部署的代码上添加代码并实时编码的功能。我们可以打开一个文件,然后点击行来向现有文件添加实现。

在以下屏幕截图中,我们在控制台窗口中添加了console.log以记录日志:

图片

此编辑器还提供内容辅助,这是根据用户请求的上下文敏感内容补全。

当我们将以下命令输入代码的任何一行时,会出现内容辅助;一个窗口会出现所有引用选项,你可以通过输入特定的字母键进一步筛选选项:

$Ctrl + Space bar

查看 HTML 页面的内容辅助:

图片

此功能有助于向现有文件添加代码,并更快地调试代码。

调试 CSS

调试 CSS 是 Web 开发的另一个方面。在本节中,我们将介绍调试和设计实时 CSS 元素。Chrome 开发者工具提供了一个更改样式元素和添加新元素的选择。我们将介绍以下功能:

  • 探索样式面板

  • 发现和修改样式

探索样式面板

一旦你打开了 Chrome 的开发者工具,打开“元素”选项卡并点击任何 HTML <div> 标签;你应该在右侧看到样式面板出现。此面板由三个标签组成:

  • 样式:这显示了应用于特定 HTML 元素的所有样式:

图片

  • 计算值:这显示了应用于特定 HTML 元素的所有计算值;这还显示了包含内容、填充、边框和边距信息的盒模型,关于所选 HTML 元素:

图片

  • 事件监听器:这显示了特定 HTML 元素可用的所有点击事件:

图片

发现和修改样式

在本节中,我们将修改 HTML 元素的样式,作为练习的一部分,我们将修改现有的用户个人资料页。我们执行以下步骤:

  1. 打开朋友应用程序。

  2. 前往用户个人资料页。

  3. 将鼠标悬停在用户个人资料页上,如下所示:

图片

  1. 在浏览器页面上单击用户个人资料页;它打开 Chrome 开发者工具,突出显示 HTML 元素,在右侧面板中你可以看到.user-profile样式。

  2. 当你悬停在样式元素上时,会出现所有样式规则的复选框,我们可以取消选中复选框来禁用特定的样式并查看用户个人资料页上的效果。在以下示例中,我们禁用了宽度样式规则:

图片

  1. 我们可以向现有的样式规则中添加新的样式。当我们悬停在溢出图标上时,会出现一个工具提示,其中包含添加新样式的选项,并且,随着我们添加样式,它还支持内容辅助:

图片

  1. 最后,我们可以通过点击现有项来编辑现有的样式。在以下示例中,我们将宽度从 50%更改为 60%:

图片

工具中的此选项有助于调试我们网页中的样式。

网络调试

网络调试在理解 API 调用及其响应方面非常有用。在 Firebase API 调用中,我们不会发现它有很大用处,因为 Firebase 数据库门户提供了 JSON 响应的视图。当我们探索网络调用的实时调试时,网络调试工具非常方便。在本节中,我们将讨论以下两个工具:

  • Postman:这与 Augury 扩展类似;您可以从 Chrome 扩展程序安装 Postman,或者您可以从www.getpostman.com/下载特定操作系统的安装程序。这个工具在开发初期阶段非常有用,因为它有助于理解 API 和响应,并相应地集成 API。您可以使用授权、头和正文创建 HTTP 方法,如 GET、POST、PUT 或 DELETE:

图片

  • Chrome 开发者工具中的网络标签页:这在 Chrome 开发者工具中实时调试网络调用时非常有用。这显示了页面加载时的所有网络调用。您还可以应用过滤器以查看特定网络调用类型:

图片

网络调试有助于确认来自服务器的预期响应。

摘要

在本章中,我们涵盖了调试技术的不同方面。我们从与浏览器相关的调试技术开始,这有助于分析和预览 HTML 元素。我们涵盖了 TypeScript 调试,并在部署的应用程序文件上设置了断点。我们还涵盖了 CSS 调试。我们在 CSS 面板中禁用并添加了样式。最后,我们涵盖了网络调试,其中我们讨论了 Postman 工具和 Chrome 开发者网络标签页。调试技术对于成为一名高效的 Web 开发者有很大帮助。

在下一章中,我们将把我们的应用程序部署到 Firebase 服务器。我们还将启用 Firebase 安全,因为这使我们的应用程序更加安全。

第十二章:Firebase 安全和托管

Firebase 提供灵活的安全规则,语法类似于 JavaScript,这有助于我们结构化数据和索引常用数据。安全规则与 Firebase 身份验证集成,有助于根据用户定义读取和写入访问权限。在本章中,我们将为 Firebase 数据库中的用户和聊天节点添加安全规则。Firebase 安全规则提供了一个很好的模拟器,在将新规则发布到生产环境之前进行检查。我们还将索引用户及其朋友的数据,以便更快地进行查询。最后,我们将应用程序部署到 Firebase 服务器。我们将设置不同的部署环境,以便我们可以在预发布服务器上测试我们的应用程序,然后将应用程序部署到生产服务器。

在本章中,我们将介绍以下主题:

  • 介绍 Firebase 安全

  • 为用户添加安全规则

  • 为聊天消息添加安全规则

  • 索引用户及其朋友

  • 设置多个部署环境

  • 在 Firebase 托管朋友应用

介绍 Firebase 安全

Firebase 提供了管理我们应用程序安全性的工具,因为我们可以在 Firebase 数据库中添加规则并验证数据输入。Firebase 为我们应用程序提供了以下安全:

  • 身份验证:确保我们的应用程序安全的第一步是识别用户。Firebase 身份验证支持多种身份验证机制,例如 Google、Facebook、电子邮件和密码身份验证。

  • 授权:一旦用户经过身份验证,我们就需要控制对数据库中数据的访问。Firebase 安全规则具有内置的变量和函数,例如auth对象,它有助于控制用户的读取和写入操作。

前往 Firebase 门户并导航到数据库|规则选项卡。默认的 Firebase 安全规则如下;auth!= null条件表示只有经过身份验证的用户才能访问 Firebase 数据库中的数据。

图片

Firebase 安全规则提供了以下四种类型的函数:

  • .read:我们可以为数据定义此函数,以控制用户的读取操作。

以下示例显示,只有已登录的用户才能读取自己的用户数据:

{
    "rules":{
        "users":{
            "$uid":{
                ".read": "auth != null && $uid === auth.uid"
            }
        }
    }
}
  • .write:我们为此数据定义此函数,以控制用户的写入操作。

以下示例显示,只有已登录的用户才能在其自己的用户数据节点上写入:

{
    "rules":{
        "users":{
            "$uid":{
                ".write": "auth != null && $uid === auth.uid"
            }
        }
    }
}
  • .validate:此函数维护数据的完整性,此变量提供数据验证。

以下示例验证name字段为字符串:

{
  "rules":{
     "users":{
        "$uid":{
           "name": {
              ".validate":"newData.isString()"                  
            }
          }
        }
     }
}
  • .indexOn:这为查询和排序数据提供了子索引。

在以下示例中,我们索引了用户数据的name字段。

{
  "rules":{
     "users":{
        ".indexOn": ["name"],
        "$uid":{
           "name": {
              ".validate":"newData.isString()"                  
            }
          }
        }
     }
}

Firebase 安全规则还提供了以下预定义变量,用于定义安全规则:

  • root:此变量提供了一个RuleDataSnapshot实例,用于从 Firebase 数据库的根访问数据。

以下根变量用于从根用户节点遍历 Firebase 数据库路径:

{
  "rules":{
     "users":{
        "$uid":{
           "image": {               
             ".read":"root.child('users').
             child(auth.uid).child('image').val() === ''"                  
            }
          }
        }
     }
}
  • newData:此变量提供了一个表示插入操作后存在的新的RuleDataSnapshot实例。

以下示例验证新的数据是否为字符串:

"name": {
    ".validate":"newData.isString()"                  
}
  • data:此变量提供了一个表示插入操作之前存在的数据的RuleDataSnapshot实例。

以下示例显示当前name字段中的数据不为空:

"user": {
   "$uid":{
      ".read":"data.child('name').val() != null"
   }
}
  • $variables:此变量代表动态 ID 和键。

在以下示例中,唯一 ID 被分配给$uid变量:

"user": {
   "$uid":{
   }
}
  • auth:这代表auth对象,它提供了用户的 UID。

在以下示例中,我们访问auth对象以获取用户的 UID:

"$uid":{
    ".write": "auth != null && $uid === auth.uid"
 }
  • now:此变量提供当前时间的毫秒数,有助于验证时间戳。

在以下示例中,我们得出结论,时间戳大于当前时间:

"$uid":{
    ".write": "newData.child('timestamp').val() >   now"
 }

为用户添加安全规则

在我们的应用程序中,用户详情起着至关重要的作用,因此我们需要为用户详情提供安全规则。我们已经看到了默认的安全设置。默认情况下,只有认证用户可以访问我们 Firebase 数据库的任何部分。我们将修改用户节点的安全规则,而暂时保留其他节点的默认安全规则。

如您从以下截图中所见,对于users节点,对于具有相同唯一用户 ID 的认证用户,允许进行readwrite操作;我们还需要验证用户节点中的数据类型以保持数据完整性:

为了验证我们的安全规则的变化,Firebase 提供了一个模拟器来测试我们的更改,在将其部署到生产环境之前。您将在“RULES”标签页的右上角看到一个“SIMULATOR”选项。此工具提供了模拟操作,而实际上并不在数据库中执行任何 CRUD 操作。我们将在模拟器上测试以下场景:

  • 认证用户的成功读取操作:打开模拟器并启用“Authenticated”开关按钮;它会在“Auth token”文本框中提供一个模拟的 uid。在“Location”文本框中,我们输入路径为/users/6e115890-7802-4f56-87ed-4e6ac359c2e0并点击“RUN”按钮。当出现“Simulated read allowed”消息时,此操作将成功,如以下截图所示:

  • 具有认证用户和正确数据的成功写入操作:在这种情况下,我们将提供包含用户 UID 的路径,字符串名称数据作为 JSON 有效载荷在模拟器中。当我们点击“运行”时,当出现“模拟写入允许”的消息时,这个操作被认为是成功的,如下面的截图所示。接下来的截图显示两个勾号,这表明我们的授权和数据验证已经成功。

图片

  • 使用不同 UID 的失败写入操作:在这种情况下,我们在用户路径位置提供了一个错误的 UID,然后执行相同的写入操作。这个操作失败了,导致出现“模拟写入被拒绝”的消息和写入标签上的一个叉号,如下所示;你可以通过点击“详情”按钮查看更多错误详情:

图片

  • 数据验证失败的写入操作:在这种情况下,我们在位置中提供了正确的用户 UID 路径,但在有效载荷中提供了错误的数据类型。例如,我们将为字符串名称数据提供数字数据类型。这个操作在数据验证标签中失败,如下所示:

图片

为聊天消息添加安全规则

在本节中,我们将启用聊天消息的安全规则。消息详情节点包含两个标识符,如下所示:

  • $identifierKey:第一个是标识符键,用于会话中的用户,并且这个键也存储在用户详情节点中。在以下示例中,"-L-0uxNuc6gC95iQytu9"是标识符键。

  • $messageKey:第二个是消息键,在我们向节点推送新消息时生成。在以下示例中,"-L-125Am3LVQQQiN_xlG"是消息键:

"message_details" : {
  "-L-0uxNuc6gC95iQytu9" : {
   "-L-125Am3LVQQQiN_xlG" : {
     "message" : "Hello",
     "receiverUid" :"2HIvnEJvN0O03PtByU2ACBhSMDe2",
     "senderUid" : "YnmOB5rTAwVErXcmMuJkHDEb4i92",
     "timestamp" : 1511862854520
   }
  }
 }

我们将为消息详情节点定义以下安全规则:

  • 读取权限:我们只授予认证用户读取权限

  • 写入权限:我们授予认证用户写入权限,并在数据推送发生之前检查是否存在任何新数据

  • 验证:我们验证消息中的所有字段,以确保在插入任何新数据时数据完整性得到保持,如下所示:

图片

最后,我们将在模拟器中验证新规则,以检查它们是否有效:

图片

索引用户和好友

Firebase 通过使用任何常见的子键收集节点来提供数据的查询和排序。当数据增长时,这个查询会变慢。为了提高性能,Firebase 建议你在特定的子字段内进行索引。Firebase 将键索引到服务器以提高查询性能。

作为本节的一部分,我们将对我们的用户数据进行索引,以便搜索或找到好友,这在任何社交应用中都很常见。为了实现这一点,我们将执行以下任务:

  • 在用户数据的名称字段中创建索引:我们在用户数据的名称字段中提供了一个索引。我们将使用.indexOn标签为名称字段,如下所示:

图片

  • 创建基于文本查询数据的服务:在这个任务中,我们将根据搜索文本查询用户数据。我们将提供orderByChild作为用户的名称字段。

这是friends-search.service.ts

import {Injectable} from '@angular/core';
import {AngularFireDatabase} from 'angularfire2/database';
import {Observable} from 'rxjs/Observable';
import {User} from './user';
import {FRIENDS_CHILD, USER_DETAILS_CHILD} from './database-constants';

/**
 * Friends search service
 *
 */
@Injectable()
export class FriendsSearchService {

  constructor(private db: AngularFireDatabase) {
  }

  getSearchFriends(start, end): Observable<User[]> {
    return this.db.list<User>('/users',
      ref => ref.orderByChild('name').limitToFirst(10).
      startAt(start).endAt(end)
    ).valueChanges();
  }

}
  • 修改模板:我们修改应用模板,以便在搜索文本下方显示搜索结果的下拉菜单。

这是修改后的app.component.html文件:

<h1 class="title">Friends - A Social App</h1>
<div class="nav-container">
<nav class="navbar navbar-expand-lg navbar-light bg-color">
  <div class="collapse navbar-collapse" id="navbarNav">
    ...
    <div class="form-container">
    <form class="form-inline my-2 my-lg-0">
      <div class="dropdown">
        <input class="form-control mr-sm-2" type="text" 
         (keyup)="onSearch($event)" name="searchText"
         data-toggle="dropdown" placeholder="Search friends..." 
         aria-label="Search">
        <div class="dropdown-menu" aria-
         labelledby="dropdownMenuButton">
          <div class="list-group" *ngFor="let user of users">
            <div class="list-group-item list-group-item-action 
             flex-column align-items-start">
              <div class="d-flex w-100 justify-content-between">
                <label>{{user?.name}}</label>
                <button type="button" class="btn btn-light" 
                 (click)="onAddFriend(user)">ADD</button>
              </div>
            </div>
          </div>
        </div>
      </div>
      <button class="btn btn-success my-2 my-sm-0" 
       type="submit">Search</button>
    </form>
    </div>
  </div>
</nav>
</div>
<router-outlet></router-outlet>
  • 修改组件:当应用程序组件加载时,我们在ngOnInit()方法中查询所有用户,当用户点击搜索文本框时,用户列表会显示所有名称。我们还通过用户类型在文本框中过滤列表,然后调用onSearch()方法,并使用查询范围查询 Firebase 数据库。

这是到目前为止完整的app.component.ts文件:

import {Component, OnInit} from '@angular/core';
import {AuthenticationService} from './services/authentication.service';
import {User} from './services/user';
import {FriendsSearchService} from './services/friends-search.service';

@Component({
  selector: 'app-friends',
  styleUrls: ['app.component.scss'],
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {

  startAt: string;

  endAt: string;

  users: User[];

  searchText: string;

  authenticationService: AuthenticationService;

  constructor(private authService: AuthenticationService,
              private friendsSearchService: FriendsSearchService) {
    this.authenticationService = authService;

  }

  ngOnInit() {
    console.log(this.currentLoginUser);
    this.searchText = '';
    this.onSearchFriends(this.searchText);
  }

  onSearch(event) {
    const text = event.target.value;
    this.onSearchFriends(text);
  }

  onSearchFriends(searchText) {
    const text = searchText;
    this.startAt = text;
    this.endAt = text + '\uf8ff';
    this.friendsSearchService.getSearchFriends(this.startAt, 
    this.endAt)
      .subscribe(users => this.users = users);
  }
}

\uf8ff字符是 Unicode 范围内的一个非常高的代码点,它允许您匹配以您的搜索文本开头的所有值。

设置多个环境

当我们的应用程序准备部署时,我们需要为开发和生产环境分别分离 Firebase 项目,以便在部署到生产环境之前在开发环境中测试我们的代码更改。我们将遵循以下步骤来设置一个单独的环境:

  • 在 Firebase 中创建新的预发布项目:因为我们不能使用相同的 Firebase 功能,例如数据库和存储,所以我们需要分别设置生产和预发布环境。然后我们将创建一个名为friends-staging的新项目;该项目具有新的 Firebase 环境变量:

图片

  • 创建新的环境变量:新的 Firebase 项目有一个新的环境变量,您可以从 Firebase 项目中获取配置。因此,导航到项目概览 | 项目设置 | 将 Firebase 添加到您的 Web 应用。

将以下代码复制到新的环境文件中,如下所示;我们有两个环境文件用于预发布和生产:

  • environment.prod.ts文件将生产环境设置为true,这用于生产环境:
export const environment = {
   production: true,
   firebase: {
      apiKey: 'XXXX',
      authDomain: 'friends-4d4fa.firebaseapp.com',
      databaseURL: 'https://friends-4d4fa.firebaseio.com',
      projectId: 'friends-4d4fa',
      storageBucket: 'friends-4d4fa.appspot.com',
      messagingSenderId: '321535044959'
   }
};
  • environment.ts文件将生产环境设置为false;这将用于预发布环境:
export const environment = {
   production: false,
   firebase: {
      apiKey: 'XXXX',
      authDomain: 'friends-4d4fa.firebaseapp.com',
      databaseURL: 'https://friends-4d4fa.firebaseio.com',
      projectId: 'friends-4d4fa',
      storageBucket: 'friends-4d4fa.appspot.com',
      messagingSenderId: '321535044959'
   }
};
  • 安装 Firebase 工具:一旦创建了一个新的 Firebase 项目,您将需要安装 Firebase 工具并使用以下命令登录 Firebase 门户:
$ npm install -g firebase-tools
$ firebase login

上述命令将打开您的 Gmail 权限页面;点击 ALLOW 将允许列出所有可用的项目:

图片

  • 使用新的 Firebase 项目:一旦我们给予权限,我们需要根据当前使用的环境添加可用的项目。假设我们需要在开发中的新功能下进行测试,我们可以选择一个测试环境,并为测试环境提供一个别名名称以供将来使用。我们可以使用别名名称切换环境,如下所示:
$ firebase use --add
$ firebase use staging

这将在基本项目目录中创建.firebaserc文件,其外观如下:

{
  "projects": {
    "default": "friends-4d4fa",
    "staging": "friends-staging"
  }
}

在 Firebase 上托管朋友应用程序

Firebase 支持作为服务提供托管,在 Firebase 上部署应用程序很容易。大多数应用程序采用两阶段部署,即首先进行测试,然后是生产。一旦我们在测试环境中测试了应用程序,我们就可以将其部署到生产环境中。部署应用程序的步骤如下:

  1. 第一步是构建应用程序,这会创建一个包含index.html和其他所需文件的dist文件夹。您只需添加一个--prod选项即可用于生产构建:
$ ng build
  1. 下一步是初始化应用程序项目。我们将执行 init 命令,并在命令提示符中使用空格键选择 Firebase 功能;对于我们的朋友应用程序,我们将使用 Firebase 的数据库、存储和托管功能,当我们选择相应的功能时,它将创建默认的数据库和存储规则:
$ firebase init

它还会创建一个firebase.json文件,其外观如下:

{
  "hosting": {
    "public": "src",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ]
  },
  "database": {
    "rules": "database.rules.json"
  },
  "storage": {
    "rules": "storage.rules"
  }
}
  1. 最后,我们使用以下命令部署我们的应用程序;一旦我们的应用程序部署完成,我们就可以在 Firebase 托管的项目中查看已部署的应用程序:
$ firebase deploy

已部署的应用程序将出现在 Firebase 门户中。您可以通过导航到 DEVELOP | Hosting,在右侧面板中查看已部署的应用程序,如下所示:

  1. 最后,您可以使用以下命令或通过粘贴 URL 作为friends-staging.firebaseapp.com打开您的实时应用程序:
$ firebase open

摘要

在本章中,我们介绍了 Firebase 的安全机制。我们为我们的朋友应用程序数据库添加了安全规则,使我们的应用程序更加安全。我们为数据库中的用户节点name字段建立了索引,以便搜索查询更快。然后我们在朋友应用程序中使用了搜索 API。最后,我们为我们的应用程序创建了多个环境,以便我们能够将测试和生产分离。然后我们在 Firebase 上部署了我们的应用程序。

在下一章中,我们将学习 Firebase 云消息、Google 分析以及广告。

第十三章:使用 Firebase 扩展我们的应用程序

在本章中,我们将探讨 Firebase 如何提供云消息以吸引我们的用户。我们将向我们的应用程序添加 Firebase 云消息功能。我们将介绍 Google 分析,它提供了一个良好的仪表板来分析我们的应用程序并相应地采取行动,因为这有助于进一步改进我们的应用程序。最后,我们将讨论 Google 广告,它有助于使我们的应用程序货币化。

在本章中,我们将涵盖以下主题:

  • Firebase 云消息简介

  • 将 FCM 添加到我们的应用程序

  • Google 数据分析

  • 了解 Google 广告

Firebase 云消息简介

Firebase 云消息 (FCM) 是一个跨平台服务,用于在不同平台之间可靠地传递消息。它使用推送方法发送消息,我们可以向客户端发送多达 4 KB 的数据。它支持许多用例,例如通过促销消息与用户互动,并在用户处于后台时发送消息。

它有两个主要模块:

  • 受信任的服务器:此服务器用于向客户端发送消息,可以是 Firebase 控制台或服务器 SDK 实现。

  • 客户端应用程序:这包括 Web、Android 或 iOS 中的客户端应用程序。它从受信任的服务器接收消息。

将 FCM 添加到我们的应用程序

在本节中,我们将配置我们的应用程序中的 FCM。我们将执行以下步骤来配置 FCM:

  1. 创建一个 manifest.json 文件:我们的第一步是在src文件夹内创建一个manifest.json文件。此文件包含gcm_sender_id,它授权客户端访问受信任的 FCM 服务器,并使 FCM 能够向我们的应用程序发送消息。对于桌面浏览器,客户端 ID——103953800507——对于 Web 应用程序是固定的,因此您不需要更改它。

网页清单文件是一个简单的 JSON 文件,在其中我们可以指定应用程序的配置,例如其名称、显示和方向。

这是manifest.json的代码:

{
   "name": "Friends",
   "short_name": "Friends",
   "start_url": "/index.html",
   "display": "standalone",
   "orientation": "portrait",
   "gcm_sender_id": "103953800507"
}
  1. index.html 中配置 manifest.json:一旦我们创建了 manifest 文件,我们就将文件引用包含在index.html中。

然后我们包含修改后的index.html

<!DOCTYPE html>
<html>
   <link rel="manifest" href="/manifest.json">
</head>
<body>
<app-friends>
   Loading...
</app-friends>
</body>
</html>
  1. 创建服务工作者:在我们创建 manifest 文件之后,我们创建一个 Firebase 服务工作者来处理来自受信任服务器的传入推送消息,并将我们的 Firebase 应用程序与消息发送者ID注册,我们可以通过导航到项目概览 > 项目设置 > 云消息获取此ID

服务工作者是一种在后台运行的 Web 工作者,它有助于推送通知。

现在的firebase-messaging-sw.js文件如下:

importScripts('https://www.gstatic.com/firebasejs/3.9.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/3.9.0/firebase-messaging.js');

firebase.initializeApp({
   'messagingSenderId': '807434545532'
});

const messaging = firebase.messaging();
  1. angular-cli.json 中引用 manifest 和 service worker:接下来,我们在angular-cli.json中提及服务工作者和 manifest 文件的引用;以下是被修改的angular-cli.json
...
"apps": [
   {
      "assets": [
         "assets",
         "favicon.ico",
         "firebase-messaging-sw.js",
         "manifest.json"
      ],
      ...
   }
]
  1. 创建 FCM 服务:此服务类用于接收客户端令牌并将令牌插入 Firebase 数据库。它还用于在令牌过期时注册令牌刷新。创建此服务类的步骤如下:

第一个步骤是通过警报对话框获取用户通知权限,一旦用户点击允许按钮,我们就从 Firebase 消息对象调用getToken()来获取令牌。我们将此令牌发送到 Firebase 数据库,以便我们可以在将来使用此令牌向所有用户发送促销消息。我们还创建了一个onTokenRefresh()方法,以便在令牌过期时刷新我们的令牌。

第二个步骤是在应用处于前台时调用onMessage()来注册推送通知消息。

现在的fcm-messaging.service.ts文件如下:

import {Injectable} from '@angular/core';
import {AngularFireDatabase} from 'angularfire2/database';
import {AngularFireAuth} from 'angularfire2/auth';
import 'firebase/messaging';

@Injectable()
export class FcmMessagingService {

   messaging = null;

   constructor(private angularFireDatabase: AngularFireDatabase, 
   private afAuth: AngularFireAuth) {
      this.messaging = angularFireDatabase.app.messaging();
   }

   getPermission() {
      this.messaging.requestPermission()
         .then(() => {
            console.log('Permission granted.');
            this.getToken();
         })
         .catch((err) => {
            console.log('Permission denied', err);
         });
   }

   getToken() {
      this.messaging.getToken()
         .then((currentToken) => {
            if (currentToken) {
               console.log(currentToken);
               this.sendTokenToServer(currentToken);
            } else {
               console.log('No token available');
            }
         })
         .catch((err) => {
            console.log('An error occurred while retrieving token. 
            ', err);
         });
   }

   onMessage() {
      this.messaging.onMessage((payload) => {
         console.log('Message received. ', payload);
      });
   }

   onTokenRefresh() {
      this.messaging.onTokenRefresh(function () {
         this.messaging.getToken()
            .then(function (refreshedToken) {
               console.log('Token refreshed.');
               this.sendTokenToServer(refreshedToken);
            })
            .catch(function (err) {
               console.log('Unable to retrieve refreshed token ', 
               err);
            });
      });

   }

   sendTokenToServer(token) {
      this.afAuth.authState.subscribe(user => {
         if (user) {
            const data = {[user.uid]: token};
            this.angularFireDatabase.object('fcm-
            tokens/').update(data);
         }
      });
   }
}
  1. 在应用组件中注册 Firebase 消息以更新:一旦我们创建了服务方法,我们就调用用户权限、令牌并在我们的应用组件中注册消息更新,如下面的app.component.ts所示:
import {Component, OnInit} from '@angular/core';
import {AuthenticationService} from './services/authentication.service';
import {FcmMessagingService} from './services/fcm-messaging.service';

@Component({
   selector: 'app-friends',
   styleUrls: ['app.component.scss'],
   templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {

   ...
   authenticationService: AuthenticationService;

   constructor(private authService: AuthenticationService,
            private friendsSearchService: FriendsSearchService,
            private fcmService: FcmMessagingService) {
      this.authenticationService = authService;
   }

   ngOnInit() {
      this.fcmService.getPermission();
      this.fcmService.onMessage();
      this.fcmService.onTokenRefresh();
   }
    ...
}

最后,我们的应用程序现在已准备好发送推送通知消息。您可以通过 curl 命令或 Postman 请求发送消息。

要从 Postman 发送推送通知,我们需要以下详细信息:

  • URL:这是一个已在我们可信服务器上注册的 FCM 端点。其唯一的 URL 是fcm.googleapis.com/fcm/send

  • 内容类型:这是发送到服务器的内容类型,在我们的案例中是 JSON 类型,作为application/json

  • 授权:这是我们的 Firebase 项目的服务器密钥。您可以通过导航到 Firebase 门户中的项目概览|项目设置|CLOUD MESSAGING|服务器密钥来找到此密钥。

  • 正文:这包含标题、正文、操作和目标发送者ID。发送者令牌 ID 已保存在我们的 Firebase 数据库中。

这是一个 Postman 请求的 Headers 标签的示例:

Postman 的 Body 标签将如下所示:

出现在您屏幕右下角的提示应该如下所示:

Google 数据分析

Google 分析是 Google 提供的一项免费服务,它提供了关于我们网站访问者和流量的统计数据。它提供了关于访问者和地理的更有价值的信息。它还提供了关于访问者在使用我们的网站时的行为信息。以下是将 Google 分析注册到您的应用程序的步骤。

创建 Google 分析账户:我们可以通过执行以下步骤使用现有的 Gmail 账户或新 Gmail 账户创建 Google 分析账户:

  1. 打开浏览器并粘贴分析 URL (analytics.google.com/analytics)

  2. 点击注册按钮

  3. 填写您的实时应用程序 URL 和表单信息

  4. 点击获取跟踪 ID按钮

将跟踪代码集成到我们的应用程序中:在成功注册后,我们可以将生成的全局站点标签集成到我们的应用程序中。请查看以下 index.html 中的示例全局站点代码:

<head>
...
<script async src="img/js?id=UA-108905892-1"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', 'UA-108905892-1');
</script>
...
</head>

在成功注册后,我们的 Google 分析控制台应该看起来像这样:

了解 Google Adsense

Google Adsense 为我们的网络应用程序提供了一个盈利平台。Firebase 广告不支持此网络应用程序,因此我们将与 Google Adsense 合作以使我们的应用程序盈利。在本节中,我们将探讨如何将广告添加到我们的应用程序中。

创建 Adsense 账户:我们可以使用现有的 Gmail 账户创建 Adsense 账户,或者通过以下步骤创建一个新的 Gmail 账户:

  1. 打开浏览器并粘贴 Adsense URL (www.google.com/adsense )

  2. 点击注册按钮

  3. 填写您的实时应用程序 URL 和地址详情

  4. 点击提交按钮

将 Adsense 脚本添加到我们的应用程序中:当您点击提交按钮时,Google Adsense 将提供步骤以将您的网站注册为广告。它提供了代码,需要粘贴到我们的应用程序的 index.html 中。请查看以下示例脚本,以粘贴到 index.html 中:

<head>
...
<script async src="img/adsbygoogle.js"></script>
<script>
  (adsbygoogle = window.adsbygoogle || []).push({
    google_ad_client: "ca-pub-6342144115183345",
    enable_page_level_ads: true
  });
</script>
...
</head>

在成功注册后,我们的 Google Adsense 控制台看起来如下:

摘要

在本章中,我们介绍了 Firebase 云消息传递。我们将 FCM 集成到我们的应用程序中,这有助于吸引我们的用户。我们还介绍了 Google 分析,并展示了如何在我们的应用程序中启用分析。这为我们提供了关于应用程序使用的良好视角。最后,我们讨论了 Google Adsense,它有助于使我们的应用程序盈利。

在下一章中,我们将讨论渐进式网络应用PWA)并添加一些功能以使我们的应用程序符合 PWA 规范。

第十四章:将我们的应用转化为 PWA

渐进式 Web 应用PWA)是开发 Web 应用的一种新方式。作为本章的一部分,你将了解 PWA 并探索使应用符合 PWA 规范的功能。作为其中的一部分,我们将把我们的朋友应用添加到移动主屏幕上,这样我们的朋友应用就成为了其他原生移动应用的组成部分。我们还将涵盖我们应用的离线模式,以便我们可以向用户展示可浏览的数据。最后,我们将使用Lighthouse工具对我们的应用进行审计,该工具为我们提供了关于我们渐进式 Web 应用的宝贵见解。

在本章中,我们将涵盖以下主题:

  • PWA 简介

  • 服务工人简介

  • 将我们的应用添加到手机主屏幕

  • 启用离线模式

  • 使用 Lighthouse 进行合规性测试

PWA 简介

PWA 是一种使用增强功能为用户提供类似移动应用体验的 Web 应用。这种 Web 应用满足某些要求,并部署到 Firebase 服务器上,通过 URL 可访问并由 Google 搜索引擎索引。近年来,渐进式 Web 应用开发是一种范式转变,使您的 Web 应用能够普遍可用。

以下是一些使应用符合 PWA 规范的功能:

  • 网站和应用的强大功能:该应用针对移动设备和浏览器进行了优化,可以完美地工作。它具有移动应用的所有功能,例如离线模式和推送通知。

  • 无需应用商店:类似于网站,我们不需要应用商店,并且可以通过最新的软件更新立即使用。

  • 类似应用:Web 应用看起来像移动应用。它与其他移动应用一起出现,并以正常应用的方式占据整个屏幕。

  • 连接无关性:这些应用不依赖于网络类型。它们在弱网络环境下也能很好地工作,为用户提供无缝的使用体验。

  • 主屏幕添加:这允许用户将我们的网站添加到他们的主屏幕上,使其成为应用家族的一部分。用户可以频繁启动应用而无需打开浏览器。

  • 安全:此应用在 HTTPS 上运行,因此它们免受攻击或黑客攻击。

  • 推送通知:随着服务工人的出现,可以向 Web 应用发送推送通知。这对与我们的应用吸引用户非常有帮助。

  • 可搜索:类似于网站,此应用可以通过 Google 搜索进行搜索。我们可以通过关键词优化我们的网站,以便 PWA 应用可被用户识别并轻松搜索。

  • 可链接:这种应用可以通过链接轻松分享,就像普通 Web 应用一样。

服务工人简介

服务工作者(service worker)是一个在后台运行的脚本。此后台脚本不与 DOM 元素交互。这有助于支持推送通知和离线模式等功能,并且服务工作者将在未来得到大幅增强以支持其他新功能。

以下是为服务工作者(service worker)的先决条件:

  • 浏览器支持: 服务工作者在 Chrome、Firefox 和 Opera 浏览器中得到支持,并且对其他浏览器的支持也将很快扩展。

  • HTTPS 支持: 超文本传输协议安全(HTTPS)是 HTTP 的安全版本,也是 PWA 的先决条件之一。这确保了浏览器和服务器之间所有通信都是加密的。

将我们的应用程序添加到手机主屏幕

这是渐进式 Web 应用程序(Progressive Web Apps)最重要的功能之一,提供了许多优势,如下所示:

  • 更易访问: 用户通常将最常用的应用程序放在主屏幕上,因为这提供了更方便的应用程序访问。

  • 参与度: 用户可以更频繁地与我们的应用程序互动。

使我们的 Web 应用程序出现在主屏幕上的步骤如下:

  1. 为了使我们的 Web 应用程序具有移动应用的外观,我们将按照以下代码修改 manifest.json 文件。

  2. 使用从 Firebase 门户提供的已部署应用程序 URL 在您的手机 Chrome 浏览器中打开朋友应用。页面会提示您将应用程序添加到主屏幕。

这是 manifest.json 文件:

{
   "name": "Friends",
   "short_name": "Friends",
   "icons": [
      {
         "src": "/assets/images/android-chrome-192x192.png",
         "sizes": "192x192",
         "type": "image/png"
      }
   ],
   "theme_color": "#689f38",
   "background_color": "#689f38",
   "start_url": "/index.html",
   "display": "standalone",
   "orientation": "portrait",
   "gcm_sender_id": "103953800507"
}

查看以下属性的详细描述:

  • name: 当添加到主屏幕的横幅出现,并且 Chrome 提供修改名称的选项时,此名称将显示。

  • short_name: 这将出现在手机主屏幕中的应用程序图标下方。在我们的应用程序中,nameshort_name 是相同的。

  • icons: 根据 PWA 标准,推荐的图标大小为 192 x 192,此图标将出现在手机主屏幕上。

  • background_color: 指定的背景颜色将作为图标的背景颜色显示。

  • theme_color: 当用户点击主屏幕上的朋友应用时,此颜色将出现在您的移动应用启动屏幕上。

  • display: 当页面打开时,Android Chrome 会提供原生样式,因为它移除了导航栏并将标签页切换到任务切换器。

  • start_url: 此页面是 Web 应用程序中的 index.html,通常这是我们主页。

  • orientation: 这强制执行纵向或横向方向。

我们的应用程序在手机主屏幕上的外观如下所示:

我们应用程序的启动屏幕如下所示:

启用离线模式

在本节中,我们将介绍如何为我们的应用程序启用离线模式,这有助于用户在没有互联网连接的情况下打开我们的 Web 应用程序。

为了支持离线模式,我们必须在客户端浏览器中缓存资源,为此,我们使用 precache 插件通过服务工作者来缓存我们的资源。它使用 sw-precache 创建服务工作者文件。涉及的步骤如下:

  1. 安装插件:第一步是在我们的当前项目中使用以下命令安装 precache 插件:
$npm install --save-dev sw-precache-webpack-plugin
  1. 创建预缓存 JavaScript:precache 插件使用 precache 配置文件来定义客户端浏览器中要缓存的资源。有关 precache 插件的更多详细信息,请参阅 github.com/goldhand/sw-precache-webpack-plugin

这是完整的 precache.config.js

var SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
module.exports = {
 navigateFallback: '/index.html',
 navigateFallbackWhitelist: [/^(?!\/__)/],
 stripPrefix: 'dist',
 root: 'dist/',
 plugins: [
  new SWPrecacheWebpackPlugin({
   cacheId: 'friend-cache',
   filename: 'service-worker.js',
   staticFileGlobs: [
    'dist/index.html',
    'dist/**.js',
    'dist/**.css'
   ],
   stripPrefix: 'dist/assets/',
   mergeStaticsConfig: true
  }),
 ]
};
  1. 配置 package.json:一旦我们创建了配置文件,我们需要创建一个新的构建标签称为 pwa 并在 package.json 中引用缓存文件。

这是修改后的 package.json

...
"scripts": {
  "ng": "ng",
  "start": "ng serve",
  "build": "ng build",
  "test": "ng test --sourcemaps false",
  "coverage": "ng test --sourcemaps false --watch=false --code-coverage",
  "lint": "ng lint",
  "e2e": "ng e2e",
  "pwa": "ng build --prod && sw-precache --root=dist --config=precache-config.js"
}
...
  1. 注册服务工作者:一旦我们创建了新的构建,我们需要在 index.html 中注册由 precache 插件创建的服务工作者,如下所示。这是修改后的 index.html
...
body>
  <app-root></app-root>

  <script>
    if ('serviceWorker' in navigator) {
      console.log("Will the service worker register?");
      navigator.serviceWorker.register('/service-worker.js')
        .then(function(reg){
          console.log("Service Worker Registered");
        }).catch(function(err) {
        console.log("Service Worker Not Registered: ", err)
      });
    }
  </script>
</body>
...
  1. 运行新的构建脚本:一旦我们配置了服务工作者,我们可以使用以下命令运行生产构建;这将创建所有包含服务工作者的文件到它们的分发文件夹中:
$ng pwa
  1. 部署:最后,我们将新创建的文件部署到 Firebase。一旦部署,我们可以在手机的首页上打开应用程序,并且它将在客户端浏览器中缓存所有必需的资源。

使用 Lighthouse 进行合规性测试

Lighthouse 是一个开源的自动化工具。它审计应用程序的性能、可访问性、渐进式网络应用程序等。这可以通过 Chrome 开发者工具中的“审计”标签获得。因此,前往 Chrome 开发者工具,然后打开“审计”标签,并点击“执行审计...”按钮

为了在我们的应用程序中看到改进,我们可以在这个工具的两个阶段中使用它:

  • 无需任何 PWA 变更:我们可以在我们的应用程序中不进行任何前面的更改运行此工具,并查看性能。由于我们的应用程序不符合 PWA 标准,我们的分数将不会很好。

查看以下截图,显示我们运行 Lighthouse 时的分数——它在五个审计中失败,分数以红色显示:

  • 应用 PWA 变更:现在,在我们的朋友应用程序中应用本章中讨论的所有 PWA 变更,然后运行此工具并查看我们的审计性能。如图所示,我们的 PWA 分数为 82,并以绿色显示:

摘要

在本章中,我们讨论了渐进式网络应用。我们涵盖了 PWAs 及其所有关键特性。我们讨论了支持推送通知、离线模式等服务工作者。我们增强了我们的网络应用的 manifest.json,并将我们的应用程序添加到手机主屏幕。我们使用 sw-precache 插件启用了离线缓存。最后,我们使用 Lighthouse 工具评估了我们的应用程序的 PWA 合规性。

最后,我们来到了本书的结尾,但这并不是网络应用开发的终点。本书向您介绍了一种实用的 Angular 和 Firebase 方法。您需要将这一知识传承下去,开发另一个实时应用程序;这将给您带来极大的信心。

祝您使用 Angular 和 Firebase 的下一个应用程序一切顺利!

posted @ 2025-09-05 09:24  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报