前言

在傳統的 Angular 開發中,我們使用 @Input()@Output() 來實現父子元件之間的通訊。Angular 16 引入了 Signal InputSignal 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/@OutputSignal 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!

參考資料