前言
在傳統的 Angular 開發中,我們使用 @Input() 和 @Output() 來實現父子元件之間的通訊。Angular 16 引入了 Signal Input 和 Signal Output,提供了更現代、更高效的元件通訊方式。這篇文章將詳細介紹如何使用這些新功能。
Signal Input
Signal Input 是傳統 @Input() 的 Signal 版本,它讓父元件能夠將資料傳遞給子元件,並且提供了更好的型別安全和響應式特性。
基本用法
import { Component, input } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="user-card">
<h3>{{ name() }}</h3>
<p>Age: {{ age() }}</p>
<p>Email: {{ email() }}</p>
</div>
`,
styles: [`
.user-card {
border: 1px solid #ccc;
padding: 16px;
border-radius: 8px;
margin: 8px;
}
`]
})
export class UserCardComponent {
// Signal Input:從父元件接收資料
name = input<string>('');
age = input<number>(0);
email = input<string>('');
}
父元件使用方式:
@Component({
selector: 'app-parent',
standalone: true,
imports: [UserCardComponent],
template: `
<div>
<h2>User List</h2>
<app-user-card
[name]="'John Doe'"
[age]="30"
[email]="'john@example.com'">
</app-user-card>
<app-user-card
[name]="'Jane Smith'"
[age]="25"
[email]="'jane@example.com'">
</app-user-card>
</div>
`
})
export class ParentComponent {}
Required vs Optional Input
Signal Input 支援必填和選填兩種模式。
import { Component, input } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="product-card">
<h3>{{ name() }}</h3>
<p>Price: ${{ price() }}</p>
<p *ngIf="description()">{{ description() }}</p>
<p *ngIf="discount()">Discount: {{ discount() }}%</p>
</div>
`
})
export class ProductCardComponent {
// Required Input:必須提供
name = input.required<string>();
price = input.required<number>();
// Optional Input:可選,有預設值
description = input<string>('');
discount = input<number>(0);
}
使用範例:
@Component({
selector: 'app-shop',
standalone: true,
imports: [ProductCardComponent],
template: `
<div>
<!-- ✅ 正確:提供了所有必填項 -->
<app-product-card
[name]="'Laptop'"
[price]="999"
[description]="'High performance laptop'"
[discount]="10">
</app-product-card>
<!-- ✅ 正確:只提供必填項 -->
<app-product-card
[name]="'Mouse'"
[price]="29">
</app-product-card>
<!-- ❌ 錯誤:缺少必填項 price,TypeScript 會報錯 -->
<!-- <app-product-card [name]="'Keyboard'"></app-product-card> -->
</div>
`
})
export class ShopComponent {}
Transform 功能
Signal Input 支援 transform 函數,可以在接收資料時進行轉換。
import { Component, input } from '@angular/core';
@Component({
selector: 'app-date-display',
standalone: true,
template: `
<div>
<p>Original: {{ dateString() }}</p>
<p>Formatted: {{ formattedDate() }}</p>
<p>Is Enabled: {{ isEnabled() }}</p>
<p>Count (doubled): {{ count() }}</p>
</div>
`
})
export class DateDisplayComponent {
// Transform:將字串轉換為 Date 物件
dateString = input<string>('');
formattedDate = input('', {
transform: (value: string) => {
const date = new Date(value);
return date.toLocaleDateString('zh-TW');
}
});
// Transform:將字串 'true'/'false' 轉換為布林值
isEnabled = input(false, {
transform: (value: string | boolean) => {
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}
return value;
}
});
// Transform:將數字乘以 2
count = input(0, {
transform: (value: number) => value * 2
});
}
使用範例:
@Component({
template: `
<app-date-display
[dateString]="'2024-01-01'"
[formattedDate]="'2024-01-01'"
[isEnabled]="'true'"
[count]="5">
</app-date-display>
<!-- count 會顯示 10(5 * 2) -->
`
})
export class AppComponent {}
與 Computed 結合使用
Signal Input 可以和 computed 結合,創建衍生值。
import { Component, input, computed } from '@angular/core';
interface User {
firstName: string;
lastName: string;
age: number;
}
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
<div class="profile">
<h2>{{ fullName() }}</h2>
<p>Age: {{ age() }}</p>
<p>Status: {{ ageStatus() }}</p>
<p>Initials: {{ initials() }}</p>
<p>Display Name: {{ displayName() }}</p>
</div>
`
})
export class UserProfileComponent {
// Signal Inputs
firstName = input.required<string>();
lastName = input.required<string>();
age = input.required<number>();
nickname = input<string>('');
// Computed:組合 firstName 和 lastName
fullName = computed(() =>
`${this.firstName()} ${this.lastName()}`
);
// Computed:根據 age 判斷狀態
ageStatus = computed(() => {
const age = this.age();
if (age < 18) return 'Minor';
if (age < 65) return 'Adult';
return 'Senior';
});
// Computed:取得姓名首字母
initials = computed(() =>
`${this.firstName().charAt(0)}${this.lastName().charAt(0)}`
);
// Computed:優先顯示 nickname,否則顯示 fullName
displayName = computed(() =>
this.nickname() || this.fullName()
);
}
Signal Output
Signal Output 是傳統 @Output() 的 Signal 版本,用於從子元件向父元件發送事件。
基本用法
import { Component, output } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter">
<p>Count: {{ count }}</p>
<button (click)="increment()">+1</button>
<button (click)="decrement()">-1</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
count = 0;
// Signal Output:定義事件
countChange = output<number>();
countReset = output<void>();
increment(): void {
this.count++;
this.countChange.emit(this.count);
}
decrement(): void {
this.count--;
this.countChange.emit(this.count);
}
reset(): void {
this.count = 0;
this.countReset.emit();
}
}
父元件監聽事件:
@Component({
selector: 'app-parent',
standalone: true,
imports: [CounterComponent],
template: `
<div>
<h2>Parent Component</h2>
<p>Received count: {{ receivedCount }}</p>
<p>Reset count: {{ resetCount }}</p>
<app-counter
(countChange)="onCountChange($event)"
(countReset)="onCountReset()">
</app-counter>
</div>
`
})
export class ParentComponent {
receivedCount = 0;
resetCount = 0;
onCountChange(count: number): void {
this.receivedCount = count;
console.log('Count changed:', count);
}
onCountReset(): void {
this.resetCount++;
console.log('Counter was reset');
}
}
傳遞複雜資料
Signal Output 可以傳遞任何型別的資料,包括物件和陣列。
interface Todo {
id: number;
title: string;
completed: boolean;
}
@Component({
selector: 'app-todo-item',
standalone: true,
template: `
<div class="todo-item">
<input type="checkbox"
[checked]="todo().completed"
(change)="toggleComplete()">
<span [class.completed]="todo().completed">
{{ todo().title }}
</span>
<button (click)="remove()">Delete</button>
<button (click)="edit()">Edit</button>
</div>
`,
styles: [`
.completed {
text-decoration: line-through;
color: #999;
}
`]
})
export class TodoItemComponent {
// Input
todo = input.required<Todo>();
// Outputs
todoToggle = output<Todo>();
todoRemove = output<number>();
todoEdit = output<Todo>();
toggleComplete(): void {
const updatedTodo = {
...this.todo(),
completed: !this.todo().completed
};
this.todoToggle.emit(updatedTodo);
}
remove(): void {
this.todoRemove.emit(this.todo().id);
}
edit(): void {
this.todoEdit.emit(this.todo());
}
}
父元件處理事件:
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [TodoItemComponent],
template: `
<div>
<h2>Todo List</h2>
<div *ngFor="let todo of todos">
<app-todo-item
[todo]="todo"
(todoToggle)="onTodoToggle($event)"
(todoRemove)="onTodoRemove($event)"
(todoEdit)="onTodoEdit($event)">
</app-todo-item>
</div>
</div>
`
})
export class TodoListComponent {
todos: Todo[] = [
{ id: 1, title: 'Learn Angular', completed: false },
{ id: 2, title: 'Learn Signals', completed: true },
{ id: 3, title: 'Build App', completed: false }
];
onTodoToggle(todo: Todo): void {
const index = this.todos.findIndex(t => t.id === todo.id);
if (index !== -1) {
this.todos[index] = todo;
}
}
onTodoRemove(id: number): void {
this.todos = this.todos.filter(t => t.id !== id);
}
onTodoEdit(todo: Todo): void {
console.log('Edit todo:', todo);
// 開啟編輯對話框
}
}
完整範例:表單元件
讓我們創建一個完整的表單元件,展示 Signal Input 和 Output 的實際應用。
子元件:表單輸入
import { Component, input, output, signal } from '@angular/core';
@Component({
selector: 'app-form-input',
standalone: true,
template: `
<div class="form-group">
<label>
{{ label() }}
<span *ngIf="required()" class="required">*</span>
</label>
<input
[type]="type()"
[value]="value()"
[placeholder]="placeholder()"
[disabled]="disabled()"
(input)="onInput($event)"
(blur)="onBlur()"
(focus)="onFocus()">
<div *ngIf="error()" class="error">{{ error() }}</div>
<div *ngIf="hint()" class="hint">{{ hint() }}</div>
</div>
`,
styles: [`
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 4px;
font-weight: 500;
}
.required {
color: red;
}
input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
input:focus {
outline: none;
border-color: #4CAF50;
}
input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.error {
color: red;
font-size: 12px;
margin-top: 4px;
}
.hint {
color: #666;
font-size: 12px;
margin-top: 4px;
}
`]
})
export class FormInputComponent {
// Inputs
label = input.required<string>();
value = input<string>('');
type = input<string>('text');
placeholder = input<string>('');
required = input<boolean>(false);
disabled = input<boolean>(false);
error = input<string>('');
hint = input<string>('');
// Outputs
valueChange = output<string>();
blur = output<void>();
focus = output<void>();
onInput(event: Event): void {
const target = event.target as HTMLInputElement;
this.valueChange.emit(target.value);
}
onBlur(): void {
this.blur.emit();
}
onFocus(): void {
this.focus.emit();
}
}
父元件:使用表單
import { Component, signal, computed } from '@angular/core';
import { FormInputComponent } from './form-input.component';
interface FormErrors {
name?: string;
email?: string;
phone?: string;
}
@Component({
selector: 'app-registration-form',
standalone: true,
imports: [FormInputComponent],
template: `
<div class="registration-form">
<h2>Registration Form</h2>
<app-form-input
label="Name"
[value]="name()"
[required]="true"
[error]="errors().name"
hint="Enter your full name"
(valueChange)="name.set($event)"
(blur)="validateName()">
</app-form-input>
<app-form-input
label="Email"
type="email"
[value]="email()"
[required]="true"
[error]="errors().email"
placeholder="example@email.com"
(valueChange)="email.set($event)"
(blur)="validateEmail()">
</app-form-input>
<app-form-input
label="Phone"
type="tel"
[value]="phone()"
[error]="errors().phone"
placeholder="+886 912-345-678"
(valueChange)="phone.set($event)"
(blur)="validatePhone()">
</app-form-input>
<div class="form-actions">
<button
(click)="submit()"
[disabled]="!isValid()">
Submit
</button>
<button
type="button"
(click)="reset()">
Reset
</button>
</div>
<div *ngIf="submitMessage()" class="message">
{{ submitMessage() }}
</div>
</div>
`,
styles: [`
.registration-form {
max-width: 500px;
margin: 0 auto;
padding: 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
button {
flex: 1;
padding: 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:first-child {
background-color: #4CAF50;
color: white;
}
button:first-child:hover:not(:disabled) {
background-color: #45a049;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
button:last-child {
background-color: #f44336;
color: white;
}
button:last-child:hover {
background-color: #da190b;
}
.message {
margin-top: 16px;
padding: 12px;
border-radius: 4px;
background-color: #4CAF50;
color: white;
}
`]
})
export class RegistrationFormComponent {
// Form values (Signals)
name = signal('');
email = signal('');
phone = signal('');
// Form errors (Signal)
errors = signal<FormErrors>({});
// Submit message (Signal)
submitMessage = signal('');
// Computed: 表單是否有效
isValid = computed(() => {
const currentErrors = this.errors();
return this.name() &&
this.email() &&
!currentErrors.name &&
!currentErrors.email &&
!currentErrors.phone;
});
validateName(): void {
const name = this.name();
if (!name) {
this.errors.update(e => ({ ...e, name: 'Name is required' }));
} else if (name.length < 2) {
this.errors.update(e => ({ ...e, name: 'Name is too short' }));
} else {
this.errors.update(e => {
const { name, ...rest } = e;
return rest;
});
}
}
validateEmail(): void {
const email = this.email();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email) {
this.errors.update(e => ({ ...e, email: 'Email is required' }));
} else if (!emailRegex.test(email)) {
this.errors.update(e => ({ ...e, email: 'Invalid email format' }));
} else {
this.errors.update(e => {
const { email, ...rest } = e;
return rest;
});
}
}
validatePhone(): void {
const phone = this.phone();
if (phone && phone.length < 10) {
this.errors.update(e => ({ ...e, phone: 'Phone number is too short' }));
} else {
this.errors.update(e => {
const { phone, ...rest } = e;
return rest;
});
}
}
submit(): void {
this.validateName();
this.validateEmail();
this.validatePhone();
if (this.isValid()) {
console.log('Form submitted:', {
name: this.name(),
email: this.email(),
phone: this.phone()
});
this.submitMessage.set('Registration successful!');
// 3 秒後清除訊息
setTimeout(() => this.submitMessage.set(''), 3000);
}
}
reset(): void {
this.name.set('');
this.email.set('');
this.phone.set('');
this.errors.set({});
this.submitMessage.set('');
}
}
Signal Input/Output vs 傳統方式比較
| 特性 | 傳統 @Input/@Output | Signal Input/Output |
|---|---|---|
| 型別安全 | ✅ | ✅✅(更強) |
| 必填檢查 | 運行時 | 編譯時 |
| 預設值 | 需要手動設定 | 內建支援 |
| Transform | 需要 setter | 內建支援 |
| 與 Computed 整合 | 困難 | 簡單 |
| 變更檢測 | 整個元件 | 精確追蹤 |
| 效能 | 一般 | 更好 |
最佳實踐
1. 明確標示必填項
// ✅ 好:使用 required
userName = input.required<string>();
// ❌ 壞:不明確
userName = input<string>('');
2. 提供有意義的預設值
// ✅ 好:有意義的預設值
pageSize = input(10);
sortOrder = input<'asc' | 'desc'>('asc');
// ❌ 壞:空字串或 0 可能不合理
pageSize = input(0);
3. 使用 Transform 簡化邏輯
// ✅ 好:使用 transform
isEnabled = input(false, {
transform: (value: string | boolean) =>
typeof value === 'string' ? value === 'true' : value
});
// ❌ 壞:在元件內部處理
isEnabled = input<string | boolean>(false);
ngOnInit() {
if (typeof this.isEnabled() === 'string') {
// 處理字串轉布林...
}
}
4. Output 命名使用動詞
// ✅ 好:使用動詞
itemClick = output<Item>();
userDelete = output<number>();
formSubmit = output<FormData>();
// ❌ 壞:使用名詞
item = output<Item>();
user = output<number>();
5. 與 Computed 結合使用
// ✅ 好:使用 computed 創建衍生值
firstName = input.required<string>();
lastName = input.required<string>();
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
// ❌ 壞:在模板中拼接
// template: `<p>{{ firstName() }} {{ lastName() }}</p>`
總結
Signal Input 和 Output 為 Angular 的元件通訊帶來了革命性的改變:
Signal Input
- 型別安全:編譯時檢查必填項
- Transform:內建資料轉換
- 預設值:簡潔的 API
- Computed 整合:輕鬆創建衍生值
Signal Output
- 簡潔語法:更直觀的事件發送
- 型別支援:完整的 TypeScript 支援
- 效能提升:更精確的變更檢測
使用 Signal Input 和 Output 可以讓你的 Angular 元件更加現代、高效且易於維護。建議在新專案中優先使用這些新 API!