SKILL.md
readonlyread-only
name
angular-signals
description
在 Angular v20+ 中實作基於 Signal 的反應式狀態管理。用於建立反應式狀態(signal())、衍生狀態(computed())、相依狀態(linkedSignal())以及副作用(effect())。當遇到狀態管理問題、從 BehaviorSubject/Observable 模式轉換為 Signal,或實作反應式資料流時觸發。
Angular Signals
Signals 是 Angular 用於狀態管理的反應式基礎元件,提供同步且細粒度的反應性。
核心 Signal API
signal() - 可寫入狀態
import { signal } from '@angular/core';
// 建立可寫入 signal
const count = signal(0);
// 讀取值
console.log(count()); // 0
// 設定新值
count.set(5);
// 根據目前值更新
count.update(c => c + 1);
// 明確指定型別
const user = signal<User | null>(null);
user.set({ id: 1, name: 'Alice' });
computed() - 衍生狀態
import { signal, computed } from '@angular/core';
const firstName = signal('John');
const lastName = signal('Doe');
// 衍生 signal - 當相依項目變更時自動更新
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // "John Doe"
firstName.set('Jane');
console.log(fullName()); // "Jane Doe"
// 複雜邏輯的 computed
const items = signal<Item[]>([]);
const filter = signal('');
const filteredItems = computed(() => {
const query = filter().toLowerCase();
return items().filter(item =>
item.name.toLowerCase().includes(query)
);
});
const totalPrice = computed(() =>
filteredItems().reduce((sum, item) => sum + item.price, 0)
);
linkedSignal() - 可重置的相依狀態
import { signal, linkedSignal } from '@angular/core';
const options = signal(['A', 'B', 'C']);
// 當 options 變更時,重置為第一個選項
const selected = linkedSignal(() => options()[0]);
console.log(selected()); // "A"
selected.set('B'); // 使用者選取 B
console.log(selected()); // "B"
options.set(['X', 'Y']); // 選項變更
console.log(selected()); // "X" - 自動重置為第一個
// 搭配前一個值存取
const items = signal<Item[]>([]);
const selectedItem = linkedSignal<Item[], Item | null>({
source: () => items(),
computation: (newItems, previous) => {
// 嘗試保留選取項目(若仍存在)
const prevItem = previous?.value;
if (prevItem && newItems.some(i => i.id === prevItem.id)) {
return prevItem;
}
return newItems[0] ?? null;
},
});
effect() - 副作用
import { signal, effect, inject, DestroyRef } from '@angular/core';
@Component({...})
export class Search {
query = signal('');
constructor() {
// 當 query 變更時執行 effect
effect(() => {
console.log('搜尋查詢:', this.query());
});
// 含清理的 effect
effect((onCleanup) => {
const timer = setInterval(() => {
console.log('目前查詢:', this.query());
}, 1000);
onCleanup(() => clearInterval(timer));
});
}
}
Effect 規則:
- 必須在注入上下文(建構子或使用
runInInjectionContext)中執行 - 元件銷毀時自動清理
元件狀態模式
@Component({
selector: 'app-todo-list',
template: `
<input [value]="newTodo()" (input)="newTodo.set($any($event.target).value)" />
<button (click)="addTodo()" [disabled]="!canAdd()">新增</button>
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li [class.done]="todo.done">
{{ todo.text }}
<button (click)="toggleTodo(todo.id)">切換</button>
</li>
}
</ul>
<p>{{ remaining() }} 項待辦</p>
`,
})
export class TodoList {
// 狀態
todos = signal<Todo[]>([]);
newTodo = signal('');
filter = signal<'all' | 'active' | 'done'>('all');
// 衍生狀態
canAdd = computed(() => this.newTodo().trim().length > 0);
filteredTodos = computed(() => {
const todos = this.todos();
switch (this.filter()) {
case 'active': return todos.filter(t => !t.done);
case 'done': return todos.filter(t => t.done);
default: return todos;
}
});
remaining = computed(() =>
this.todos().filter(t => !t.done).length
);
// 動作
addTodo() {
const text = this.newTodo().trim();
if (text) {
this.todos.update(todos => [
...todos,
{ id: crypto.randomUUID(), text, done: false }
]);
this.newTodo.set('');
}
}
toggleTodo(id: string) {
this.todos.update(todos =>
todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
}
}
RxJS 互通
toSignal() - Observable 轉 Signal
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({...})
export class Timer {
private http = inject(HttpClient);
// 從 observable 轉換 - 需要初始值或 allowUndefined
counter = toSignal(interval(1000), { initialValue: 0 });
// 從 HTTP 轉換 - 載入完成前為 undefined
users = toSignal(this.http.get<User[]>('/api/users'));
// 使用 requireSync 處理同步 observable(BehaviorSubject)
private user$ = new BehaviorSubject<User | null>(null);
currentUser = toSignal(this.user$, { requireSync: true });
}
toObservable() - Signal 轉 Observable
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap, debounceTime } from 'rxjs';
@Component({...})
export class Search {
query = signal('');
private http = inject(HttpClient);
// 將 signal 轉為 observable 以使用 RxJS 運算子
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`))
),
{ initialValue: [] }
);
}
Signal 相等性
// 自訂相等函式
const user = signal<User>(
{ id: 1, name: 'Alice' },
{ equal: (a, b) => a.id === b.id }
);
// 僅在 ID 變更時觸發更新
user.set({ id: 1, name: 'Alice Updated' }); // 不觸發更新
user.set({ id: 2, name: 'Bob' }); // 觸發更新
非追蹤讀取
import { untracked } from '@angular/core';
const a = signal(1);
const b = signal(2);
// 僅相依於 'a',而非 'b'
const result = computed(() => {
const aVal = a();
const bVal = untracked(() => b());
return aVal + bVal;
});
服務狀態模式
@Injectable({ providedIn: 'root' })
export class Auth {
// 私有可寫入狀態
private _user = signal<User | null>(null);
private _loading = signal(false);
// 公開唯讀 signal
readonly user = this._user.asReadonly();
readonly loading = this._loading.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null);
private http = inject(HttpClient);
async login(credentials: Credentials): Promise<void> {
this._loading.set(true);
try {
const user = await firstValueFrom(
this.http.post<User>('/api/login', credentials)
);
this._user.set(user);
} finally {
this._loading.set(false);
}
}
logout(): void {
this._user.set(null);
}
}
如需進階模式(包含 resource()),請參閱 references/signal-patterns.md。






