从 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. 写在最后

把整篇文章收进三句话:

  1. 绑定从来不会自己更新界面——旧世界是 zone.js 全量兜底,新世界是 signal 精确记账。普通绑定依然能用,但「随手改字段界面会动」的特权没了;
  2. 状态用 signal 装、派生用 computed 算、副作用用 effect 做、组件接口用 input/output/model 声明——它们全是同一套记账机制的不同入口;
  3. 异步请求用 rxResource 变成「异步的 computed」:params 说要什么,stream 说怎么取,模板过 hasValue() 门控消费——loading、竞态、取消、销毁,都不再是你的代码。

如果只带一句口诀走,我选这句:更新必造新引用,消费先过状态门控。 前半句管 signal,后半句管 resource,剩下的都是框架的事。

本站的 Angular 前台(就是你正在看的这个博客)已经在生产环境全量跑这套范式了:路由数据经 input() 进组件、分类/标签摘要用 rxResource 检索、正文高亮和评论区挂载用 effect + afterNextRender 组合——这篇文章里的每个代码片段都能在这个站的源码里找到原型。