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