FORMA

Signal 基础

Signals 是 Angular 16 引入的响应式原语:可读写的 signal、派生的 computed、副作用 effect。用于组件内状态与跨组件细粒度更新,见 变更检测

核心概念

Signal 是一个包裹了值的容器,读取时要像函数一样调用(count()),而不是像普通变量那样直接访问。这个「调用」的动作让 Angular 能够追踪「谁读取了这个 Signal」,从而在值变化时只通知真正依赖它的地方(模板绑定、computedeffect),而不必对整棵组件树做脏检查。这是 Signal 相比传统 Zone.js 变更检测更「精细」的根本原因。

signal 与 computed

ts
import { signal, computed } from "@angular/core";

const count = signal(0);
const double = computed(() => count() * 2);

count.set(3);
count.update((n) => n + 1);
  • 在模板或类中通过 count() 读取(函数调用语法)。
  • 写入使用 .set / .update不要直接修改 signal 返回的对象引用(对象类型应替换不可变副本)。
  • computed惰性且带缓存的:只有在依赖的 signal 变化后,下次读取才会重新计算;多次连续读取而依赖未变时直接返回缓存值。

对象与数组类型的更新

ts
interface Todo { id: string; text: string; done: boolean }

const todos = signal<Todo[]>([]);

// 错误:原地修改,Angular 无法感知变化
// todos()[0].done = true;

// 正确:产生新数组/新对象
todos.update((list) =>
  list.map((t) => (t.id === targetId ? { ...t, done: true } : t))
);

// 新增
todos.update((list) => [...list, { id: crypto.randomUUID(), text: "新任务", done: false }]);

effect

ts
import { effect } from "@angular/core";

effect(() => {
  console.log("count =", count());
});

effect 在依赖的 signal 变化后运行;Angular 19 起已移除 allowSignalWrites 选项,effect 内部默认即可写 signal,无需额外配置。仍需避免在 effect 中产生无法追踪的副作用循环(如在 effect 内部又 set 了自己读取的同一个 signal,形成死循环)——出现这种需求时,通常说明该逻辑更适合用 computed/linkedSignal 表达。

ts
@Component({ standalone: true, template: `` })
export class SyncTitleComponent {
  title = signal("首页");

  constructor() {
    effect(() => {
      document.title = `${this.title()} · MyApp`; // 典型用途:同步到浏览器 API
    });
  }
}

effect 更适合「同步到外部世界」(DOM 标题、localStorage、第三方库实例),业务派生数据优先用 computed,避免 effect 滥用成隐式的数据流。

与 Zone 变更检测

Signal 更新可参与 Angular 的 signal-based 变更检测路径,减少不必要的全树检查(与 OnPush 等配合)。默认应用仍加载 Zone.js;是否启用 zoneless 以项目 angular.json官方实验文档为准。

input 作为 signal

ts
title = input.required<string>();
subtitle = input<string>("默认副标题"); // 带默认值的可选输入
// 模板:{{ title() }}

input() 返回的是 Signal,与 @Input() 装饰器相比类型推断更好(Angular 17.1+)。可以直接在 computed 中组合多个 input:

ts
@Component({
  standalone: true,
  template: `<h2>{{ heading() }}</h2>`,
})
export class HeaderComponent {
  title = input.required<string>();
  count = input(0);
  heading = computed(() => `${this.title()}${this.count()}`);
}

model:双向绑定的 Signal(Angular 17.2+)

ts
@Component({
  selector: "app-toggle",
  standalone: true,
  template: `<button (click)="checked.set(!checked())">{{ checked() ? '开' : '关' }}</button>`,
})
export class ToggleComponent {
  checked = model(false); // 支持 [(checked)]="parentValue" 双向绑定
}

model() 生成的 Signal 既可读也可写,且父组件用 [(checked)] 语法即可完成双向绑定,替代了旧版 @Input() + @Output() 手写 xxxChange 事件的模式。

最佳实践

  • 派生值优先用 computed,不要用 effect 去「模拟」派生(会导致多一次渲染周期、更难追踪数据流)。
  • 服务中的可变状态保持 private,只导出 asReadonly()computed(见 状态管理)。
  • 组件的 @Input() 逐步迁移为 input(),尤其是新写的 Standalone 组件,类型推断与只读语义更清晰。
  • effect 中若要写其它 signal,明确评估是否会形成循环依赖(Angular 19+ 已默认允许 effect 内写 signal,不再需要 allowSignalWrites)。
  • 避免在模板里直接写复杂表达式,抽成 computed,便于测试与复用。

常见坑

现象常见原因处理
视图没更新原地修改了对象/数组,signal 引用未变使用 update 产生新引用
computed 报错「无法在渲染期间写入」computed 函数体内调用了 .set()computed 应是纯函数,只读不写
effect 无限触发effect 内 set 了自己也读取的 signal拆分读/写依赖,或改用 computed
忘记调用函数直接把 signal 传给模板判断写成 @if (count) 而非 @if (count())signal 必须调用才能取值
SSR 下 effect 报错或未执行effect 默认与视图注入上下文绑定,SSR/测试环境上下文不同参考官方 SSR/Testing 章节调整调用位置

延伸阅读

参考文献

以下链接在编写时均可正常访问:

资料说明
Angular Signals 指南官方
computedAPI
effectAPI
input()Signal 输入
model()双向绑定 Signal

Series

signals

1 / 2