Signals 是 Angular 近几个版本最重要的架构演进:它把”值”和”谁依赖这个值”变成框架可以追踪的显式关系。到 v22,Signals 已经是整个框架的地基——模板、Signal Forms、resource、Router 输入绑定都建立在它之上。本文系统讲解 Signals 的核心 API 与底层模型。
从”全量检查”到”精确通知”
Zone.js 时代的变更检测解决的是”什么时候可能有变化”,但不知道”哪里变了”,只能从根组件向下检查整棵树。Signals 反过来:值被包进一个可追踪的容器,模板里每读取一次信号,Angular 就记录一条”该组件依赖该信号”的边。写入信号时,框架顺着这条边精确标记受影响的组件。
这也是 Zoneless 能在 v21 走向稳定的前提:没有 Zone 兜底之后,变更检测的触发完全依赖 Signals 的通知机制。v22 中组件默认使用 OnPush 策略(原来的 Default 策略更名为 Eager,仅作为显式选择的兜底保留),新应用脚手架默认 Zoneless。
signal():创建可写信号
signal() 创建一个可写信号,读写都要通过调用它返回的函数:
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>当前计数:{{ count() }}</p>
<button (click)="increment()">+1</button>
<button (click)="reset()">重置</button>
`,
})
export class CounterComponent {
count = signal(0);
increment(): void {
this.count.update((value) => value + 1);
}
reset(): void {
this.count.set(0);
}
}
可写信号有三个核心 API:
set(value):直接替换值update(fn):基于旧值计算新值asReadonly():返回只读视图,适合暴露给外部使用,防止别人改你的状态
注意模板中必须写 count() 而不是 count——信号本身是个函数,调用才会读取当前值并建立依赖追踪。
computed():派生只读状态
computed() 定义从其他信号派生而来的只读状态:
const items = signal<Item[]>([]);
const filter = signal<'all' | 'active' | 'done'>('all');
const visibleItems = computed(() => {
switch (filter()) {
case 'active':
return items().filter((i) => !i.done);
case 'done':
return items().filter((i) => i.done);
default:
return items();
}
});
const remaining = computed(() => items().filter((i) => !i.done).length);
computed 有两个关键特性:
- 惰性求值:没有人读取
remaining()时,它不会重新计算 - 记忆化:依赖没变时重复读取直接返回缓存值;即使依赖变了,若计算结果
equal相等,下游也不会被通知
依赖是调用时动态收集的:filter() 分支走不到的信号,这次计算就不构成依赖。写 computed 时要保证它是纯函数——不要在里面发请求、打日志、改状态。
equal:相等性比较
每个信号创建时都可以指定自定义相等比较,默认使用 Object.is:
interface Point {
x: number;
y: number;
}
const position = signal<Point>({ x: 0, y: 0 }, {
equal: (a, b) => a.x === b.x && a.y === b.y,
});
position.set({ x: 0, y: 0 }); // 与当前值"相等",传播终止,不触发任何渲染
相等比较是 Signals 性能优化的第一道闸门:新值与旧值判定相等时,整条依赖链都会被截断。两点实践建议:
- 对”内容语义”的值(坐标、范围、集合)提供结构化
equal,避免换引用导致的无效更新 equal是可写信号的一个属性,运行期也能整体替换比较函数:
position.equal = (a, b) => Math.abs(a.x - b.x) < 0.01 && Math.abs(a.y - b.y) < 0.01;
effect():把状态同步到外部世界
effect() 用来执行副作用:把信号变化同步到 console、localStorage、图表库等不认识信号的世界:
@Injectable()
export class ThemeService {
private readonly theme = signal<'light' | 'dark'>('light');
constructor() {
effect(() => {
localStorage.setItem('theme', this.theme());
});
}
}
几个必须知道的规则:
- 需要注入上下文。
effect()默认在构造函数、字段初始化器等注入上下文中调用;脱离上下文时要显式传注入器:effect(fn, { injector: rootInjector }) - 批量合并。同一个同步代码块里多次写信号,effect 只执行一次
- 调度时机。effect 回调由 Angular 在变更检测的调度周期执行,不保证写入后同步执行
- 允许写信号,但警惕循环。在 effect 中写信号是合法的,但如果形成”写→触发→写”的环,会抛出循环检测错误
effect 的清理函数
effect 每次执行前,可以注册一个清理函数,在下次执行前或 effect 被销毁时调用。这是对接命令式 API 的正确姿势:
effect((onCleanup) => {
const controller = new AbortController();
const term = this.query();
fetch(`/api/suggest?q=${encodeURIComponent(term)}`, { signal: controller.signal })
.then((res) => res.json())
.then((data) => this.suggestions.set(data));
// query 再次变化时,中止上一次未完成的请求
onCleanup(() => controller.abort());
});
effect 与它所在注入器绑定生命周期,组件销毁时自动清理,不需要手动注销。
effect 还是 computed
| 判断维度 | computed | effect |
|---|---|---|
| 产出 | 返回值(只读信号) | 副作用 |
| 执行时机 | 惰性,有人读才算 | 被调度就执行 |
| 典型用途 | 派生 UI 状态 | 日志、持久化、桥接非响应式库 |
| 纯度要求 | 必须纯函数 | 允许副作用 |
经验法则:能用 computed 表达的绝不用 effect,副作用才会落到 effect。
linkedSignal():可变的”链接”状态
linkedSignal() 表示”由某个源派生、但允许本地修改”的状态。源变化时它会重算;源不变时,本地修改得以保留。典型场景是编辑草稿:
export class UserEditor {
private readonly store = inject(UserStore);
// 源:当前选中的用户 id
readonly selectedId = signal<number | null>(null);
// 链接状态:选中项的"可编辑草稿"
readonly draft = linkedSignal<{ id: number; name: string; email: string } | null>({
source: this.selectedId,
computation: (id, previous) => {
if (id === null) {
return null;
}
// 源没变:保留用户在表单里的本地修改
if (previous && previous.source === id) {
return previous.value;
}
// 源变了:从服务端数据重置草稿
return this.store.findById(id);
},
});
}
computation 的第二个参数携带 { source, value } 形式的历史快照,用于判断”这次重算是不是因为源变了”。返回值是可写信号——草稿可以被 draft.update(...) 直接修改,等下一次 selectedId 切换时自动重置。它还有一个简写形式 linkedSignal(fn),函数内追踪的信号即视为源。
信号如何驱动 Zoneless 变更检测
Signals、computed、effect 一起构成一张生产者-消费者图(producer/consumer graph)。理解这张图,就能预测任何更新的传播路径:
graph TD
W[写入信号 set 或 update] --> E{equal 比较}
E -- 相等 --> STOP[传播终止]
E -- 不相等 --> D[消费者标记为 dirty]
D --> C[computed 惰性重算 - 读时生效]
D --> T[模板依赖的组件标记 dirty]
D --> F[effect 进入调度队列]
T --> S[Zoneless 调度器安排一次变更检测]
S --> CD[只访问 dirty 组件的模板]
F --> FE[调度周期内执行 effect 回调]
在 Zoneless 模式下,模板里读取信号的那一刻,组件就成为该信号的消费者;写入信号会让调度器安排一次变更检测,检测时只重新执行被标记 dirty 的模板函数。不再需要 Zone 补丁去猜测”哪里可能变了”,也不再需要 ChangeDetectorRef.markForCheck() 这类手动通知——事实上在 Zoneless 下这些老 API 已经没有意义。
同理,OnPush(v22 起为默认值)的”输入引用变化或事件触发才检查”的旧心智模型也应升级为:谁读了这个信号,谁就会被检查。
实验性:debounced()
搜索框是典型的防抖场景。v22 提供了实验性的 debounced() 函数,可以对任意信号做防抖:
import { signal, debounced } from '@angular/core'; // debounced 目前为实验性 API
const query = signal('');
const debouncedQuery = debounced(query, { delay: 300 });
// 注意:debouncedQuery 是 Resource 而不是信号
// 通过 value() / isLoading() 访问状态
effect(() => {
console.log(debouncedQuery.value(), debouncedQuery.isLoading());
});
它返回的是与 resource() 一致的 Resource 接口而不是普通信号,因此天然能作为 httpResource/resource 的 request 输入,把”防抖后的搜索词 → 发请求”串成一条声明式链路(完整示例见本系列第 23 篇)。在实验期 API 形态可能调整,生产使用需关注 changelog。
常见误区
- 传函数还是传值:
{{ count }}渲染不出数字,computed(() => items)永远返回同一个函数引用。记住:读信号必须调用 - 在 computed 里做副作用:副作用会被惰性求值”吞掉”——没人读就不执行,且执行次数不可预测。副作用一律放 effect
- 依赖了整个对象却只用一个字段:默认引用相等 + 结构
equal二选一。频繁替换大对象时考虑equal只比较关心的字段 - effect 里同步循环写:
effect(() => count.set(count() + 1))会立即触发循环检测错误;状态推进应该交给事件处理函数或linkedSignal