2023 重學 Angular

PingCode研發中心發表於2022-11-30

作者:徐海峰
就在前幾天(2022-11-07) Angular 正式釋出了 v15 版本,本人第一時間用我那不專業的英文翻譯了一下  [[譯] Angular 15 正式釋出!](https://zhuanlan.zhihu.com/p/...) 文章一出就遭到社群部分人的質疑,什麼 "Angular 落寞很久了,勸我們換框架",還有什麼 "這玩意居然可以活到 2022 年,遠古生物突然復活"。 對此呢我也不想過多的評價,我只在乎透過工具能否改變我司前端的開發效率,讓每位同事早點下班,高質量完成目標,讓 PingCode 遠超競品。
稍微熟悉 Angular 框架的人應該都知道, Angular 是一個完全遵循語義化版本的框架,每年會固定釋出2個主版本,目前雖然已經是 v15,但基本和 v2 版本保持一致的主旋律,那麼 v15 可以說是 Angular 框架在嘗試改變邁出的一大步,獨立元件 APIs 正式穩定,指令的組合 API 以及很多特性的簡化。
雖然很多 Angular 開發者已經習慣了 NgModules,但是 Angular 模組對於新手來說的確會帶來一些學習成本,對於很多小專案而言帶來的收益比並不高,所以支援獨立元件(無 Modules) 也算是覆蓋了更多的場景,同時也簡化了學習成本,那麼今天我想透過這篇文章以最小化的示例重新學習一下 Angular 框架,讓不瞭解 Angular 的人重新認識一下這個 "~遠古的生物~"

建立一個獨立元件的專案

首先透過如下ng new 命令建立一個 ng-relearning 的示例專案:

ng new ng-relearning --style scss --routing false
ng 命令需要透過 npm install @angular/cli -g 全域性安裝 @angular/cli 模組才可以使用。

建立後的專案目錄如下:

.
├── README.md
├── angular.json
├── package.json
├── src
│   ├── app
│   │   ├── app.component.html
│   │   ├── app.component.scss
│   │   ├── app.component.ts
│   │   ├── app.component.spec.ts
│   │   └── app.module.ts
│   ├── assets
│   ├── favicon.ico
│   ├── index.html
│   ├── main.ts
│   └── styles.scss
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json

預設生成檔案的介紹見下方表格,已經熟悉 Angular 的開發者可以跳過,相比較其他框架 CLI 生成的目錄結構而言,我個人覺得 Angular 的最合理也是最優雅的(純個人見解)。

目前 ng new 命令初始化的專案還是帶 Modules 的,支援 --standalone 引數建立獨立元件的專案特性正在開發中,可以關注 此 Issue 。
把預設的專案改為獨立元件需要做如下幾件事:
手動刪除 app.module.ts 
啟動元件 AppComponent 中 @Component 後設資料新增  standalone: true 並新增 imports: [CommonModule] 
修改 main.ts 程式碼為:

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

bootstrapApplication(AppComponent).catch((err) => console.error(err));

這樣執行  npm start ,會在本地啟動一個 4200 埠的服務,訪問  http://localhost:4200/  會展示 Angular 預設的站點:

bootstrapApplication 函式啟動應用,第一個引數 AppComponent 元件是啟動元件,一個應用至少需要一個啟動元件,也就是根元件,這個根元件選擇器為  app-root ,那麼生成的 index.html 會有對應的  <app-root> 佔位元素,Angular 啟動時會把它替換為 AppComponent 渲染的 DOM 元素。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>NgRelearning</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>

為了讓 Angular 更容易學習和上手,Angular CLI 在 v15 初始化的專案做了一些簡化(大家可以忽略):

  • 去掉了獨立的 polyfills.ts 檔案,使用 angular.json  build 選項  "polyfills": [ "zone.js"] 代替
  • 去掉了 environments  環境變數, 這個功能還在,當你需要的時候單獨配置 fileReplacements 即可
  • 去掉了 main.ts 中的 enableProdMode 
  • 去掉了 .browserslistrc
  • 去掉了 karma.conf.js
  • 去掉了 test.ts

    Hello Angular Relearning

    由於  app.component.html 的示例太複雜,為了簡化學習,我們嘗試刪除 html 所有內容,修改為繫結 title 到 h2 模板元素中,使用雙花括號 {{ 和 }} 將元件的變數 title 動態插入到 HTML 模板中,這種叫 文字插值 ,花括號中間的部分叫插值表示式。
    <h2>{{title}}</h2>
    同時修改元件類的程式碼,新增 title 屬性,初始化值為 'Hello Angular Relearning!' 

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

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  standalone: true,
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  title = 'Hello Angular Relearning!';
}

這樣開啟瀏覽器,發現 title 被渲染在介面上:

Angular 的元件就是一個普通的 class 類,透過 @Component 裝飾器裝飾後就變成了元件,透過裝飾器引數可以設定選擇器(selector)、模板(templateUrl 或者 template)、樣式(styleUrls 或者 styles),元件模板中可以直接繫結元件類的公開屬性。
預設模板和樣式是獨立的一個 html 檔案,如果是一個很簡單的元件,也可以設定內聯模板,上述示例可以簡化為:

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

@Component({
  selector: 'app-root',
  template: `<h2>{{title}}</h2>`,
  standalone: true,
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  title = 'Hello Angular Relearning!';
}

條件判斷

在實際的應用中會經常用到的就是條件判斷,我們修改一下  app.component.ts 為:

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

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  standalone: true,
  styleUrls: ['./app.component.scss'],
  imports: [
    CommonModule,
  ],
})
export class AppComponent {
  relearned = false;

  constructor() {
    setTimeout(() => {
      this.relearned = true;
    }, 2000);
  }
}

元件類中新增了 relearned 屬性,預設為 false,setTimeout 2s 後設定  relearned 值為 true。
 app.component.html 修改為:

<p *ngIf="relearned else default">很高興看到你重新學習 Angular 這款優秀的框架!</p>

<ng-template #default>
  <p>我還沒有接觸過 Angular 框架</p>
</ng-template>
  • *ngIf 為 Angular 內建的條件判斷結構型指令,當 ngIf 的表示式為 true 時渲染此模板,否則不渲染,那麼示例中的表示式為 "relearned" ,也就是 AppComponent 元件中的 relearned 屬性
  • else 表示表示式為 false 後的模板,透過 ng-template 定義了一個預設模板,並透過 #default 宣告這個模板變數為 default,這樣  ngIf else 就可以使用這個變數 default
  • ng-template 是 Angular 定義的一個模板,模板預設不會渲染,ngIf 指令會在表示式為 else 的時候透過 createEmbeddedView 建立內嵌檢視渲染這個模板,同時也可以透過  NgTemplateOutlet 指令渲染模板
    展示效果為:

    在 AppComponent 中我們設定了 imports: [CommonModule] ,如果去掉這行程式碼會報錯:

    這是因為獨立元件的模板中使用其他指令或者元件的時候需要顯示的透過  imports 宣告, ngIf 結構性指令是在  @angular/common 模組中提供的,如需使用需要匯入:
import { Component } from '@angular/core';
import { NgIf } from '@angular/common';

@Component({
  ...
  imports: [NgIf]
})
export class AppComponent {
}

 @angular/common 模組除了提供 NgIf 內建指令外還有大家常用的  NgClass 、 NgFor 、 NgSwitch 、 NgStyle 等等,所以直接把整個 CommonModule 都匯入進來,這樣在元件模板中就可以使用 CommonModule 模組的任意指令。

事件繫結

我們接下來透過如下 ng 命令建立一個新元件  event-binding 改善了一下上述的示例:

ng generate component event-binding --standalone // 簡寫 ng g c event-binding --standalone

執行後會在 src/app 資料夾下建立一個 event-binding 資料夾存放新增的 event-binding 元件

├── src
│   ├── app
│   │   ├── app.component.html
│   │   ├── app.component.scss
│   │   ├── app.component.spec.ts
│   │   ├── app.component.ts
│   │   └── event-binding
│   │       ├── event-binding.component.html
│   │       ├── event-binding.component.scss
│   │       └── event-binding.component.ts
│   ├── ...

修改 event-binding.component.html 新增一個按鈕,繫結一個點選事件,同時在模板中透過  *ngIf="relearned" 語法新增一個條件判斷。

<p *ngIf="relearned">很高興看到你重新學習 Angular 這款優秀的框架!</p>

<button (click)="startRelearn()">開始重學 Angular</button>

EventBindingComponent 元件中新增一個 relearned 屬性和 startRelearn 函式:

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

@Component({
  selector: 'app-event-binding',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './event-binding.component.html',
  styleUrls: ['./event-binding.component.scss']
})
export class EventBindingComponent {
  relearned = false;

  startRelearn() {
    this.relearned = true;
  }
}

最後在 AppComponent 元件中匯入 EventBindingComponent,並在模板中插入 <app-event-binding></app-event-binding> ,執行的效果如下:

當使用者點選按鈕時會呼叫 startRelearn 函式,設定 relearned 屬性為 true,模板中的 ngIf 結構指令檢測到資料變化,渲染 p 標籤。
 (click)="startRelearn()" 為 Angular 事件繫結語法, () 內為繫結的事件名稱, = 號右側為模板語句,此處的模板語句是呼叫元件內的 startRelearn() 函式,當然此處的模板語句可以直接改為  (click)="relearned = true" ,瀏覽器所有支援的事件都可以透過 () 包裹使用。

迴圈渲染列表

除了條件判斷與事件繫結外,應用中最常用的就是迴圈渲染元素,我們透過如下命令建立一個 ng-for 元件

ng generate component ng-for --standalone

同時在元件中新增  items 屬性,設定為陣列。

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

@Component({
  selector: 'app-ng-for',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './ng-for.component.html',
  styleUrls: ['./ng-for.component.scss'],
})
export class NgForComponent {
  items = [
    {
      id: 1,
      title: 'Angular 怎麼不火呢?',
    },
    {
      id: 2,
      title: 'Angular 太牛逼了!',
    },
    {
      id: 3,
      title:
        '優秀的前端工程師和框架無關,但是 Angular 會讓你更快的成為優秀前端工程師!',
    },
  ];
}

最後在 AppComponent 中匯入  NgForComponent 後在模板中透過  <app-ng-for></app-ng-for> 渲染 NgForComponent 元件。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgForComponent } from './ng-for/ng-for.component';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  standalone: true,
  imports: [CommonModule, NgForComponent],
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  message = 'Hello Angular Relearning!';

  relearned = false;

  startRelearn() {
    this.relearned = true;
  }
}

渲染後的效果為:

路由

以上我們簡單新增了三個示例元件:

  •  event-binding  展示事件繫結
  •  ng-for 展示迴圈渲染一個列表
  • 我們再把條件判斷的示例從 AppComponent 中移動到獨立的示例  ng-if 元件中

接下來透過 router 路由模組分別展示這三個示例,首先需要修改 main.ts,bootstrapApplication 啟動應用的第二個引數,透過 provideRouter(routes) 函式提供路由賦值給 providers ,routes 設定三個示例元件的路由,輸入根路由的時候跳轉到 ng-if 路由中,程式碼如下:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, Routes } from '@angular/router';
import { AppComponent } from './app/app.component';
import { NgForComponent } from './app/ng-for/ng-for.component';
import { EventBindingComponent } from './app/event-binding/event-binding.component';
import { NgIfComponent } from './app/ng-if/ng-if.component';

const routes: Routes = [
  {
    path: '',
    redirectTo: 'ng-if',
    pathMatch: 'full',
  },
  {
    path: 'ng-if',
    component: NgIfComponent,
  },
  {
    path: 'event-binding',
    component: EventBindingComponent,
  },
  {
    path: 'ng-for',
    component: NgForComponent,
  },
];

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes)],
}).catch((err) => console.error(err));

在 AppComponent 根元件中匯入  RouterModule 模組。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  standalone: true,
  styleUrls: ['./app.component.scss'],
  imports: [CommonModule, RouterModule],
})
export class AppComponent {
  title = 'Hello Angular Relearning!';

  constructor() {}
}

這樣在根元件的模板中就可以使用  router-outlet 和 routerLink 元件或者指令。

<h2>{{ title }}</h2>

<div>
  <a [routerLink]="['./ng-if']">NgIf</a> |
  <a [routerLink]="['./event-binding']">EventBinding</a> |
  <a [routerLink]="['./ng-for']">NgFor</a>
</div>

<router-outlet></router-outlet>
  • router-outlet 為路由佔位器,Angular 會根據當前的路由器狀態動態渲染對應的元件並填充它的位置
  • routerLink 讓 a 標籤元素成為開始導航到某個路由的連結,開啟連結會在頁面上的 router-outlet 位置上渲染對應的路由元件
    執行效果如下:

    示例程式碼:  ng-relearning v0.4 分支

    HttpClient 遠端呼叫

    在 Angular 中內建了遠端呼叫的 HttpClient 模組,可以直接透過此模組呼叫 API。
    修改 ng-for 的示例,在 NgForComponent 元件中透過建構函式注入 HttpClient 服務,並呼叫 HttpClient 的 get 函式獲取 todos 列表。

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';

export interface Todo {
  id?: string;
  title: string;
  created_by?: string;
}

@Component({
  selector: 'app-ng-for',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './ng-for.component.html',
  styleUrls: ['./ng-for.component.scss'],
})
export class NgForComponent implements OnInit {
  todos!: Todo[];

  constructor(private http: HttpClient) {}

  ngOnInit(): void {
    this.http
      .get<Todo[]>('https://62f70d4273b79d015352b5e5.mockapi.io/items')
      .subscribe((items) => {
        this.todos = items;
      });
  }
}

一般元件初始化的工作推薦放在 ngOnInit 生命週期函式中,比如初始化資料等。Angular 會在元件所有的 Input 屬性第一次賦值後呼叫 ngOnInit 函式,生命週期更多瞭解參考: lifecycle-hooks 。
然後在模板中透過 *ngIf 判斷資料是否有值顯示載入狀態。

<ol *ngIf="todos else loading">
  <li *ngFor="let item of todos">
    {{ item.title }}
  </li>
</ol>

<ng-template #loading>
  <p>載入中...</p>
</ng-template>

執行後發現程式碼報錯,沒有 HttpClient 的 provider 。

我們需要修改 main.ts 新增  provideHttpClient() 到 providers 中去,這樣才可以在系統中透過依賴注入使用 http 相關的服務。

注意:http 服務在  @angular/common/http 模組中。
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, Routes } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
...

const routes: Routes = [
  ...
];

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes), provideHttpClient()],
}).catch((err) => console.error(err));

執行效果為:

表單

透過如下命令建立一個 forms 表單示例元件
ng g c forms --standalone --skip-tests
修改 FormsComponent 為如下程式碼,新增 user 物件包含 name 和 age,同時新增 save 函式傳入 form,驗證透過後彈出提交成功提示。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-forms',
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: './forms.component.html',
  styleUrls: ['./forms.component.scss'],
})
export class FormsComponent {
  user = {
    name: '',
    age: '',
  };

  save(form: NgForm) {
    if (form.valid) {
      alert('Submit success');
    }
  }
}

forms.component.html 編寫一個表單,包含 name 輸入框和 age 數字輸入框,透過 [(ngModel)] 雙向繫結到 user 物件的 name 和 age,設定 name 輸入框為 required 必填,age 數字輸入框最大值和最小值為 100 和 1,最終新增 type="submit" 的提交按鈕並在 form 上繫結  (submit)="save(userForm)" 提交事件。

<form name="user-form" #userForm="ngForm" (submit)="save(userForm)">
  <input
    name="name"
    #userName="ngModel"
    [(ngModel)]="user.name"
    required=""
    placeholder="請輸入使用者名稱"
  />
  <input
    name="age"
    type="number"
    #userAge="ngModel"
    [(ngModel)]="user.age"
    max="100"
    min="1"
    placeholder="請輸入年齡"
  />
  <button type="submit">Submit</button>

  <div *ngIf="userForm.invalid">
    <div *ngIf="userName.invalid">使用者名稱不能為空</div>
    <div *ngIf="userAge.invalid">年齡必須在 1-100 之間</div>
  </div>
</form>

執行效果為:

[()] 是 Angular 雙向繫結的語法糖, [(ngModel)]="value" 相當於

<input [ngModel]="value" (ngModelChange)="value = $event" />

只要元件有一個輸入引數和輸出事件,且命名符合  xxx 和  xxxChange ,這個 xxx 可以是任何值,這樣就可以透過  [(xxx)]="value" 這樣的語法糖使用,ngModel 是 Angular Forms 表單內建的一個符合這種規則的語法糖指令,瞭解更多檢視: two-way-binding 。

使用第三方類庫 material

透過如下命令引入 material 元件庫。
ng add @angular/material
根據提示選擇樣式以及其他選項,最終安裝依賴並自動修改程式碼:

  • 修改 package.json 的 dependencies 新增  @angular/cdk  和  @angular/material 
  • 會自動引入選擇的 theme 主題,在 angular.json 檔案中新增 styles
  • 修改 main.ts 匯入 BrowserAnimationsModule (這是因為選擇了支援動畫)
  • 引入 google 字型和樣式

主要變更如下:

讓我們在之前的 forms 示例中匯入  MatButtonModule 和  MatInputModule 模組,使用表單和按鈕元件美化一下介面。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';

@Component({
  selector: 'app-forms',
  standalone: true,
  imports: [CommonModule, FormsModule, MatButtonModule, MatInputModule],
  templateUrl: './forms.component.html',
  styleUrls: ['./forms.component.scss'],
})
export class FormsComponent {
  user = {
    name: '',
    age: '',
  };

  save(form: NgForm) {
    if (form.valid) {
      alert('Submit success');
    }
  }
}

forms.component.html 修改為:

<form name="user-form" #userForm="ngForm" (submit)="save(userForm)">
  <mat-form-field appearance="fill">
    <mat-label>Name</mat-label>
    <input
      matInput
      name="username"
      #userName="ngModel"
      [(ngModel)]="user.name"
      required=""
      placeholder="請輸入使用者名稱"
    />
  </mat-form-field>

  <mat-form-field appearance="fill">
    <mat-label>Age</mat-label>
    <input
      matInput
      name="age"
      type="number"
      #userAge="ngModel"
      [(ngModel)]="user.age"
      max="100"
      min="1"
      placeholder="請輸入年齡"
    />
  </mat-form-field>
  <div class="error-messages" *ngIf="userForm.invalid">
    <div *ngIf="userName.invalid">使用者名稱不能為空</div>
    <div *ngIf="userAge.invalid">年齡必須在 1-100 之間</div>
  </div>

  <button mat-raised-button color="primary" type="submit">Submit</button>
</form>

最終的美化效果為:

同時也使用 MatTabsModule 替換了之前導航連結。
注意:因為透過  ng add @angular/material 安裝  material 後修改了 angular.json 檔案,需要重新啟動才可以看到新的樣式,Angular CLI 目前還沒有做到 angular.json 變化後實時更新。

使用服務

在 Angular 中推薦使用服務把相關業務邏輯從元件中獨立出去,讓元件只關注檢視,我們改造一下 ng-for 示例,先透過以下命令建立一個服務:
ng g s todo --skip-tests
Angular CLI 會自動幫我們在 app 目錄建立一個  todo.service.ts 檔案,程式碼為:

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

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

  constructor() { }
}

修改程式碼透過建構函式注入 HttpClient 服務,並新增 fetchTodos  函式呼叫 HttpClient 的 get 函式獲取 todos 列表,並在最後賦值給服務的 todos 屬性。

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs';

export interface Todo {
  id?: string;
  title: string;
  created_by?: string;
}

@Injectable({
  providedIn: 'root',
})
export class TodoService {
  todos!: Todo[];

  constructor(private http: HttpClient) {}

  fetchTodos() {
    return this.http
      .get<Todo[]>('https://62f70d4273b79d015352b5e5.mockapi.io/items')
      .pipe(
        tap((todos) => {
          this.todos = todos;
        })
      );
  }
}

然後我們修改 ng-for 的示例,這次透過 inject 函式在屬性初始化的時注入 TodoService 服務,在初始化時呼叫 fetchTodos 獲取資料。

Angular 在 v14 之前只能透過建構函式引數注入服務,在 v14 版本之後可以在屬性初始化、建構函式以及 factory 函式中透過 inject 注入服務或者其他供應商,瞭解更多參考: inject
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Todo, TodoService } from '../todo.service';

@Component({
  selector: 'app-ng-for',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './ng-for.component.html',
  styleUrls: ['./ng-for.component.scss'],
})
export class NgForComponent implements OnInit {

  todoService = inject(TodoService);

  constructor() {}

  ngOnInit(): void {
    this.todoService.fetchTodos().subscribe();
  }
}

然後在模板中直接使用  todoService 的 todos 資料。

<ol *ngIf="todoService.todos else loading">
  <li *ngFor="let item of todoService.todos">
    {{ item.title }}
  </li>
</ol>

<ng-template #loading>
  <p>載入中...</p>
</ng-template>

透過上述示例我們發現,在 Angular 中把資料和邏輯抽取到服務中,只要設定為元件的屬性就可以在模板中繫結服務的資料(也就是狀態),這些狀態變化後,Angular 檢視會實時更新,同時只要多個元件注入同一個服務就實現了資料共享,這樣輕鬆解決了前端的邏輯複用資料共享兩大難題,這一點和其他框架有很大的不同:

  • React 必須要透過 setState 或者 Hooks 的 set 函式設定狀態才會重新渲染
  • Vue 必須定義在 data 資料中或者透過 ref 標記

Angular 什麼也不需要做是因為透過 Zone.js 攔截了所有的 Dom 事件以及非同步事件,只要有 Dom Event、Http 請求,微任務、宏任務都會觸發髒檢查,從根元件一直檢查到所有葉子元件,只要有資料變化就會更新檢視,那麼上述的示例中,fetchTodos 函式有一個 API GET 請求,這個請求被 Angular 攔截,請求結束後賦值 todos 資料後,Angular 就開始從 app-root 根元件向下檢查,發現 todos 資料變化了,更新檢視。

指令組合 API (Directive Composition API)

透過服務在 Angular 中可以很好做邏輯複用,但是對於一些偏 UI 操作的邏輯複用,有時候使用服務會多加一些樣板程式碼,因為在 Angular 中除了元件還有一個指令的概念,指令是對已經的元件或者元素新增行為,我們在前面示例中使用的 NgIf 、 NgFor 、 NgModel 都是內建的指令,有時候需要重複利用不同的指令組合,如果要實現邏輯複用只能透過 Class 繼承和 Mixin 實現類似多重繼承的效果,那麼指令組合 API 就是解決此類問題的。
讓我先透過如下命令建立一個 color 設定文字顏色的指令:
ng g d color --standalone --skip-tests
然後修改 color.directive.ts 程式碼如下:

import { Directive, ElementRef, Input, OnInit, Renderer2 } from '@angular/core';

@Directive({
  selector: '[appColor]',
  standalone: true,
})
export class ColorDirective implements OnInit {
  @Input() color!: string;

  constructor(private elementRef: ElementRef, private renderer: Renderer2) {}

  ngOnInit(): void {
    this.renderer.setStyle(this.elementRef.nativeElement, 'color', this.color);
  }
}

主要透過注入獲取 ElementRef,並透過 Renderer2 服務設定 DOM 元素的 color 樣式。

elementRef.nativeElement 就是當前指令繫結的 DOM 元素,透過 Renderer2 操作 DOM 主要是為了相容不同的環境,比如服務端渲染等。

這樣在 AppComponent 元件中匯入 AppColor 後就可以透過如下模板使用:
<div appColor color="red">我是紅色</div>
展示效果為:

那麼我們再建立一個 directive-composition 元件,這個元件的選擇器是 app-directive-composition ,如果這個元件也想要具備設定字型顏色的功能,我們只能這樣使用

 <app-directive-composition appColor color="blue">我的字型顏色時紅色</app-directive-composition>

如果是多個指令,等於需要在使用的地方自行組合,我們改造一下這個元件,透過  hostDirectives 設定  ColorDirective 並指令 inputs color。

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ColorDirective } from '../color.directive';

@Component({
  selector: 'app-directive-composition',
  standalone: true,
  imports: [CommonModule, ColorDirective],
  template: '<ng-content></ng-content>',
  styleUrls: ['./directive-composition.component.scss'],
  hostDirectives: [
    {
      directive: ColorDirective,
      inputs: ['color'],
    },
  ],
})
export class DirectiveCompositionComponent {}

這樣直接使用  app-directive-composition 傳入引數 color 就具備了 appColor 指令的功能。

<div appColor color="red">我是紅色</div>

<app-directive-composition color="blue">我的字型顏色時紅色</app-directive-composition>

展示效果如下:

以上就是組合指令 API 的魅力所在。

總結

以上我是想透過一種漸進式的 Angular 入門讓大家初步瞭解 Angular 這款我認為特別優秀的框架,拋棄了 Modules 後也更加適合新手入門,站在 2022 乃至 2023 年的時間點來說,它並不是一個落後的框架,反而是更加的先進,同時 Angular 也在不斷的變得更好。 同時上述的功能只是 Angular 框架的冰山一角,深入後還有更有的寶藏等著你去挖掘。
以上所有示例倉儲地址為: https://github.com/why520crazy/ng-relearning 
Open in StackBlit: https://stackblitz.com/github/why520crazy/ng-relearning

相關文章