iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Modern Web

Angular 進階實務 30天系列 第 27

Day27:如何寫出可維護的元件?

  • 分享至 

  • xImage
  •  

前言

昨天我們聊了什麼時候該抽元件,相信大家心裡已經有個底了。接下來要討論的是:抽出來的元件要怎麼設計才好用?
今天就來分享幾個實戰中最常用到的核心技術 - 雙向綁定、模板投影,還有讓自製元件跟 Angular 表單整合的方法。

網頁參考:Day27

元件設計的核心技術

1. 雙向綁定的正確姿勢

雙向綁定是Angular元件通訊的基石,我用前面卡片的案例簡單做一個,引用之後在 html 簡單這樣寫就可以囉。

互動方式是點擊狀態標籤 = 切換 active/inactive
數據會自動同步到父組件

app.component.html

 <app-user-info-card [(user)]="userA" [isAdmin]="false"></app-user-info-card>
 <app-user-info-card [(user)]="adminUser" [title]="'管理員資訊'" [isAdmin]="true"></app-user-info-card>

user-info-card.component.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NzTagModule } from 'ng-zorro-antd/tag';

export interface UserInfo {
  name: string;
  status: 'active' | 'inactive';
}

@Component({
  selector: 'app-user-info-card',
  standalone: true,
  imports: [CommonModule, NzTagModule],
  template: `
    <div class="card">
      <div class="card-header" [class.admin]="isAdmin">
        <span class="header-title">{{ title }}</span>
        <span class="header-icon">{{ isAdmin ? '⚡' : '👤' }}</span>
      </div>

      <div class="card-body">
        <span class="user-name">{{ user.name }}</span>
        <nz-tag [nzColor]="user.status === 'active' ? 'green' : 'red'"
                (click)="toggleStatus()"
                class="clickable-tag">
          {{ user.status }}
        </nz-tag>
      </div>
    </div>
  `,
  styleUrls: ['./user-info-card.component.scss']
})
export class UserInfoCardComponent {
  @Input() user!: UserInfo;
  @Input() title: string = '使用者資訊';
  @Input() isAdmin: boolean = false;

  // 雙向綁定
  @Output() userChange = new EventEmitter<UserInfo>();

  toggleStatus() {
    const newStatus = this.user.status === 'active' ? 'inactive' : 'active';
    this.user = { ...this.user, status: newStatus };
    this.userChange.emit(this.user);
  }
}

user-info-card.component.scss

.card {
  background: #ffffff;
  border-radius: 16px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
  overflow: hidden;
  width: 320px;
  transition: all 0.3s ease;
  margin: 10px;

  &:hover {
    transform: translateY(-4px);
    box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
  }

  .card-header {
    background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
    color: white;
    padding: 20px 24px;
    display: flex;
    align-items: center;
    justify-content: space-between;

    &.admin {
      background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
    }

    .header-title {
      font-size: 18px;
      font-weight: 600;
    }

    .header-icon {
      font-size: 20px;
      opacity: 0.8;
    }
  }

  .card-body {
    padding: 24px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;

    .user-name {
      font-size: 16px;
      font-weight: 500;
      color: #1f2937;
      flex: 1;
    }

    .clickable-tag {
      cursor: pointer;
      transition: transform 0.2s ease;

      &:hover {
        transform: scale(1.05);
      }
    }
  }
}

🔍 小補充
傳遞物件的時候要小心喔,分享一下物件型態傳遞機制

  • 物件是引用型態:傳遞的是記憶體位址,不是副本
  • 雙向綁定的秘密:必須創建新物件而非修改原物件
  • Angular 變更檢測:基於物件引用比較

2. Template 投影的靈活運用

保持彈性最好的方式,就是使用 ng-contentng-template 實現高度客製化:
這樣可以避免元件太死,給使用者客製化的空間,我們稍微加工一下前一個卡片元件。

user-info-card.component.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NzTagModule } from 'ng-zorro-antd/tag';

export interface UserInfo {
  name: string;
  status: 'active' | 'inactive';
}

@Component({
  selector: 'app-user-info-card',
  standalone: true,
  imports: [CommonModule, NzTagModule],
  template: `
    <div class="card">
      <div class="card-header" [class.admin]="isAdmin">
        <span class="header-title">{{ title }}</span>
        <span class="header-icon">{{ isAdmin ? '⚡' : '👤' }}</span>
      </div>

      <div class="card-body">
        <span class="user-name">{{ user.name }}</span>
        <nz-tag [nzColor]="user.status === 'active' ? 'green' : 'red'"
                (click)="toggleStatus()"
                class="clickable-tag">
          {{ user.status }}
        </nz-tag>
                <!-- 投影區域 -->
        <div class="extra-content">
          <ng-content></ng-content>
        </div>
      </div>
    </div>
  `,
  styleUrls: ['./user-info-card.component.scss']
})
export class UserInfoCardComponent {
  @Input() user!: UserInfo;
  @Input() title: string = '使用者資訊';
  @Input() isAdmin: boolean = false;

  // 雙向綁定
  @Output() userChange = new EventEmitter<UserInfo>();

  toggleStatus() {
    const newStatus = this.user.status === 'active' ? 'inactive' : 'active';
    this.user = { ...this.user, status: newStatus };
    this.userChange.emit(this.user);
  }
}

user-info-card.component.scss

.card {
  background: #ffffff;
  border-radius: 16px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
  overflow: hidden;
  width: 320px;
  transition: all 0.3s ease;
  margin: 10px;

  &:hover {
    transform: translateY(-4px);
    box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
  }

  .card-header {
    background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
    color: white;
    padding: 20px 24px;
    display: flex;
    align-items: center;
    justify-content: space-between;

    &.admin {
      background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
    }

    .header-title {
      font-size: 18px;
      font-weight: 600;
    }

    .header-icon {
      font-size: 20px;
      opacity: 0.8;
    }
  }

  .card-body {
    padding: 24px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;

    .user-name {
      font-size: 16px;
      font-weight: 500;
      color: #1f2937;
      flex: 1;
    }

    .clickable-tag {
      cursor: pointer;
      transition: transform 0.2s ease;

      &:hover {
        transform: scale(1.05);
      }
    }
  }
  .extra-content {
    padding: 16px 24px;
    border-top: 1px solid #f3f4f6;
    background: #a8dbd7;

    p {
      margin: 0;
      font-size: 14px;
      color: #4b5563;
    }
  }
}

使用方式
他變得像 div 那樣,可以直接在中間加入客製化的內容,但是又能包含樣式設定根固定的資料顯示格式。

app.component.html

<app-user-info-card [(user)]="userA" [isAdmin]="false">
  <!-- 投影的內容 -->
  <button nz-button nzType="primary">額外操作</button>
  <p>這裡可以放任何自訂內容</p>

</app-user-info-card>
<app-user-info-card [(user)]="adminUser" [title]="'管理員資訊'" [isAdmin]="true"></app-user-info-card>

3. 表單元件的標準實現

以下實現 ControlValueAccessor 讓自定義元件與 Angular 表單無縫整合:
這樣元件就能與 Angular 的表單機制整合,享受驗證、狀態追蹤、Reset 等好處。

import { Component, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NzButtonModule } from 'ng-zorro-antd/button';

@Component({
  selector: 'app-counter',
  standalone: true,
  imports: [CommonModule, NzButtonModule],
  template: `
    <div class="counter">
      <button nz-button (click)="decrease()">-</button>
      <span>{{ value }}</span>
      <button nz-button (click)="increase()">+</button>
    </div>
  `,
  styles: [`
    .counter {
      display: flex;
      align-items: center;
      gap: 8px;
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CounterComponent),
      multi: true
    }
  ]
})
export class CounterComponent implements ControlValueAccessor {
  value: number = 0;

  private onChange = (value: number) => {};
  private onTouched = () => {};

  increase() {
    this.value++;
    this.onChange(this.value);
    this.onTouched();
  }

  decrease() {
    this.value--;
    this.onChange(this.value);
    this.onTouched();
  }

  // ---- ControlValueAccessor 實作 ----
  writeValue(value: number): void {
    this.value = value ?? 0;
  }

  registerOnChange(fn: (value: number) => void): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
}

使用方式

Template-Driven Form

<p>目前數值:{{ count }}</p>

Reactive Form

form = this.fb.group({
  quantity: [5]
});

<form [formGroup]="form">
  <app-counter formControlName="quantity"></app-counter>
</form>
<p>目前數值:{{ form.value | json }}</p>

以上就是一個最基本的可重用 Counter 元件。

避免常見的設計陷阱

過度共用元件

// ❌ 試圖做成萬能元件
@Component({
  selector: 'app-universal-form',
  template: `
    <form nz-form [formGroup]="form">
      <div *ngFor="let field of fields" [ngSwitch]="field.type">

        <nz-form-item *ngSwitchCase="'input'">
          <nz-form-label>{{field.label}}</nz-form-label>
          <nz-form-control>
            <input nz-input [formControlName]="field.key">
          </nz-form-control>
        </nz-form-item>

        <nz-form-item *ngSwitchCase="'select'">
          <nz-form-label>{{field.label}}</nz-form-label>
          <nz-form-control>
            <nz-select [formControlName]="field.key">
              <nz-option *ngFor="let opt of field.options"
                         [nzValue]="opt.value"
                         [nzLabel]="opt.label">
              </nz-option>
            </nz-select>
          </nz-form-control>
        </nz-form-item>

        <!-- 需要支援越來越多的類型... -->
      </div>
    </form>
  `
})
export class UniversalFormComponent {
  @Input() fields: FormField[] = [];
  form!: FormGroup;

  // 變得越來越複雜的邏輯...
}

問題

  • 配置變得極其複雜
  • 新增功能需要修改核心元件
  • 除錯困難
  • 測試複雜度指數增長

解決方案:針對具體場景設計專用元件,像是前面是示範過的巢狀表單案例

// ✅ 針對性的表單元件
@Component({
  selector: 'app-user-basic-form',
  template: `
    <form nz-form [formGroup]="userForm">
      <nz-form-item>
        <nz-form-label nzRequired>用戶名稱</nz-form-label>
        <nz-form-control nzErrorTip="請輸入用戶名稱">
          <input nz-input formControlName="name" placeholder="輸入用戶名稱">
        </nz-form-control>
      </nz-form-item>

      <nz-form-item>
        <nz-form-label>電子郵件</nz-form-label>
        <nz-form-control nzErrorTip="請輸入正確的電子郵件格式">
          <input nz-input formControlName="email" type="email">
        </nz-form-control>
      </nz-form-item>

      <nz-form-item>
        <nz-form-label>用戶角色</nz-form-label>
        <nz-form-control>
          <app-role-selector formControlName="role"></app-role-selector>
        </nz-form-control>
      </nz-form-item>
    </form>
  `
})
export class UserBasicFormComponent implements ControlValueAccessor {
  userForm = this.fb.group({
    name: ['', [Validators.required]],
    email: ['', [Validators.email]],
    role: ['user']
  });

  constructor(private fb: FormBuilder) {}

  // 實現 ControlValueAccessor...
}

設計原則總結

  1. 單一職責原則:每個元件只做一件事,且做好這件事
  2. 開放封閉原則:對擴展開放,對修改封閉
  3. 依賴反轉原則:依賴抽象而非具體實現
  4. 組合優於繼承:使用組合和投影實現複雜功能

技術要點回顧

  1. 合理使用 ControlValueAccessor:讓自定義元件與 Angular 表單體系無縫整合
  2. 善用 Template 投影:通過 ng-content 和 ng-template 提供靈活性
  3. 實現響應式設計:考慮不同螢幕尺寸的使用體驗
  4. 注重性能優化:使用 OnPush 策略、trackBy 函數、虛擬滾動等技術
  5. 完善的錯誤處理:提供載入、錯誤、空狀態的完整體驗

上一篇
Day 26:何時該抽元件?從視覺相似到業務邏輯的判斷法則
系列文
Angular 進階實務 30天27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言