从 zone.js 到 signal:把异步请求写成响应式
这篇写给两类人:刚接触 Angular、想搞明白 signal 到底是什么的新人;以及在 zone.js 时代的 Angular(v13~v16 之类)里写了多年
@Input+ngOnChanges+subscribe赋值,最近升级发现世界变了的老兵。文章从「一个会撒谎的计数器」讲起,一路推进到用rxResource把一个 HTTP 请求变成 signal——所有代码都在 Angular 22.1 下实际跑过,版本差异在第九节集中列了速查表。
1. 一个会撒谎的计数器
先看一段代码。如果你写过老版本 Angular,应该觉得毫无问题:
@Component({
selector: 'app-counter',
template: `
<h1>{{ counter }}</h1>
<button (click)="add()">Add</button>
`,
})
export class CounterComponent {
counter = 0;
add() {
this.counter = this.counter + 1;
}
}
普通字段、普通赋值、普通绑定。点击按钮,数字加一,界面更新——一切正常。
但如果你把这个赋值挪个地方,事情就变了:
addLater() {
setTimeout(() => this.counter++, 1000); // 1 秒后数字变了,但界面不动
}
界面不更新了。要等到下一次随便什么点击、随便什么事件,它才「顺带」跳出来。
这个现象在老 Angular 里不存在。为什么?因为老 Angular 的界面更新根本不是绑定自己干的,而是 zone.js 兜底干的。zone.js 的工作方式是把浏览器里几乎所有异步 API(setTimeout、XHR、addEventListener……)全部打上补丁——任何异步任务开始和结束,它都看在眼里,然后喊一嗓子:「有变化发生了,Angular 你把所有组件全部检查一遍!」
所以老世界里 this.counter++ 能更新界面,靠的不是 Angular 的绑定机制,而是 zone.js 在 setTimeout 结束后喊的那一嗓子。绑定本身从始至终都是同一套逻辑:变更检测跑到这个组件时,把 {{ counter }} 重新求值一遍,和上次的值做对比,有变化才写 DOM。绑定从来没有「值一变就自动更新」的能力——那一直是 zone.js 在替所有人跑腿。
zone.js 方案的问题是粗放:不管哪个组件变了,全应用所有组件都检查一遍。应用大了以后,每一声「有变化」都是一次全量体检,性能账越欠越多。于是 Angular 的答案分两步:先把「精确知道谁变了」的机制建好(signal),再把兜底的中间商裁掉(zoneless,v21 起默认)。
2. signal:会记账的变量
signal 就是一个会记账的变量容器。它解决的问题是:变量被改了之后,「谁需要知道」这件事不再靠 zone.js 广播,而是精确记账。
import { signal } from '@angular/core';
const counter = signal(0);
counter(); // 读:0 —— 像调函数一样
counter.set(1); // 写:直接设
counter.update(c => c + 1); // 写:基于当前值算新值
「记账」发生在读取的那一刻:任何一段响应式代码(组件模板、computed、effect)读了 counter(),signal 就把这段代码记到自己的账本上;之后 set/update 一执行,账本上的人精准收到通知。
两个使用细节,都是从旧习惯迁移时最容易踩的:
① 相等比较是 Object.is,原地改内部不生效。 Object.is 比较的是引用,所以:
const list = signal<string[]>(['a']);
list().push('b'); // ❌ 数组内部变了,但引用没变——没人收到通知
list.update(arr => [...arr, 'b']); // ✅ 造出新数组,引用变了——通知发出
写 signal 的心智口诀就一句:更新必造新引用。这条纪律替代了旧世界里 ngOnChanges 的「引用比较才触发」的老知识——其实是一回事,只是现在每个 signal 都这么做。
② mutate 已经没了。 老教程里可能见过 list.mutate(arr => arr.push('b')),这个 API 在新版里已移除。别找了,update 造新引用就是正路。
3. computed:派生值,不是方法
有了 signal,第二个反应通常是写个方法拼展示值:
// ❌ 能用,但每次变更检测都会重跑,且没有缓存
fullName() {
return `${this.firstName} ${this.lastName}`;
}
正确工具是 computed——一个从其他 signal 派生出来的、自动维护的只读 signal:
import { computed, signal } from '@angular/core';
const firstName = signal('Ada');
const lastName = signal('Lovelace');
const fullName = computed(() => `${firstName()} ${lastName()}`);
fullName(); // 'Ada Lovelace'
lastName.set('Byron');
fullName(); // 'Ada Byron' —— 依赖变了,重算
computed 有三条性质,理解了它们,用起来才敢放心:
- 懒:写入
lastName不会立刻重算,只是把fullName标成「脏」;等真正有人读它才重算。没人读,就永远不跑; - 带缓存:算完存住结果;重算出来和旧值相等(
Object.is)时,直接沿用旧值、不再向下游传播。这相当于一层天然的「无变化不刷新」闸门; - 无毛刺:多个 signal 同时变,下游无论从几条路径读到它们,只重算一次。
第三条性质反过来规定了纪律:computed 里必须只做纯计算——不写别的 signal、不发请求、不碰 DOM。因为它的执行次数不可预测(可能被跳过、可能压根没人读),副作用写进去会随机丢失。副作用的正确去处是下一节的 effect。
模板里同理:{{ fullName() }} 走的是缓存的派生值,而 {{ fullName() }} 换成 {{ getFullName() }} 这种组件方法,每次变更检测都要裸跑一遍。派生一律进 computed,别在模板里调方法——这条在新旧世界都成立,只是 signal 时代有了正解。
4. effect:副作用的正确去处
「counter 变了,我要顺便干点别的」——更新文档标题、写 localStorage、给后端上报。旧世界的惯性是塞进 ngOnChanges 或者 ngOnChanges 的替代品里到处判断,signal 时代统一收敛到一个原语:
import { effect, signal } from '@angular/core';
constructor() {
effect(() => {
console.log('counter 变成了', counter());
});
}
关键性质:
- 时机:不在创建处同步执行,而是排在本次变更检测的 effect 阶段。初次必跑一次,之后依赖的 signal 变了再跑;
- 自动记账:函数体里读了哪些 signal,哪些就是它的依赖——和
computed同一套机制; - 自动清理:在组件里创建的 effect 随组件销毁自动清理,不用手动退订;
- v22 起 effect 里写 signal 默认放行(老版本的
allowSignalWrites开关已废弃),但要克制:effect 写 signal 容易连成环,想清楚再写。
一个真实项目里的组合拳——本站文章详情页的复制代码按钮,需要在「文章切换后、DOM 渲染完成后」重新注入按钮:
effect(() => {
this.content(); // 响应式部分:文章变了 → 触发
afterNextRender(() => this.injectCopyButtons(), { injector: this.injector });
});
effect 回答「什么时候该做」(依赖变化即触发),afterNextRender 回答「什么时候能做」(真实 DOM 就绪、且只在浏览器端跑,SSR/预渲染安全)。effect 里直接摸 DOM 是常见错误——跑的时候 DOM 还没更新,服务端更是根本没有 DOM。这两个 API 各管一段,是标准组合。
5. 组件 API 全家桶:输入输出本来就是 signal
到这一步你会发现,signal 不只是状态管理工具,它把组件的对外接口也重写了一遍。对照表:
| 旧写法(装饰器) | 新写法(函数) | 说明 |
|---|---|---|
@Input() title = '' |
title = input('') |
只读 signal,父组件 [title]="x" 绑定 |
@Input({required: true}) |
title = input.required<string>() |
不绑定直接运行时报错,类型不含 undefined |
@Output() change = new EventEmitter<string>() |
change = output<string>() |
事件都叫 .emit(v),用法几乎没变,只是不再需要 EventEmitter |
@Input() + @Output() 成对写双向 |
value = model(0) |
可写的输入,父组件 [(value)]="x",组件内可直接 set |
@ViewChild('el') + static 判断 |
el = viewChild.required<ElementRef>('el') |
signal 查询,模板就绪后才有值 |
最值得关注的是 model():它是「组件内可以写的输入」,set/update 之后自动向父组件发事件,双向绑定的样板代码全部消失。
一个高频迁移坑在这里先点名:
// ❌ 把输入的值「落袋为安」拷进普通字段——此后输入再变,这个字段永远是旧值
constructor() {
this.myTitle = this.title();
}
// ✅ 永远在响应式上下文里现读:模板 {{ title() }}、computed、effect、事件回调里
旧世界的习惯是「变更到达时快照一份」,新世界里这么做等于亲手剪断响应式链条。输入是 signal,就永远读它、别存它。
6. zone.js 退休之后:什么还能用,什么不能了
现在把第一节的伏笔收掉。新 Angular 默认没有 zone.js,那么「谁触发界面更新」?官方文档列出的调度源只有这几条:
- 模板或 host 上绑定的监听器被触发(
(click)="add()"这类); - 更新了模板读过的 signal;
ChangeDetectorRef.markForCheck()(async管道内部就是干这个的);ComponentRef.setInput(路由参数经withComponentInputBinding绑进input()走的就是它);- 视图的挂载/移除。
对照着检查你手里的旧代码,结论是一张两列的表:
| 场景 | zone.js 世界 | zoneless 世界 |
|---|---|---|
| 点击回调里改普通字段 | ✅(zone 兜底) | ✅(监听器本身就是调度源) |
setTimeout 里改普通字段 |
✅(zone 盯着定时器) | ❌ 没有任何调度源,界面停在旧值 |
| HTTP 订阅回调里改普通字段 | ✅ | ❌ 同上 |
| 任何地方写 signal(模板读过) | ✅ | ✅ signal 写入自己就是调度源 |
RxJS 数据经 async 管道进模板 |
✅ | ✅ 管道发值时内部 markForCheck |
看清楚这张表你会发现:zoneless 真正杀死的是「随手改个字段界面就会跟着动」的预期,普通绑定本身一个都没失效。而 signal 化的状态在新旧两个世界里行为完全一致——这就是为什么迁移的落点非常简单:
组件里一切会被异步改动的状态,一律 signal 化。 RxJS 流进组件只剩三条合法通道:路由 resolver →
input()、模板里的async管道、以及下一节的rxResource。
顺便说一句:RxJS 没有被淘汰,也不需要淘汰。事件流、组合、防抖、重试这些流式逻辑依然是它的主场。signal 接管的是「状态」这一段,不是「流」这一段。
7. 异步数据的老三样,和它们没解决完的事
进入正题前,先诚实地看一遍旧世界拉数据的几种姿势和各自的账单。
姿势一:subscribe + 赋值。
users: User[] = [];
loading = false;
loadUsers(keyword: string) {
this.loading = true;
this.http.get<User[]>(`/api/users?q=${keyword}`).subscribe({
next: (users) => {
this.users = users;
this.loading = false;
},
error: () => { this.loading = false; /* 错误处理呢? */ },
});
}
问题清单:loading/error 每个字段手写一遍;搜索框连打时慢请求覆盖快请求(竞态)没人管;组件销毁要手动退订;在 zoneless 下还犯了第六节的戒律(订阅回调里赋值,界面不刷)。
姿势二:async 管道。 解决了退订和 zoneless 调度(内部 markForCheck),但一个请求要同时展示 loading、error、数据三个状态时,管道就不够用了——你需要在服务层把 Observable 加工成 {value, loading, error} 的组合流,样板代码只是换了地方。
这些痛点的共同根源:「异步请求」和「组件状态」之间缺一个标准件。我们要的其实是——
一个「异步的 computed」:依赖的 signal 变了就自动重新请求,自带 loading/error 状态,自动取消过期的请求,组件销毁自动清理。
这就是 rxResource。
8. rxResource:把请求变成 signal
先看完整例子——一个按关键词搜用户的组件,注意一共只有这一个声明:
import { Component, computed, inject, input } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-user-search',
template: `
@if (results.isLoading()) {
<p>搜索中…</p>
} @else if (results.error(); as err) {
<p>搜索失败:{{ err.message }}</p>
} @else if (results.hasValue(); as data) {
<ul>
@for (u of data; track u.id) {
<li>{{ u.name }}</li>
}
</ul>
}
`,
})
export class UserSearch {
private readonly http = inject(HttpClient);
/** 搜索关键词(比如来自输入框的 signal 或路由的 input()) */
readonly keyword = input.required<string>();
readonly results = rxResource({
// ① params:要什么请求。读过的 signal 自动成为依赖
params: () => this.keyword(),
// ② stream:怎么取。keyword 变化 → 自动取消旧请求 → 重新执行
stream: ({ params: kw }) =>
this.http.get<User[]>(`/api/users?q=${encodeURIComponent(kw)}`),
});
}
心智模型就两个词:params 声明「要什么」,stream 声明「怎么取」。剩下的一切——重新请求、取消过期请求、loading/error 状态、销毁清理——框架接手。
拿到手的对象上有什么
results 是一个 ResourceRef,常用心智:
| 成员 | 说明 |
|---|---|
value() |
当前值(signal)。加载中为 undefined;error 状态下读取会抛异常(见下面的坑) |
hasValue() |
是否有可用值;响应式,且带 TS 类型收窄(收窄后 value() 不再含 undefined) |
isLoading() |
是否在加载/重载 |
status() |
'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local' |
error() |
error 状态时的那个错误对象 |
reload() |
手动重新请求 |
set() / update() |
直接写值(乐观更新场景) |
模板消费的推荐姿势是上面例子里那样用 hasValue() 收窄——@if (results.hasValue(); as data) 拿到的 data 类型是干净的 User[]。
三个实战要点
① params 返回 undefined = 整个跳过。 请求的前提条件不满足时,让 params 返回 undefined,资源进入 idle 状态,一个请求都不发:
readonly results = rxResource({
params: () => (this.keyword() ? this.keyword() : undefined), // 没有关键词就不搜
stream: ({ params: kw }) => this.http.get<User[]>(`/api/users?q=${kw}`),
});
这个特性配合路由的 input() 特别好用:路由参数变了 params 跟着变、组件复用场景下自动换数据,条件不满足时安静待机。
② stream 的 Observable 必须发出值或报错,绝不能返回 EMPTY。 资源需要「要么值、要么错误」来收敛状态,一个什么都不发就完成的 Observable 会让它不知道该展示什么,直接抛运行时错误(NG0991)。想「失败时给个空值」就用 catchError(() => of(null)) 之类发一个值出来。
③ 最大的坑:error 状态下裸读 value() 会抛异常。 很多人(包括写这篇文章的我)第一反应都是「失败时 value() 是 undefined」,不对——error 状态下读 value() 会抛 ResourceValueError。如果模板里不经过 hasValue()/isLoading()/error() 的门控直接 {{ results.value()?.name }},一条异常数据就能把整个组件的渲染炸掉。纪律一句话:模板里消费 resource,永远先过状态门控,别裸读 value()。
和 RxJS 的关系:管道留下,状态上移
注意例子里 stream 返回的还是 HttpClient 的 Observable——RxJS 管道一切照旧。rxResource 接管的只是「请求结果 → 组件状态」这最后一段。真正舒心的分界线是:
- 服务层:RxJS 管道的世界——重试、缓存、组合、去重,继续用操作符写;
- 组件层:signal 的世界——
input()接参、computed派生、rxResource桥接异步、模板hasValue()消费。
一个在两种技术栈里都成立的判断:rxResource 本质上是把「computed 的关键词 → switchMap 请求 → toSignal 回来,再手工拼 loading/error」这套高频样板收编成了标准件。你在 v19 之前的版本里见过的手写版大概长这样:
// rxResource 之前的世界(老版本可用的等价写法,仅供理解原理)
private readonly keyword$ = toObservable(this.keyword);
readonly results = toSignal(
this.keyword$.pipe(
switchMap(kw => this.http.get<User[]>(`/api/users?q=${kw}`)),
catchError(() => of(null)),
),
);
能跑,但 loading、竞态取消的精细控制、error 语义都得自己补。rxResource 就是把这坨标准化之后的产物。
9. 版本速查表
写给带着老版本过来的读者,所有条目对照过 Angular 22.1 实装的类型标注:
| 能力 | 引入 | 备注 |
|---|---|---|
signal / computed / effect |
v16(preview)→ v17 稳定 | 各版本通用,放心用 |
input() / output() / model() |
v17.1 起(preview),v19 起正式 public API | 装饰器写法仍可用,但已是旧范式 |
viewChild() / viewChildren() |
v17.2 起(preview) | 替代 @ViewChild |
linkedSignal() |
v19(preview)→ v20 正式 | 「可写的 computed」:源变重置、又允许手写 |
resource / rxResource |
v19(preview) | 参数名叫 request / loader——网上老教程都是这个形态 |
| 同上,转正 + 改名 | v22 正式,参数改名 params / stream |
v19~v21 的代码升级时这两处要跟着改 |
mutate() |
—— | v22 已移除,一律 update 造新引用 |
| zoneless | v18 实验性 → v20.2 provideZonelessChangeDetection() → v21+ 默认 |
v21 起 zone.js 不再是默认依赖 |
两条给不同版本读者的直接建议:
- 还在 v16~v18:
signal/computed/effect/input()可以立刻用起来,它们不依赖 zoneless;异步那步先用第 7 节的手写switchMap+toSignal模式,升级后再换rxResource; - v19~v21:
rxResource已可用但仍是 preview,注意用的是request/loader参数名;升级 v22 时全局搜一遍这两个词。
10. 写在最后
把整篇文章收进三句话:
- 绑定从来不会自己更新界面——旧世界是 zone.js 全量兜底,新世界是 signal 精确记账。普通绑定依然能用,但「随手改字段界面会动」的特权没了;
- 状态用
signal装、派生用computed算、副作用用effect做、组件接口用input/output/model声明——它们全是同一套记账机制的不同入口; - 异步请求用
rxResource变成「异步的 computed」:params说要什么,stream说怎么取,模板过hasValue()门控消费——loading、竞态、取消、销毁,都不再是你的代码。
如果只带一句口诀走,我选这句:更新必造新引用,消费先过状态门控。 前半句管 signal,后半句管 resource,剩下的都是框架的事。
本站的 Angular 前台(就是你正在看的这个博客)已经在生产环境全量跑这套范式了:路由数据经 input() 进组件、分类/标签摘要用 rxResource 检索、正文高亮和评论区挂载用 effect + afterNextRender 组合——这篇文章里的每个代码片段都能在这个站的源码里找到原型。