[前端框架] Angular - 服務與路由

Service 用於明確定義用途的類別,通常用來將共用的程式邏輯或資料存取邏輯抽象出來,且可以在多個元件之間共用,以避免程式邏輯的重複編寫或將元件與底層實作解耦,讓元件類別更加精簡、高效。
Routing 可以幫助我們實現多頁面應用程式或單頁面應用程式 (SPA)。當使用者在應用程式中導航時,路由會根據使用者的操作選擇適當的畫面,並將該畫面呈現給使用者。

Service 服務

Service 可藉由 Dependency Injection 的方式被注入到元件中。舉例來說,如果一個應用程式需要與後端 API 進行資料交換,我們可以使用 Service 將資料的存取邏輯抽象出來,而不是將此邏輯直接放在元件中。這樣可以讓元件專注於畫面呈現的邏輯,而不用擔心資料交換的實作細節。同時,使用 Service 也可以讓應用程式更容易測試和維護,因為相關邏輯都被封裝在 Service 中。

在 Angular 中,Service 是一個可注入的類別,它通常會使用 @Injectable 裝飾器來進行標記,以便 Angular 的 DI 機制可以找到它。透過這種方式,我們可以輕鬆地在需要的元件中使用 Service,並且可以方便地進行單元測試和模擬。

討論服務的作用,假設一個應用程式擁有以下組織並提供一些方法:

  • AppComponent
    • AboutComponent (Method:log data to console)
    • UserComponent (Method:store user data)
      • UserDetailComponent (Method:log data to console)

有兩個 (Method: 能將 data 輸出到 console.log) 的用途很相近幾乎相同,然後另一個 (Method: 儲存用戶資訊)在 User 原件上雖然只有單獨,也許之後這個單獨 Method 之後可能也會出現在其他元件內重複使用。

服務就是將這些 Method 獨立起來成為應用程式的另一部分,使用服務可以將你重複使用的代碼集中起來並可添加到任何元件類別底下做成注入依賴。因此我們可以產生一個 log 用的服務以及儲存用戶資料的服務。

示範與前置準備

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

Github download at lokiService-start Folder

素材內可看到

  • 有三個元件,app 根元件、新增用戶元件、用戶紀錄元件。
  • 且用戶紀錄元件能按鈕控制切換狀態輸出到 console.log 並被儲存起來(字串插植),也許這可以做為服務做為代碼集中。
  • app 元件將整個東西整合在一起。對 new-account 元件進行@Output自訂事件綁定,對 account 元件進行@input/@Output屬性與自訂事件綁定。
  • 由 app 元件提供 account[] 陣列資料,提供迴圈重複 account 元件使用
  • account 透過按鈕觸發更改狀態並透過自訂事件通知 app 元件做資料更改。
  • new-account 透過按鈕觸發除了 console.log 資訊也透過自訂事件通知 app 元件做資料新增。

建立 logging 用的服務 dddddd

我們要建立出一個服務做為 ts 檔案,可手動到 app 目錄下新增一個logging.serve.ts並編寫以下內容:

logging.service.ts
export class LoggingService {

}

或者透過 CLI 指令 ng g service loggingng g s logging來自動生成。

如果你選擇手動新增此檔案,你可能以為我們創建了元件,以為下一步是對本 ts 檔案宣告@Component資訊,以及還要到 add.module.ts 填寫 declarations。事實上不用做這些事,因為 service 不是元件他只是一個 typescript 腳本代碼,目前這些就已經叫做 service。

我們在該 service 內新增一個 Method 能幫助我們進行 console.log。console.log 格式可參考 new-account 的 ts。

logging.service.ts
export class LoggingService {
// constructor() { } //用不到

logState(status: string) {
console.log('A server status changed, new status: ' + status);
}
}

接下來我們要將服務放入到有需要的元件內使用,這裡會示範手動方式(不推薦)與 Angular 提供的注入依賴(推薦)來實施。

手動將服務實體化 (不推薦)

接下來我們要剛做好的服務,放回到素材內可以服務來取代的相同代碼。透過 new 實體化來載入服務(記得宣告來源)。

new.account.component.ts
import { Component, EventEmitter, Output } from '@angular/core';
import { LoggingService } from './../logging.service'; //※重點,我們要宣告服務來源

@Component({
selector: 'app-new-account',
templateUrl: './new-account.component.html',
styleUrls: ['./new-account.component.css']
})
export class NewAccountComponent {
@Output() accountAdded = new EventEmitter<{ name: string, status: string }>();

onCreateAccount(accountName: string, accountStatus: string) {
this.accountAdded.emit({
name: accountName,
status: accountStatus
});
const service = new LoggingService(); //透過 new 得到剛剛的服務 object 並實體化
service.logState(accountStatus); //使用這個服務底下的函式方法

// console.log('A server status changed, new status: ' + accountStatus);
}
}

Hierarchical Injector 注入依賴

是指這個元件類別所需要依賴的東西為何,例如目前我們的 new-account 將依賴我們的 loggingService。透過注入依賴把 loggingService 注入到 new-account。由 angular 來幫我們處理在 new-account 內引用 loggingService 做實體化。作法如下:

  • 首先我們需要在 angular 對這個元件實體化時,在建構函式內提供該私有參數使得元件內能使用到這個方法(注意強型別來自於 LoggingService)。
  • 接著要告知這個元件所依賴的提供者為何,在@Component內宣告 providers 並使用陣列。
  • 現在你能在元件類別內去直接使用這個方法,因為 Angular 已經幫你實體化到這個元件內。
  • 最後測試一下是否與前一節的動作獲得相同的 console.log 預期結果。

providers 可以被定義在模塊、元件、指令或管道中,是用來注入依賴的一種機制。在 Angular 中,我們通常會將服務定義為一個提供者,然後將該提供者注入到元件或其他服務中。通過注入服務,我們可以實現元件之間的數據共享和通信,也可以將服務抽象出來,方便進行代碼重用和維護。

new-account.component.ts
import { Component, EventEmitter, Output } from '@angular/core';
import { LoggingService } from './../logging.service'; //※重點,我們要宣告服務來源

@Component({
selector: 'app-new-account',
templateUrl: './new-account.component.html',
styleUrls: ['./new-account.component.css'],
providers:[LoggingService] //※重點:配置所依賴的提供者為何
})
export class NewAccountComponent {
@Output() accountAdded = new EventEmitter<{ name: string, status: string }>();

constructor(private theLoggingService: LoggingService) { }
/*
※重點:
在建構函式內定義此物件做為參數,是讓 Angular 當對元件進行實體化時,在建構階段上能夠加載到這個參數
才能提供我們類別內的後續進行使用
*/

onCreateAccount(accountName: string, accountStatus: string) {
this.accountAdded.emit({
name: accountName,
status: accountStatus
});

// console.log('A server status changed, new status: ' + accountStatus);
this.theLoggingService.logState(accountStatus); //※重點:透過注入依賴所獲得方法來執行
}
}

將同樣的方法應用在另一個相同需要相同的服務,並透過注入依賴的方式放入到 account 元件內。

account.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { LoggingService } from './../logging.service'; //※重點

@Component({
selector: 'app-account',
templateUrl: './account.component.html',
styleUrls: ['./account.component.css'],
providers: [LoggingService] //※重點
})
export class AccountComponent {
@Input() account!: { name: string; status: string; };
@Input() id!: number;
@Output() statusChanged = new EventEmitter<{ id: number, newStatus: string }>();

constructor(private theLoggingState: LoggingService) { } //※重點

onSetTo(status: string) {
this.statusChanged.emit({ id: this.id, newStatus: status });
// console.log('A server status changed, new status: ' + status);
this.theLoggingState.logState(status); //※重點
}
}

目前為止,我們將相同的 log 輸出代碼集中成一個服務做成外包,另外利用注入依賴的方式提供給所需要的元件們,且是由 Angular 來幫助我們在元件內實體化這些方法(服務內的方法)。簡化大量重複的代碼。

建立儲存資料的服務

這一節我們將原本在 app 元件負責進行資料新增修改的代碼,也打包給服務。

  • 先建立 accounts 服務,這裡會存放我們用戶資料以及新增修改方法 (參考 app 元件做外包)。
  • 然而 app 本身負責要將用戶資料集中站,會需要提供自己的陣列給 account 元件跑迴圈,所以我們要透過注入依賴,先初始空陣列之後在生命週期的 OnInit 階段從服務那裡取得用戶資料覆蓋此本地的用戶陣列資料。
accounts.services.ts
export class AccountsService {
accounts = [
{
name: 'Master Account',
status: 'active'
},
{
name: 'Test Account',
status: 'inactive'
},
{
name: 'Hidden Account',
status: 'unknown'
}
];

AccountAdd(newAccount: { name: string, status: string }) {
this.accounts.push(newAccount);
}

//原本 App 那裏的參數是物件資料,這裡簡化成兩個參數做提供,其實觀念都一樣
StatusChange(id: number, newStatus: string) {
this.accounts[id].status = newStatus;
}
}
app.component.ts
import { AccountsService } from './accounts.service';
import { Component, OnInit } from '@angular/core';

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

constructor(private accountsService: AccountsService) { }

ngOnInit() {
this.accounts = this.accountsService.accounts;
}
}

此時畫面報錯,找不到 App 元件內的 onAccountAdded() 與 onStatusChanged(),因為我們把這些也都外包給服務了。因此換個角度來說這些子元件現在應該是向服務直接做資料新增修改,不用再@Output給 App 元件了。

  • 將 app 元件與 new-account 元件的@Output與自訂事件綁定給取消,讓 new-account 自己跟服務做新增。
  • 對 new-account 進行注入依賴、注意 import、providers、constructor private
  • 取消原本 new account 的自訂事件,直接對服務的方法進行新增
app.component.html
<!-- <app-new-account (accountAdded)="AccountAdd($event)"></app-new-account> -->
<app-new-account></app-new-account>
new-account.component.ts
import { LoggingService } from './../logging.service';
import { AccountsService } from './../accounts.service'; //※重點,我們要宣告服務 AccountsService 來源
import { Component, EventEmitter, Output } from '@angular/core';

@Component({
selector: 'app-new-account',
templateUrl: './new-account.component.html',
styleUrls: ['./new-account.component.css'],
providers: [LoggingService, AccountsService] //※重點:配置所依賴的提供者 AccountsService
})
export class NewAccountComponent {
//※重點 - 原本配自訂事件是為了@Output 給 App,現在透過服務所以就不需要了
// @Output() accountAdded = new EventEmitter<{ name: string, status: string }>();

constructor(
private theLoggingService: LoggingService,
private accountsService: AccountsService // ※重點
) { }

onCreateAccount(accountName: string, accountStatus: string) {

//※重點 - 原本配自訂事件是為了@Output 給 App,現在透過服務所以就不需要了
// this.accountAdded.emit({
// name: accountName,
// status: accountStatus
// });

//※重點 - 直接將資料提供給服務內的方法做新增作業
this.accountsService.AccountAdd({ name: accountName, status: accountStatus });

// console.log('A server status changed, new status: ' + accountStatus);
this.theLoggingService.logState(accountStatus);
}
}

現在剩下 app 元件與 account 元件的通信也要取消。都由服務來直接操作。

  • 將 app 元件與 account 元件的@Output與自訂事件綁定給取消,讓 account 自己跟服務做修改。
  • 對 account 進行注入依賴、注意 import、providers、constructor private
  • 取消原本 account 的自訂事件,直接對服務的方法進行修改
app.component.html
<!-- <app-account
*ngFor="let acc of accounts; let i = index"
[account]="acc"
[id]="i"
(statusChanged)="onStatusChanged($event)"
></app-account> -->
<app-account
*ngFor="let acc of accounts; let i = index"
[account]="acc"
[id]="i"
></app-account>
account.component.ts
import { LoggingService } from './../logging.service';
import { AccountsService } from './../accounts.service'; //※重點
import { Component, EventEmitter, Input, Output } from '@angular/core';

@Component({
selector: 'app-account',
templateUrl: './account.component.html',
styleUrls: ['./account.component.css'],
providers: [LoggingService, AccountsService] //※重點
})
export class AccountComponent {
@Input() account!: { name: string; status: string; };
@Input() id!: number;
// @Output() statusChanged = new EventEmitter<{ id: number, newStatus: string }>();

constructor(
private theLoggingState: LoggingService,
private accountsService: AccountsService //※重點
) { }

onSetTo(status: string) {
// this.statusChanged.emit({ id: this.id, newStatus: status });
this.accountsService.StatusChange(this.id, status);

// console.log('A server status changed, new status: ' + status);
this.theLoggingState.logState(status); //※重點
}
}

目前沒有報錯了且 console 部分看似很正常,只剩下 View 的部分沒有準確更新似乎有問題。這是因為在服務的使用邏輯上出了些問題。

非相同實體化的服務解決

注入依賴是一種具備分層級的用途,各自層級進行建立允許有單獨的注入依賴。如果沒有特別宣告提供者則會根據樹狀層級單向往下應用。

目前來說我們對三個元件(一個上層兩個下層)都進行一樣的注入依賴,都是要求提供者為何才進行建立。因此每當 Angular 針對我們的需求會從 service 那裏獲得實體化物件,目前來說這三個元件的服務都是獨立的實體化彼此不是相同記憶體所在處的值。自然而然都是各自的資料獨立存取。(而前一節 log 服務來說沒有發現因為只是 console.log)

因此如果需要透過服務來對資料存取,勢必需要讓 Angular 由上層 app 元件來告知一組提供者,而下層元件就依賴上層元件的實體化物件即可,使得三者元件都是修改自同一個記憶體位置。

作法很簡單,只需要取消下層元件的提供者資料即可,這樣會去爬上層的提供者對象。

account.component.ts & new-account.component.ts
// providers: [LoggingService, AccountsService]
providers: [LoggingService] //※重點:下層不要寫,這會吃到上層的提供者

將服務注入到另一個服務

回頭想一下整個 app 的服務邏輯,new-account 會去執行 accounts 服務做新增,也會去執行 logging 服務做 console。account 也是一樣做修改與 console 共兩個服務。如果這兩個小元件只需要對 account 服務就好,由 account 服務去帶動 logging 服務工作更好。

我們可以把 logging 服務注入到 account 服務內,使得所有的元件只需要吃到 account 服務,除了能使用 account 本身的服務,也能使用 logging 服務。

在這之前還有一招可介紹,我們能將服務的樹狀影響層級拉到最高,最高不是 app 元件而是整個應用程式。可發現 app.module 內也有提供者可以填寫。

app.module.ts
import { AccountsService } from './accounts.service'; //※重要
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { AccountComponent } from './account/account.component';
import { NewAccountComponent } from './new-account/new-account.component';

@NgModule({
declarations: [
AppComponent,
AccountComponent,
NewAccountComponent
],
imports: [
BrowserModule,
FormsModule,
],
providers: [AccountsService], //※重要
bootstrap: [AppComponent]
})
export class AppModule { }
app.component.ts
import { AccountsService } from './accounts.service';
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
// providers: [AccountsService]
//我們只是取消提供者而已,改由上層的應用程式來負責
})
export class AppComponent implements OnInit {
accounts: { name: string, status: string }[] = [];

constructor(private accountsService: AccountsService) { }

ngOnInit() {
this.accounts = this.accountsService.accounts;
}
}

如此一來注入依賴會將同一個服務往下延伸到所有元件給予實體化使用相同服務。現在整份文件都有 account 服務,接著就能將 logging 服務放到 account 服務內,因此勢必的 app 模組也是需要提供者將 logging 寫入(我們不會再對元件填寫提供者 logging 服務)。之後回到 account 與 new-account 元件,將原本的提供者取消。

app.module.ts
import { LoggingService } from './logging.service';//※重要
import { AccountsService } from './accounts.service';

// ...

@NgModule({
declarations: [
AppComponent,
AccountComponent,
NewAccountComponent
],
imports: [
BrowserModule,
FormsModule,
],
providers: [AccountsService, LoggingService], //※重要
bootstrap: [AppComponent]
})
export class AppModule { }
new-account.component.ts & account.component.ts
// providers: [LoggingService]

接著,我們不會透過 account 與 new-account 元件來執行 logging 服務了,而是用 account 服務來執行 logging 服務。因此取消這兩個原本對服務的動作。

new-account.component.ts & account.component.ts
// this.theLoggingService.logState(accountStatus);   //※重點 取消該元件要對服務做的事,到時候由 account 服務來做

現在需要由 account 服務來執行 logging 服務內的方法,因為是 Class 寫法我們也是需要利用 constructor 的參數來實體化 logging。接著整合到原本 addAccount() 與 updateStatus() 內去做 log 工作。

accounts.service.ts
import { LoggingService } from './logging.service'; //※重要
export class AccountsService {
accounts = [
{
name: 'Master Account',
status: 'active'
},
{
name: 'Test Account',
status: 'inactive'
},
{
name: 'Hidden Account',
status: 'unknown'
}
];

constructor(private loggingService: LoggingService) { } //※重點

AccountAdd(newAccount: { name: string, status: string }) {
this.accounts.push(newAccount);
this.loggingService.logState(newAccount.status); //※重點
}

StatusChange(id: number, newStatus: string) {
this.accounts[id].status = newStatus;
this.loggingService.logState(newStatus); //※重點
}
}

此時又獲得一個錯誤資訊。

The class ‘AccountsService’ cannot be created via dependency injection, as it does not have an Angular decorator. This will result in an error at runtime.
Either add the @Injectable() decorator to ‘AccountsService’, or configure a different provider (such as a provider with ‘useFactory’).
(-992005)

“AccountsService” 不能通過注入依賴創建,因為它沒有 Angular 裝飾器。這將導致運行時出錯。
將 @Injectable() 裝飾器添加到“AccountsService”,或配置不同的提供者(例如具有“useFactory”的提供者)。

原因是我們的 accounts 服務內沒有@Component{}這樣的元資料 (MetaData),因此 Angular 無法像元件那樣知道如何處理、實體化和使用。但這又不是元件不應該這樣寫。因此我們需要透過@Injectable()寫在類別之前,告知 Angular 關於這個類別是可注入的。

換言之,如果你需要在某服務內注入一些東西,就需要在該類別添加前綴符號@Injectable

accounts.service.ts
import { Injectable } from '@angular/core';//※重要
import { LoggingService } from './logging.service';

@Injectable() export class AccountsService { //※重點
//...
}

過去看到的@Component()其實是 export class 的前墜符號。

利用服務進行跨元件通信

目前知道,透過服務我們不再需要依賴@Input/@Output來依賴父元件通信,而是各自元件透過服務來進行資料處理。換言之兩個兄弟元件可透過服務來通信。簡單舉例如下:

  • 對 accounts 服務增加一個自訂事件之屬性,而整份 app 都能同樣吃到這個自訂事件
    accounts.service.ts
    mport { Injectable, EventEmitter } from '@angular/core';//※重要
    import { LoggingService } from './logging.service';

    @Injectable() export class AccountsService {
    accounts = [
    {
    name: 'Master Account',
    status: 'active'
    },
    {
    name: 'Test Account',
    status: 'inactive'
    },
    {
    name: 'Hidden Account',
    status: 'unknown'
    }
    ];
    statusAlert=new EventEmitter<string>(); //※重點:添加一個自訂事件的屬性

    constructor(private loggingService: LoggingService) { }

    AccountAdd(newAccount: { name: string, status: string }) {
    this.accounts.push(newAccount);
    this.loggingService.logState(newAccount.status);
    }

    StatusChange(id: number, newStatus: string) {
    this.accounts[id].status = newStatus;
    this.loggingService.logState(newStatus);
    }
    }
  • 來到 new-account 元件,我們利用建構函式執行當下,去對服務內的自訂事件 subscribe 註冊此實例發出事件的處理器。
    new-account.component.ts
    export class NewAccountComponent {
    constructor(
    private theLoggingService: LoggingService,
    private accountsService: AccountsService
    ) {
    accountsService.statusAlert.subscribe( // ※重點 - 這裡會在建構實體化時會執行一次
    (status: string) => alert('the status is ' + status)
    );
    }

    onCreateAccount(accountName: string, accountStatus: string) {
    this.accountsService.AccountAdd({ name: accountName, status: accountStatus });
    }
    }
  • 來到 account 元件,我們利用建構函式執行當下,去對服務內的自訂事件 emit 發出包含給定值的事件。
account.component.ts
onSetTo(status: string) {
this.accountsService.StatusChange(this.id, status);
this.accountsService.statusAlert.emit(status); //※重點 發射執行之結果

}

這樣的範例中,我們沒有使用到屬性綁定或事件綁定,單純透過相同服務搭配 Emit 進行通信。

自訂事件 EventEmitter 詳閱 Angular.tw

Routing 路由

Routing 是一個非常重要的功能,用於實現單頁應用程序(SPA:Single Page Application)的路由功能。Routing 讓我們可以通過定義路由器來管理應用程序中的路由。每當用戶進行瀏覽器操作,例如單擊超鏈接或刷新頁面時,路由器會解析 URL 並確定要顯示的 Component。通過 Routing,我們可以實現不同的路由和導航功能,例如:

  • 當用戶單擊超鏈接時,顯示特定的 Component
  • 動態生成 URL 並將其顯示給用戶
  • 將用戶導航到應用程序中的不同部分
  • 當用戶進行某些操作時,導航到不同的頁面

Routing 通常是在 app.module.ts 中配置。我們可以通過在 RouterModule.forRoot 方法中定義路由配置來設置應用程序的路由。

簡單來說,路由設計可以在同一頁面下不會重新請求伺服器網頁載入,直接替換 DOM 模擬出新的大版面內容以及更換 URL,你感覺好像有重新連結網頁但事實上你的連線沒有重新加載。

示範與前置準備

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

Github download at lokiRouting-start Folder

素材內可看到:

  • 一開始的 app 內有主要元件為 users, servers, home,且各自有自己的子元件先不討論這部份。
  • 目前希望從上面的 nav-bar 透過點選連結來動態加載這三個,一次只顯示一個元件而不是像這樣看到三個。

宣告路由

首先我們需要將這些超連結的位置被記錄起來,來到最外面的 app.module.ts

  • 進行設定一變數並指定型別為 Routes(來自 Angular/router 導入),因為是型別為複數之陣列所以路由指向可以很多項目。位置最好提早於 NgModule 設定前。
  • 在自訂變數內設定你的 URL 路徑之字串,哪個 path(不用添加/前綴路徑) 指向到哪個對應的元件名稱,若空字串為代表一開始首頁。
  • 接著來到 imports 這裡要求載入 RouterModule(來自 Angular/router 導入),在利用 forRoot() 告知 Angular 我們的路徑設定檔
app.module.ts
import { RouterModule, Routes } from '@angular/router'; //※重點

const lokiRoutes: Routes = [ //※重點
{ path: '', component: HomeComponent },
{ path: 'users', component: UsersComponent },
{ path: 'servers', component: ServersComponent }
];

@NgModule({
declarations: [
AppComponent,
HomeComponent,
UsersComponent,
ServersComponent,
UserComponent,
EditServerComponent,
ServerComponent
],
imports: [
BrowserModule,
FormsModule,
RouterModule.forRoot(lokiRoutes) //※重點
],
providers: [ServersService],
bootstrap: [AppComponent]
})
export class AppModule { }

目前 Angular 已經知道我們路由要求變化,但需要告知路由的動態載入要規劃至範本的何處以及提供有效連結給 nav-bar。

  • 來到 app 元件的 html 調整原本的元件模板取消,使用 router-outlet 來插入。
  • 在接著來到 nav-bar 的範本這裡,試著將以下超連結對應綁到三個 a:link。
app.component.html
<div class="container">
<div class="row">
<div class="col">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/servers">Servers</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/users">Users</a>
</ul>
</div>
</div>
<hr>
<div class="row">
<div class="col">
<router-outlet></router-outlet>
</div>
</div>
<!-- <div class="row">
<div class="col">
<app-home></app-home>
</div>
</div>
<div class="row">
<div class="col">
<app-users></app-users>
</div>
</div>
<div class="row">
<div class="col">
<app-servers></app-servers>
</div>
</div> -->
</div>

嘗試輸入以下完整網址觀察<router-outlet></router-outlet>的 View 變化。

  • http:localhost:4200/
  • http:localhost:4200/users
  • http:localhost:4200/severs

目前處理程度上,已可透過手動指定網址要求<router-outlet></router-outlet>對應載入哪一份元件。然而實際操作下,你並不是真正的透過路由進行動態 DOM 抽換,而是還是每次透過連結請求網址來進行加載。

觸發路由動作

觸發的方式可以從範本上的屬性綁定呼叫 Route 指令來執行。或者從 TypeScript 內去執行 Route 指令。

從 Template

你不應該直接對 href 直接綁定為相對路徑,這會觸發網頁重新加載失去了 SPA 的應用優勢。因此需要改一下前面的範本的連結設定讓 Angular 知道這是要內部路由模擬處理。將原本的 href 屬性抽換成 routerLink 屬性。

app.component.html
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" routerLink="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="/servers">Servers</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['/users']">Users</a>
</ul>

現在網址不會加載,檢查 F12 可發現 DOM 自行做切頁的效果。

  • touterLink 本身是一個屬性綁定,routerLink="/users"等價於[routerLink]="['/users']"。比較特別這裡的指定值是陣列。
  • routerLink 所填寫的值為相對路徑且觀念一致,寫法根據文件所在地以及 index 頁面有關。
  • 可利用 routerLinkActive 屬性幫我們針對運作中的元件添加指定樣式 class,我們使用 Bootstrap 規則的 active 來做美化。
app.component.html
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" routerLinkActive="active" routerLink="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLinkActive="active" routerLink="/servers">Servers</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLinkActive="active" routerLink="/users">Users</a>
</ul>

Home 的 active 經操作檢查下持續保持 active,這是因為默認預設 Home Root 的關係。路由位置在/server/users的同本 root 路徑。如果你希望這個 home 這個唯獨不要這樣效果,可以透過選項的屬性[routerLinkActiveOptions]="{exact:true}"綁定把活動項目對象為固定。

app.component.html
<a
class="nav-link"
routerLinkActive="active"
routerLink="/"
[routerLinkActiveOptions]="{exact:true}"
>Home</a>

現在 home 只會在自己的網址上反應,不會對其他對象有作用。

從 TypeScript

我們也可以從 TypeScript 對路由下指令,先弄素材放置到 Home 元件,透過 button click 事件綁定要求 TypeScript 來執行路由指令。

home.component.html
<button class="btn btn-primary" (click)="loadServers()">load Servers</button>

來到 typeScript 部份,作業如下:

  • 規劃私有 Router 屬性,來自於 Router 這個模組。
  • 方法執行當下透過 navigate() 能幫助我們對路由下達指令切頁,你可以在那之前做一些代碼工作才切頁。
home.component.ts
import { Router } from '@angular/router';//※重點
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {

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

ngOnInit() {
}

loadServers() { //※重點
//run sql content then go page to servers
this.lokiRouter.navigate(['/servers']);
}
}

navigate()的路徑觀念跟範本上使用routerLink=''是不同的。首先討論範本上的 routerLink 部份會根據你是否添加/而有不同的相對路徑影響。但 TypeScript 上的 navigate 的相對路徑是非絕對的,因為 TypeScript 是腳本代碼,因此不會知道當前頁會是在哪裡,因此 Angular 只能根據整個網站的路徑開始指向。

如果你需要明確地給navigate()有明確的相對位置參考,使得它能依據相對路徑來獲得疊加的 URL 位置。對第二參數增加指定物件名,並提供目前作用的 activeRoute。

import { ActivatedRoute, Router } from '@angular/router'; //※重點
import { Component, OnInit } from '@angular/core';
import { ServersService } from './servers.service';

@Component({
selector: 'app-servers',
templateUrl: './servers.component.html',
styleUrls: ['./servers.component.css']
})
export class ServersComponent implements OnInit {
public servers: { id: number, name: string, status: string }[] = [];

constructor(
private serversService: ServersService,
private router: Router, //※重點
private nowAt: ActivatedRoute //※重點
) { }

ngOnInit() {
this.servers = this.serversService.getServers();
}

reloadServer() { //※重點
//at: localhost/servers
// this.router.navigate(['/servers'], { relativeTo: this.nowAt });

//at: localhost/servers/servers
this.router.navigate(['servers'], { relativeTo: this.nowAt });
}
}

如果路由的位置,跟目前所在的切頁位置相同且資料不變,Angular 會自動忽略此路由要求。

向路由傳遞參數

假設我們有個頁面需要顯示特定用戶的資料,而路由的網址參數可以做為我們的動態變數使用。舉例來說你可以訪問http://localhost/user/3代表 id 為 3 的單筆資料。首先需要從 app.modules.ts 那裏先設定路由位置。其中:id代表 id 是我們的動態變數。可以多個

app.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'users', component: UsersComponent },
{ path: 'users/:id/:name', component: UserComponent }, //※重點
{ path: 'servers', component: ServersComponent }
];

接著來到該 user 子元件這裡,透過 TypeScript 來獲得目前路由上的動態變數,該變數放置於路由快照下的 params 陣列內,根據你的變數名稱取得。

  • 規劃私有自訂名稱 lokiRoute 屬性賦予 ActivatedRoute 型別,使得 class 內可讀取該模組變數。
  • 當網頁初始化時,向該變數 lokiRoute 的深層位置 params 參數並找到該動態變數,指定回我們的初始變數 user。
user.component.ts
import { ActivatedRoute } from '@angular/router';
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-user',
templateUrl: './user.component.html',
styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit {
user!: { id: number, name: string };
constructor(private lokiRoute: ActivatedRoute) { }

ngOnInit() {
this.user = {
id: this.lokiRoute.snapshot.params['id'], //※重點
name: this.lokiRoute.snapshot.params['name'] //※重點
}
console.log(this.user); //try http://localhost:4200/users/3/loki
}
}

現在我們有了元件屬性綁定,回到範本就能透過字串差值顯示在畫面上。

user.component.html
<p>User with ID {{user.id}} loaded.</p>
<p>User name is {{user.name}}</p>

snapshot 快照是指當下動作成功時的備份處,他用於有發生路由轉換時列入。但某條件不會列入快照。如下節說明。

從 Template 觸發路由參數

此時我們添加一個超連結按鈕,利用[routerLink]="['/users']"來完成,而陣列內的其餘參數能代表路徑上的動態變數值。

user.component.html
<p>User with ID {{user.id}} loaded.</p>
<p>User name is {{user.name}}</p>
<hr>
<a [routerLink]="['/users',10,'max']">User Max(sn:10)</a>

如果你是從http://localhost:4200/users的按鈕跳躍到http://localhost:4200/users/10/max這沒問題快照有捕捉到參數。但是如果從某用戶跳躍例如http://localhost:4200/users/3/loki的按鈕想跳躍http://localhost:4200/users/10/max,會發現快照沒有更新停留在 loki 的資料上。這是因為 Angular 收到 link 的變化但元件沒有重新加載的需求,因此不會觸發 OnInit 初始化,自然不會有快照。如果要克服我們需要使用到訂閱功能。

講訂閱之前,需要先提到 Angular 在處理一些非同步任務(某代碼之後才會在某時間下才執行),這是 Angular 利用 RxJS 第三方函式庫的 Observable 可觀察對象所達到的,之後會在介紹。只需要透過訂閱就能向 Observable 要求當時機達到時做甚麼事情。而 Angular 很多模組參數(屬於 Observable) 並提供訂閱讓你做到時候該做的事。

因此我們在本元件初始化時,除了要求初始一開始先獲得快照下的參數。同時也從這個路由的參數觀察來訂閱他,當這個地方發生非同步作業(也就是之後才有變化),就幫我們取得參數寫回我們的屬性值。而不是靠下次重新加載本元件才能獲得。

user.component.ts
import { ActivatedRoute, Params } from '@angular/router';
import { Component, OnInit } from '@angular/core';

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

export class UserComponent implements OnInit {
user!: { id: number, name: string };
constructor(private lokiRoute: ActivatedRoute) { }

ngOnInit() {
this.user = {
id: this.lokiRoute.snapshot.params['id'],
name: this.lokiRoute.snapshot.params['name']
}

this.lokiRoute.params.subscribe((obsParams: Params) => { //※重點
this.user = {
id: obsParams.id,
name: obsParams.name
}
});
console.log(this.user); //try http://localhost:4200/users/3/loki
}
}

現在不管元件有沒有重新加載初始化,只要我們的路由位置有獲得動態變數也就是 params 參數時,就幫我們指定給 user 物件並更新值。

subscribe 訂閱用途,在可用的場合下,提前跟 Angular 說到時候這裡會需要做些什麼事情。

取消訂閱於銷毀階段

在 Angular 觀念當中,需要知道每一次的訂閱動作本身會占用記憶體空間。隨著你離開畫面時 Angular 自動銷毀元件釋放空間但訂閱不會。之後再重新加載這頁面你又進行新訂閱隨著重複動作將使記憶體空間不足。因此你需要透過生命週期 OnDestroy 階段主動取消訂閱,使得訂閱會隨著元件銷毀時一起結束。

像 setInterval 一樣我們需要一個 key 記住當下訂閱的編號,之後在 OnDestroy 階段透過這個 key 來取消訂閱。

  • 規劃一個 Subscription 區域變數為 key,型別注意。
  • 在訂閱當下儲存給此變數,在銷毀階段上對此變數執行
user.component.ts
import { ActivatedRoute, Params } from '@angular/router';
import { Component, OnDestroy, OnInit } from '@angular/core'; //※重點
import { Subscription } from 'rxjs'; //※重點

@Component({
selector: 'app-user',
templateUrl: './user.component.html',
styleUrls: ['./user.component.css']
})
export class UserComponent implements OnInit, OnDestroy {
user!: { id: number, name: string };
subscriptionKey!: Subscription; //※重點
constructor(private lokiRoute: ActivatedRoute) { }

ngOnInit() {
this.user = {
id: this.lokiRoute.snapshot.params['id'],
name: this.lokiRoute.snapshot.params['name']
}
this.subscriptionKey = this.lokiRoute.params.subscribe((obsParams: Params) => { //※重點,使用自訂變數儲存
this.user = {
id: obsParams.id,
name: obsParams.name
}
});
}
ngOnDestroy(): void { //※重點,透過自訂變數取消訂閱
this.subscriptionKey.unsubscribe();
}
}

讀寫 Query 參數與片段

假設熟悉 GET 的資料應用,這裡主要談如何規劃網址上的資料,包含了如何生成如?category=37&article=33或錨點連結#go(錨點跳躍不會自動觸發,你需要自己添加向下滾動行為)。以及後半段的如何讀取這些 Query 資料。

從 Template 指定

  • 來到 app.module 這裡先添加一個路由,一個允許我們編輯某項 Server 的頁面指定給 edit-server 元件。
  • 來到 servers 元件的範本左半部 list-group,先將 href 更改為 routerLink 屬性綁定使得能連結到指定路由位置為指定位置,例如http://localhost:4200/servers/1/edit
  • 而 query 參數是?透過 queryParams 屬性綁定並指定物件資料。
  • 錨點參數#則是 fragment 屬性綁定並指定字串,所以可簡化[]直接指定關鍵字
app.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'users', component: UsersComponent },
{ path: 'users/:id/:name', component: UserComponent },
{ path: 'servers', component: ServersComponent },
{ path: 'servers/:id/edit', component: EditServerComponent } //※重點
];
servers.component.html
<div class="row">
<div class="col-xs-12 col-sm-4">
<div class="list-group">
<!-- 重點 : queryParams 為 get 參數 -->
<!-- 重點 : fragment 為 錨點參數 -->
<a
[routerLink]="['/servers',server.id,'edit']"
[queryParams]="{allowEdit:1}"
fragment="loading"
class="list-group-item"
*ngFor="let server of servers"
>
{{ server.name }}
</a>
</div>
</div>
<div class="col-xs-12 col-sm-4">
<button (click)="reloadServer()">Reload Server</button>
<app-edit-server></app-edit-server>
<hr>
<app-server></app-server>
</div>
</div>

測試並點選畫面上的 side menu 你會獲得類似這樣的路由位置

sample
http://localhost:4200/servers/2/edit?allowEdit=1#loading

從 TypeScript 指定

我們來到 home 元件做示範,假設需要一個按鈕能載入 server 1 號。

home.component.html
<button class="btn btn-primary" (click)="loadServers(1)">load Servers 1</button>

現在回到 home 元件的 ts 部份,對 query 與 fragment 的寫入方式位於 Router.navigate 之第二參數,以物件資料指定。

home.component.ts
loadServers(id: number) {  //※重點
// this.lokiRouter.navigate(['/servers']);
this.lokiRouter.navigate(
['/servers', id, 'edit'], //這裡取消靜態 5 改抓函式參數
{
queryParams: {
allowEdit: 1
},
fragment: "loading"
}
);
}

測試並點選 Home 畫面上的按鈕你會獲得類似這樣的路由位置

sample
http://localhost:4200/servers/1/edit?allowEdit=1#loading

從 TypeScript 讀取

來到 edit-server 元件,這裡已經出現很多現成代碼,大致說明會透過 ServersService 這支服務,進行獲得全部 server 資資料、獲得指定 id 資料、更新指定 id 資料。

可透過 ActivatedRoute 的快照來獲得非即時反應性資料,但如同前面所說自加載本元件之後當前頁面進行變動參數時無法即時更新當前修改(因為沒有重新加載元件)。

edit-server.component.ts
import { ActivatedRoute } from '@angular/router'; //※重點
import { Component, OnInit } from '@angular/core';

import { ServersService } from '../servers.service';

@Component({
selector: 'app-edit-server',
templateUrl: './edit-server.component.html',
styleUrls: ['./edit-server.component.css']
})
export class EditServerComponent implements OnInit {
// server: { id: number, name: string, status: string };
server: any;
serverName = '';
serverStatus = '';

constructor(
private serversService: ServersService,
private route: ActivatedRoute //※重點
) {
}

ngOnInit() {
console.log(this.route.snapshot.fragment);
console.log(this.route.snapshot.queryParams);

// if (this.serversService.getServer(1) !== null)
this.server = this.serversService.getServer(1);
this.serverName = this.server.name;
this.serverStatus = this.server.status;
}

onUpdateServer() {
this.serversService.updateServer(this.server.id, { name: this.serverName, status: this.serverStatus });
}
}

另一種直接從 queryParams 的可觀察對象進行訂閱,一旦這處發生變化仍可以獲得目前值。

edit-server.component.ts
this.route.fragment.subscribe(e => console.log(e));
this.route.queryParams.subscribe(e => console.log(e));

Router 模組與 ActivatedRoute 模組各自主要工作為,Router 能幫助我們前往某指定路由位置依賴旗下的 navigate,ActivatedRoute 能幫助我獲得路由上的資訊,像是 snapshot 快照

操作示範與陷阱注意

讓我們先回到 users 元件,先修正 side menu 超連結要帶領要去的路由位置。參考 app.module 稍早的定義類似要去/users/id/name,因此到範本這裡使用 routerLink 來帶入動態變數。讓我們可以訪問到單項 user 資訊。

users.component.html
<a
[routerLink]="['/users',user.id,user.name]"
class="list-group-item"
*ngFor="let user of users"
>

同樣之前的 servers 元件之 side menu 僅是為了示範直接跳到 edit 模式,這裡協助修正路由適當的位置,

  • 從 servers 元件關於列表部份,取消原單一 server 連結的 edit 路徑,使得變成只有訪問單項 server 資訊。之後再適合的動線上再進入 edit 切頁。
  • app.module 也要設定路徑增加此項。幫助我們訪問單一時帶到 server 元件去。
app.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'users', component: UsersComponent },
{ path: 'users/:id/:name', component: UserComponent },
{ path: 'servers', component: ServersComponent },
{ path: 'servers/:id', component: ServerComponent } //※重點
{ path: 'servers/:id/edit', component: EditServerComponent }
];
servers.component.html
<!-- 拿掉 ,'edit' -->
<a
[routerLink]="['/servers',server.id]"
[queryParams]="{allowEdit:1}"
fragment="loading"
class="list-group-item"
*ngFor="let server of servers"
>
{{ server.name }}
</a>

現在我們需要當從 server side menu 點選時拜訪單一 server 切頁,將直接 URL 上面獲得 id,並透過 server.service 來進行資料查詢單筆。

  • 來到 server 元件我們需要啟用可訪問路由的 ActivatedRoute。
  • 在 OnInit 階段上,透過 ActivatedRoute 來獲得我們的 params 上的 id(層級名稱),拿來提供 server 服務部份所需要的 getServer 參數刷新網頁
  • 同時需要注意 params 因同頁面下的變化,因此需要以訂閱方式來更新我們的 getServer 參數刷新網頁
server.component.ts
import { ActivatedRoute, Params } from '@angular/router';
import { Component, OnInit } from '@angular/core';
import { ServersService } from '../servers.service';

@Component({
selector: 'app-server',
templateUrl: './server.component.html',
styleUrls: ['./server.component.css']
})
export class ServerComponent implements OnInit {
// server: {id: number, name: string, status: string};
server: any;

constructor(private serversService: ServersService, private route: ActivatedRoute) { } //※重點

ngOnInit() {
const id = this.route.snapshot.params.id;
this.server = this.serversService.getServer(id); //※重點
// this.server = this.serversService.getServer(1);

this.route.params.subscribe((prm: Params) => {
this.server = this.serversService.getServer(prm.id); //※重點
});
}
}

現在有兩個 Bug 我們需要修復,透過畫面來到 servers 頁面 (http://localhost:4200/servers)。這裡出現了以下資訊:

Cannot read properties of null (reading 'name') at ServerComponent_Template, ...

這因為原本總列表這裡我們有塞一個 server 元件並提供靜態 serverID 為 1 的示範素材。現在被我們改成將從路由上抓取 id 作為資料處理的一部份,在目前頁面上抓不到就產生不了 server.name 的字串差值。等於我們路由設計不適合這樣被放在 servers 總表的畫面上,可以先註解先拿掉。

servers.component.html
<!-- <app-server></app-server> -->

servers 總列表的畫面正常後,再來是另一個問題,當點選任何 server 進入細節時發生錯誤。

ERROR TypeError: Cannot read properties of null (reading 'name') at ServerComponent_Template, ...

這是因為型別錯誤,原因是從 Params 捕捉回來的資料格式全都是 string,而我們 id 的型別為 number 因此這樣指定出現問題。你可以使用很快的方式強迫將 string 轉為 number。在需要的地方前綴增加+符號使得型態轉為數字,也包含訂閱那裏。

server.component.ts
ngOnInit() {
const id = +this.route.snapshot.params.id; //型別 string to number
this.server = this.serversService.getServer(id);
// this.server = this.serversService.getServer(1);

this.route.params.subscribe((prm: Params) => {
this.server = this.serversService.getServer(+prm.id); //型別 string to number
});
}

嵌套路由 Nesting Routes

舉例 servers 的 side menu 如果可以像 frame 那樣的技術,讓左側選單的是對應對右側<app-edit-server>做路由切換。以及 app.module 設定在 servers 路徑上有點重複。

app app.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'users', component: UsersComponent },
{ path: 'users/:id/:name', component: UserComponent },
{ path: 'servers', component: ServersComponent },
{ path: 'servers/:id', component: ServerComponent },
{ path: 'servers/:id/edit', component: EditServerComponent }
];

可透過第三個屬性 children (注意這裡是設定型別為陣列結構)來代表子路由位置進行分組。列入 serversComponent 內的子路由。

app.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'users', component: UsersComponent },
{ path: 'users/:id/:name', component: UserComponent },
{
path: 'servers', component: ServersComponent, children: [
{ path: ':id', component: ServerComponent },
{ path: ':id/edit', component: EditServerComponent }
]
}
];

此時檢查頁面,發現所有在 servers 底下的 side menu 的切頁功能會失效,只有 url 變化有成功。還記得我們在 app.component 上的路由動作是在該 app 元件下進行路由導向輸出到<router-outlet></router-outlet>位置。而這些子路由則目前在 servers 元件底下,因此我們缺乏一個 servers 子路由所需要的<router-outlet></router-outlet>。現在我們需要能切頁到右側就應該這樣處理:

servers.component.html
<div class="row">
<div class="col-xs-12 col-sm-4">
<div class="list-group">
<a
[routerLink]="['/servers',server.id]"
[queryParams]="{allowEdit:1}"
fragment="loading"
class="list-group-item"
*ngFor="let server of servers"
>
{{ server.name }}
</a>
</div>
</div>
<div class="col-xs-12 col-sm-4">
<router-outlet></router-outlet>
<!-- <button (click)="reloadServer()">Reload Server</button>
<app-edit-server></app-edit-server>
<hr> -->
<!-- <app-server></app-server> -->
</div>
</div>

現在請幫忙優化 users 那裏也改成嵌套路由。

app.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users', component: UsersComponent, children: [
{ path: ':id/:name', component: UserComponent }
]
},
{
path: 'servers', component: ServersComponent, children: [
{ path: ':id', component: ServerComponent },
{ path: ':id/edit', component: EditServerComponent }
]
}
];
servers.component.html
<div class="row">
<div class="col-xs-12 col-sm-4">
<div class="list-group">
<a
[routerLink]="['/users',user.id,user.name]"
class="list-group-item"
*ngFor="let user of users"
>
{{ user.name }}
</a>
</div>
</div>
<div class="col-xs-12 col-sm-4">
<!-- <app-user></app-user> -->
<router-outlet></router-outlet>
</div>
</div>

練習使用 Query 參數

現在試著讓範例的動線更好,我們將 edit server 的功能放置在各自 server 畫面底下。

  • 對 server 元件的範本上規劃按鈕並賦予事件綁定為 onEdit。
  • 對 server 元件的 TS 之此方法進行導覽到指定路由位置為/server/id/edit,這需要透過 navigate 達到指令因此我們需要使用到 Router 模組。
  • 前往位置的方式有兩種,你可以重新再寫一次完整路徑,或根據相對目前路徑做延伸。但這兩種都不會協助將原本目前路徑的 query 參數轉向(之後再解決)。
server.component.html
<h5>{{ server.name }}</h5>
<p>Server status is {{ server.status }}</p>
<button (click)="onEdit()">Edit Button</button> <!-- ※重點 -->
```
```ts server.component.ts
import { ActivatedRoute, Params, Router } from '@angular/router'; // ※重點
import { Component, OnInit } from '@angular/core';
import { ServersService } from '../servers.service';

@Component({
selector: 'app-server',
templateUrl: './server.component.html',
styleUrls: ['./server.component.css']
})
export class ServerComponent implements OnInit {
// server: {id: number, name: string, status: string};
server: any;

constructor(
private serversService: ServersService,
private route: ActivatedRoute,
private router: Router
) { }

ngOnInit() {
const id = +this.route.snapshot.params.id;
this.server = this.serversService.getServer(id);

this.route.params.subscribe((prm: Params) => {
this.server = this.serversService.getServer(+prm.id);
});
}

onEdit() { // ※重點
//目前位置為 /servers/2 ,並自帶參數為 ?allowEdit=1#loading
// this.router.navigate(['/servers', this.server.id, 'edit']); // 方法一:再指定相同位置路徑寫入
this.router.navigate(['edit'], { relativeTo: this.route }) //方法二:透過目前的相對位置路徑添加 edit 位置
}
}

再來是舉例?allowEdit=1的 Query 資料進行應用,設計為在某條件下才允許編輯資訊,假設我們靜態指定當 id 為 3 的 server 才能進行編輯 (1 為 true)。回到 servers 元件這裡的 html 部份,這是 allowEdit 一開始出現的地方,符合上句描述。

servers.component.html
<a
[routerLink]="['/servers',server.id]"
[queryParams]="{allowEdit:server.id===3?1:0}"
fragment="loading"
class="list-group-item"
*ngFor="let server of servers"
>

現在只有第三個 server 的詳細資訊有夾帶?allowEdit=1。試著去在 edit-component 訪問下底下進行判斷,如果持有?allowEdit=1則提供編輯功能否則提示文字。

edit-server.component.ts
import { ActivatedRoute, Params } from '@angular/router';  //※重點
import { Component, OnInit } from '@angular/core';
import { ServersService } from '../servers.service';

@Component({
selector: 'app-edit-server',
templateUrl: './edit-server.component.html',
styleUrls: ['./edit-server.component.css']
})
export class EditServerComponent implements OnInit {
// server: { id: number, name: string, status: string };
server: any;
serverName = '';
serverStatus = '';
allowEdit = false; //※重點

constructor(private serversService: ServersService, private route: ActivatedRoute) {
}

ngOnInit() {
// console.log(this.router.snapshot.fragment);
// console.log(this.router.snapshot.queryParams);
// this.router.fragment.subscribe(e => console.log(e));
// this.router.queryParams.subscribe(e => console.log(e));

this.route.queryParams.subscribe((params: Params) => { //※重點
this.allowEdit = params.allowEdit === '1' ? true : false;
});

// if (this.serversService.getServer(1) !== null)
this.server = this.serversService.getServer(1);
this.serverName = this.server.name;
this.serverStatus = this.server.status;
}

onUpdateServer() {
this.serversService.updateServer(this.server.id, { name: this.serverName, status: this.serverStatus });
}
}

然後來到 edit-server 元件的範本,透過 ngIf 來評估是否呈現編輯元素或提示字。

edit-server.component.html
<h4 *ngIf="!allowEdit">You're not not allow to edit!</h4>
<section *ngIf="allowEdit">
<div class="form-group">
<label for="name">Server Name</label>
<input
type="text"
id="name"
class="form-control"
[(ngModel)]="serverName"
>
</div>
<div class="form-group">
<label for="status">Server Status</label>
<select
id="status"
class="form-control"
[(ngModel)]="serverStatus"
>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
</div>
<button
class="btn btn-primary"
(click)="onUpdateServer()"
>Update Server</button>
</section>

最後來修正如何讓 navigate 可以夾帶目前的 query 參數。來到消失前的關鍵按鈕動作位於 server 元件的 onEdit() 部份。主要為讓 navigate 協助導覽時,在第二參數多一個屬性為 queryParamsHandling 並提供字串保留,你可以要求舊值處理。 選 preserve 則為只用舊值,選 merge 則為與新值合併。

server.component.ts
onEdit() { // ※重點
//目前位置為 /servers/2 ,並自帶參數為 ?allowEdit=1#loading
// this.router.navigate(['/servers', this.server.id, 'edit']); // 方法一
// this.router.navigate(['edit'], { relativeTo: this.route }) //方法二:使用相對目前位置路徑添加 edit 位置
this.router.navigate(['edit'], {
relativeTo: this.route,
queryParamsHandling: 'preserve' // ※重點 
}) //可合併原本的 query params
}

科普知識:preserve 與 merge
queryParamsHandling 是一個選項,用於設置在導航時如何處理查詢參數。當我們進行導航時,通常會帶有查詢參數,這些參數可以用於過濾、排序、分頁等操作。而 queryParamsHandling 選項可以控制這些查詢參數在導航時的行為。

queryParamsHandling 選項有三個值:

  • ‘merge’:合併新的查詢參數和現有的查詢參數。如果有相同的參數,則新的查詢參數值將覆蓋現有的查詢參數值。
  • ‘preserve’:保留現有的查詢參數,並忽略新的查詢參數。這意味著,如果你將路由導航到具有新的查詢參數的頁面,但使用’preserve’選項,則新的查詢參數將被忽略,頁面將仍然顯示現有的查詢參數。
  • null:清除現有的查詢參數,並使用新的查詢參數。這意味著,如果你將路由導航到具有新的查詢參數的頁面,但使用 null 選項,則現有的查詢參數將被清除,並使用新的查詢參數。

根據這兩者的應用如下,假設你目前位置為/firstUrl?name=bat7要跳到/secondUrl

this.router.navigate(['/secondUrl'], { queryParamsHandling: 'preserve' });
// like this http://localhost:4200/secondUrl?name=bat7

this.router.navigate(['/secondUrl/newVal'], { queryParams: { age: 'not-known'}, queryParamsHandling: 'merge' });
//http://localhost:4200/secondUrl?name=bat7&age=not-known

路由重新導向

你可以指定當用戶輸入無效的位置時,進行重新導向到像似 page 404 之類的動作。先手動添加一個新元件命名為 page-not-found 並規劃範本為簡單的文字說明

ng g c page-not-found
page-not-found.component.html
<h1>THIS PAGE WAS NO FOUND</h1>

再來是來到 app.module.ts 來註冊這個預設路由位置。同時再多新增一個測試用的位置,透過 redirectTo 帶往到這個 404 位置。

app.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users', component: UsersComponent, children: [
{ path: ':id/:name', component: UserComponent }
]
},
{
path: 'servers', component: ServersComponent, children: [
{ path: ':id', component: ServerComponent },
{ path: ':id/edit', component: EditServerComponent }
]
},
{ path: '404', component: PageNotFoundComponent },// ※重點 
{ path: 'try', redirectTo: '/404' }
];

測試 404 與 try 最後都是在 404 的畫面,這就是重新導向的做法。如果你需要排除已知以外的路徑都要導向到 404,可使用**通用字符來代表。需注意位置順序,由上優先到下解析。因此必需要放在陣列處最後一組,當都找不到適合的路由會以最後找到的為設定。

app.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users', component: UsersComponent, children: [
{ path: ':id/:name', component: UserComponent }
]
},
{
path: 'servers', component: ServersComponent, children: [
{ path: ':id', component: ServerComponent },
{ path: ':id/edit', component: EditServerComponent }
]
},
{ path: '404', component: PageNotFoundComponent },
{ path: '**', redirectTo: '/404' }
];

新手陷阱:pathMatch=’prefix’
Angular 所有路由的 pathMatch 參數其預設值為 prefix,這代表會參考前綴符號來查找。舉例來說

const lokiRoutes: Routes = [
// ...
{ path: '404', component: PageNotFoundComponent },
{ path: 'a', redirectTo: '/404' }
];

會發生跳轉到 404 的組合為只要網址如下例有 a 的分類都會跳轉。

http://localhost:4200/a
http://localhost:4200/a/b
http://localhost:4200/a/b/c

因此,如果你希望只有/a的位置會跳轉而後綴其他位置不影響,你需要綁定為 full 作為匹配範圍

const lokiRoutes: Routes = [
// ...
{ path: '404', component: PageNotFoundComponent },
{ path: 'a', redirectTo: '/404', pathMatch: 'full' }
];

而危險的動作是,如果你這樣設計寫法為任何網頁都會是要去 404,但想停留在 404 時連自己也找不到。因為整個都被覆蓋了

const lokiRoutes: Routes = [
// ...
{ path: '404', component: PageNotFoundComponent },
{ path: '', redirectTo: '/404'}
];

路由配置的檔案 app-routing.module.ts

如果你的路由設定非常的多都寫在 app.module.ts 是不健康的,一般作法會將路由相關配置獨立一個檔案在外部。在 CLI 協助建立專案過程中特別詢問你是否要建立 Routing 如果你確定(假設專案名取為 app) 則會多產生一隻 app-routing.module.ts,並幫你添加到 app.module.ts 內。

你可以事後要求產生模組檔案並提供這兩支給你。

ng generate module loki --routing
# same ng g m loki --routing

請手動新增檔案app-routing.module.ts位於相對app.module.ts位置參考這兩隻檔案進行初步整理。或參考以下初始檔案跟著動作:

app-routing.module.ts
import { NgModule } from '@angular/core';

@NgModule({
})
export class AppRoutingModule { }
  • 將 const lokiRoutes 等路由資料,從 app.module.ts 搬移至 app-routing.module.ts 檔內。
  • 利用修復工具快速將所有遺失的元件宣告來源補回來。
  • 找到原本 app.module.ts 內的 imports 部分,試著搬移 RouterModule 的資訊
  • 同時要添加 exports 將這個路由模駔輸出。
  • 回到 app.module.ts 需要將 AppRoutingModule 宣告。
  • 現在路由模組名字換成這裡要 import 模組進來。
  • 最後測試一下有無成功。
app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { EditServerComponent } from './servers/edit-server/edit-server.component';
import { ServerComponent } from './servers/server/server.component';
import { ServersComponent } from './servers/servers.component';
import { UserComponent } from './users/user/user.component';
import { UsersComponent } from './users/users.component';

const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users', component: UsersComponent, children: [
{ path: ':id/:name', component: UserComponent }
]
},
{
path: 'servers', component: ServersComponent, children: [
{ path: ':id', component: ServerComponent },
{ path: ':id/edit', component: EditServerComponent }
]
},
{ path: '404', component: PageNotFoundComponent },
{ path: '**', redirectTo: '/404' }
];

@NgModule({
imports: [
RouterModule.forRoot(lokiRoutes)
],
exports: [RouterModule]
})
export class AppRoutingModule { }
app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { UsersComponent } from './users/users.component';
import { ServersComponent } from './servers/servers.component';
import { UserComponent } from './users/user/user.component';
import { EditServerComponent } from './servers/edit-server/edit-server.component';
import { ServerComponent } from './servers/server/server.component';
import { ServersService } from './servers/servers.service';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { AppRoutingModule } from './app-routing.module'; //※重點

@NgModule({
declarations: [
AppComponent,
HomeComponent,
UsersComponent,
ServersComponent,
UserComponent,
EditServerComponent,
ServerComponent,
PageNotFoundComponent
],
imports: [
BrowserModule,
FormsModule,
AppRoutingModule //※重點
],
providers: [ServersService],
bootstrap: [AppComponent]
})
export class AppModule { }

Guards 路由守衛

路由守衛(Guards)是一種機制,用於保護路由的訪問權限。守衛可以檢查用戶是否有權限訪問某個路由,如果用戶沒有權限,則可以阻止路由的訪問或重定向到其他頁面。Angular 提供了幾種不同類型的守衛,這些守衛都是實現了對應接口的類別,它們在路由的定義中可以作為路由守衛:

  • CanActivate:用於防止進入路由。
  • CanActivateChild:用於防止進入子路由。
  • CanDeactivate:用於防止離開路由。
  • Resolve:用於在進入路由前解決必要的數據。

因此,當你要進行路由加載之前以及離開路由之前可以進行一系列的動作前置,舉例來說在範例上來到 server 列表之中可以選擇單一 server 進行編輯,在進入這個路由之前進行用戶身分認證的功能。而不是透過元件初始化階段 onInit 階段進行檢查。守衛本身只是一個服務上的系列動作之設計,主要仍回歸使用內建函式 CanActivate 與 CanDeactivate 返還給我們的資料去達到路由進出的控制。

CanActivate 許可路由進入

要使用守衛方式透過自建服務並透過 CanActivate 來自訂守衛。服務添加的使用方式與其他一般服務添加方式一致。

  • 在跟目錄下新增服務檔案或 CLI 指令ng g s auth-guard。獲得auth-guard.service.ts
  • 接著我們需要對 CanActivate 這隻模組做宣告載入 implements 介面使用,並使用固定命稱 canActivate 方法且本身會吃兩個參數。
  • 在該函式規劃一參數自訂名為 route 作為路由已啟用之快照,並指定為 ActivatedRouteSnapshot 型別。
  • 在該函式規劃一參數自訂名為 state 作為路由狀態之快照,並指定為 RouteStateSnapshot 型別。
  • 整個 CanActivate 函式會回傳一個 Observable 可觀察對象,因此要確保該型別為 Observable,同時這個 Observable 會包含一個 Boolean 值。另外一種可能會回傳一個 Promise,Promise 會包含 Boolean 值,也可能直接回傳一個 Boolean。因此我們有三種回傳的型別可能。

以上規畫皆固定,可參考複製於官方的 canActivate 結構說明

auth-guard.service.ts
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';

export class AuthGuardService implements CanActivate {

canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean{
//something
}
}

我們希望做一個能登入登出功能,因此規劃另一個服務作為身分認證使用,命名為 auth.service.ts ,這裡只是測試實際工作上會連線到後端進行登入登出並檢查當前的身分狀態。

  • 可透過 CLI 進行ng g s auth,獲得該檔案。
  • 規劃區域屬性 loggedIn 為 false 代表未登入,方法 login() 能改此為 true,方法 logout() 能改此為 false。
  • 規劃 isAuthenticated() 方法,這是為了假裝是跟後端諮詢登入狀態,利用 Promise 並延遲 1 秒後回傳告知登入狀態初始為 false,整個服務範圍都是為了假裝跟後端拿登入的布林值。
auth.service.ts
export class AuthService {
loggedIn = false;

isAuthenticated() {
const promise = new Promise(
(res, rej) => {
setTimeout(() => {
res(this.loggedIn);
}, 1000);
}
);
}

login() {
this.loggedIn = true;
}

logout() {
this.loggedIn = false;
}
}

現在已模擬一個假後端的服務,透過這個服務能一秒後告知我們用戶的登入狀態為何,我們將利用到這個 auth-guard 服務,因此需要設定注入添加到 auth-guard 內。讓 auth 服務能注入到這個守衛服務。現在能在 AuthGround 的建構函式來私有化使用

  • 對 auth-ground.service 添加@Injectable()
  • 宣告匯入 AuthService 這支服務
  • 同時規劃一 authService 私有屬性到 constructor,並指定 AuthService 型別
  • 來到 canActivate() 方法內,找到 authService 屬性內的 isAuthenticated() 告知我們登入狀態,因為 isAuthenticated() 本身是 promise 所以用 then 來後續處理。
  • 在 then 裡面透過判斷是否登入並回傳布林值到 canActivate(),同時如果未登入 false 利用 router.navigate 導向到首頁去。
  • 最後 canActivate() 本身也要回傳出去,所以將這個 boolean 再回傳出去。
auth-ground.service.ts
import { AuthService } from './auth.service';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { Injectable } from '@angular/core';

@Injectable()

export class AuthGuardService implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) { }

canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
//something
return this.authService.isAuthenticated().then(
(value:boolean) => {
console.log(value);
if (value) return true;
else {
this.router.navigate(['/']);
return false;
}
}
);
}
}

現在已設計完守衛的功能,就看是要在哪裡使用守衛服務。來到定義路由的 app.routing.module 這裡試著將守衛服務放入到路由內,使得路由執行前能觸發守衛。

  • 對想要守衛服務的路徑設定上,插入屬性為 canActive 值為 Boolean。範例這裡是對 servers 這支作業,其子路由也會吃到這個守衛。
  • 現在透過除非 AuthGuardService 回傳 true,這個 servers 路徑才能被訪問。
  • 以及需要到 app.module 這裡將這個兩隻服務放入到 providers 內。
app-routing.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users', component: UsersComponent, children: [
{ path: ':id/:name', component: UserComponent }
]
},
{
path: 'servers', canActivate: [AuthGuardService], component: ServersComponent, children: [ //※重點
{ path: ':id', component: ServerComponent },
{ path: ':id/edit', component: EditServerComponent }
]
},
{ path: '404', component: PageNotFoundComponent },
{ path: '**', redirectTo: '/404' }
];
app.module.ts
//...
import { AuthGuardService } from './auth-guard.service'; //※重點
import { AuthService } from './auth.service'; //※重點

@NgModule({
declarations: [
AppComponent,
HomeComponent,
UsersComponent,
ServersComponent,
UserComponent,
EditServerComponent,
ServerComponent,
PageNotFoundComponent
],
imports: [
BrowserModule,
FormsModule,
AppRoutingModule
],
providers: [
ServersService,
AuthService, //※重點
AuthGuardService //※重點
],
bootstrap: [AppComponent]
})
export class AppModule { }

CanActivateChild 許可子路由進入

現在服務已被設定給整份 app,在還沒呼喚 login() 之前,因為初始為 false 現在會拒絕我們訪問整個 servers 路由與子路由。現在試著從 users 位置點選 servers 位置是否一秒後轉回首頁。目前設計為適合要阻擋整個 servers 路徑,但如果只想阻擋在內部下層的 path 路徑上,你可以剪貼到下層各自的 path 去,但還有更好的方法為 CanActivateChild,它能設定在父路由上,使得子路由都會吃到這個守衛設定。

  • 來到 auth-guard.service 這裡,多一組介面為 CanActivateChild。
  • 同 canActivate() 結構,多做一份固定名稱 canActivateChild() 結構一致。而 return 的項目為依賴 canActive 動作即可。將獲得的兩參數轉給 canActive。
auth-guard.service.ts
import { AuthService } from './auth.service';
import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { Injectable } from '@angular/core';

@Injectable()

export class AuthGuardService implements CanActivate, CanActivateChild {
constructor(
private authService: AuthService,
private router: Router
) { }

canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
//something
return this.authService.isAuthenticated().then(
(value) => {
console.log(value);
if (value) return true;
else {
this.router.navigate(['/']);
return false;
}
}
);
}
canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | Observable<boolean> | Promise<boolean> { //※重點
return this.canActivate(childRoute, state);
}
}

再回到 app-routing.module 這裡,現在不使用 canActivate 而是使用 canActivateChild。

app-routing.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users', component: UsersComponent, children: [
{ path: ':id/:name', component: UserComponent }
]
},
{
path: 'servers',
// canActivate: [AuthGuardService],
canActivateChild: [AuthGuardService], //※重點
component: ServersComponent,
children: [
{ path: ':id', component: ServerComponent },
{ path: ':id/edit', component: EditServerComponent }
]
},
{ path: '404', component: PageNotFoundComponent },
{ path: '**', redirectTo: '/404' }
];

現在只有這底下的子路由才會受阻擋。現在你學會阻擋整個路由或是侷限在子路由的阻擋。

模擬已登入效果

現在要模擬登入後的守衛狀態,來到 home 元件這裡的範本規劃兩組按鈕登入登出。再來到 ts 這裡將我們的 AuthService 私有實例介面化才能使用 login 與 logout 改變布林值。

home.component.html
<button class="btn btn-success" (click)="onLogin()">Login</button>
<button class="btn btn-danger" (click)="onLogout()">Logout</button>
home.component.ts
import { Router } from '@angular/router';
import { Component, OnInit } from '@angular/core';
import { AuthService } from './../auth.service';//※重點

@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {

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

ngOnInit() {
}

loadServers(id: number) { //※重點
// this.lokiRouter.navigate(['/servers']);
this.lokiRouter.navigate(
['/servers', id, 'edit'],
{
queryParams: {
allowEdit: 1
},
fragment: "loading"
}
);
}

onLogin() { //※重點
this.authService.login();
}
onLogout() { //※重點
this.authService.logout();
}
}

現在你擬透過 login 切換布林值來獲得 servers 底下子路由的權限。

canDeactivate 許可路由離開

現在可以來討論如何控制目前路由是否允許你離開。例如進行表單編輯當下不小心操作到離開頁面,就能提醒住戶表單資料未送出或是否取消等設計。防止住戶進行意料之外的路由導航。

  • 規劃新服務為 deactivate-guard,可利用 CLI 指令ng g s deactivate-guard完成,這支檔案我們放置在 edit-server 的目錄下只有這裡會用到。
  • 同樣需要規劃 CanDeactivate 這隻模組做宣告載入 implements 介面使用,並使用固定名稱來調用 canDeactivate 方法且本身會回傳三個可能 (Observable、Promise、Boolean)。
  • 由於之後會使用到這個型別模型 (canDeactivate 使用方法),因此規劃成 interface 型別介面並命名為 ComponentNameComponent。
  • 這支 DeactivateGuardService 服務先合併基礎的 ComponentNameComponent 型別資料 (Observable、Promise、Boolean)
  • canDeactivate 的四個參數:
    • 第一個參數為告知目前所在的元件,且需要 ComponentNameComponent 的型別。
    • 第二參數為當前路由為何,可透過 ActivatedRouteSnapshot 獲得型別。
    • 第三參數為當前狀態為何,可透過 RouterStateSnapshot 獲得型別。
    • 第四參數(非必要)為下一路由為何,也可透過 ActivatedRouteSnapshot 獲得型別。
  • 這隻 canDeactivate 為可觀察的,能回傳 Observable 的 boolean 或 Promise 的 boolean 或 boolean。
  • 最後 canDeactivate 會回傳我們當前元件的調用 canDeactivate 結果。

以上規畫皆固定,可參考複製官方對於 canDeactivate 結構 說明。或使用 VSCode 關鍵字生成做參考。

deactivate-guard.service.ts
import { ActivatedRouteSnapshot, CanDeactivate, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';

export interface ComponentNameComponent {
canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
}

export class DeactivateGuardService implements CanDeactivate<ComponentNameComponent> {
canDeactivate(
component: ComponentNameComponent,
currentRoute: ActivatedRouteSnapshot,
currentState: RouterStateSnapshot,
nextStat?:RouterStateSnapshot
): Observable<boolean> | Promise<boolean> | boolean {
return component.canDeactivate();
}
}

現在離開檢查工具服務已設計完成,就剩下你需要在哪個路由 path 上套用這個路由離開守衛,以及如何去接收這個布林值做想做的事情。回到 app-routing 的路由設定模組這。

  • 這裡我們假定只有當編譯 server 時未送出前離開需要做守衛阻擋。指定參數為 canDeactivate 而值對應某類別下同名的函式。
app-routing.module.ts
//...
import { DeactivateGuardService } from './servers/edit-server/deactivate-guard.service'; //※重點

const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users', component: UsersComponent, children: [
{ path: ':id/:name', component: UserComponent }
]
},
{
path: 'servers',
// canActivate: [AuthGuardService],
canActivateChild: [AuthGuardService],
component: ServersComponent,
children: [
{ path: ':id', component: ServerComponent },
{ path: ':id/edit', component: EditServerComponent, canDeactivate: [DeactivateGuardService] } //※重點:增加 canDeactivate
]
},
{ path: '404', component: PageNotFoundComponent },
{ path: '**', redirectTo: '/404' }
];
//...

現在守衛已經在指定的 edit-server 頁面上執行,但可不可以批准的布林值我們還沒設計出來。因此現在對 server 3 進行編輯時無法離開路由且報錯。

設計提示離開效果

現在我們要來設計當此頁面離開時,經檢查提供用戶是否確定要離開。為了確保元件能讀到 Service 我們到 App.module 底下宣告該 Deactivate 能被所有元件使用到。

app.moudle.ts
//...
import { DeactivateGuardService } from './servers/edit-server/deactivate-guard.service';

@NgModule({
declarations: [
AppComponent,
HomeComponent,
UsersComponent,
ServersComponent,
UserComponent,
EditServerComponent,
ServerComponent,
PageNotFoundComponent
],
imports: [
BrowserModule,
FormsModule,
AppRoutingModule
],
providers: [
ServersService,
AuthService,
AuthGuardService,
DeactivateGuardService //※重點
],
bootstrap: [AppComponent]
})
export class AppModule { }

回到我們需要會調用這個 Deactivate 功能的對象也就是 edit-server 元件,對其進行 implements 工具化整合入內,這樣我們才能在這個元件內使用 canDeactivate 模組。接著對該模組直接操作提供可觀察的結果布林值。這樣 app-routing 那裏會得到對應的布林值允許離開 (true) 或不離開 (false)

edit-server.component.ts
import { DeactivateGuardService } from './deactivate-guard.service'; //※重點
import { ActivatedRoute, Params } from '@angular/router';
import { Component, OnInit } from '@angular/core';
import { ServersService } from '../servers.service';
import { Observable } from 'rxjs';

@Component({
//...
})
export class EditServerComponent implements OnInit, DeactivateGuardService { //※重點:套入 DeactivateGuardService
// server: { id: number, name: string, status: string };
server: any;
serverName = '';
serverStatus = '';
allowEdit = false;
changesSave = false; //※重點,還沒有設計判斷是否改變,這裡是假靜態

constructor(private serversService: ServersService, private route: ActivatedRoute) {
}

ngOnInit() {
// console.log(this.router.snapshot.fragment);
// console.log(this.router.snapshot.queryParams);
// this.router.fragment.subscribe(e => console.log(e));
// this.router.queryParams.subscribe(e => console.log(e));
this.route.queryParams.subscribe((params: Params) => {
this.allowEdit = params.allowEdit === '1' ? true : false;
});

// if (this.serversService.getServer(1) !== null)
this.server = this.serversService.getServer(1);
this.serverName = this.server.name;
this.serverStatus = this.server.status;
}

onUpdateServer() {
this.serversService.updateServer(this.server.id, { name: this.serverName, status: this.serverStatus });
}

canDeactivate(): boolean | Observable<boolean> | Promise<boolean> { //※重點:當路由離開時,方法名稱為 DeactivateGuardService 內所寫的名稱 canDeactivate
if (!this.allowEdit) return true; //如果不允許編輯則可離開
// 如果某欄位有修改且發生變化
if (this.serverName != this.server.name || this.serverStatus != this.server.status && !this.changesSave)
return confirm('Do you want leave page?'); //如果住戶回復 yes 會得到 true 而離開 false 則不能離開
else return false; //沒有改變,可以離開
}
}

最後順手更新原本的靜態 id=1 為動態,id 來自於路由 route 的快照內可獲得(網址參數),注意的是之前已提過自串轉數字的作法。

edit-server.component.ts
ngOnInit() {
this.route.queryParams.subscribe((params: Params) => {
this.allowEdit = params.allowEdit === '1' ? true : false;
});

// if (this.serversService.getServer(1) !== null)
const id = this.route.snapshot.params.id; //※重點
this.server = this.serversService.getServer(+id); //※重點

this.serverName = this.server.name;
this.serverStatus = this.server.status;
}

現在才是正確載入 server 3 原有資料,而 server1 與 2 因沒有開放 allowEdit 仍無法登入。

路由底下的 data 屬性資料傳遞

在 Angular 路由中,可以使用 data 屬性來傳遞靜態資料。這些資料是定義在路由配置物件中的屬性,可以在路由的任何地方使用,包括路由守衛和元件中。我們可以透過路由來獲得資料,而不透過網址參數來獲得。可用在一些 404 錯誤的頁面上,網址是乾淨的但是 404 元件能從路由那裏獲得一些 data 進行字串插植到畫面上顯示特定文字。

靜態數據示範

先從簡單開始,試著從路由取得固定的字串到畫面上。

  • 來到 page-not-found.ts 代碼規劃新屬性為 errorMessage。
  • 來到 page-not-found.html 範本上做字串差值輸出。
  • 由於靜態資料是固定的,所以可以把資料寫在 app-routing 模組。透過屬性 data 其值格式為物件
page-not-found.component.ts
export class PageNotFoundComponent implements OnInit {
errorMessage: string = '';
}
page-not-found.component.html
<h1>{{errorMessage}}</h1>
app-routing.module.ts
// { path: '404', component: PageNotFoundComponent },
{
path: '404', component: PageNotFoundComponent, data: {
message: "you miss the way...."
}
},

回到 page-not-found 元件,現在能從 ActivateRoute 來獲得這 data 屬性資料。一樣這裡有兩種方法,你需要知道兩者執行上的差異在於:

  • 如果路由沒有發生變化也就是同頁面露由觸發,快照不會感應到 data 有變化。
  • 對 data 進行可觀察對象,這樣一旦發生變化就會立即更新。可用於同頁面路由。(但對本範例的靜態資料來說 data 不會變所以這兩招都沒差。)
page-not-found.component.ts
import { ActivatedRoute, Data } from '@angular/router';
import { Component, OnInit } from '@angular/core';

@Component({
selector: 'app-page-not-found',
templateUrl: './page-not-found.component.html',
styleUrls: ['./page-not-found.component.css']
})
export class PageNotFoundComponent implements OnInit {
errorMessage: string = '';
constructor(private route:ActivatedRoute){} //※重點:引用 router

ngOnInit() {
// this.errorMessage = this.route.snapshot.data.message; //方法一
this.route.data.subscribe((data: Data) => { // 方法二:建議
this.errorMessage = data.message;
});
}
}

動態數據透過 Resolve 守衛

動態數據的用途在於,能否在新路由渲染出來之前就開始獲取。舉例目前範例上對單一 server 查看時,我們事先進行 onInit 階段才跟後端非同步動作進行拿資料而等待 1 秒。如果是在觸發路由當下去獲得資料,確定有資料再從路由提供 server 資訊,最後於 server 元件上進行捕獲。

在 Angular 路由中,resolve 是一個守衛 (guard),它可以在路由完成之前解析資料。解析資料通常用於從服務器或其他資料源中取得數據,並將其作為路由參數傳遞給目標路由。使用 resolve 守衛可以確保在路由組件被顯示之前,必須要先取得需要的數據,以避免在畫面顯示時還需要等待數據的加載。這樣可以提高應用的性能和用戶體驗。

因此,開頭提到的設計技巧我們需要創立並使用 Resolve 守衛的 service 來幫忙處理這件事。就像我們使用守衛那樣但是本身只是解析後端,並不會對路由觸發進行阻礙進出仍會進行渲染組建,但會進行預先加載畫面並等待後端資料回來更新。

  • 目前要做的 service 指對單一 server 元件所用,因此在同層目錄下建立 ng g s server-resolve 之服務。
  • 宣告 Resolve 並實例介面化,這是一個泛型型別,它能包裝你任何的資料進行獲得。同時我們手動定義回傳的型別為 id,name,status,也就是原本 server 要從後端拿到的東西。
  • 因為型別大量用到,可用 interface 介面化定義 ServerType 型別起來。
  • 參考 官方對於 resolve 定義 規劃結構。這裡我們要拿的是後端資料所以所有的回傳都會是 ServerType 型別,不管是不是非同步或可訂閱對象。
server-resolve.service.ts
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';

interface ServerType { id: number, name: string, status: string }

export class ServerResolveService implements Resolve<ServerType> {
constructor() { }
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) : Observable<ServerType> | Promise<ServerType> | ServerType{
//something here
}
}
  • 我們要在 resolve 裡面模擬假裝到後端拿資料,這需要跟 server.service 來提供,因此需要進行 Injectable 以及建構函式上私有實體化。
  • 屆時 app-routing 設定那,一但使用本 ServerResolveService 時,會參數提供 id 給我,就能從 server.service 獲得 server 資訊。
  • 回到 app.module 註冊一下此 service。
server-resolve.service.ts
import { ServersService } from './../servers.service';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';

interface ServerType { id: number, name: string, status: string }
@Injectable()
export class ServerResolveService implements Resolve<ServerType> {

constructor(private serverService: ServersService) { }
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<ServerType> | Promise<ServerType> | ServerType {
return this.serverService.getServer(+route.params.id)!;
//被 TypeScript 認為有 null 可能這裡!強迫它
}
}
app.module.ts
providers: [
ServersService,
AuthService,
AuthGuardService,
DeactivateGuardService,
ServerResolveService //※重點
],

現在服務做好了也加入 app 模組內了,就剩下是誰要去執行這個服務。我們會在對應到只有 server 單一頁面的路由,需要靠這裡去做撈資料的動作。

  • 使用resolve:{...,...}加入到範圍上的路由位置,透過服務我們能獲得物件資料指定給自訂名稱 server 的值。
  • 現在資料會在 route.data.server 內,回到 server 元件,原本的透過後端服務之管道取得的方式取消,改成路由之管道取得方式。
  • 我們去訂閱路由的資料,等他跑回來就能拿到資料,剛好結構位置直接對到 this.server 與 data.server。
app-routing.module.ts
const lokiRoutes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'users', component: UsersComponent, children: [
{ path: ':id/:name', component: UserComponent }
]
},
{
path: 'servers',
// canActivate: [AuthGuardService],
canActivateChild: [AuthGuardService],
component: ServersComponent,
children: [
{ path: ':id', component: ServerComponent, resolve: { server: ServerResolveService } }, //※重點
{ path: ':id/edit', component: EditServerComponent, canDeactivate: [DeactivateGuardService] }
]
},
// { path: '404', component: PageNotFoundComponent },
{
path: '404', component: PageNotFoundComponent, data: {
message: "you miss the way...."
}
},
{ path: '**', redirectTo: '/404' }
];
server.component.ts
import { ActivatedRoute, Params, Router } from '@angular/router'; // ※重點
import { Component, OnInit } from '@angular/core';
import { ServersService } from '../servers.service';

@Component({
selector: 'app-server',
templateUrl: './server.component.html',
styleUrls: ['./server.component.css']
})
export class ServerComponent implements OnInit {
server: {id: number, name: string, status: string};
// server: any;

constructor(
private serversService: ServersService,
private route: ActivatedRoute,
private router: Router
) { }

ngOnInit() {
this.route.data.subscribe((data: Data) => {
this.server = data.server;
});
// const id = +this.route.snapshot.params.id;
// this.server = this.serversService.getServer(id);

// this.route.params.subscribe((prm: Params) => {
// this.server = this.serversService.getServer(+prm.id);
// });
}

網址策略

路由器提供了一個選項,可以將路由配置成使用哈希 (#) 碎片來處理路由,這個選項稱為 useHash。當這個選項被設置為 true 時,路由器會使用哈希碎片來處理 URL。這個選項主要是為了解決一些老舊的瀏覽器對 HTML5 歷史記錄 API 的支持不佳問題而設置的。使用哈希碎片可以讓瀏覽器忽略 URL 中的碎片部分,因此即使使用舊版本的瀏覽器,路由器仍然可以正確地處理路由。

使用哈希碎片的路由 URL 看起來像這樣:

http://example.com/#/path/to/route

在這個 URL 中,#/後面的部分就是哈希碎片,代表路由的路徑。這種 URL 形式可以讓瀏覽器正確地解析路由,同時也可以避免一些兼容性問題。不過,這種 URL 形式看起來可能不太美觀,因此在開發中需要仔細考慮是否需要使用 useHash 選項。

因此,當你在本機環境開發很爽的時候上傳到實體網頁伺服器會發現一個問題。在本地開發時由 Angular 去控制每個 URL 目錄是對應到哪個位置,但實體網頁伺服器是自己去尋找每個目錄下的 index.html 作為 URL 路徑規劃,這部分你的 Angular 不會有這些東西,你只有一個 index.html 跟一狗包的 Angular 應用。如果你可能發生了這樣問題,你的 Angular 無法從 client 端去解析位置,試試看這個方法,來到 app-routing 模組啟用此功能。

app-routing.module.ts
@NgModule({
imports: [
// RouterModule.forRoot(lokiRoutes)
RouterModule.forRoot(lokiRoutes, { useHash: true })
],
exports: [RouterModule]
})

預設值為 false,一旦開啟這個功能,你會發現所有的網站跟目錄都會多一個#,這能確保告知實體伺服器忽略#以後的 URL 解析,因為#本身對瀏覽器來說是標記不是網址目錄的一部分。如此一來就不會讓實體伺服器產生找不到實體 index 檔案的問題。

參考文獻