1. 为什么需要 Signals
传统 Angular 使用 Zone.js 拦截所有异步操作来触发变更检测,这意味着每次异步操作后,Angular 可能会检查整棵组件树,即使大多数组件的数据没有变化。
Signals 引入了细粒度响应式:每个 signal 记录了哪些模板使用了它,当 signal 变化时,只有直接依赖它的部分会更新,效率大幅提升。
Signals 发展历程:Angular 16 引入(开发者预览),Angular 17 稳定化,Angular 18 新增 Signal inputs/outputs API。这是 Angular 有史以来最重要的响应式系统升级。
2. signal() — 可写信号
signal() 创建一个可写的响应式状态容器。读取值时调用它(像函数),修改值时用 .set()、.update() 或 .mutate()。
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>计数:{{ count() }}</p>
<button (click)="increment()">+1</button>
<button (click)="decrement()">-1</button>
<button (click)="reset()">重置</button>
`
})
export class CounterComponent {
count = signal(0); // 创建信号,初始值 0
increment() {
this.count.update(v => v + 1); // 基于当前值更新
}
decrement() {
this.count.update(v => v - 1);
}
reset() {
this.count.set(0); // 直接设置新值
}
}
// 对象 Signal 的更新
const user = signal({ name: 'Alice', age: 25 });
// set:整体替换
user.set({ name: 'Bob', age: 30 });
// update:基于当前值,返回新值
user.update(u => ({ ...u, age: u.age + 1 })); // age: 26
// 读取当前值(同步,无需订阅)
console.log(user().name); // 'Alice'
3. computed() — 派生信号
computed() 根据其他信号的值自动计算派生值。当依赖的信号变化时,computed 会自动重新计算(懒执行,且有缓存)。
import { signal, computed } from '@angular/core';
export class ShoppingCartComponent {
items = signal([
{ id: 1, name: 'Angular 书籍', price: 89, qty: 2 },
{ id: 2, name: 'TypeScript 手册', price: 69, qty: 1 },
]);
discount = signal(0.9); // 九折
// computed:自动跟踪 items 和 discount 的变化
subtotal = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0)
);
total = computed(() =>
Math.round(this.subtotal() * this.discount())
);
itemCount = computed(() =>
this.items().reduce((sum, i) => sum + i.qty, 0)
);
addItem(item: CartItem) {
this.items.update(items => [...items, item]);
// items 变化 → subtotal/total/itemCount 自动重新计算
}
}
4. effect() — 副作用
effect() 在信号变化时执行副作用(如写入 localStorage、调用日志 API、更新外部系统)。它自动追踪内部读取的所有信号,任一变化就重新执行。
import { Component, signal, effect, inject } from '@angular/core';
@Component({ /* ... */ })
export class ThemeComponent {
theme = signal<'light' | 'dark'>('dark');
fontSize = signal(16);
constructor() {
// effect 在构造函数中注册,自动追踪 theme 和 fontSize
effect(() => {
// 每次 theme 或 fontSize 变化时执行
document.body.setAttribute('data-theme', this.theme());
document.documentElement.style.fontSize = `${this.fontSize()}px`;
// 持久化到 localStorage
localStorage.setItem('theme', this.theme());
localStorage.setItem('fontSize', String(this.fontSize()));
});
}
toggleTheme() {
this.theme.update(t => t === 'dark' ? 'light' : 'dark');
}
}
不要在 effect 中修改信号:在 effect 内部修改它追踪的信号会导致无限循环。如果必须修改,在 effect 选项中传入 { allowSignalWrites: true } 并确保有终止条件。
5. 与 RxJS 互转
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { interval, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
export class DataComponent {
// Observable → Signal(自动订阅/取消订阅)
tick = toSignal(interval(1000), { initialValue: 0 });
// 将 HTTP 请求结果转为 Signal
users = toSignal(this.userService.getUsers(), {
initialValue: [] as User[]
});
// Signal → Observable(用于需要 RxJS 操作符的场景)
selectedId = signal(1);
// 将 selectedId Signal 转为 Observable,再用 switchMap 请求数据
selectedUser = toSignal(
toObservable(this.selectedId).pipe(
switchMap(id => this.userService.getUser(id))
),
{ initialValue: null }
);
}
6. NgRx SignalStore
@ngrx/signals 提供了基于 Signals 的轻量级状态管理方案 SignalStore,无需 Actions/Reducers/Effects 的繁琐模版,适合中小规模状态。
// npm install @ngrx/signals
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed } from '@angular/core';
type TodoState = {
todos: Todo[];
filter: 'all' | 'active' | 'done';
loading: boolean;
};
export const TodoStore = signalStore(
{ providedIn: 'root' },
withState<TodoState>({
todos: [],
filter: 'all',
loading: false
}),
withComputed(({ todos, filter }) => ({
filteredTodos: computed(() => {
const all = todos();
return filter() === 'all' ? all
: filter() === 'active' ? all.filter(t => !t.done)
: all.filter(t => t.done);
}),
doneCount: computed(() => todos().filter(t => t.done).length)
})),
withMethods((store, todoService = inject(TodoService)) => ({
addTodo(title: string) {
patchState(store, state => ({
todos: [...state.todos, { id: Date.now(), title, done: false }]
}));
},
async loadTodos() {
patchState(store, { loading: true });
const todos = await firstValueFrom(todoService.getTodos());
patchState(store, { todos, loading: false });
}
}))
);
// 在组件中使用 SignalStore
@Component({ /* ... */ })
export class TodoListComponent {
protected store = inject(TodoStore);
// 直接在模板中用:store.filteredTodos()、store.doneCount()
// 调用方法:store.addTodo('新任务')
}
7. 何时用 Signals vs RxJS
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 组件本地状态 | Signals | 更简单,无需 subscribe |
| 派生/计算状态 | computed() | 自动缓存,惰性求值 |
| 副作用(持久化、日志) | effect() | 响应式执行 |
| HTTP 请求 | HttpClient Observable → toSignal | 复杂操作符链 |
| 防抖/节流搜索 | RxJS(debounceTime + switchMap) | Signals 无内置时间操作 |
| WebSocket 流 | RxJS | 真实的事件流 |
| 跨组件状态(小规模) | SignalStore 或 inject() Service + signal | 简单直接 |
| 跨组件状态(大规模) | NgRx Store(经典 Redux) | 可追踪、DevTools 支持 |
本章小结:Signals 是 Angular 响应式系统的未来,为精细化变更检测奠定基础。signal/computed/effect 三件套处理同步状态,toSignal/toObservable 桥接 RxJS。新项目应优先使用 Signals 处理组件状态。