前言

在前面的文章中,我們學習了 Signal 的基礎、Computed、Effect、Input 和 Output。這篇文章將介紹最後一個重要功能:Signal ViewChild,以及一些 Signal 的進階應用和最佳實踐。

Signal ViewChild

viewChild() 是傳統 @ViewChild() 的 Signal 版本,用於在元件中存取子元件或 DOM 元素的引用。

基本用法

import { Component, viewChild, ElementRef } from '@angular/core';

@Component({
  selector: 'app-focus-input',
  standalone: true,
  template: `
    <div>
      <input #myInput type="text" placeholder="Enter text...">
      <button (click)="focusInput()">Focus Input</button>
      <button (click)="clearInput()">Clear Input</button>
    </div>
  `
})
export class FocusInputComponent {
  // Signal ViewChild:存取 DOM 元素
  inputRef = viewChild<ElementRef<HTMLInputElement>>('myInput');
  
  focusInput(): void {
    // 使用 () 讀取 Signal
    this.inputRef()?.nativeElement.focus();
  }
  
  clearInput(): void {
    const input = this.inputRef()?.nativeElement;
    if (input) {
      input.value = '';
      input.focus();
    }
  }
}

Required vs Optional ViewChild

與 Input 類似,ViewChild 也支援必填和選填。

import { Component, viewChild, ElementRef } from '@angular/core';

@Component({
  selector: 'app-video-player',
  standalone: true,
  template: `
    <div>
      <video #videoElement [src]="videoUrl" controls></video>
      <div class="controls">
        <button (click)="play()">Play</button>
        <button (click)="pause()">Pause</button>
        <button (click)="restart()">Restart</button>
      </div>
      
      <!-- 可選的字幕元素 -->
      <div #subtitles *ngIf="showSubtitles">
        Subtitles here...
      </div>
    </div>
  `
})
export class VideoPlayerComponent {
  videoUrl = 'assets/video.mp4';
  showSubtitles = false;
  
  // Required ViewChild:必定存在
  videoElement = viewChild.required<ElementRef<HTMLVideoElement>>('videoElement');
  
  // Optional ViewChild:可能不存在
  subtitlesElement = viewChild<ElementRef<HTMLDivElement>>('subtitles');
  
  play(): void {
    this.videoElement().nativeElement.play();
  }
  
  pause(): void {
    this.videoElement().nativeElement.pause();
  }
  
  restart(): void {
    const video = this.videoElement().nativeElement;
    video.currentTime = 0;
    video.play();
  }
  
  updateSubtitles(text: string): void {
    const subtitles = this.subtitlesElement();
    if (subtitles) {
      subtitles.nativeElement.textContent = text;
    }
  }
}

存取子元件

ViewChild 也可以用來存取子元件實例。

// 子元件
@Component({
  selector: 'app-alert',
  standalone: true,
  template: `
    <div class="alert" [class.visible]="isVisible">
      <p>{{ message }}</p>
      <button (click)="close()">Close</button>
    </div>
  `,
  styles: [`
    .alert {
      padding: 16px;
      background-color: #ff9800;
      color: white;
      border-radius: 4px;
      display: none;
    }
    
    .alert.visible {
      display: block;
    }
  `]
})
export class AlertComponent {
  message = '';
  isVisible = false;
  
  show(message: string): void {
    this.message = message;
    this.isVisible = true;
  }
  
  close(): void {
    this.isVisible = false;
  }
}

// 父元件
@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [AlertComponent],
  template: `
    <div>
      <button (click)="showAlert()">Show Alert</button>
      <app-alert></app-alert>
    </div>
  `
})
export class ParentComponent {
  // ViewChild:存取子元件實例
  alert = viewChild.required(AlertComponent);
  
  showAlert(): void {
    this.alert().show('This is an alert message!');
  }
}

ViewChildren - 存取多個元素

當需要存取多個元素時,使用 viewChildren()

import { Component, viewChildren, ElementRef, signal } from '@angular/core';

@Component({
  selector: 'app-tab-panel',
  standalone: true,
  template: `
    <div class="tabs">
      <button 
        *ngFor="let tab of tabs; let i = index"
        #tabButton
        [class.active]="activeTab() === i"
        (click)="selectTab(i)">
        {{ tab }}
      </button>
    </div>
    
    <div class="content">
      <div *ngFor="let content of tabContents; let i = index"
           [hidden]="activeTab() !== i">
        {{ content }}
      </div>
    </div>
  `,
  styles: [`
    .tabs {
      display: flex;
      border-bottom: 2px solid #ddd;
    }
    
    button {
      padding: 12px 24px;
      border: none;
      background: none;
      cursor: pointer;
    }
    
    button.active {
      border-bottom: 2px solid #4CAF50;
      color: #4CAF50;
    }
    
    .content {
      padding: 16px;
    }
  `]
})
export class TabPanelComponent {
  tabs = ['Tab 1', 'Tab 2', 'Tab 3'];
  tabContents = ['Content 1', 'Content 2', 'Content 3'];
  activeTab = signal(0);
  
  // ViewChildren:存取所有 tab 按鈕
  tabButtons = viewChildren<ElementRef<HTMLButtonElement>>('tabButton');
  
  selectTab(index: number): void {
    this.activeTab.set(index);
    
    // 可以對所有按鈕進行操作
    const buttons = this.tabButtons();
    buttons.forEach((button, i) => {
      if (i === index) {
        button.nativeElement.focus();
      }
    });
  }
  
  ngAfterViewInit(): void {
    // 在視圖初始化後,可以存取所有按鈕
    console.log('Total tabs:', this.tabButtons().length);
  }
}

與 Effect 結合使用

ViewChild 搭配 Effect 可以在元素變化時執行操作。

import { Component, viewChild, ElementRef, effect, signal } from '@angular/core';

@Component({
  selector: 'app-auto-scroll',
  standalone: true,
  template: `
    <div>
      <div #messageContainer class="messages">
        <div *ngFor="let msg of messages()" class="message">
          {{ msg }}
        </div>
      </div>
      
      <div class="input-area">
        <input [(ngModel)]="newMessage" 
               (keyup.enter)="sendMessage()"
               placeholder="Type a message...">
        <button (click)="sendMessage()">Send</button>
      </div>
    </div>
  `,
  styles: [`
    .messages {
      height: 300px;
      overflow-y: auto;
      border: 1px solid #ccc;
      padding: 16px;
      margin-bottom: 16px;
    }
    
    .message {
      padding: 8px;
      margin-bottom: 8px;
      background-color: #f0f0f0;
      border-radius: 4px;
    }
    
    .input-area {
      display: flex;
      gap: 8px;
    }
    
    input {
      flex: 1;
      padding: 8px;
    }
  `]
})
export class AutoScrollComponent {
  messages = signal<string[]>([]);
  newMessage = '';
  
  // ViewChild:存取訊息容器
  messageContainer = viewChild<ElementRef<HTMLDivElement>>('messageContainer');
  
  constructor() {
    // Effect:當訊息變化時自動滾動到底部
    effect(() => {
      const messages = this.messages();
      const container = this.messageContainer()?.nativeElement;
      
      if (container && messages.length > 0) {
        // 延遲執行以確保 DOM 已更新
        setTimeout(() => {
          container.scrollTop = container.scrollHeight;
        }, 0);
      }
    });
  }
  
  sendMessage(): void {
    if (this.newMessage.trim()) {
      this.messages.update(msgs => [...msgs, this.newMessage]);
      this.newMessage = '';
    }
  }
}

進階應用

1. 表單管理

使用 Signal 打造響應式表單。

import { Component, signal, computed } from '@angular/core';

interface FormField<T> {
  value: T;
  error: string;
  touched: boolean;
}

@Component({
  selector: 'app-advanced-form',
  standalone: true,
  template: `
    <form (submit)="onSubmit($event)">
      <div>
        <label>Username</label>
        <input 
          [value]="username.value()"
          (input)="updateUsername($any($event.target).value)"
          (blur)="touchUsername()">
        <div class="error" *ngIf="username.error() && username.touched()">
          {{ username.error() }}
        </div>
      </div>
      
      <div>
        <label>Email</label>
        <input 
          type="email"
          [value]="email.value()"
          (input)="updateEmail($any($event.target).value)"
          (blur)="touchEmail()">
        <div class="error" *ngIf="email.error() && email.touched()">
          {{ email.error() }}
        </div>
      </div>
      
      <div>
        <label>Password</label>
        <input 
          type="password"
          [value]="password.value()"
          (input)="updatePassword($any($event.target).value)"
          (blur)="touchPassword()">
        <div class="error" *ngIf="password.error() && password.touched()">
          {{ password.error() }}
        </div>
      </div>
      
      <div>
        <label>Confirm Password</label>
        <input 
          type="password"
          [value]="confirmPassword.value()"
          (input)="updateConfirmPassword($any($event.target).value)"
          (blur)="touchConfirmPassword()">
        <div class="error" *ngIf="confirmPassword.error() && confirmPassword.touched()">
          {{ confirmPassword.error() }}
        </div>
      </div>
      
      <button type="submit" [disabled]="!isFormValid()">
        Submit
      </button>
      
      <div class="summary">
        <h3>Form Status</h3>
        <p>Valid: {{ isFormValid() ? 'Yes' : 'No' }}</p>
        <p>Errors: {{ errorCount() }}</p>
      </div>
    </form>
  `
})
export class AdvancedFormComponent {
  // 表單欄位 Signals
  username = {
    value: signal(''),
    error: signal(''),
    touched: signal(false)
  };
  
  email = {
    value: signal(''),
    error: signal(''),
    touched: signal(false)
  };
  
  password = {
    value: signal(''),
    error: signal(''),
    touched: signal(false)
  };
  
  confirmPassword = {
    value: signal(''),
    error: signal(''),
    touched: signal(false)
  };
  
  // Computed:表單是否有效
  isFormValid = computed(() => 
    !this.username.error() &&
    !this.email.error() &&
    !this.password.error() &&
    !this.confirmPassword.error() &&
    this.username.value() &&
    this.email.value() &&
    this.password.value() &&
    this.confirmPassword.value()
  );
  
  // Computed:錯誤數量
  errorCount = computed(() => {
    let count = 0;
    if (this.username.error()) count++;
    if (this.email.error()) count++;
    if (this.password.error()) count++;
    if (this.confirmPassword.error()) count++;
    return count;
  });
  
  updateUsername(value: string): void {
    this.username.value.set(value);
    this.validateUsername();
  }
  
  touchUsername(): void {
    this.username.touched.set(true);
  }
  
  validateUsername(): void {
    const value = this.username.value();
    if (!value) {
      this.username.error.set('Username is required');
    } else if (value.length < 3) {
      this.username.error.set('Username must be at least 3 characters');
    } else {
      this.username.error.set('');
    }
  }
  
  updateEmail(value: string): void {
    this.email.value.set(value);
    this.validateEmail();
  }
  
  touchEmail(): void {
    this.email.touched.set(true);
  }
  
  validateEmail(): void {
    const value = this.email.value();
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    
    if (!value) {
      this.email.error.set('Email is required');
    } else if (!emailRegex.test(value)) {
      this.email.error.set('Invalid email format');
    } else {
      this.email.error.set('');
    }
  }
  
  updatePassword(value: string): void {
    this.password.value.set(value);
    this.validatePassword();
    // 當密碼變更時,也要重新驗證確認密碼
    if (this.confirmPassword.value()) {
      this.validateConfirmPassword();
    }
  }
  
  touchPassword(): void {
    this.password.touched.set(true);
  }
  
  validatePassword(): void {
    const value = this.password.value();
    if (!value) {
      this.password.error.set('Password is required');
    } else if (value.length < 8) {
      this.password.error.set('Password must be at least 8 characters');
    } else {
      this.password.error.set('');
    }
  }
  
  updateConfirmPassword(value: string): void {
    this.confirmPassword.value.set(value);
    this.validateConfirmPassword();
  }
  
  touchConfirmPassword(): void {
    this.confirmPassword.touched.set(true);
  }
  
  validateConfirmPassword(): void {
    const value = this.confirmPassword.value();
    const password = this.password.value();
    
    if (!value) {
      this.confirmPassword.error.set('Please confirm your password');
    } else if (value !== password) {
      this.confirmPassword.error.set('Passwords do not match');
    } else {
      this.confirmPassword.error.set('');
    }
  }
  
  onSubmit(event: Event): void {
    event.preventDefault();
    
    if (this.isFormValid()) {
      console.log('Form submitted:', {
        username: this.username.value(),
        email: this.email.value(),
        password: this.password.value()
      });
    }
  }
}

2. 狀態管理模式

使用 Signal 實現簡單的狀態管理。

// state.service.ts
import { Injectable, signal, computed } from '@angular/core';

export interface AppState {
  user: User | null;
  theme: 'light' | 'dark';
  notifications: Notification[];
  isLoading: boolean;
}

export interface User {
  id: number;
  name: string;
  email: string;
}

export interface Notification {
  id: number;
  message: string;
  type: 'info' | 'success' | 'warning' | 'error';
  timestamp: Date;
}

@Injectable({
  providedIn: 'root'
})
export class StateService {
  // 私有狀態
  private state = signal<AppState>({
    user: null,
    theme: 'light',
    notifications: [],
    isLoading: false
  });
  
  // 公開的唯讀 Signals
  user = computed(() => this.state().user);
  theme = computed(() => this.state().theme);
  notifications = computed(() => this.state().notifications);
  isLoading = computed(() => this.state().isLoading);
  
  // Computed:是否已登入
  isLoggedIn = computed(() => this.user() !== null);
  
  // Computed:未讀通知數量
  unreadNotificationCount = computed(() => 
    this.notifications().length
  );
  
  // Actions
  setUser(user: User | null): void {
    this.state.update(s => ({ ...s, user }));
  }
  
  setTheme(theme: 'light' | 'dark'): void {
    this.state.update(s => ({ ...s, theme }));
    document.body.className = theme;
    localStorage.setItem('theme', theme);
  }
  
  addNotification(notification: Omit<Notification, 'id' | 'timestamp'>): void {
    const newNotification: Notification = {
      ...notification,
      id: Date.now(),
      timestamp: new Date()
    };
    
    this.state.update(s => ({
      ...s,
      notifications: [...s.notifications, newNotification]
    }));
    
    // 5 秒後自動移除通知
    setTimeout(() => {
      this.removeNotification(newNotification.id);
    }, 5000);
  }
  
  removeNotification(id: number): void {
    this.state.update(s => ({
      ...s,
      notifications: s.notifications.filter(n => n.id !== id)
    }));
  }
  
  clearNotifications(): void {
    this.state.update(s => ({ ...s, notifications: [] }));
  }
  
  setLoading(isLoading: boolean): void {
    this.state.update(s => ({ ...s, isLoading }));
  }
  
  // 重置狀態
  reset(): void {
    this.state.set({
      user: null,
      theme: 'light',
      notifications: [],
      isLoading: false
    });
  }
}

// 使用範例
@Component({
  selector: 'app-header',
  template: `
    <header [class]="stateService.theme()">
      <div *ngIf="stateService.isLoggedIn()">
        Welcome, {{ stateService.user()?.name }}!
      </div>
      
      <button (click)="toggleTheme()">
        Switch to {{ stateService.theme() === 'light' ? 'Dark' : 'Light' }} Mode
      </button>
      
      <div class="notifications">
        <span class="badge">{{ stateService.unreadNotificationCount() }}</span>
        <div *ngFor="let notification of stateService.notifications()">
          {{ notification.message }}
        </div>
      </div>
    </header>
  `
})
export class HeaderComponent {
  constructor(public stateService: StateService) {}
  
  toggleTheme(): void {
    const newTheme = this.stateService.theme() === 'light' ? 'dark' : 'light';
    this.stateService.setTheme(newTheme);
  }
}

3. 與 RxJS 整合

Signal 可以與 RxJS 無縫整合。

import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';

@Component({
  selector: 'app-search-integration',
  template: `
    <div>
      <input 
        [value]="searchTerm()"
        (input)="searchTerm.set($any($event.target).value)"
        placeholder="Search...">
      
      <div *ngIf="isLoading()">Searching...</div>
      
      <ul>
        <li *ngFor="let result of searchResults()">
          {{ result.title }}
        </li>
      </ul>
    </div>
  `
})
export class SearchIntegrationComponent {
  searchTerm = signal('');
  
  // 將 Signal 轉換為 Observable
  searchTerm$ = toObservable(this.searchTerm);
  
  // 使用 RxJS operators 處理搜尋
  searchResults$ = this.searchTerm$.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(term => this.searchService.search(term))
  );
  
  // 將 Observable 轉回 Signal
  searchResults = toSignal(this.searchResults$, { initialValue: [] });
  isLoading = toSignal(this.searchResults$.pipe(
    switchMap(() => of(false)),
    startWith(true)
  ), { initialValue: false });
  
  constructor(private searchService: SearchService) {}
}

最佳實踐總結

1. 何時使用 Signal

適合使用 Signal:

  • ✅ 元件內部狀態管理
  • ✅ 需要響應式更新的資料
  • ✅ 計算衍生值
  • ✅ 簡單的狀態同步

不適合使用 Signal:

  • ❌ 複雜的異步流程(使用 RxJS)
  • ❌ 需要豐富的 operators(使用 RxJS)
  • ❌ 跨多個元件的複雜狀態(考慮狀態管理庫)

2. Signal 命名規範

// ✅ 好:清晰的命名
count = signal(0);
userName = signal('');
isLoading = signal(false);
items = signal<Item[]>([]);

// ❌ 壞:模糊的命名
data = signal({});
temp = signal('');
flag = signal(false);

3. Computed 優於 Effect

// ✅ 好:使用 computed
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);

// ❌ 壞:使用 effect 更新其他 signal
constructor() {
  effect(() => {
    this.fullName.set(`${this.firstName()} ${this.lastName()}`);
  });
}

4. 保持 Signal 的單一職責

// ✅ 好:每個 Signal 負責一件事
firstName = signal('');
lastName = signal('');
age = signal(0);

// ❌ 壞:一個 Signal 包含太多資訊
user = signal({
  firstName: '',
  lastName: '',
  age: 0,
  address: {},
  preferences: {}
});

5. 使用 Required Input 提高型別安全

// ✅ 好:使用 required
userId = input.required<number>();

// ❌ 壞:可選但實際上必須
userId = input<number>();

常見問題與解決方案

Q1: Signal 會造成記憶體洩漏嗎?

A: 不會。Signal 會自動清理依賴關係,不需要手動取消訂閱。

Q2: Signal 可以在 Service 中使用嗎?

A: 可以!Signal 不僅可以在元件中使用,也可以在 Service 中使用來管理共享狀態。

Q3: Signal 和 Observable 可以一起使用嗎?

A: 可以!使用 toObservable()toSignal() 可以在兩者之間轉換。

Q4: Effect 中可以更新 Signal 嗎?

A: 技術上可以,但不推薦。應該使用 Computed 來計算衍生值。

Q5: ViewChild Signal 什麼時候可用?

A:ngAfterViewInit 生命週期之後,ViewChild Signal 才會有值。

總結

Angular Signal 為現代 Angular 開發帶來了革命性的變化:

核心功能回顧

  1. Signal:響應式狀態容器
  2. Computed:計算衍生值
  3. Effect:執行副作用
  4. Input/Output:元件通訊
  5. ViewChild:存取 DOM 和子元件

主要優勢

  • 🚀 效能提升:精確的變更檢測
  • 💡 簡單直觀:易於理解和使用
  • 🔒 型別安全:完整的 TypeScript 支援
  • 🎯 自動追蹤:不需手動管理訂閱
  • 🛠️ 易於維護:清晰的資料流

遷移建議

  • 新專案:優先使用 Signal
  • 現有專案:漸進式遷移
  • 混合使用:Signal 和 RxJS 可以共存

掌握 Signal 的使用,將讓你的 Angular 應用更加現代、高效且易於維護!

參考資料