Chapter 08

Signals 现代状态管理

Angular 的响应式革命:精细化变更检测,告别 Zone.js 全量检查

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 处理组件状态。