昨天我們聊了什麼時候該抽元件,相信大家心裡已經有個底了。接下來要討論的是:抽出來的元件要怎麼設計才好用?
今天就來分享幾個實戰中最常用到的核心技術 - 雙向綁定、模板投影,還有讓自製元件跟 Angular 表單整合的方法。
網頁參考:Day27
雙向綁定是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);
      }
    }
  }
}
🔍 小補充
傳遞物件的時候要小心喔,分享一下物件型態傳遞機制
保持彈性最好的方式,就是使用 ng-content 和 ng-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>
以下實現 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...
}