“Signals 会不会取代 RxJS?“这是 Angular 社区被问得最多的问题之一。到 v22,答案已经很清晰:不会取代,而是分工。Signals 管”状态现在是什么”,RxJS 管”事件随时间怎么流”。两者各守本职,加上官方提供的互操作工具,就是现代 Angular 的响应式全貌。
各自的定位:状态与事件流
状态和事件是两种不同的问题:
- 状态(State):任何时刻都有一个”当前值”,关心的是读和写,重复读取同一时刻的值应该得到相同结果。Signals 的记忆化、
equal截断、惰性求值都是为这设计的 - 事件流(Events):一系列瞬时发生的动作,“上一个值”和”下一个值”之间没有必然关系,关心的是组合、变换、节流、取消。RxJS 的操作符体系(
switchMap、merge、debounceTime)是几十年沉淀的流处理工具箱
用 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 版本毫无问题。