[前端框架] Angular - HTTP 請求、身分驗證、動態元件


本篇深入探討一些 REST API 串接相關技術應用觀念,並使用 Firebase 做為後端實作端點。以及相關用戶身分認證的通用作法,包含 token 驗證機制與路由守衛規劃。以及示範如何透過將靜態元件改為一組動態創建的元件根據事件過程產生。

Http Request 伺服器請求

本節將介紹如何跟後端進行連線作業,透過 API 的方式進行串接。而 API 常見的 Server 連線方式有 REST 或 GraphQL 等,我們將以 REST 方式進行教導,以及搭配免費的虛擬後端做測試,減少開發練習的環境複雜性。
前端的 API 是指用於在網頁應用程式中通過瀏覽器與伺服器進行溝通的程式接口。API 可以是一個單獨的 JavaScript 函數,也可以是一組由伺服器提供的 RESTful API 端點。

API 基本觀念

前端的 API 通常是用於從伺服器獲取數據,並將其用於呈現網頁上的內容。例如,當用戶在網頁應用程式中進行搜索時,前端的 API 將向伺服器發送請求,以獲取符合搜索條件的結果。伺服器端會處理這個請求,返回符合條件的數據,然後前端的 API 將這些數據轉換為適當的格式,例如 JSON 或 XML,以便在網頁上顯示。

除了從伺服器獲取數據,前端的 API 還可以用於將數據發送到伺服器,例如在使用者提交表單時。這種情況下,前端的 API 會將表單數據轉換為適當的格式,例如 JSON 或表單編碼,然後將其發送到伺服器上的 API 端點進行處理。伺服器端會處理這個請求,然後返回適當的響應,例如成功或錯誤信息。

總的來說,前端的 API 是一個關鍵的技術,它使得網頁應用程式能夠與伺服器進行通信,以獲取數據和處理用戶操作。API 主要是由前端透過 http request 向後端做資料請求,透過後端回應的 http response 取得資料庫資料,再進行資料處理回饋到 DOM 上呈現。

HTTP Request

HTTP Request 是客戶端向伺服器發送請求的格式,用於請求特定的資源或服務。一個 HTTP Request 包含三部分:

  • Request Line:包含請求方法、URL 和協議版本。常用的請求方法有 GET、POST、PUT、DELETE 等。
  • Request Headers:包含一系列請求頭部資訊,用於描述請求的內容、格式、身份驗證等資訊,如 User-Agent、Host、Accept、Content-Type 等。
  • Request Body:可選的請求體,包含發送到伺服器的資料,例如表單資料、JSON 資料等。

Request Line

Request Line(請求行)是 HTTP Request 中的第一部分,用於描述客戶端向伺服器發送的請求方法、URL 和協議版本等基本資訊。Request Line 的格式如下:

# <Method> <URL> <HTTP Version>
GET /index.html HTTP/1.1
  • Method 是客戶端使用的請求方法,常見的有 GET、POST、PUT、DELETE 等;
  • URL 是客戶端要訪問的資源地址,可以是絕對 URL 或相對 URL;
  • HTTP Version 是 HTTP 協議的版本,通常是 HTTP/1.0 或 HTTP/1.1。

而這個 Request Line 例子,請求方法是 GET,URL 是 /index.html,HTTP 協議版本是 HTTP/1.1。當客戶端向伺服器發送這個 Request Line 時,伺服器會根據這些資訊來找到對應的資源並返回相應的內容。

Request Headers

Request Headers(請求頭部)是 HTTP Request 中的第二部分,用於描述客戶端向伺服器發送的請求的額外資訊,例如 User-Agent、Accept、Content-Type 等。以下是一個 HTTP Request Headers 的例子:

GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

這個 Request Headers 中,包含了 Host、User-Agent、Accept、Accept-Encoding 和 Connection 等頭部資訊。這些頭部資訊可以告訴伺服器一些額外的資訊,例如客戶端使用的瀏覽器類型、網頁編碼格式、支援的資源類型等。

Request Body

Request Body(請求主體)是 HTTP Request 中的第三部分,通常用於在客戶端向伺服器發送資料。Request Body 的格式取決於請求的內容類型,例如在發送 JSON 格式的資料時,可以使用 application/json 作為 Content-Type,並在 Request Body 中以 JSON 格式傳遞資料。另外,Request Body 也可以是空的,例如當客戶端向伺服器發送 GET 請求時,通常是不需要 Request Body 的。

POST /api/data HTTP/1.1
Host: www.example.com
Content-Type: application/json

{
"name": "John Doe",
"age": 30,
"email": "johndoe@example.com"
}

這個 HTTP Request 中使用了 POST 方法,Content-Type 是 application/json,Request Body 中包含了一個 JSON 物件。當客戶端向伺服器發送這個請求時,伺服器會根據 URL 和 Request Body 中的資訊,來處理請求並返回相應的結果。

環境建置準備

本篇的起始素材放置於 GitHub 底下提供使用,使用資料目錄 lokiHttp 作為初始環境。下載請記得 npm install 初始化環境。

Github download at lokiHttp-start Folder

另外,為了練習與後端連線,這裡會借用 Google 提供的 FireBase 進行後端模擬使用。請先註冊免費會員並根據以下步驟進行準備。

  • 登入帳號,並建立專案名稱自訂與完成專案
  • 來到專案頁面選擇 Realtime Database 並建立可即時性的資料庫,選擇新加坡網速會稍快些。
  • 選擇以測試模式啟動,使得全公開可以任意連線(練習用)。此時可見到 URL 請求網址使用。




Firebase
可以用作網站的真實後端。Firebase 提供了一個全面的後端解決方案,包括用戶身份驗證、資料庫、雲端存儲、主機、分析和通知等功能,這些功能可以幫助你快速構建高效可靠的網站。

Firebase 的資料庫功能提供了實時數據庫(Realtime Database)和雲端 FireStore 兩個選項,這些都是 NoSQL 資料庫,可以存儲結構化和非結構化數據。Firebase 還提供了用戶身份驗證,可以通過電子郵件、社交媒體等方式進行註冊和登錄,以及用於管理和保護數據的安全規則。Firebase 的主機功能可以部署和管理網站代碼,而分析和通知功能可以幫助你了解用戶行為和互動並進行即時反饋。

總體而言,Firebase 是一個強大而靈活的後端解決方案,可以滿足大多數網站的需求。

基本 POST/GET/DELETE

此小節透過基本的 post 方式與 get 方式進行 api 測試,以 REST 之協定觀念上,post 主要用於新增資料,而 get 作為請求資料(可以是全部或是一筆)。

POST 請求

從素材之中,已規劃好 TD 表單並提供 title 與 content 的表單提交,本節將示範如何透過 API 進行 HTTP Request 進行 POST 發送。首先要進行之前,需進行先掛載 HttpClient 模組。

app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http'; //※重點

import { AppComponent } from './app.component';

@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
FormsModule,
HttpClientModule //※重點
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

現在就能再 app.component.ts 內進行使用 HttpClient 使用。注意以下要點:

  • 於建構子建立本地變數為 http 以及型別為 HttpClient
  • 於 submit 處 onCreatePost 進行發送 post 請求,注意這裡的 URL 因 firebase 的 no-SQL 規則要求填寫為 posts.json。這不是標準 TEST 方法,這要取決真實後端的 URL 規定。
  • 第二參數為 body,傳送 json 表單資料,以目前的設定簡略,Angular 會自動整理判斷所有 Request 所需要的 Header,Body。包含Content-Type: application/json會自動判定。
  • Angular 內使用 http 必須要進行 subscribe 才能運作,subscribe 能讓我們接受到 response 回來的資料為何。
  • 編寫完嘗試發送出去,觀察 F12 > Network 以及 FireBase 後台資料情況。你應該可觀察到 require 與獲得 response。
app.component.ts
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';//※重點

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
loadedPosts = [];

constructor(
private http: HttpClient //※重點
) { }

ngOnInit() { }

onCreatePost(postData: { title: string; content: string }) {
// Send Http request

// console.log(postData);

this.http.post(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
postData
).subscribe(response => console.log(response));
}
// ...
}

後端會回傳一個 name 參數,這是便於之後要找到該資料時所用。

GET 請求

接著以範例獲取所有資料,透過 GET 方式做請求,此功能規畫於 fetch Button 使用,以及初始化 init 時全部載入。

  • 規劃私有函式 fetchPosts,以 get 方式請求所有資料,這裡不需要發送 require 資料。
  • 將 fetchPosts 觸發於 init 與 onFetchPosts 事件上。
app.component.ts
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
loadedPosts = [];

constructor(
private http: HttpClient
) { }

ngOnInit() {
this.fetchPosts();
}

//...

onFetchPosts() {
// Send Http request
this.fetchPosts();
}

//...

private fetchPosts() {
this.http.get(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json'
).subscribe(response => console.log(response));
}
}

此時載入畫面以及按下 fetch 按鈕都能透過 http get 取得完整資料。

透過 pipe 轉換 response 數據

由於 API 是非同步的訂閱對象,我們可以使用 rxjs 的 pipe 對資料進行轉換,方便於我們的 response 數據轉成前端所需要的資料格式。

舉例來說,回傳的資料格式為了方便整理成以下:

// before
{
"-NQ-JEPX3OgPGMQNGBq0": {
"content": "BB",
"title": "AA"
}
}
// after
[
{
"content": "BB",
"title": "AA",
"id": "-NQ-JEPX3OgPGMQNGBq0"
}
]

因此,於 pipe 管道內,透過 map 試著轉換為陣列方式呈現。

app.component.ts
private fetchPosts() {
this.http.get(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json'
).pipe( //※重點
map(responseData => {
console.log(responseData);
const postAry = [];
for (const key in responseData) {
if (responseData.hasOwnProperty(key))
postAry.push({ ...responseData[key], id: key })
}
return postAry;
})
).subscribe(response => console.log(response));
}

同時,並遵循 TypeScript 試著套入型別。

目前為止都能正常運行,為了遵循 TypeScript 應該對這些資料的往返進行加強型別。試著在原處 post 與 get 的 api 處理上追加型別。

  • 透過 cli 指令ng g i post --type=modelng g interface post --type=model,也可手動建置 post.model.ts
  • 規劃 response 內所出現的三種字串型別。
post.model.ts
export interface PostModel {
title: string;
content: string;
id?: string;
}
  • 造訪 app.component.ts,找到 onCreatePost,原本這裡的型別已宣告成 model,可以抽換為 PostModel。
app.component.ts
// ...
import { PostModel } from './post.model';
// ...
export class AppComponent implements OnInit {
// onCreatePost(postData: { title: string; content: string }) {
onCreatePost(postData: PostModel) { //※重點
this.http.post(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
postData
).subscribe(response => console.log(response));
}
// ...
}
  • 同處找到 fetchPosts,map 之後的陣列會是PostModel[]型別。
  • 而 map 之前的 response 型別,可以規劃為{ [key: string]: PostModel },但不是很優。
  • 同上,我們可以使用 TypeScript 的泛型,在 get 處添加<{ [key: string]: PostModel }>讓 Angular 知道 get 所回傳的型別為何。
app.component.ts
private fetchPosts() {
// this.http.get(
this.http.get<{ [key: string]: PostModel }>( //※重點:方法二
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json'
).pipe(
// map((responseData: { [key: string]: PostModel }) => { //※重點:方法一
map(responseData => {
console.log(responseData);
const postAry: PostModel[] = []; //※重點
for (const key in responseData) {
if (responseData.hasOwnProperty(key))
postAry.push({ ...responseData[key], id: key })
}
return postAry;
})
).subscribe(response => console.log(response));
}

GET 數據顯示畫面上與 Loading 提示

接著將 get 訂閱出來的資料回饋到畫面上,目前已存在一個本地變數為 loadedPosts 試圖回存於此。另外於 html 模板上透過判斷 ngIf 有資料透過迴圈 ngFor 則 list 出來。

app.component.ts
private fetchPosts() {
// this.http.get(
this.http.get<{ [key: string]: PostModel }>(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json'
).pipe(
map(responseData => {
console.log(responseData);
const postAry: PostModel[] = [];
for (const key in responseData) {
if (responseData.hasOwnProperty(key))
postAry.push({ ...responseData[key], id: key })
}
return postAry;
})
).subscribe(response => this.loadedPosts = response); //※重點
}
app.component.html
<div class="row">
<div class="col-xs-12 col-md-6 col-md-offset-3">
<p *ngIf="loadedPosts.length<1">No posts available!</p>
<ul
*ngIf="loadedPosts.length>0"
class="list-group"
>
<li class="list-group-item" *ngFor="let item of loadedPosts">
<h3>{{item.title}}</h3>
<p>{{item.content}}</p>
</li>
</ul>
</div>
</div>

爾後針對 GET 的 API 作業中,增加一個 loading 訊息作為等待提示,只需多宣告一個本地變數 Boolean,在於 require 之前與 response 之後去改變。以及畫面上根據此 Boolean 做對應的顯示即可。

app.component.ts
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs/operators';
import { PostModel } from './post.model';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
loadedPosts = [];
loading = false; //※重點

// ...

private fetchPosts() {
this.loading = true;//※重點
this.http.get<{ [key: string]: PostModel }>(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json'
).pipe(
map(responseData => {
console.log(responseData);
const postAry: PostModel[] = [];
for (const key in responseData) {
if (responseData.hasOwnProperty(key))
postAry.push({ ...responseData[key], id: key })
}
return postAry;
})
).subscribe(response => {
this.loading = false; //※重點
this.loadedPosts = response;
});
}
}
app.component.html
<div class="row">
<div class="col-xs-12 col-md-6 col-md-offset-3">
<p *ngIf="loading">Loading...</p>
<p *ngIf="loadedPosts.length<1 && !loading">No posts available!</p>
<ul
*ngIf="loadedPosts.length>0 && !loading"
class="list-group"
>
<li
class="list-group-item"
*ngFor="let item of loadedPosts"
>
<h3>{{item.title}}</h3>
<p>{{item.content}}</p>
</li>
</ul>
</div>
</div>

Service 規劃

目前設計來說都依賴於 app 元件上過於複雜,因此可將這些 HTTP 工作搬移至 Service 服務去,如此你的元件就相對精簡乾淨專注於與模板 HTML 相關工作。

搬移 POST

  • 透過 CLI 指令ng g s http --skip-tests=true生成 http service
  • 規劃 addPost 來處理原本 http post 相同代碼搬入
  • 傳遞參數的型別可共用 PostModel,同時我們也需要設定 HttpClient 於建構子內
  • 最後修改 component 這裡,改從 Service 這裡處理 http,注意需要設定 httpService 於建構子內
http.service.ts
import { PostModel } from './post.model';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root'
})
export class HttpService {

constructor(
private http: HttpClient
) { }

addPost(postData: PostModel) {
this.http.post(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
postData
).subscribe(response => console.log(response));
}
}
import { HttpService } from './http.service'; //※重點
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs/operators';
import { PostModel } from './post.model';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
loadedPosts = [];
loading = false;

constructor(
private http: HttpClient,
private HttpService: HttpService //※重點
) { }

// ...

onCreatePost(postData: PostModel) {
this.HttpService.addPost(postData); //※重點
// this.http.post(
// 'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
// postData
// ).subscribe(response => console.log(response));
}
// ...
}

搬移 GET

同樣作業搬移 fetchPost 工作部分,與前作業不同的是 GET 所回應的 response 有資料處理需求,而 POST 不需要考慮回應的資料如何處理。因此在 subscribe 部分應由 app 元件做等待,Service 只需處理到 pipe 即可,將整個非同步動作直接 return 給元件做訂閱。而 loading 提示的動作仍由 app 元件處置。

http.service.ts
fetchPost() {
return this.http.get<{ [key: string]: PostModel }>(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json'
).pipe(
map(responseData => {
console.log(responseData);
const postAry: PostModel[] = [];
for (const key in responseData) {
if (responseData.hasOwnProperty(key))
postAry.push({ ...responseData[key], id: key })
}
return postAry;
})
);
// ).subscribe(response => {
// this.loading = false; //※重點
// this.loadedPosts = response;
// });
}
app.component.ts
private fetchPosts() {
this.loading = true;//※重點

this.HttpService.fetchPost().subscribe(response => {
this.loading = false; //※重點
this.loadedPosts = response;
});
// this.http.get<{ [key: string]: PostModel }>(
// 'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json'
// ).pipe(
// map(responseData => {
// console.log(responseData);
// const postAry: PostModel[] = [];
// for (const key in responseData) {
// if (responseData.hasOwnProperty(key))
// postAry.push({ ...responseData[key], id: key })
// }
// return postAry;
// })
// ).subscribe(response => {
// this.loading = false; //※重點
// this.loadedPosts = response;
// });
}

DELETE 請求

同樣的對 clear Button 進行 HTTP 請求刪除所有資料,這部分依賴 Service 完成並回傳,使 app 元件透過訂閱完成後續動作(例如清空變數資料)。

http.service.ts
deletePostAll() {
return this.http.delete(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json'
);
}
app.component.ts
onClearPosts() {
// Send Http request
this.HttpService.deletePostAll().subscribe(() => {
this.loadedPosts = [];
});
}

隨著操作解析,第一次刪除按鈕會要求後端刪除資料,回應後前也刪除資料。隨事後更新按鈕按下則後端回傳空陣列使前端呈現無資料。

Error 事件處置

進行 API 串接時,可能根據一些狀況產生非預期的行為進行回報。HttpClient 在發送 HTTP 請求時,如果收到非 2xx 的狀態碼,就會產生錯誤。錯誤對象包含多個屬性,前端可針對這部分做期望的錯誤考量處置。

首先我們先將 Firebase 的 read 權限進行關閉,使得 API 進行 GET 會發生錯誤的拒絕連線問題。來到 Firebase 網頁後台即時資料庫的規則參數,調整 read 為 false 拒絕寫入。

現在任何的 GET 操作都能F12 > network 發現 401 連線錯誤。接下來會介紹三種方式處理 Error 作業,介紹順序以逐漸由高而推薦。

訂閱 HttpClient 的 HttpErrorResponse

使用 HttpClient 當發生錯誤時,不會進入正常 Response 環節(也就是原本的 pipe 管道不會觸發進行)。而是會以第二組參數回傳。因此訂閱此 httpClient 結果,能在第二個回傳參數做 Error 工作。唯一的缺點是如果沒有使用訂閱去處理此 API,我們會不知道發生什麼事。

若要將錯誤資訊顯示於畫面上,可規劃一個本地變數 errorMsg 為 null,當訂閱於發生錯誤時將錯誤資訊存於此。模板根據邏輯判斷是否有內容則顯示畫面上。

app.component.ts
export class AppComponent implements OnInit {
// ...
errorResponse = null; //※重點
// ...
private fetchPosts() {
this.loading = true;

// 寫法一:
// this.HttpService.fetchPost().subscribe(response => {
// this.loading = false;
// this.loadedPosts = response;
// }, err => { //※重點,於第二組參數做 HttpErrorResponse
// this.loading = false; //※重點:發生錯誤也代表 loading 結束才對
// console.log(err); // ※重點:可觀察看看 HttpErrorResponse 夾帶那些 Spec. 屬性值被使用
// this.errorResponse = err;
// });

// 寫法二:
this.HttpService.fetchPost().subscribe({
next: response => {
this.loading = false;
this.loadedPosts = response;
},
error: err => {
this.loading = false;
console.log(err);
this.errorResponse = err;
}
});
}
}
app.component.html
<div class="row">
<div class="col-xs-12 col-md-6 col-md-offset-3">
<p *ngIf="loading && !errorResponse">Loading...</p> <!--無錯誤時-->
<div class="alert alert-danger" *ngIf="errorResponse"> <!--有錯誤時-->
<strong>Opps! - {{errorResponse.error.error}}</strong> <!--根據 API Error Spec. 狀況而使用 -->
<p>{{errorResponse.message}}</p> <!--根據 API Error Spec. 狀況而使用 -->
</div>
<p *ngIf="loadedPosts.length<1 && !loading">No posts available!</p>
<ul
*ngIf="loadedPosts.length>0 && !loading"
class="list-group"
>
<li
class="list-group-item"
*ngFor="let item of loadedPosts"
>
<h3>{{item.title}}</h3>
<p>{{item.content}}</p>
<button
class="btn btn-danger"
(click)="errorResponse=null"
>Close</button><!--可關閉訊息,消除 errorResponse-->
</li>
</ul>
</div>
</div>

用 Subject 觀察 subscribe

如前面提到,當沒有訂閱者去追蹤 API 的後續我們無法探知問題回報。如我們的 addPost 作業,subscribe 是寫在 service 內,而 app 元件不會得知問題出現。針對這問題可以考慮用 subject 來處理看看。

  • 於 Service 內建立一個 Subject,並於 post 的 errorResponse 內使該 Subject 受到變化
  • 要求初始化生命階段 ngOnInit 下,對該 service 的 Subject 的訂閱結果做 errMsg 的處理。
  • 為了銷毀這個訂閱之效能,於 component 建立一個型別為 Subscription 的變數,使得 ngOnDestroy 階段能找到此訂閱消除之。
http.service.ts
export class HttpService {
errSubject = new Subject<string>(); //※重點
// ...
addPost(postData: PostModel) {
this.http.post(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
postData
).subscribe(
response => console.log(response),
err => this.errSubject.next(err) //※重點
);
}
// ...
}
app.component.ts
import { HttpService } from './http.service';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { PostModel } from './post.model';
import { Subscription } from 'rxjs';//※重點

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit, OnDestroy {
loadedPosts = [];
loading = false;
errorResponse = null;
private errSubject: Subscription;//※重點

constructor(
private http: HttpClient,
private HttpService: HttpService
) { }

ngOnInit() {
this.errSubject = this.HttpService.errSubject.subscribe(errMsg => {//※重點
this.errorResponse = errMsg;
console.log(errMsg);
});
this.fetchPosts();
}

ngOnDestroy() {
this.errSubject.unsubscribe();//※重點
}

//...
}

回到 Firebase 階段,可以把 write 也關閉為 false,使得寫入 POST 失敗。觀察是否新增資料時也呈現於畫面上(回應訊息會跟 Fetch 失敗一樣)。

用 rxjs 的 catchError 處理

如果希望在 service 階段尚針對 error 做處理,可以利用 rxjs 的 catchError 於 pipe 管道內進行特別處理後。使得訂閱者可以獲得經處理過的 error 資訊。這裡額外多利用 throwError 做錯誤代表的 Observable 再回傳給前面提到的 HttpErrorResponse 或 Subject。

http.service.ts
import { catchError, map } from 'rxjs/operators';
import { Subject, throwError } from 'rxjs';
// ...

export class HttpService {
// ...
fetchPost() {
return this.http.get<{ [key: string]: PostModel }>(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json'
).pipe(
map(responseData => {
console.log(responseData);
const postAry: PostModel[] = [];
for (const key in responseData) {
if (responseData.hasOwnProperty(key))
postAry.push({ ...responseData[key], id: key })
}
return postAry;
}),
catchError(errorRes => { //※重點:catchError 可以處理發生錯誤時的處理
return throwError(errorRes); //※重點:產生錯誤的 Observable 並回傳給訂閱者
})
);
}
// ...
}

HttpClient 的相關設置

用於指定 Http 請求的配置選項。它可以包括請求的 URL、Http 的 method(GET、POST、PUT、DELETE 等等)、HttpHeaders、URLSearchParams、request body 等等。可以透過在 Angular 中注入 HttpClient,並使用該物件的 get()、post()、put()、delete() 等方法來發出 Http 請求。在這些方法中,可以傳遞一個選項對象。

HttpHeaders 設定 Header 資訊

在使用 Angular 的 HttpClient 進行 HTTP 請求時,可以透過 HttpHeaders 類別設定 HTTP 請求的 headers,這可以讓我們在發送請求時,提供一些必要的信息,例如認證信息、內容類型等等。HttpHeaders 是不可變對象,需要使用 set 方法來添加或更改單個頭,或使用 append 方法來添加一個或多個頭。可以使用 HttpHeaders 的多種方法來創建或轉換頭。

  • 於 get 或 post 的下一個參數提供 httpOptions 相關資訊。其中重點為 headers 的值
  • 使用 new HttpHeaders() 來建立 Headers 所需套入的各屬性
http.service.ts
import { HttpClient, HttpHeaders } from '@angular/common/http';

// ...
export class HttpService {
// ...
fetchPost() {
return this.http.get<{ [key: string]: PostModel }>(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
{
headers: new HttpHeaders({ //※重點
CustomHeader: 'Loki Jiang',
Authorization: 'my-auth-token'
})
}
).pipe(
map(responseData => {
console.log(responseData);
const postAry: PostModel[] = [];
for (const key in responseData) {
if (responseData.hasOwnProperty(key))
postAry.push({ ...responseData[key], id: key })
}
return postAry;
}),
catchError(errorRes => throwError(errorRes))
);
}
// ...
}

觀察經過 Fetch Button 動作的 F12 > network > Headers > Request Headers 內是否多了兩個自訂屬性。

HttpHeaders 物件是不可變的
一但創建了一個 headers 物件就不能再修改了。如果需要在現有的 headers 基礎上添加新的 headers,可以改使用 append() 方式插入,舉例如下:

http.service.ts
fetchPost() {
let myHeaders = new HttpHeaders(); // 空的
myHeaders = myHeaders.append('CustomHeader', 'Loki Jiang'); // 插入
myHeaders = myHeaders.append('Authorization', 'my-auth-token'); // 插入

return this.http.get<{ [key: string]: PostModel }>(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
{
headers: myHeaders,
// headers: new HttpHeaders({
// CustomHeader: 'Loki Jiang',
// Authorization: 'my-auth-token'
// }),
}
).pipe(
map(responseData => {
console.log(responseData);
const postAry: PostModel[] = [];
for (const key in responseData) {
if (responseData.hasOwnProperty(key))
postAry.push({ ...responseData[key], id: key })
}
return postAry;
}),
catchError(errorRes => throwError(errorRes))
);
}

HttpParams 設定 Query Params

除了可以直接於 URL 上面填寫 Query 參數(例如.../posts.json?print=pretty),也能在 httpClient 內透過 HttpParams 指定,寫法與前面 HttpHeaders 雷同。

http.service.ts
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';

// ...
export class HttpService {
// ...
fetchPost() {
let myHeaders = new HttpHeaders();
myHeaders = myHeaders.append('CustomHeader', 'Loki Jiang');
myHeaders = myHeaders.append('Authorization', 'my-auth-token');

let myParams = new HttpParams();
myParams = myParams.append('print', 'pretty');
myParams = myParams.append('todo', 'add');

return this.http.get<{ [key: string]: PostModel }>(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
{
headers: myHeaders,
// headers: new HttpHeaders({
// CustomHeader: 'Loki Jiang',
// Authorization: 'my-auth-token'
// }),
params: myParams
// params: new HttpParams().set('article', '3')
}
).pipe(
map(responseData => {
console.log(responseData);
const postAry: PostModel[] = [];
for (const key in responseData) {
if (responseData.hasOwnProperty(key))
postAry.push({ ...responseData[key], id: key })
}
return postAry;
}),
catchError(errorRes => throwError(errorRes))
);
}
// ...
}

觀察經過 Fetch Button 動作的 F12 > network > General > Request URL 內是否多了兩個 Query 屬性。

?print=pretty剛始是 Firebase 的 Query 參數功能,可以將 response 格式美化,可以嘗試到 Firebase 後台將 read 功能 true 查看效果。

observe 觀察 response

observe 參數是指要觀察的 response 型別,可選值包括 body、response、events。若設為 body,則 HttpClient 會嘗試將 response body 轉換為指定的 response 型別,如 JSON 或文字等。若設為 response,則 HttpClient 會將整個 response 回傳,包含 headers、status code 等資訊。若設為 events,則 HttpClient 會回傳一個事件流(Observable),可用於監聽 request 的進度及狀態。通常預設值是 “body”。

未指定的情況下只會回傳 response 數據,若要檢查 HttpResponse  完整資訊,可這樣做(以 addPost 為示範):

addPost(postData: PostModel) {
this.http.post(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
postData,
{
observe: 'response' //※重點:觀察完整的 HttpResponse 
}
).subscribe(
response => console.log(response),
err => this.errSubject.next(err)
);
}

觀察經過 Send Button 動作的 F12 > console.log 能呈現所有 HttpResponse 資訊,涵蓋了 body,headers,status… 等等。

若要觀察 API 的 event 事件處理,舉例來說當進行 deletePostAll 時,會有 api 的送出與返回兩個事件。可以透過observe: 'events'來觀察並做對應處理。其中 event 底下 type 能代表目前是 Send(數字 0),Response(數字 4)… 等等事件類別。我們可利用 angular 的HttpEventType做判斷。以下根據刪除 API 作判別處理 SEND 與 RESPONSE 的處置。

http.service.ts
import { HttpClient, HttpEventType, HttpHeaders, HttpParams } from '@angular/common/http';
// ...
export class HttpService {
// ...
deletePostAll() {
return this.http.delete(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
{
observe: 'events'
}
).pipe(
tap(event => {
if (event.type === HttpEventType.) console.log('刪除提交中');
if (event.type === HttpEventType.Response) console.log('刪除已回應');
console.log(event);
})
);
}
}

RxJS 的 tap 操作符用於監視 Observable 的值,並在它們被發出時執行一些操作,而不影響流中的元素。它是一種副作用操作符,因為它不會修改流中的元素,但是可以對 Observable 中的值執行副作用操作,例如打印日誌、修改變數等等。
需要注意的是,tap 操作符不會改變流中的元素,而是返回一個與源 Observable 相同的 Observable。因此,在 tap 操作符後面可以接其他操作符,例如 map、filter、reduce 等等。

responseType 指定回應型別

預設為 json 型別,我們也能指定回傳的 response 該用怎樣的型別回傳。除非你有需要可以抽換成’text’而不是預設的’json’,此範例不會特別改成其他避免 json 處理失敗。

http.service.ts
deletePostAll() {
return this.http.delete(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/posts.json',
{
observe: 'events',
responseType: 'text'
}
).pipe(
tap(event => {
if (event.type === HttpEventType.Sent) console.log('刪除提交中');
if (event.type === HttpEventType.Response) console.log('刪除已回應');
console.log(event);
})
);
}

HttpInterceptor 攔截處理

HttpInterceptor 是 Angular 中一個重要的功能,可以在發送請求和接收響應的過程中對它們進行攔截和處理。通常情況下,HttpInterceptor 被用來實現以下幾個功能:

  • 設置 Http 請求的 headers,如授權信息,token 等等。
  • 在發送 Http 請求前進行簡單的日誌記錄。
  • 在響應返回之前對其進行處理和轉換,例如:添加 response header,篩選和修改返回的數據等等。

通過實現 HttpInterceptor,我們可以將這些重複的代碼抽取出來,使代碼更加清晰簡潔,而且還可以很方便地對 Http 請求和響應進行統一處理。

  • 透過 cli 指令ng g s auth-interceptor --skip-tests=true建立 service 為 auth-interceptor.service.ts
  • 對 class AuthInterceptorService 進行介面定義取自 HttpInterceptor,這部分可從 angular http 取得 import。

intercept 實作方法

intercept 是 HttpInterceptor 介面中必須實作的方法之一,它用來攔截 HTTP 請求,並且可以對請求進行變更、增加欄位、設定 headers 等等。intercept 方法的輸入參數是 HttpRequest 和 HttpHandler。當 intercept 方法被呼叫時,會把當前的 HttpRequest 對象傳入這個方法,並傳入一個 HttpHandler 對象。HttpHandler 對象可以用來發送 HTTP 請求。

在 intercept 方法中,可以先對 HttpRequest 對象進行變更、增加欄位、設定 headers 等等。然後,再使用 HttpHandler 對象的 handle 方法來發送經過修改後的請求。

最後,handle 方法返回一個 Observable<HttpEvent<any>>。這個 Observable 可以被訂閱,以獲取由後端返回的數據。如果不訂閱這個 Observable,那麼這個請求就不會被發送出去。

  • 接著編寫 intercept 於 AuthInterceptorService 內。
  • 下列代碼範例中,我們嘗試攔截 request 並調整為含有 Headers 的新 authReq,以及 response 回應的部分動作。
auth-interceptor.service.ts
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
// import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

// 不宣告於此,稍晚定義於 app.modules.ts 內
// @Injectable({
// providedIn: 'root'
// })

export class AuthInterceptorService implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
console.log('API 攔截處理中');

// 在請求被發送前執行一些前置處理,例如加入 token 到 headers 中
const authReq = req.clone({
headers: req.headers.set('Authorization', 'Loki insert by intercept!!')
});
//這裡用 set 修改,也可以使用 append 插入新的 headers 其他屬性資訊

// 攔截並繼續執行請求,處理後續的響應
return next.handle(authReq).pipe(
tap(
event => console.log('HttpResponse', event),
error => console.log('HttpErrorResponse', error)
)
);
}
// constructor() { }
}

註冊 Interceptor 於 app.modules.ts

要註冊 Interceptor 於 AppModule 中,需先在 providers 陣列中加入對應的 Interceptor 服務,並使用 { provide, useClass } 的方式告訴 Angular 要提供哪個服務以及使用哪個 Interceptor 類別。透過提供 HTTP_INTERCEPTORS 令牌,將 Interceptor 加入 providers 陣列中。

在下面的範例中,我們提供了 HTTP_INTERCEPTORS 令牌,設定其值為 AuthInterceptorService 此服務,並將 multi 設為 true。multi 的設定代表我們可能會有多個 Interceptor,所以需要設為 true,讓 Angular 知道有多個 Interceptor,可以和其他的 Interceptor 一起運作。

app.module.ts
import { AuthInterceptorService } from './auth-interceptor.service'; //※重點
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; //※重點
import { AppComponent } from './app.component';

@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
FormsModule,
HttpClientModule
],
providers: [{ //※重點
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptorService,
multi: true
}],
bootstrap: [AppComponent]
})
export class AppModule { }

註冊多個 Interceptor

這裡多規劃一個新的 Interceptor 服務,試圖註冊兩組 Interceptor 到 app.module.ts

  • 透過 cli 指令ng g s login-interceptor --skip-tests=true建立 service 為 login-interceptor.service.ts
  • 調整與前例雷同的代碼環境。包含implements HttpInterceptorintercept()
login-interceptor.service.ts
import { HttpInterceptor, HttpEventType, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

export class LoginInterceptorService implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
console.log('Login 攔截處理中');
console.log(req.url);

return next.handle(req).pipe(
tap(
event => {
if (event.type === HttpEventType.Response) console.log('Login is Response');
}
)
);
}
}

回到 app.module.ts,只需要第二組 intercept 添加在 providers 陣列內即可。

注意陣列順序前後,所有的 api 會先依序套入前一個開始依序做攔截處理。

app.module.ts
import { LoginInterceptorService } from './login-interceptor.service';
import { AuthInterceptorService } from './auth-interceptor.service';
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';

import { AppComponent } from './app.component';

@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
FormsModule,
HttpClientModule
],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptorService,
multi: true
},
{
provide: HTTP_INTERCEPTORS,
useClass: LoginInterceptorService,
multi: true
}
],
bootstrap: [AppComponent]
})
export class AppModule { }

身分驗證與路由防護

身分驗證是一個非常重要的安全性功能,尤其對於需要保護資料或是資源的應用程式來說更是必要的。以下是一個基本的身分驗證設計:

  • 登入流程
    使用者輸入帳號密碼,系統驗證使用者身分是否正確,如果正確則系統會回傳一組 token,通常是一個經過加密的字串。
  • token
    token 是一個代表使用者身分的識別碼,通常儲存在使用者端的 cookie 或是 local storage 中,可以透過這個 token 來辨識使用者的身分。為了增加安全性,通常會將 token 設定一個過期時間,以確保不會長時間存在於使用者端。
  • 身分驗證
    當使用者需要訪問受保護的資源時,系統會先檢查使用者是否已經登入,如果沒有登入則導向登入頁面;如果已經登入,系統會檢查 token 是否合法,以確保使用者的身分是正確的。
  • 登出流程
    使用者可以透過系統提供的登出功能來銷毀 token,以結束身分驗證的狀態。
  • token 更新
    為了避免 token 過期,系統可以提供一個 token 更新的機制,當 token 快要過期時,系統會自動更新 token,以確保使用者可以繼續使用應用程式而不會因為 token 過期而被迫重新登入。

總結來說,身分驗證是一個涉及到安全性的重要功能,必須要設計得周全並且符合實際應用需求,例如使用加密的 token、設定過期時間、提供登出機制等,才能夠確保使用者的資料和資源得到適當的保護。

以傳統網頁設計來說,後端會使用 SESSION 方式作為身分驗證的設計。但由於 Angular 的 SPA 設計理念會使用 RESTful API 作為伺服器的連線處理,因此 SESSION 並不適合被使用,API 類型的 Server 本身並不會對 Client 端進行綁定 SESSION 對應,而用戶端透過 HttpClient 進行通信。API 類型的 Server 會向 Client 端發送一個 JSON 格式的 Web Token 字串,通常是未加密編碼可被解讀出。並被存放於 Client 端的 LocalStorage 之內。

隨著每次 API 連線透過 Headers 傳送,後端會進行該 Token 驗證特定算法與私鑰產稱 Token 對比檢查,確保 token 正確性而鴂定是否禁止拜訪。

環境建置準備

本篇的起始素材放置於 GitHub 底下提供使用,使用資料目錄 lokiHttp 作為初始環境。下載請記得 npm install 初始化環境。

Github download at lokiHttp-start Folder

我們會在此素材上設計一個 auth 頁面提供註冊與登入。而整個 app 根據用戶是否登入來決定部分功能是否顯示使用。

設計 auth 元件

本素材已提供 AuthComponent 以下步驟可省略。僅作為提示要新增一元件於 App 內遵循以下步驟:

  • 手動建立 AuthComponent 或透過 CLI 指令ng g c auth --skip-tests=true完成。
  • 至 app.module.ts 內將 AuthComponent 加入 declarations 的聲明陣列內,確保可被使用。
  • 至 app-routing.module.ts 內將指定路徑(例如{ path: 'auth', component: AuthComponent }) 添加於 Routes 指定陣列內,確保路徑下能拜訪此元件。
  • 同上,嘗試網址輸入 localhost:4200/auth 能否頁面存在且成功。
  • 於想要的畫面位置(本素材為 header 元件)上使用 routerLink 呈現該連結導向(例如<a routerLink="/auth">Authenticate</a>),引導用戶連結拜訪頁面。

Login/SignUp 切換

為了介面好看,在此設計一個 switch 按鈕能改變表單用途(登入用或註冊用),可利用一個本地變數作為 flag 標記。

  • 建立 isLoginMode 本地變數為 boolean
  • 規劃 onSwitchMode 控制 isLoginMode 顛倒布林值
  • 於 html 模板上規劃對應的 click 事件,以及應呈現的文字之三元邏輯。
auth.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'app-auth',
templateUrl: './auth.component.html'
})
export class AuthComponent {
isLoginMode = true;

onSwitchMode() {
this.isLoginMode = !this.isLoginMode;
}
}
auth.component.html
<div class="row">
<div class="col-xs-12 col-md-6 col-md-offset-3">
<form>
<div class="form-group">
<label for="email">E-Mail</label>
<input
type="email"
id="email"
class="form-control"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
class="form-control"
/>
</div>
<div>
<button
class="btn btn-primary"
type="submit"
>{{isLoginMode?'Login':'Sign Up'}}</button> | <button
class="btn btn-primary"
(click)="onSwitchMode()"
>Switch to {{!isLoginMode?'Login':'Sign Up'}}</button>
</div>
</form>
</div>
</div>

Template-driven 表單處理

本素材使用 TD 方式進行處理(亦可使用 Reactive forms 設計),遵循以下設計發想:

  • 對 email 欄位增加 TD 需要的 ngModel、名稱 email、必填 required、email 為驗證條件
  • 對 password 欄位增加 TD 需要的 ngModel、名稱 password、必填 required、最少 6 字元為驗證條件
  • 對 form 元素綁定 ngForm 且規劃別名為 authForm。
  • 對 submit 按鈕設定禁用條件為當 authForm 為驗證錯誤時
  • 規劃 ngSubmit 事件成立時執行自訂函式 onSubmit 並將整個 authForm 作為參數處理
  • onSubmit 會將此 form 的 value 測試輸出
auth.component.html
<div class="row">
<div class="col-xs-12 col-md-6 col-md-offset-3">
<form
#authForm="ngForm"
(ngSubmit)="onSubmit(authForm)"
>
<div class="form-group">
<label for="email">E-Mail</label>
<input
type="email"
id="email"
class="form-control"
ngModel
name="email"
required
email
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
class="form-control"
ngModel
name="password"
required
minlength="6"
/>
</div>
<div>
<button
class="btn btn-primary"
type="submit"
[disabled]="authForm.invalid"
>{{isLoginMode?'Login':'Sign Up'}}</button> | <button
class="btn btn-primary"
(click)="onSwitchMode()"
>Switch to {{!isLoginMode?'Login':'Sign Up'}}</button>
</div>
</form>
</div>
</div>
auth.component.ts
import { NgForm } from '@angular/forms';
import { Component } from '@angular/core';

@Component({
selector: 'app-auth',
templateUrl: './auth.component.html'
})
export class AuthComponent {
isLoginMode = true;

onSwitchMode() {
this.isLoginMode = !this.isLoginMode;
}

onSubmit(form: NgForm) {//※重點
console.log(form.value);
}
}

fireBase 註冊登入的身分驗證

本素材仍以 fireBase 作為我們的後端 API 站點,拜訪 fireBase

  • 找到即時資料庫之規則編寫讀寫條件當 auth 有內容才允許讀寫。
{
"rules": {
".read": "auth!=null",
".write": "auth!=null",
}
}
  • 根據 firebase 文檔教學,點選”建構>Authentication”,新增一組登入方式為”電子郵件/密碼”做為我們登入方式


REST API 文件
你可以從 Firebase 說明文件 去了解如何進行 REST API 之完整應用規劃。在此我們會用到的 auth 功能文件 當中的使用電子郵件/密碼註冊使用電子郵件/密碼登錄

註冊 API

使用電子郵件/密碼註冊 文件當中,已提供指定的端點 URL, 請求 require 格式,回應 response 格式。

[API_KEY] 可以從專案總覽>專案設定>一般設定>網路 API 金鑰找到。而文件內因中文翻譯請改用英文確認 Property Name 為屬性名稱。

設計 auth.service.ts 與元件

藉此規劃我們所需的 auth.service.ts。

  • 手動新增 auth.service.ts 或 CLI 指令ng g s auth --skip-tests=true
  • 規劃 signUp 函式,接受來自表單引數 email 與 password,指向 URL 並提供指定的 require 型別。
  • 同上,response 返回的型別透過 interface 規劃,並宣告於該 http.post 的<>泛型函式之上。
  • 記得 return 這個可觀察的 API 結果。
auth/auth.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

interface AuthResponseData {
idToken: string; // Firebase 身份驗證 ID 令牌
email: string; // 用戶的電子郵件
refreshToken: string; // Firebase 身份驗證刷新 token
expiresIn: string; // token 過期的秒數
localId: string; // 用戶的 uid
}

@Injectable({
providedIn: 'root'
})

export class AuthService {

constructor(
private http: HttpClient
) { }

singUp(email: string, password: string) {
return this.http.post<AuthResponseData>(
'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyBD5oglUIsgkYiQlabvMw1Y8OGsGC_w6JA',
{
email: email,
password: password,
returnSecureToken: true
}
);
}
}

接著編寫註冊動作的 API 串接,有以下步驟要點:

  • 不論是 Submit 還是 Login,只要表單驗證存在我們就不處理
  • 判斷 isLoginMode 是登入用還是註冊用做對應的行為
  • 透過 AuthService 傳送 form 的 email 與 password,並訂閱結果成功顯示 response,失敗顯示 error 資訊。
  • 最後不論是登入用還是註冊用,都清除表單欄位
auth.component.ts
import { AuthService } from './auth.service';
import { NgForm } from '@angular/forms';
import { Component } from '@angular/core';

@Component({
selector: 'app-auth',
templateUrl: './auth.component.html'
})
export class AuthComponent {
isLoginMode = true;

constructor(
private AuthService: AuthService //※重要
) { }

onSwitchMode() {
this.isLoginMode = !this.isLoginMode;
}

onSubmit(form: NgForm) {
// console.log(form.value);
if (form.invalid) return; //※重要

if (this.isLoginMode) { //※重要
// 登入作業
} else {
//※重要:註冊作業
this.AuthService.singUp(form.value.email, form.value.password).subscribe(
resData => console.log(resData),
error => console.error(error)
);
}
form.reset();
}
}

嘗試畫面操作註冊提交,觀察 console 以及 fireBase 的 Users 資料是否已添加。且 Firebase 預設會對重複註冊相同 email 的帳戶作拒絕。

設計 Loading 與 Error Alert

可新增一個 component 做為 html/css 版型,規劃一個 Loading 動畫圖示插入根據 API 處理等待過程做為畫面提示。Loading 動畫可以從免費的 loading.io 取得喜歡圖示的 html+css 語法。

  • 手動或透過 CLI 指令ng g c shared/loading-spinner --skip-tests --inline-template,只生成 ts 與 css 的元件規畫於 Shared 目錄下,注意 app.module.ts 的 declarations 需聲明置入此 component。
  • 將選用 loading icon 的 HTML 部分編寫於 loading-spinner.component.ts 底下 Component 的 template 內
  • 將選用 loading icon 的 CSS 部分編寫於 loading-spinner.component.css
src\app\shared\loading-spinner\loading-spinner.component.ts
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-loading-spinner',
template: `
<div class="loadingio-spinner-spinner-7262uuxelp"><div class="ldio-tozapb7zt8">
<div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</div></div>
`,
styleUrls: ['./loading-spinner.component.css']
})
export class LoadingSpinnerComponent implements OnInit {
}
src\app\shared\loading-spinner\loading-spinner.component.css
@keyframes ldio-tozapb7zt8 {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.ldio-tozapb7zt8 div {
left: 94px;
top: 48px;
position: absolute;
animation: ldio-tozapb7zt8 linear 1s infinite;
background: #fe718d;
width: 12px;
height: 24px;
border-radius: 6px / 12px;
transform-origin: 6px 52px;
}
.ldio-tozapb7zt8 div:nth-child(1) {
transform: rotate(0deg);
animation-delay: -0.9166666666666666s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(2) {
transform: rotate(30deg);
animation-delay: -0.8333333333333334s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(3) {
transform: rotate(60deg);
animation-delay: -0.75s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(4) {
transform: rotate(90deg);
animation-delay: -0.6666666666666666s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(5) {
transform: rotate(120deg);
animation-delay: -0.5833333333333334s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(6) {
transform: rotate(150deg);
animation-delay: -0.5s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(7) {
transform: rotate(180deg);
animation-delay: -0.4166666666666667s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(8) {
transform: rotate(210deg);
animation-delay: -0.3333333333333333s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(9) {
transform: rotate(240deg);
animation-delay: -0.25s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(10) {
transform: rotate(270deg);
animation-delay: -0.16666666666666666s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(11) {
transform: rotate(300deg);
animation-delay: -0.08333333333333333s;
background: #fe718d;
}
.ldio-tozapb7zt8 div:nth-child(12) {
transform: rotate(330deg);
animation-delay: 0s;
background: #fe718d;
}
.loadingio-spinner-spinner-7262uuxelp {
width: 200px;
height: 200px;
display: inline-block;
overflow: hidden;
background: #ffffff;
}
.ldio-tozapb7zt8 {
width: 100%;
height: 100%;
position: relative;
transform: translateZ(0) scale(1);
backface-visibility: hidden;
transform-origin: 0 0; /* see note above */
}
.ldio-tozapb7zt8 div {
box-sizing: content-box;
}
/* generated by https://loading.io/ */
  • 回到 auth.component.html,將剛新增的 component 以 app 版型方式插入位置處。並以 isLoading 變數來控制顯示 form 元素或是 loading 元素。
  • isLoading 初始為 false,隨 API 的發送與回應階段控制 Boolean 值變換。
  • 同樣巧思規劃錯誤訊息,以 isError 變數來控制顯示 alert 元素是否出現。
auth.component.html
<div class="row">
<div class="col-xs-12 col-md-6 col-md-offset-3">
<app-loading-spinner *ngIf="isLoading"></app-loading-spinner>
<form
#authForm="ngForm"
(ngSubmit)="onSubmit(authForm)"
*ngIf="!isLoading"
>
<!-- ... -->
</form>
</div>
</div>
auth.component.ts
import { AuthService } from './auth.service';
import { NgForm } from '@angular/forms';
import { Component } from '@angular/core';

@Component({
selector: 'app-auth',
templateUrl: './auth.component.html'
})
export class AuthComponent {
isLoginMode = true;
isLoading = false;
isError = null;

constructor(
private AuthService: AuthService
) { }

onSwitchMode() {
this.isLoginMode = !this.isLoginMode;
}

onSubmit(form: NgForm) {
// console.log(form.value);
if (form.invalid) return;
if (this.isLoginMode) {
// 登入作業
} else {
// 註冊作業
this.isLoading = true;
this.AuthService.singUp(form.value.email, form.value.password).subscribe(
resData => {
console.log(resData);
this.isLoading = false;
},
error => {
// console.error(error);
this.isError = error.error.error.message;
this.isLoading = false;
}
);
}
form.reset();
}
}

此時故意重複帳號註冊,會出現 loading icon 也能出現 Alert 指定的錯誤字串。

然而為了更好的 Error 事件處理,我們可以將錯誤處理放置於 Service 內執行,使得 Component 更精簡。此外錯誤訊息的對應文字可以更白話,參考該文件的常見錯誤代碼表示:

  • EMAIL_EXISTS:電子郵件地址已被另一個帳戶使用。
  • OPERATION_NOT_ALLOWED:此項目禁用密碼登錄。
  • TOO_MANY_ATTEMPTS_TRY_LATER:由於異常活動,我們已阻止來自此設備的所有請求。稍後再試。

因此跟隨以下步驟優化錯誤代碼的處理:

  • 改為 auth.service.ts 於 http.post 的回應處理增加 pipe 管道作業,使用 catchError 捕獲問題當下的處置並回傳 throwError 資訊。
  • auth.component.ts 階段只接受 error 回來的字串即可。
auth/auth.service.ts
import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
// ...
export class AuthService {
// ...
singUp(email: string, password: string) {
return this.http.post<AuthResponseData>(
'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyBD5oglUIsgkYiQlabvMw1Y8OGsGC_w6JA',
{
email: email,
password: password,
returnSecureToken: true
}
).pipe( //※重點
catchError(err => {
let errorMsg = 'unknown Error';
if (!err.error || !err.error.error) return throwError(errorMsg);

switch (err.error.error.message) {
case 'EMAIL_EXISTS':
errorMsg = '電子郵件地址已被另一個帳戶使用。';
break;
case 'OPERATION_NOT_ALLOWED':
errorMsg = '此項目禁用密碼登錄。';
break;
case 'TOO_MANY_ATTEMPTS_TRY_LATER':
errorMsg = '由於異常活動,我們已阻止來自此設備的所有請求。稍後再試。';
break;
}

return throwError(errorMsg);
})
);
}
}
auth.component.ts
onSubmit(form: NgForm) {
// console.log(form.value);
if (form.invalid) return;

if (this.isLoginMode) {
// 登入作業
} else {
// 註冊作業
this.isLoading = true;
this.AuthService.singUp(form.value.email, form.value.password).subscribe(
resData => {
console.log(resData);
this.isLoading = false;
},
error => {
// console.error(error);
// this.isError = error.error.error.message;
this.isError = error;
this.isLoading = false;
}
);
}

form.reset();
}

登入 API

使用電子郵件/密碼登錄 文件當中,已提供指定的端點 URL, 請求 require 格式,回應 response 格式。

編寫 auth.service.ts 與元件

同樣的作業規劃 signIn 方法,特別注意 response 回傳的 type 多一個registered?:boolean

  • 因此 interface 多增加一個非必要的此項目做為共用 AuthResponseData TypeModel。
  • 元件部分為了優化共用變數,我們可以將訂閱這個物件成為 authObservable 一個可觀察的變數(該回傳的變數型別為 AuthResponseData),將代碼更簡約些
  • 同上,可對 service 內的 AuthResponseData 進行 export,使得元件這裡可以 import 取得此 AuthResponseData。
auth/auth.service.ts
export interface AuthResponseData { // ※重點:改 export,讓別處可以拿到這個 Type
idToken: string; // Firebase 身份驗證 ID 令牌
email: string; // 用戶的電子郵件
refreshToken: string; // Firebase 身份驗證刷新 token
expiresIn: string; // token 過期的秒數
localId: string; // 用戶的 uid
registered?:boolean; // ※重點:signIn response 多這個,電子郵件是否用於現有帳戶
}
//...
export class AuthService {
// ...
singIn(email: string, password: string) {
return this.http.post<AuthResponseData>(
'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=AIzaSyBD5oglUIsgkYiQlabvMw1Y8OGsGC_w6JA',
{
email: email,
password: password,
returnSecureToken: true
}
);
}
}
auth.component.html
import { AuthResponseData, AuthService } from './auth.service'; // ※重點:匯入 response 型別
import { NgForm } from '@angular/forms';
import { Component } from '@angular/core';
import { Observable } from 'rxjs';

@Component({
selector: 'app-auth',
templateUrl: './auth.component.html'
})
export class AuthComponent {
isLoginMode = true;
isLoading = false;
isError = null;

constructor(
private AuthService: AuthService
) { }

onSwitchMode() {
this.isLoginMode = !this.isLoginMode;
}

onSubmit(form: NgForm) {
// console.log(form.value);
if (form.invalid) return;

let authObservable: Observable<AuthResponseData>; // ※重點:新增一個可觀察變數,該變數最後回傳的會是指定型別

this.isLoading = true;
if (this.isLoginMode)
// 登入作業
authObservable = this.AuthService.singIn(form.value.email, form.value.password); // ※重點,該 Service 會給一個可觀察的數據

else
// 註冊作業
authObservable = this.AuthService.singUp(form.value.email, form.value.password); // ※重點,該 Service 會給一個可觀察的數據
// this.AuthService.singUp(form.value.email, form.value.password).subscribe(
// resData => {
// console.log(resData);
// this.isLoading = false;
// },
// error => {
// // console.error(error);
// // this.isError = error.error.error.message;
// this.isError = error;
// this.isLoading = false;
// }
// );


// ※重點,集中於此一起做訂閱後的處理
authObservable.subscribe(
resData => {
console.log(resData);
this.isLoading = false;
},
error => {
this.isError = error;
this.isLoading = false;
}
);

form.reset();
}
}

設計 Error Alert

目前設計的 Error 只有 SignUp 才有在 Service 內做處理,同樣的參考 firebase 文件處理代碼字串。此外由於 signUp 與 signIn 作法相近因此可以將 Error 處理的做法獨立為一個私有函式額外一併處理。

catchError 引數的型別為 HttpErrorResponse

auth/auth.service.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

export interface AuthResponseData {
idToken: string; // Firebase 身份驗證 ID 令牌
email: string; // 用戶的電子郵件
refreshToken: string; // Firebase 身份驗證刷新 token
expiresIn: string; // token 過期的秒數
localId: string; // 用戶的 uid
registered?: boolean; // 電子郵件是否用於現有帳戶
}

@Injectable({
providedIn: 'root'
})

export class AuthService {

constructor(
private http: HttpClient
) { }

singUp(email: string, password: string) {
return this.http.post<AuthResponseData>(
'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyBD5oglUIsgkYiQlabvMw1Y8OGsGC_w6JA',
{
email: email,
password: password,
returnSecureToken: true
}
).pipe(
catchError(this.errorHandle) //※重點
// catchError(err => {
// let errorMsg = 'unknown Error';
// if (!err.error || !err.error.error) return throwError(errorMsg);

// switch (err.error.error.message) {
// case 'EMAIL_EXISTS':
// errorMsg = '電子郵件地址已被另一個帳戶使用。';
// break;
// case 'OPERATION_NOT_ALLOWED':
// errorMsg = '此項目禁用密碼登錄。';
// break;
// case 'TOO_MANY_ATTEMPTS_TRY_LATER':
// errorMsg = '由於異常活動,我們已阻止來自此設備的所有請求。稍後再試。';
// break;
// }
// return throwError(errorMsg);
// })
);
}

singIn(email: string, password: string) {
return this.http.post<AuthResponseData>(
'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=AIzaSyBD5oglUIsgkYiQlabvMw1Y8OGsGC_w6JA',
{
email: email,
password: password,
returnSecureToken: true
}
).pipe(
catchError(this.errorHandle) //※重點
);
}
private errorHandle(errorRes: HttpErrorResponse) { //※重點:獨立一個 private function
let errorMsg = 'unknown Error';
if (!errorRes.error || !errorRes.error.error) return throwError(errorMsg);

switch (errorRes.error.error.message) {
case 'EMAIL_EXISTS':
errorMsg = '電子郵件地址已被另一個帳戶使用。';
break;
case 'OPERATION_NOT_ALLOWED':
errorMsg = '此項目禁用密碼登錄。';
break;
case 'TOO_MANY_ATTEMPTS_TRY_LATER':
errorMsg = '由於異常活動,我們已阻止來自此設備的所有請求。稍後再試。';
break;
case 'EMAIL_NOT_FOUND':
errorMsg = '沒有與此標識符對應的用戶記錄。該用戶可能已被刪除。';
break;
case 'INVALID_PASSWORD':
errorMsg = '密碼無效或用戶沒有密碼。';
break;
case 'USER_DISABLED':
errorMsg = '用戶帳戶已被管理員禁用。';
break;
}
return throwError(errorMsg);
}
}

儲存用戶資訊

隨登入或註冊作業流程,需將用戶登入資訊紀錄儲存起來並引導用戶離開 login 表單來到指定後台頁面。

User Model 資訊

將登入註冊的 firebase 回傳作業的 email,id,token,expireDate 放入到 Subject 紀錄,以 TypeScript 的規矩之下可以規劃一個 model 整理該型別:

  • 建立 auth/user.model.ts 編寫 export class,由於結構簡單可直接手動新增。
  • 根據 firebase 手冊提到的型別規劃,唯獨 token 的 expire 手冊回傳的是 String,為了方便作業改為 Date
  • 提前寫好取得 token 的條件必須是有效的時間內才能取出。之後會用到
user.model.ts
export class User {
constructor(
public email: string,
public id: string,
private _token: string,
private _tokenExpireDate: Date,
) { }

get token() {
if (!this._tokenExpireDate || new Date() > this._tokenExpireDate)
//如果沒有 token 期效或現在時間大於 token 期效(已過期)
return null;
return this._token;
}
}

class 中的 get 與 set
用來取得和設定類別內部私有變數的方法。get 與 set 方法通常都會搭配一個私有變數,私有變數只能在類別內部被存取。透過 get 方法,我們可以取得私有變數的值;透過 set 方法,我們可以設定私有變數的值。

以下是一個使用 get 與 set 的範例:

class Person {
private _name: string;

get name(): string {
return this._name;
}

set name(newName: string) {
this._name = newName;
}
}

let person = new Person();
person.name = 'John'; // 呼叫 set 方法
console.log(person.name); // 呼叫 get 方法
  • 回到 service,新增一個 subject 變數為 userSbj,該結果型別資料會是我們的 User(來自 model)。
  • 同上,新增一個可以管理用戶資訊的 AuthHandel。將指定的 user 建構物件存放到 subject.next() 內。
  • 利用 tap,將 firebase 的 response(登入跟註冊都要)丟給 AuthHandel,使得 userSbj 的 subject 事件進入 next 階段。
auth/auth.service.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { throwError, Subject } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { User } from './user.model';

export interface AuthResponseData {
idToken: string; // Firebase 身份驗證 ID 令牌
email: string; // 用戶的電子郵件
refreshToken: string; // Firebase 身份驗證刷新 token
expiresIn: string; // token 過期的秒數
localId: string; // 用戶的 uid
registered?: boolean; // 電子郵件是否用於現有帳戶
}

@Injectable({
providedIn: 'root'
})

export class AuthService {

userSbj = new Subject<User>();//※重點

constructor(
private http: HttpClient
) { }

singUp(email: string, password: string) {
return this.http.post<AuthResponseData>(
'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyBD5oglUIsgkYiQlabvMw1Y8OGsGC_w6JA',
{
email: email,
password: password,
returnSecureToken: true
}
).pipe(
catchError(this.errorHandle),
tap(response => this.AuthHandle(//※重點
response.email,
response.localId,
response.idToken,
+response.expiresIn //※重點:string to number
))
);
}

singIn(email: string, password: string) {
return this.http.post<AuthResponseData>(
'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=AIzaSyBD5oglUIsgkYiQlabvMw1Y8OGsGC_w6JA',
{
email: email,
password: password,
returnSecureToken: true
}
).pipe(
catchError(this.errorHandle),
tap(response => this.AuthHandle(//※重點
response.email,
response.localId,
response.idToken,
+response.expiresIn //※重點:string to number
))
);
}

// ...
private AuthHandle(email: string, userId: string, token: string, expiresIn: number) {//※重點
const user = new User(
email,
userId,
token,
new Date(new Date().getTime() + expiresIn * 1000) //※重點:時間物件,以現在時間 timestamp + 持續可活之間格數(毫秒)
);
this.userSbj.next(user);
}
}

作業路由導向與 UI 控制

目前隨登入註冊流程會產生由 Subject 持有的用戶資訊,透過此用戶資訊能代表用戶已成功完成表單提交與用戶資訊卻有所回傳。我們接著可以做:

  • 隨著登入註冊動作成功,引導用戶轉向到後台指定頁面,例如 Recipes 頁面。這部分於 auth 元件的 submit 末段作業找到。需依賴 route 完成 navigate。
auth.component.ts
import { Router } from '@angular/router';
import { AuthResponseData, AuthService } from './auth.service';
import { NgForm } from '@angular/forms';
import { Component } from '@angular/core';
import { Observable } from 'rxjs';

@Component({
selector: 'app-auth',
templateUrl: './auth.component.html'
})
export class AuthComponent {
isLoginMode = true;
isLoading = false;
isError = null;

constructor(
private AuthService: AuthService,
private Router: Router //※重點
) { }

onSwitchMode() {
this.isLoginMode = !this.isLoginMode;
}

onSubmit(form: NgForm) {
if (form.invalid) return;

let authObservable: Observable<AuthResponseData>;
this.isLoading = true;

if (this.isLoginMode)
authObservable = this.AuthService.singIn(form.value.email, form.value.password);
else
authObservable = this.AuthService.singUp(form.value.email, form.value.password);

authObservable.subscribe(
resData => {
// console.log(resData);
this.isLoading = false;
this.Router.navigate(['/recipes']); //※重點
},
error => {
this.isError = error;
this.isLoading = false;
}
);

form.reset();
}
}
  • 新規劃一個 Logout 按鈕,這動作部分稍晚設計。
  • 接著 header.component.html 模板可規劃那些選單給登入前限定顯示 (ex: 登入、後台),登入後限定顯示 (ex: 登出、管理),可利用一個本地屬性 isLogged 根據對 service 的 userSbj 訂閱結果來判別是否登入成功(注意:考量訂閱的初始銷毀週期)。
header.component.ts
import { AuthService } from './../auth/auth.service';
import { Subscription } from 'rxjs';
import { Component, OnInit, OnDestroy } from '@angular/core';

import { DataStorageService } from '../shared/data-storage.service';

@Component({
selector: 'app-header',
templateUrl: './header.component.html'
})
export class HeaderComponent implements OnInit, OnDestroy {
private userSub: Subscription; //※重點
isLogged = false; //※重點

constructor(
private dataStorageService: DataStorageService,
private AuthService: AuthService
) { }

ngOnInit(): void { //※重點
this.userSub = this.AuthService.userSbj.subscribe(user => {
this.isLogged = !!user; // user 存在就等價 true
});
}

ngOnDestroy(): void { //※重點
this.userSub.unsubscribe();
}

onSaveData() {
this.dataStorageService.storeRecipes();
}

onFetchData() {
this.dataStorageService.fetchRecipes().subscribe();
}
}
header\header.component.html
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a
routerLink="/"
class="navbar-brand"
>Recipe Book</a>
</div>
<div class="collapse navbar-collapse">
<ul class="nav navbar-nav">
<!-- 登入顯示 -->
<li
routerLinkActive="active"
*ngIf="isLogged"
><a routerLink="/recipes">Recipes</a></li>
<!-- 未登入顯示 -->
<li
routerLinkActive="active"
*ngIf="!isLogged"
><a routerLink="/auth">Authenticate</a></li>
<li routerLinkActive="active"><a routerLink="/shopping-list">Shopping List</a></li>
</ul>
<!-- 登入顯示 -->
<ul class="nav navbar-nav navbar-right" *ngIf="isLogged">
<!-- 新追加登出 -->
<li><a style="cursor:pointer">Logout</a></li>
<li
class="dropdown"
appDropdown
>
<a
style="cursor: pointer;"
class="dropdown-toggle"
role="button"
>Manage <span class="caret"></span></a>
<ul class="dropdown-menu">
<li>
<a
style="cursor: pointer;"
(click)="onSaveData()"
>Save Data</a>
</li>
<li>
<a
style="cursor: pointer;"
(click)="onFetchData()"
>Fetch Data</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>

傳送 token

我們的 token 資訊位於原本的 userSbj 內,欲取得 token 可從 userSbj.token 拿取,由於他是一個 Subject 型別,考量初始訂閱當下的初始值 null 讀取,可調整改用 BehaviorSubject 方式獲取。

  • 將 auth.service.ts 內的 Subject 改為 BehaviorSubject 並賦予初始值 null
auth/auth.service.ts
// userSbj = new Subject<User>();
userSbj = new BehaviorSubject<User>(null);

關於 BehaviorSubject 與其他

在 RxJS 中,有許多種不同的 Subject,以下是各種 Subject 的細節說明:

  • Subject
    一種可觀察的序列和觀察者。它是一種 Multicast(多播) 的型別,可以向多個 Observer(觀察者) 發送訊息。當一個 Observable 被訂閱時,它會建立一個新的 Observable 並將所有發射的元素發送給新的 Observable。Subject 是 Multicast 特性的一個代表,它可以同時訂閱多個觀察者並將事件廣播給這些觀察者。
  • BehaviorSubject
    一種特殊的 Subject。它需要一個初始值,每當訂閱者訂閱時都會發出該初始值。之後,它會發出任何新值。在新的訂閱者訂閱該 BehaviourSubject 時,他會立即得到當前值,然後在將來收到每個新值。
  • ReplaySubject
    也是一種 Subject,它可以緩存發出的元素,並在新的 Observer 訂閱時重新發射這些緩存的元素。ReplaySubject 可以緩存多個元素,當一個新的 Observer 訂閱時,它可以將這些元素傳送給新的 Observer。
  • AsyncSubject
    一種特殊的 Subject。當 Observable 完成時,AsyncSubject 只會發出最後一個元素。如果 Observable 沒有發出任何元素,AsyncSubject 將不會發出任何元素。
  • PublishSubject
    常規的 Subject,但它只發射在 Subject 被訂閱之後發生的事件。PublishSubject 不會將元素傳遞給新訂閱的觀察者,只傳遞 Subject 被訂閱之後的元素。
  • PublishRelay
    RxJava 中的 Relay 之一。它是 PublishSubject 的簡化版本。它只是 PublishSubject 的一個輕量級版本,可以方便地使用 Observable 所提供的多個操作符。
  • BehaviorRelay
    RxJava 中的 Relay 之一。它是 BehaviorSubject 的簡化版本。它包含一個初始值,每次訂閱時都會發出當前值,並發出任何新值。

Subject 與 BehaviorSubject 是 RxJS 中的兩個主要類型,它們都是用來實現觀察者模式的。

Subject 是一個可觀察的對象,可以被訂閱,也可以通過 next() 方法將新的值發送給訂閱者。Subject 是一種熱的可觀察對象,這意味著當一個觀察者訂閱了 Subject 時將只接收到訂閱之後發出的新值,而不會收到 Subject 之前發出的值。

BehaviorSubject 是一個特殊的 Subject,它可以儲存最新的一個值,並且當有一個新的觀察者訂閱時立即向該觀察者發送目前值。如果在訂閱之前,BehaviorSubject 還沒有發送過值,那麼訂閱者將收到初始值,通過這個特性,BehaviorSubject 可以被用來實現狀態管理等功能。

在使用上,可以通過以下方式創建一個 Subject 或 BehaviorSubject:

import { Subject, BehaviorSubject } from 'rxjs';
const subject = new Subject();
const behaviorSubject = new BehaviorSubject('initial value');

接下來,可以通過 next() 方法向 Subject 或 BehaviorSubject 發送新的值:

subject.next('new value');
behaviorSubject.next('new value');

在訂閱時,可以像訂閱任何其他的可觀察對象一樣進行訂閱:

subject.subscribe(value => console.log(value));
behaviorSubject.subscribe(value => console.log(value));

需要注意的是,當一個 Subject 或 BehaviorSubject 完成時,它將不再發送任何新值,並且訂閱者將收到 complete() 通知。在此之後,即使對 Subject 或 BehaviorSubject 進行 next() 操作,訂閱者也不會再接收到這些值。

手動添加 token 於指定 api

以選單右側的 Manage 功能來示範如何夾帶 Token 至 Firebase,這部分的功能是將 Pecipes 頁面上的 recipeService 資料 PUT 或 GET 至 firebase。因此常是以下步驟作業:

  • 修改 data-storage.service.ts 內的兩處 URL 修改為您 firebase 的 URL,尾段添加/recipes.json已另一個資料表做存取。
  • 原本為直接向 http 作業與訂閱,更改為先對 AuthService.userSb 取得,由於我們只需要拿一次不需要去觀察值得變化,因此藉由 pipe 的 take(1) 只拿一次就完成該訂閱。
  • 將這個觀察對象從原本的 AuthService.userSb 獲得結果之後,再 map 轉給另一個 this.http 觀察對象,這透過 exhaustMap 來完成。
  • 然後,根據 firebase 文檔提到 token 需要以 params 方式 (?token=abc) 來提交,因此藉由 httpClient 的 option 來處理 HttpParams 追加。
shared/data-storage.service.ts
import { AuthService } from './../auth/auth.service';
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { exhaustMap, map, take, tap } from 'rxjs/operators';

import { Recipe } from '../recipes/recipe.model';
import { RecipeService } from '../recipes/recipe.service';

@Injectable({ providedIn: 'root' })
export class DataStorageService {
constructor(
private http: HttpClient,
private recipeService: RecipeService,
private AuthService: AuthService,
) { }

storeRecipes() { // MENU manage-> Save Date 作業
const recipes = this.recipeService.getRecipes();

this.AuthService.userSbj.pipe( //※重點
take(1),
exhaustMap(user => {
return this.http
.put(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/recipes.json',
recipes,
{ params: new HttpParams().set('auth', user.token) }
)
})
).subscribe(response => console.log(response));

// this.http
// .put(
// 'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/recipes.json',
// recipes,
// )
// .subscribe(response => {
// console.log(response);
// });
}

fetchRecipes() { //MENU manage -> Fetch Data 作業
return this.AuthService.userSbj.pipe( //※重點
take(1),
exhaustMap(user => {
return this.http
.get<Recipe[]>(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/recipes.json',
{ params: new HttpParams().set('auth', user.token) }
)
}),
map(recipes => {
return recipes.map(recipe => {
return {
...recipe,
ingredients: recipe.ingredients ? recipe.ingredients : []
};
});
}),
tap(recipes => this.recipeService.setRecipes(recipes))
);
// return this.http
// .get<Recipe[]>(
// 'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/recipes.json'
// )
// .pipe(
// map(recipes => {
// return recipes.map(recipe => {
// return {
// ...recipe,
// ingredients: recipe.ingredients ? recipe.ingredients : []
// };
// });
// }),
// tap(recipes => {
// this.recipeService.setRecipes(recipes);
// })
// )
}
}

take(1) 是 RxJS 中的操作符之一,它的作用是取得 Observable 發出的第一個值後就自動完成 (completes)。具體來說,take(1) 會將 Observable 的第一個值傳遞給下游訂閱者 (subscriber),然後自動完成 Observable,不再傳遞其他值。

exhaustMap 是 RxJS 中的操作符(operator),它可以將來自一個 observable 的輸出映射到另一個 observable,但只有在前一個 observable 完全完成(complete)時,才會開始處理下一個 observable。

最後請嘗試登入完成之下,操作新增 1~2 筆的 New Recipe 進行 Save(圖片可借用 picsum 免費圖片),再透過 Save Data 作業與 Fetch Data 作業觀察 firebase 的存入與讀取是否成功。

透過 HttpInterceptor 自動 token

若每次 API 都要自己加 token 是蠻麻煩的,我們可以利用一開始有提到的攔截處理方式,幫我們每次進行 httpClient 都要自動帶入 params 寫入 Token(但排除 Login/SignUp)。

  • 手動或 CLI 指令ng g s auth/auth-interceptor --skip-tests增加一個為 AuthInterceptorService
  • 要使用 HttpInterceptor 需對 service 的 class 進行 implements 實體化帶入
  • 規劃 intercept 捕獲原本 require 與 next 事件,處理後回傳 return,回傳的動作來自於 AuthService 的處理。
  • 參考手動的做法,一樣需要 take(1) 與 exhaustMap 轉 map 給 next.handle 的觀察對象。
  • 注意只有登入註冊不需要修改 req,可根據 User 內容是否存在做回傳哪種 require。
  • @Injectable()留空白,稍後要去 app.module.ts 規劃 intercept 詳細參數。
auth/auth-interceptor.service.ts
import { take, exhaustMap } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpParams, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

@Injectable()
export class AuthInterceptorService implements HttpInterceptor {
constructor(
private AuthService: AuthService
) { }

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return this.AuthService.userSbj.pipe(
take(1),
exhaustMap(user => {
if (!user) return next.handle(req);

const modifyReq = req.clone({
params: new HttpParams().set('auth', user.token)
});
return next.handle(modifyReq);
})
);
}
}

來到 app.module.ts 的 providers,加入持有特定的物件屬性設定

app.module.ts
import { AuthInterceptorService } from './auth/auth-interceptor.service';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
// ...
@NgModule({
// ...
providers: [
ShoppingListService,
RecipeService, {
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptorService,
multi: true
}
],
// ...
})
// ...

最後回到 data-storage.service.ts,將原本手動的做法取消返回到一開始的寫法。

shared/data-storage.service.ts
storeRecipes() {
const recipes = this.recipeService.getRecipes();

// this.AuthService.userSbj.pipe(
// take(1),
// exhaustMap(user => {
// return this.http
// .put(
// 'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/recipes.json',
// recipes,
// { params: new HttpParams().set('auth', user.token) }
// )
// })
// ).subscribe(response => console.log(response));

this.http
.put(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/recipes.json',
recipes,
)
.subscribe(response => {
console.log(response);
});
}

fetchRecipes() {
// return this.AuthService.userSbj.pipe(
// take(1),
// exhaustMap(user => {
// return this.http
// .get<Recipe[]>(
// 'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/recipes.json',
// { params: new HttpParams().set('auth', user.token) }
// )
// }),
// map(recipes => {
// return recipes.map(recipe => {
// return {
// ...recipe,
// ingredients: recipe.ingredients ? recipe.ingredients : []
// };
// });
// }),
// tap(recipes => this.recipeService.setRecipes(recipes))
// );
return this.http
.get<Recipe[]>(
'https://loki-angular-training-default-rtdb.asia-southeast1.firebasedatabase.app/recipes.json'
)
.pipe(
map(recipes => {
return recipes.map(recipe => {
return {
...recipe,
ingredients: recipe.ingredients ? recipe.ingredients : []
};
});
}),
tap(recipes => {
this.recipeService.setRecipes(recipes);
})
)
}

登出作業

要將用戶進行登出,需要將 Subject 的用戶資訊透過 next 清除為 null,這部分的作業會在 AuthService 完成並導向到登入頁面 auth,而 header 元件透過 click 事件去執行該 Service。

auth/auth.service.ts
import { Router } from '@angular/router';
// ...
constructor(
private http: HttpClient,
private Router: Router //※重點
) { }
// ...
signOut() {
this.userSbj.next(null);
this.Router.navigate(['auth']);
}
header\header.component.html
<li><a style="cursor:pointer" (click)="onLogout()">Logout</a></li>
header\header.component.ts
onLogout(){
this.AuthService.signOut();
}

自動儲存 Storage

介紹如何在重整網頁情況下,會記得上次登入的所有用戶資訊。保持 subject 原有的資料。以及如何在 token 期限到達的情況下自動登出用戶與清除資訊。

autoLogin 重整頁面

我們將登入成功捕獲的資訊存放於 localStorage 內,每次重刷網頁時,如果在 localStorage 內可以找到這些資訊,就能補回原本 Subject 內應有的資料(原本的 Subject 需要登入成功時寫入 next)。

  • 在 AuthHandle 的 Subject next 工作,隨後一個存入 Storage ,由於只能存入 string 格式需要轉換 JSON.stringify
  • 規劃一個 autoSign 方法,幫助我們從 localStorage 取回(若已存在資料並需轉回 json),由於 json 內的資料格式有變所以要重新從 new User 建立起塞回 Subject。
  • 最後回到 app.component.ts,每次初始化 ngOnInit 時就跑一下 autoSign,如果能找回 UserSbj 就能連動所有 UI 權限,形成已登入的 subject 狀態。
auth\auth.service.ts
autoSignIn() { //※重點
const userKeep = JSON.parse(localStorage.getItem('userData'));
if (!userKeep) return;

const reloadUser = new User( //※重點:需重新創造 User 物件,因為有效期的型別是 Date Object,JSON.parse 給的是 string
userKeep.email,
userKeep.id,
userKeep._token,
new Date(userKeep._tokenExpireDate)
);

if (reloadUser.token) this.userSbj.next(reloadUser);
}
// ...
private AuthHandle(email: string, userId: string, token: string, expiresIn: number) {
const user = new User(
email,
userId,
token,
new Date(new Date().getTime() + expiresIn * 1000)
);
this.userSbj.next(user);
localStorage.setItem('userData', JSON.stringify(user)); //※重點
}
app.component.ts
import { AuthService } from './auth/auth.service';
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})

export class AppComponent implements OnInit {
constructor(
private AuthService: AuthService
) { }

ngOnInit(): void { //※重點
this.AuthService.autoSignIn();
}
}

逾時自動登出

firebase 預設提供的 token 期限為 1 小時 (3600000 毫秒),因此需要每次登入或刷新時重新綁定 setTimeout 於指定時間下做 signOut 作業,這部分的工作由 autoSignOut 負責處理。並在登入成功與刷新頁面的時機下執行 autoSignOut。

// ...
export class AuthService {
userSbj = new BehaviorSubject<User>(null);
tokenExpireTimer: NodeJS.Timer; //※重點

constructor(
private http: HttpClient,
private Router: Router
) { }

// ...

autoSignIn() {
const userKeep = JSON.parse(localStorage.getItem('userData'));
if (!userKeep) return;

const reloadUser = new User(
userKeep.email,
userKeep.id,
userKeep._token,
new Date(userKeep._tokenExpireDate)
);

if (reloadUser.token) {
this.userSbj.next(reloadUser);

const expirationDuration = new Date(userKeep._tokenExpireDate).getTime() - new Date().getTime(); //※重點:原預計的時間距離現在還差?毫秒
this.autoSignOut(expirationDuration);//// ※重點:刷新網頁成功找回 user 資訊時,執行 autoSignOut ,要求指定時間(單位毫秒)內 timeout
}
}

signOut() {
this.userSbj.next(null);
this.Router.navigate(['auth']);
localStorage.removeItem('userData'); // ※重點:順便幫忙清除 storage
if (this.tokenExpireTimer) { // ※重點:如果自己登出,我們清掉 timeout 預設動作與值
clearTimeout(this.tokenExpireTimer);
this.tokenExpireTimer = null;
}
}

autoSignOut(expireTime: number) { // ※重點
console.log(expireTime); //firebase 預設為 1H,觀察是不是 3600000
this.tokenExpireTimer = setTimeout(() => {
this.signOut();
}, expireTime)
}
// ...
private AuthHandle(email: string, userId: string, token: string, expiresIn: number) {
const user = new User(
email,
userId,
token,
new Date(new Date().getTime() + expiresIn * 1000)
);
this.userSbj.next(user);
localStorage.setItem('userData', JSON.stringify(user));
this.autoSignOut(expiresIn * 1000); // ※重點:登入成功,執行 autoSignOut ,要求指定時間內 timeout
}
}

路由守衛阻止未登入的動作

目前若未登入直接拜訪網址http://localhost:4200/recipes仍可以正常讀取,我們需要透過路由守衛去檢查 Subject 內的 User 用戶資訊是否存在。如果存在就 canActivate 允許拜訪回傳 True,否則就強制帶到網址 /auth 要求登入。

建立守衛規則 auth-guard-service.ts

  • 手動或 CLI 指令ng g s auth/auth-guard --skip-tests建立 auth/auth-guard-service.ts 路由守衛規則
  • 此規則引用了 AuthService 的 userSbj 進行觀察嘗試拿到 Boolean 值,由於只需一次性所以添加take(1)
  • 同上,接著判斷該內容(透過 map) 有值,如果沒有值可以強制路由導向到指定的 auth 表單頁面。導向除了 navigate 方式直接當下轉走,canActivate 也可以使用 createUrlTree 方式將 UrlTree 回傳,由路由守衛幫我們轉址。
auth\auth-guard.service.ts
import { map, take } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { Observable } from 'rxjs';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(
private AuthService: AuthService,
private Router: Router,
) { }

canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | UrlTree | Promise<boolean | UrlTree> | Observable<boolean | UrlTree> {
return this.AuthService.userSbj.pipe(
take(1),
map(user => {
const isAuth = !!user;
if (isAuth) return true;
return this.Router.createUrlTree(['/auth']);
}),
);
}
}

createUrlTree 與 navigate
createUrlTree 與 navigate 都是 Angular Router 提供的導航方法。createUrlTree 可以用來創建一個路由樹,並返回一個新的 UrlTree,但不會執行導航。它主要用於在程式邏輯中建立路由鏈接,例如在頁面中點擊一個按鈕後需要導航到另一個頁面。以下是一個示例:

import { Router, UrlTree } from '@angular/router';

constructor(private router: Router) {}

onButtonClick(): void {
const urlTree: UrlTree = this.router.createUrlTree(['/products', productId]);
const url: string = urlTree.toString();
// do something with the url
}

當用戶單擊按鈕時,將創建一個包含 /products 和產品 ID 的路由樹,但不會執行導航。然後,可以使用 toString 方法將路由樹轉換為字符串形式,並將其用於任何需要的地方,例如用戶點擊後在新窗口中打開。

相反,navigate 方法可用於在應用程序中執行實際導航。以下是一個示例:

import { Router } from '@angular/router';

constructor(private router: Router) {}

onButtonClick(): void {
this.router.navigate(['/products', productId]);
}

在上面的例子中,當用戶單擊按鈕時,將立即執行導航到指定的路由。這將導致應用程序在瀏覽器中顯示新頁面。

設定路由規則 app-routing.module.ts

現在示範只示範,若拜訪網址http://localhost:4200/recipes需要套用 auth-guard-service.ts 的守衛機制。

  • 找到該 Routs.path 為 recipes 的位置,追加 canActivate 屬性並指向 AuthGuard 規則。
import { AuthGuard } from './auth/auth-guard.service';
// ...
const appRoutes: Routes = [
{ path: '', redirectTo: '/recipes', pathMatch: 'full' },
{
path: 'recipes',
component: RecipesComponent,
canActivate: [AuthGuard], //※重點
children: [
// ...
]
},
// ...
];
// ...

Dynamic Component 動態元件

動態元件(Dynamic Component)是指在執行時期建立並插入到應用程式中的元件。相較於在模板中直接使用 這樣的標籤方式,動態元件通常是透過程式碼動態產生並加入到應用程式中的。

在 Angular 中,要建立動態元件可以使用 ComponentFactoryResolver 來取得 ComponentFactory,再透過 ViewContainerRef 的 createComponent() 方法來建立元件。 ComponentFactoryResolver 負責載入元件的定義,而 ViewContainerRef 則負責定位要插入動態元件的容器。

動態元件常見的應用情境包括:

  • 需要在執行時期才能決定元件的類型、樣式或配置。
  • 在路由切換時,需要根據路由參數決定要顯示哪一個元件。
  • 需要動態增加或刪除元件,例如使用者點擊按鈕後,產生一個新的元件並插入到應用程式中。

使用動態元件可以提升應用程式的彈性,可以更容易地應對變化的需求。

重新規劃 Alert Modal 於靜態元件

在素材內先前已設計一組若 auth 表單提交錯誤,透過 Bootstrap Alert 產生錯誤資訊。

src\app\auth\auth.component.html
<div class="alert alert-danger" *ngIf="isError">
{{isError}}
</div>

這裡重新設計為層疊彈跳 modal 視窗 UI,並使用傳統元件搭配 ngIf 來控制該元件存取的條件。

  • 手動或 CLI 指令ng g c shared/alert/alert --skip-tests建立指定目錄下之 alert.component.ts
  • 配合設計指定 html/css 樣式,其中 html 提供 error 資訊之外,另有 Close 的 click 事件落於 backdrop 或 button 之上。
  • alert 元件上面德 error 資訊與 click 動作 (event 發射器),透過 Input 與 Output 方式給 auth 元件輸入輸出。

注意 app.module.ts 要在 declarations 內註冊此新 AlertComponent

src\app\shared\alert\alert\alert.component.ts
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';

@Component({
selector: 'app-alert',
templateUrl: './alert.component.html',
styleUrls: ['./alert.component.css']
})
export class AlertComponent {
@Input() message: string;
@Output() close = new EventEmitter<void>();

onClose() {
this.close.emit();
}
}
src\app\shared\alert\alert\alert.component.html
<div class="backdrop" (click)="onClose()"></div>
<div class="alert-box">
<p>{{message}}</p>
<div class="alert-box-actions">
<button class="btn btn-primary" (click)="onClose()">Close</button>
</div>
</div>
src\app\shared\alert\alert\alert.component.css
.backdrop{
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #000b;
z-index: 50;
}

.alert-box{
position: fixed;
top: 30vh;
left: 20vw;
width: 60vw;
padding: 16px;
z-index: 100;
background: white;
box-shadow: 0 2px 8px #0004;
}

.alert-box-actions{
text-align: right;
}
  • auth 元件部分,對應處理輸入與輸出作業。
  • 從 alert 元件傳過來的互動按鈕要求關閉 Alert,只需要將 error 資訊清除就會根據原設計不會顯示該 alert。
src\app\auth\auth.component.html
<!-- <div class="alert alert-danger" *ngIf="isError">
{{isError}}
</div> -->
<app-alert
[message]="isError"
*ngIf="isError"
(close)="onErrorHandle()"
></app-alert>
src\app\auth\auth.component.ts
onErrorHandle() {
this.isError = null;
}

改以動態元件方式生成 alert

傳統靜態元件的方式只需要依賴 ngIf 方式控制是否讀取該已寫好的元件。相較於動態元件是由 angular 在執行過程當下將一個指定元件塞入至 DOM。塞入 DOM 需先預先規劃一個 ViewContainerRef 容器。

在 Angular 中,ViewContainerRef 是一個抽象類別,它代表一個視圖容器,可以用來動態加入或刪除 Angular 元件。當需要動態地創建元件時,可以使用 ViewContainerRef 來訪問視圖容器,並在需要的地方插入元件。舉例來說,當你想在用戶觸發某些事件後,插入一個元件來顯示資訊時,就可以使用 ViewContainerRef 來實現這個功能。

  • 移除原本 HTML 模板上的靜態元件插入,我們改使用 ng-template(亦可使用 ng-container) 並指定自訂別名如 dynamicAlertComp
  • 透過 ViewChild 找到這個自訂模板成為本地屬性 theViewContainerRef,並指定 option 參數為 ViewContainerRef,同時這個屬性的型別也是 ViewContainerRef。
  • 接著規劃一個新方法 showErrorAlert(errorMessage),當發生錯誤時將錯誤丟給該函式處理控制動態元件的生成。
src\app\auth\auth.component.html
<!-- <app-alert
[message]="isError"
*ngIf="isError"
(close)="onErrorHandle()"
></app-alert> -->
<ng-template #dynamicAlertComp></ng-template>
src\app\auth\auth.component.ts
import { Component, ViewChild, ViewContainerRef } from '@angular/core';

// ...

export class AuthComponent {
isLoginMode = true;
isLoading = false;
isError = null;
@ViewChild('dynamicAlertComp', { //※重點
read: ViewContainerRef,
static: false
}) theViewContainerRef: ViewContainerRef;

//...

onSubmit(form: NgForm) {
//...
authObservable.subscribe(
resData => {
console.log(resData);
this.isLoading = false;
this.Router.navigate(['/recipes']);
},
error => {
this.isError = error;
this.showErrorAlert(error); //※重點
this.isLoading = false;
}
);
form.reset();
}
//...
private showErrorAlert(errMessage: string) {
//規劃動態元件工作
}
}

ng-template 和 ng-container
為 Angular 中的指令,用於在模板中定義和處理內容。

  • ng-template
    是用來定義一段 HTML 模板,它並不會在頁面上顯示任何內容,而是當作一個容器或者容器的複本來使用。
  • ng-container
    也是一個容器元素,但它的主要作用是作為佔位符使用,它可以幫助我們結構化模板,提高可讀性,同時不影響 DOM 結構。

ViewChild 參數
範例內的第二組 options 參數提供以下說明:

  • read
    可以使用 read 選項來指定要注入的提供者。如果指定了這個選項,則 ViewChild 會查找該提供者,而不是使用元素本身的類型。這對於需要在組件中訪問服務或其他提供者的情況非常有用。
  • static ( Angular8+ 必填)
    設置 static 為 true,可以在 ngAfterViewInit 之前解析 ViewChild。這個選項可以解決 Angular 的讀取順序問題。
  • emitEvent
    設置 emitEvent 為 true,可以在變更時發出事件,通常在與 ngModel 或 FormGroup 一起使用時使用。

我們需要透過 ComponentFactoryResolver 來建立一個元件工廠(你需要在建構子宣告),ComponentFactoryResolver 是 Angular 中一個重要的服務,它允許動態創建和載入組件,並且在執行時期動態創建這些組件,可以滿足各種動態組件的需要。

  • 使用 ComponentFactoryResolver 的 resolveComponentFactory 將一個靜態元件(我們的 alert 模板 AlertComponent) 加入。
  • 可以先將 ViewContainerRef 清除避免原畫面上的容器有舊東西,透過 clear()
  • 接著要求這個 AlertComponentFactory 製程建立於 ViewContainerRef 上面,使得畫面上得以產生動態元件。
  • 別忘了我們需要 Input/Out 去操作此元件,可使用 instance 去寫入屬性與方法
  • close 本身是一個可訂閱的事件,一旦偵測到可以清除整個容器下的東西(由於每次按下元件都被清除,所以訂閱不會一值存在,可省去銷毀的取消訂閱)
src\app\auth\auth.component.ts
import { AlertComponent } from './../shared/alert/alert/alert.component';
import { Component, ComponentFactoryResolver, ViewChild, ViewContainerRef } from '@angular/core';

// ...

export class AuthComponent {
// ...
constructor(
private AuthService: AuthService,
private Router: Router,
private ComponentFactoryResolver: ComponentFactoryResolver
) { }

//...

private showErrorAlert(errMessage: string) {
// 建立 AlertComponent 製程
const AlertCmpFty = this.ComponentFactoryResolver.resolveComponentFactory(AlertComponent);
this.theViewContainerRef.clear();

// 插入 AlertComponent 到 ng-template 中
const compRef = this.theViewContainerRef.createComponent(AlertCmpFty);

compRef.instance.message = errMessage;
compRef.instance.close.subscribe(() => {
this.theViewContainerRef.clear();
});
}
}

另外在 Angular 中,如果希望動態加載某個 Component,需要在相關的 NgModule 中聲明該 Component,這通常可以在 declarations 中完成,但如果要動態加載,還需要在 entryComponents 中聲明。

src\app\app.module.ts
@NgModule({
declarations: [
// ...
AlertComponent,
],
// ...
entryComponents:[
AlertComponent,
]
})

參考文獻