github Source code
除非你和我一樣是個‘老傢伙’,使用過 Angular ver 4.3 以前的版本,否則你一定會遇到它 - interceptor(攔截器)。接下來將用幾篇筆記把各種使用 interceptor 情境的範例給記錄下來。
Angular Interceptor 可以用來作什麼用途呢
- 修改 HTTP Headers
- 修改 HTTP request body
- 設定 authentication/authorization token (身份認證)
- 模擬 backend api
- 修改 HTTP response
- 處理 HTTP 錯誤 (HTTP Error Handlin)
- 顯示 Loading Spinner
- 格式化 JSON Responses
- 日誌記錄
首先先來看看如何使用 Interceptor 來完成一個當你在讀取後端資料時可在畫面上顯示一個“正在讀取中…“的動畫效果,這可以讓使用者有良好的使用體驗。
筆記最後要達的效果是,在按下“取得資料”按鈕時,由程式向後端 API 讀取資料,而在讀取完成前,畫面中的所有 UI (如:按鈕)都是呈現無作用的狀況,直到資料回傳完畢。同時如果“向後端 API 讀取資料”的作業時間小於一秒鐘,則不顯示 “讀取中…" 這個動畫效果。
建立 Angular 專案架構#
使用 npm init @angular 語法來建立 Angular project
1
2
3
|
$ npm init @angular loadingSpin -- --routing --style=scss
$ cd loadingSpin
$ code .
|
安裝 material component
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
$ npx ng add @angular/material
ℹ Using package manager: npm
✔ Found compatible package version: @angular/material@15.1.2.
✔ Package information loaded.
The package @angular/material@15.1.2 will be installed and executed.
Would you like to proceed? Yes
✔ Packages successfully installed.
? Choose a prebuilt theme name, or "custom" for a custom theme: Indigo/Pink [ Preview:
https://material.angular.io?theme=indigo-pink ]
? Set up global Angular Material typography styles? No
? Include the Angular animations module? Include and enable animations
UPDATE package.json (1108 bytes)
✔ Packages installed successfully.
UPDATE src/app/app.module.ts (502 bytes)
UPDATE angular.json (3077 bytes)
UPDATE src/index.html (556 bytes)
UPDATE src/styles.scss (181 bytes)
|
使用 @angular/material component 來建立 UI#
在使用 material component 之前,先在 app.module 中 import 要使用的 UI component,如: Toolbar、Button、Progress Spinner等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
# app.module.ts {linenos=table}
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
MatToolbarModule,
MatButtonModule,
MatProgressSpinnerModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
|
在 app.component.html 中,建立一個表頭、二個按鈕、及放入 material spinner(用來顯示正在處理中的動畫圖示) 組件。
1
2
3
4
5
6
7
8
9
10
|
<!-- app.component.html -->
<mat-toolbar color="primary">
使用 RxJS and HttpInterceptor 來實現 "資料讀取中..." 動畫效果的功能
</mat-toolbar>
<div class="content">
<button mat-raised-button (click)="showSpinner()">顯示</button>
<button mat-raised-button color="warn" (click)="hideSpinner()">隐蔵</button>
</div>
<mat-spinner *ngIf="loading"></mat-spinner>
|
在 app.component.scss 中設定了畫面上按鈕排版位置。
1
2
3
4
5
6
7
8
|
/* app.component.scss */
.content {
padding: 16px;
}
button {
margin: 8px;
}
|
在 app.component.ts component class 中宣告了一個 property: loading,用來控制畫面上 Spinner 的顯示與否。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
loading = false;
showSpinner() {
this.loading = true;
}
hideSpinner() {
this.loading = false;
}
}
|
測試 mat-spinner 顯示效果#
測試後,微調了一下 spinner 在畫面中的位置。
app.component.scss 調整如下:
/* ... */
/* 加入以下設定:調整顯示的位置 */
mat-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 5;
}
調整後效果如下:
使用 HTTPClient 讀取後端資料#
建立一個呼叫後端 Api 的 service
$ npx ng g s services/getData
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// services/get-data.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class GetDataService {
constructor(private http: HttpClient) { }
getData() {
return this.http.get('https://jsonplaceholder.typicode.com/posts');
}
}
|
使用到了 HttpClient,所以要記得在 AppModule 中匯入 HttpClientModule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { HttpClientModule } from '@angular/common/http';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
MatToolbarModule,
MatButtonModule,
MatProgressSpinnerModule,
HttpClientModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
|
在 Template (app.component.html) 中加入一個“取得資料”的按鈕。
1
2
3
4
5
6
7
8
9
10
|
<mat-toolbar color="primary">
使用 RxJS and HttpInterceptor 來實現 "資料讀取中..." 動畫效果的功能
</mat-toolbar>
<div class="content">
<button mat-raised-button (click)="showSpinner()">顯示</button>
<button mat-raised-button color="warn" (click)="hideSpinner()">隐蔵</button>
<button mat-raised-button color="primary" (click)="getData()">取得資料</button>
</div>
<mat-spinner *ngIf="loading"></mat-spinner>
|
在 component class 中呼叫 Service 來向後端 API 讀取資料。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
import { Component } from '@angular/core';
import { GetDataService } from './services/get-data.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
loading = false;
data: unknown;
constructor(private _getData: GetDataService){ }
showSpinner() {
this.loading = true;
}
hideSpinner() {
this.loading = false;
}
getData() {
this.loading = true;
this._getData.getData().subscribe(data => {
this.loading = false;
this.data = data;
});
}
}
|
完成以上程式後,在按下“取得資料“按鈕執行讀取後端 API 時畫面已可以正確的顯示資料讀取中的動畫圖示了。
使用 Interceptor 來優化程式#
接下來要透過 Angular Interceptor 來優化程式。上述程式碼在每支與後端API有互動的程式中都會重複出現,可以透過將相同邏輯統一放置在 Interceptor 中來優化整個系統。
建立一個 Interceptor
1
2
3
4
|
$ npx ng g interceptor interceptors/httpLoadingSpin
CREATE src/app/interceptors/http-loading-spin.interceptor.spec.ts (472 bytes)
CREATE src/app/interceptors/http-loading-spin.interceptor.ts (420 bytes)
|
將 ‘狀態’ 資訊放在另一支 Service 程式中
1
2
3
4
|
$ npx ng g s services/loading
CREATE src/app/services/loading.service.spec.ts (362 bytes)
CREATE src/app/services/loading.service.ts (136 bytes)
|
loading.service.ts 程式內容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class LoadingService {
private _loading$ = new BehaviorSubject<boolean>(false);
loading$ = this._loading$.asObservable();
constructor() { }
show() {
this._loading$.next(true);
}
hide() {
this._loading$.next(false);
}
}
|
在前面新建立的 Interceptor 程式修改如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor
} from '@angular/common/http';
import { finalize, Observable } from 'rxjs';
import { LoadingService } from '../services/loading.service';
@Injectable()
export class HttpLoadingSpinInterceptor implements HttpInterceptor {
constructor(private loadingService: LoadingService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
this.loadingService.show();
return next.handle(request).pipe(
finalize(() => {
this.loadingService.hide();
})
);
}
}
|
並且記得要在 app.moudle.ts 註冊這個新建立的 interceptor
1
2
3
4
5
6
7
|
// ...
providers: [{
provide: HTTP_INTERCEPTORS,
useClass: HttpLoadingSpinInterceptor,
multi: true
}],
// ...
|
app.component.ts 這個 component class 程式改成使用新建立的 LoadingService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
import { Component } from '@angular/core';
import { GetDataService } from './services/get-data.service';
import { LoadingService } from './services/loading.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
// loading = false;
loading$ = this.loadingService.loading$;
data: unknown;
constructor(private _getData: GetDataService
, private loadingService: LoadingService){ }
showSpinner() {
// this.loading = true;
this.loadingService.show();
}
hideSpinner() {
// this.loading = false;
this.loadingService.hide();
}
getData() {
// this.loading = true;
this._getData.getData().subscribe(data => {
// this.loading = false;
this.data = data;
});
}
}
|
template(app.component.html)中的 ngIf 判斷條件改使用 loading$,並搭配使用 async pipe
1
2
3
4
5
6
7
8
9
10
|
<mat-toolbar color="primary">
使用 RxJS and HttpInterceptor 來實現 "資料讀取中..." 動畫效果的功能
</mat-toolbar>
<div class="content">
<button mat-raised-button (click)="showSpinner()">顯示</button>
<button mat-raised-button color="warn" (click)="hideSpinner()">隐蔵</button>
<button mat-raised-button color="primary" (click)="getData()">取得資料</button>
</div>
<mat-spinner *ngIf="loading$ | async"></mat-spinner>
|
以上是透過 interceptor 方式來優化程式。
透過使用 RXJS Operator 再對程式進行優化#
最後,將再進行更新一步的優化:
- 在按下“取得資料”按鈕後與取得資料前(即資料回傳完畢前)畫面上的所有按鈕(UI)都保持 disable 狀況,直到資料回傳完畢
- 如果“取得資料”的作業時間很短(如:小於等於1秒鐘),將不顯示“讀取中…“這個動畫效果
需求一#
上述需求一可透過 CSS 來達成,首先將 app.component.html mat-spinner 程式碼調整如下
<!-- <mat-spinner *ngIf="loading$ | async"></mat-spinner> -->
<ng-container *ngIf="loading$ | async">
<div class="overlay"></div>
<mat-spinner></mat-spinner>
</ng-container>
其中的 overlay div 是用來遮敝在整個晝面上,來達到讀取完成前畫面是無回應的要求。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
.content {
padding: 16px;
}
button {
margin: 8px;
}
mat-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 5;
}
.overlay {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
z-index: 2;
backdrop-filter: blur(2px);
}
|
使用後的效果如下圖:
需求二#
第二個需求可以在 LoadingService 使用相關的 RXJS Operator 來達成:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// ...
// loading$ = this._loading$.asObservable();
loading$: Observable<boolean> = this._loading$.pipe(
switchMap(isLoading => {
if (!isLoading) {
return of(false);
}
return of(true).pipe(delay(1000));
})
)
// ...
|