CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-186 · Angular 高级教程

Angular 22+ 教程 21:Signals——响应式编程新范式

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

判断维度computedeffect
产出返回值(只读信号)副作用
执行时机惰性,有人读才算被调度就执行
典型用途派生 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/resourcerequest 输入,把”防抖后的搜索词 → 发请求”串成一条声明式链路(完整示例见本系列第 23 篇)。在实验期 API 形态可能调整,生产使用需关注 changelog。

常见误区

  • 传函数还是传值{{ count }} 渲染不出数字,computed(() => items) 永远返回同一个函数引用。记住:读信号必须调用
  • 在 computed 里做副作用:副作用会被惰性求值”吞掉”——没人读就不执行,且执行次数不可预测。副作用一律放 effect
  • 依赖了整个对象却只用一个字段:默认引用相等 + 结构 equal 二选一。频繁替换大对象时考虑 equal 只比较关心的字段
  • effect 里同步循环写effect(() => count.set(count() + 1)) 会立即触发循环检测错误;状态推进应该交给事件处理函数或 linkedSignal

系列导航

← 算法 021:树:前缀树(Trie Tree) 目录 设计模式 021:备忘录(Memento) →
← 返回文章列表