前言
在前面的文章中,我們學習了 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 開發帶來了革命性的變化:
核心功能回顧
- Signal:響應式狀態容器
- Computed:計算衍生值
- Effect:執行副作用
- Input/Output:元件通訊
- ViewChild:存取 DOM 和子元件
主要優勢
- 🚀 效能提升:精確的變更檢測
- 💡 簡單直觀:易於理解和使用
- 🔒 型別安全:完整的 TypeScript 支援
- 🎯 自動追蹤:不需手動管理訂閱
- 🛠️ 易於維護:清晰的資料流
遷移建議
- 新專案:優先使用 Signal
- 現有專案:漸進式遷移
- 混合使用:Signal 和 RxJS 可以共存
掌握 Signal 的使用,將讓你的 Angular 應用更加現代、高效且易於維護!