CHARLIE SAYS

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

Angular 22+ 教程 22:Signals 与 RxJS 的共存之道

“Signals 会不会取代 RxJS?“这是 Angular 社区被问得最多的问题之一。到 v22,答案已经很清晰:不会取代,而是分工。Signals 管”状态现在是什么”,RxJS 管”事件随时间怎么流”。两者各守本职,加上官方提供的互操作工具,就是现代 Angular 的响应式全貌。

各自的定位:状态与事件流

状态和事件是两种不同的问题:

  • 状态(State):任何时刻都有一个”当前值”,关心的是读和写,重复读取同一时刻的值应该得到相同结果。Signals 的记忆化、equal 截断、惰性求值都是为这设计的
  • 事件流(Events):一系列瞬时发生的动作,“上一个值”和”下一个值”之间没有必然关系,关心的是组合、变换、节流、取消。RxJS 的操作符体系(switchMapmergedebounceTime)是几十年沉淀的流处理工具箱

用 RxJS 管状态的问题:BehaviorSubject 只有当前值语义,每次 next 都会执行所有订阅者的回调,没有惰性求值,也没有相等性截断,派生状态要靠 combineLatest 手工拼装。用 Signals 管事件流的问题:信号本质是”变量”,没有背压、没有完成语义、没有操作符,硬模拟流只会写出扭曲的 effect。

一句话总结:UI 状态和服务端数据的”当前快照”交给 Signals 体系(signal/computed/resource),随时间发生的连续事件交给 RxJS

toSignal():把流收进状态

toSignal() 把 Observable 转成只读信号,来自 @angular/core/rxjs-interop

import { toSignal } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';

@Component({...})
export class ToolbarComponent {
  private readonly router = inject(Router);

  // 路由事件流 → "当前 URL"状态
  readonly currentUrl = toSignal(
    this.router.events.pipe(filter((e) => e instanceof NavigationEnd)),
    { initialValue: '' },
  );
}

要点:

  • 必须提供初始值。除非源是 BehaviorSubject 这类同步吐值的流,否则要传 initialValue,信号类型会变成 T | undefined 与初始值联合
  • 立即订阅,随注入器销毁自动退订。它在字段初始化时(注入上下文中)就完成了订阅,因此不需要手动 unsubscribe
  • 注入上下文要求。这是最大的坑,见下文

toObservable():把状态暴露成流

toObservable() 反向桥接,把信号转成 Observable,常用于和 RxJS 生态的库对接:

import { toObservable } from '@angular/core/rxjs-interop';

export class SearchComponent {
  readonly query = signal('');

  // 供 RxJS 生态消费的流
  readonly query$ = toObservable(this.query);
}

两个容易踩的坑:

  • 惰性且每个订阅独立toObservable 直到有人订阅才在注入器里创建一个内部 effect;每个订阅者各自拿到一份”初始快照 + 后续变化”的序列,两个订阅者看到的初始值可能因为订阅时机不同而不同
  • 只有变化才发值。初始快照发出后,写一个 equal 相等的值不会触发下一个 emission——这正是不想要的通知,但如果你依赖”每次都收到”,就会觉得”丢了事件”,此时你需要的其实是事件流而不是状态

rxResource():用 RxJS loader 加载资源

如果数据加载逻辑已经是 Observable(比如老服务返回 http.get),不必先 toSignal 再包一层——rxResource() 直接以 RxJS 作为 loader,且自动管理订阅生命周期:

import { rxResource } from '@angular/core/rxjs-interop';

export class UsersComponent {
  readonly page = signal(1);

  readonly usersResource = rxResource({
    request: () => ({ page: this.page() }),
    loader: ({ request }) =>
      this.usersApi.list({ page: request.page }), // 返回 Observable<User[]>
  });
}

request 变化时,框架会自动退订上一个 loader 的订阅再启动新的——switchMap 的取消语义被内置了。资源状态通过 usersResource.value()isLoading()error() 读取,与 resource()/httpResource() 完全一致(详见第 23 篇)。

互转的坑:注入上下文

toSignal/toObservable/rxResource 内部都要创建 effect,因此都要求在注入上下文中调用:

  • 组件/指令/服务的构造函数或字段初始化器中——OK
  • ngOnInit、事件回调、普通函数、setTimeout 里——报错 NG0203: effect() can only be used within an injection context

脱离上下文时必须显式传入注入器:

export class ReportService {
  private readonly injector = inject(Injector);

  buildReport(source$: Observable<ReportData>): Signal<ReportData | undefined> {
    // 在任意位置创建,显式指定注入器
    return toSignal(source$, { injector: this.injector });
  }
}

另一个坑是双向冗余包装:看到 toSignal(obs$) 之后又 toObservable(signal),或者对 toSignal 的结果再套 async 管道。每次桥接都多一层调度和快照复制,多数时候说明架构没想清楚该用哪个范式。

什么场景仍然需要 RxJS

即使 Signals 全线成为默认,以下场景 RxJS 依然是正确答案:

WebSocket 与长连接

连接生命周期(开、关、重连、错误)天然是流,消息序列没有”当前值”语义:

export class TradeFeedService {
  private readonly socket$ = webSocket<{ symbol: string; price: number }>(
    'wss://feed.example.com/trades',
  );

  // 多路复用 + 指数退避重连 + 超时,全是 RxJS 的强项
  readonly quotes = toSignal(
    this.socket$.pipe(
      timeout(30_000),
      retry({ delay: (err, n) => timer(Math.min(1000 * 2 ** n, 15_000)) }),
    ),
    { initialValue: [] },
  );
}

复杂流操作

多事件源合并、按时间窗聚合、拖拽坐标流组合、防抖加锁序(先 A 后 B)这类时间维度的编排,操作符仍然是表达力最强的工具。Signals 没有能力也不打算覆盖这类需求。

需要显式取消与完成语义

Observable 的 unsubscribe/complete 是契约的一部分。例如上传进度条:取消上传是业务动作,用 Subscription 表达比用信号状态表达更直接。

决策表

场景推荐方案原因
本地 UI 状态(开关、选中项)signal同步读写、开销最小
派生状态(过滤、合计)computed惰性求值 + equal 截断
服务端数据加载resource / httpResource内建状态机、自动取消、SSR 友好
已有 Observable 数据服务rxResource复用代码、保留取消语义
模板消费流toSignal替代 async 管道,Zoneless 友好
信号交给 RxJS 生态toObservable对接动画库、图表库等
WebSocket / SSE / 长连接RxJS webSocket + toSignal生命周期管理是流问题
多事件源时间编排RxJS 操作符Signals 无时间操作能力
搜索防抖请求debounced() + httpResource声明式,见第 23 篇

一次重构对照

老写法(v15 时代的经典搜索组件):

export class SearchComponent {
  private readonly query = new FormControl('');

  results$ = this.query.valueChanges.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((q) => this.api.search(q ?? '')),
    share(),
  );
}

v22 写法:

export class SearchComponent {
  readonly query = signal('');
  private readonly debouncedQuery = debounced(this.query, { delay: 300 }); // 实验性

  readonly results = httpResource<SearchResult[]>(() => {
    const q = this.debouncedQuery.value();
    return q === '' ? undefined : `/api/search?q=${encodeURIComponent(q)}`;
  });
}

两段代码能力等价,但后者没有手动管理订阅,错误与加载状态直接从 results 读取,模板绑定 results.value()results.isLoading() 即可。这正是”状态归信号,管道交给框架”的收益。当然,如果搜索逻辑还要和”选中的过滤器流”做复杂时间合并,保留 RxJS 版本毫无问题。

系列导航

← 算法 022:图:基础和Overview 目录 设计模式 022:观察者(Observer) →
← 返回文章列表