和服务端打交道占了前端一半的工作量。v22 的数据访问故事由两层组成:底层是 HttpClient(默认走 Fetch API),上层是 resource() / httpResource()——把”请求 + 状态 + 取消 + SSR”封装成信号的 Resources。本文自底向上讲透。
HttpClient:默认存在,默认 Fetch
v21 起 provideHttpClient() 不再是必需的——HttpClient 默认即可注入。v22 又把默认后端从 XMLHttpRequest 切换为 Fetch API:
- 旧的
withFetch()已废弃(迁移 schematics 会自动移除),因为它成了默认行为 - 少数依赖 XHR 特性的场景(如上传进度,见下文)可以用
provideHttpClient(withXhr())显式回退
基础用法与泛型没有任何变化:
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable()
export class UsersApi {
private readonly http = inject(HttpClient);
getUser(id: number) {
return this.http.get<User>(`/api/users/${id}`);
}
updateUser(id: number, patch: Partial<User>) {
return this.http.patch<User>(`/api/users/${id}`, patch);
}
search(params: { q: string; page: number }) {
return this.http.get<Page<User>>('/api/users', { params });
}
}
注意 HttpClient 的方法返回的是冷的 Observable,订阅才发请求。在 v22 中,更推荐直接用 httpResource() 消费 HTTP(下文),Observable 形式主要留给服务层封装与 RxJS 组合场景。
函数式拦截器
拦截器从 v14 起全面函数式(functional interceptor),v22 中 HTTP_INTERCEPTORS 的类式写法已是历史。函数式拦截器就是一个 (req, next) 函数:
import { HttpInterceptorFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
return next(
req.clone({
setHeaders: { Authorization: `Bearer ${auth.token()}` },
}),
);
};
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
console.debug('[http]', req.method, req.urlWithParams);
return next(req);
};
注册拦截器才需要 provideHttpClient():
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor]),
// 需要上传进度时回退 XHR:
// withXhr(),
),
],
});
函数式拦截器中可以放心使用 inject()(它在注入上下文中执行),也可以直接读信号——比如上例的 auth.token()。
上传与下载进度:reportProgress 的拆分
reportProgress: true 已废弃,拆分为两个独立选项:
upload(file: File) {
const body = new FormData();
body.append('file', file);
return this.http.post('/api/files', body, {
reportUploadProgress: true, // 上传进度事件
});
}
downloadReport(id: string) {
return this.http.get(`/api/reports/${id}`, {
responseType: 'blob',
reportDownloadProgress: true, // 下载进度事件
});
}
一个关键限制:Fetch API 不支持上传进度。默认 Fetch 后端下,reportUploadProgress 不会产生可信的 UploadProgress 事件;确实需要上传进度条的应用,应通过 provideHttpClient(withXhr()) 回退 XHR 后端。下载进度两者都支持。
SSR:请求转移与增量 hydration
在 SSR 模式下,服务端渲染时发出的请求结果会自动转移到客户端(HTTP transfer cache),客户端 hydrate 时直接命中缓存,不再重复请求。v22 的增量 hydration(默认开启)与 HttpClient、resource 均已打通:视口外的延迟 hydration 区块内发起的请求同样会被转移,首屏数据不二次加载。
resource() 还提供 id 选项,用于在 SSR 与测试中标识资源、做去重与缓存关联:
const userResource = resource({
id: 'current-user', // 稳定标识,SSR/测试可据此缓存
loader: () => fetchCurrentUser(),
});
resource():通用的异步资源
resource() 是 Resources 的通用形态:request 声明”要什么”(信号依赖),loader 声明”怎么拿”。v22 起稳定。
import { resource } from '@angular/core';
export class UsersComponent {
readonly page = signal(1);
readonly pageSize = signal(20);
readonly usersResource = resource({
request: () => ({ page: this.page(), size: this.pageSize() }),
loader: async ({ request, abortSignal }) => {
const res = await fetch(
`/api/users?page=${request.page}&size=${request.size}`,
{ signal: abortSignal },
);
if (!res.ok) {
throw new Error(`加载失败:HTTP ${res.status}`);
}
return (await res.json()) as Page<User>;
},
});
}
几个要点:
request是响应式的:读取的任何信号变化都会触发重新加载loader拿到abortSignal——请求参数变化时,Angular 自动 abort 上一次未完成的加载,把取消语义下沉到了 fetch 层request返回undefined时资源进入 idle 状态,不发请求。这是表达”条件加载”的正道:
readonly detailResource = resource({
request: () => (this.selectedId() === null ? undefined : { id: this.selectedId() }),
loader: ({ request }) => loadDetail(request!.id),
});
资源状态机
Resource 对外暴露统一的状态 API:hasValue()、value()、isLoading()、error()、status()。状态流转如下:
stateDiagram-v2
[*] --> Idle: request 返回 undefined
Idle --> Loading: request 变为有效值
Loading --> Resolved: loader 成功
Loading --> Error: loader 抛出异常
Resolved --> Loading: request 再次变化
Error --> Loading: request 再次变化
Loading --> Loading: 重载期间保留旧值 - hasValue 为 true
注意”重载期间保留旧值”这条:翻页时 isLoading() 为 true,但 hasValue() 仍为 true、value() 仍返回上一页数据——模板里可以继续展示旧数据 + 角标加载态,而不是闪一屏 spinner。
@if (usersResource.isLoading() && !usersResource.hasValue()) {
<app-skeleton />
} @else if (usersResource.hasValue()) {
<app-user-table [page]="usersResource.value()" />
} @else if (usersResource.error(); as err) {
<p>出错了:{{ err.message }}</p>
}
httpResource():为 HTTP 而生
纯 JSON GET 场景不需要手写 loader,httpResource() 一个函数搞定:
import { httpResource } from '@angular/common/http';
export class UserDetailComponent {
readonly userId = input.required<number>();
readonly user = httpResource<UserDetail>(() => `/api/users/${this.userId()}`);
}
- URL 函数是响应式的,依赖变化自动重新请求,过期请求自动取消
- 返回值类型由泛型给出,默认解析 JSON
- 同样支持
undefined→ idle 的约定,以及hasValue()/value()/isLoading()/error()状态 API - 也可以传 request 对象定制方法、body、头等:
httpResource(() => ({ url, method: 'POST', body }))
chain():资源间依赖
“先拿列表,再根据列表中默认项拿详情”这类级联加载,用 chain() 在 request/URL 函数里声明依赖。chain() 会读取上游资源的值,并让下游资源自动继承其加载状态——上游没就绪时下游不发无效请求:
export class DashboardComponent {
readonly settingsResource = httpResource<AppSettings>(() => '/api/settings');
readonly dashboardResource = httpResource<Dashboard>(() => {
// chain:settings 未就绪时,本资源保持等待,不会发出无效请求
const settings = chain(this.settingsResource);
return `/api/dashboard?range=${settings.defaultRange}`;
});
}
没有 chain() 时,在上游 value() 还未就绪的阶段,下游 URL 函数只能拿到 undefined 手工分支,状态联动也要自己拼。chain() 把”依赖资源”变成一等公民,这是 v22 Resources 稳定后的推荐级联写法。
实战:防抖搜索完整示例
把 debounced()(第 21 篇介绍,实验性)与 httpResource() 组合,一个生产级搜索框只需要声明式代码:
import { Component, signal } from '@angular/core';
import { debounced } from '@angular/core';
import { httpResource } from '@angular/common/http';
interface SearchResult {
id: number;
title: string;
}
@Component({
selector: 'app-user-search',
template: `
<input
type="search"
placeholder="搜索用户"
[value]="query()"
(input)="query.set($any($event.target).value)"
/>
@if (results.isLoading() && results.hasValue()) {
<p class="hint">更新中……</p>
} @else if (results.error(); as err) {
<p class="error">{{ err.message }}</p>
} @else if (results.hasValue(); as list) {
<ul>
@for (item of list; track item.id) {
<li>{{ item.title }}</li>
}
</ul>
} @else {
<p class="hint">输入关键词开始搜索</p>
}
`,
})
export class UserSearchComponent {
readonly query = signal('');
// 防抖 300ms 的搜索词(Resource 形态)
private readonly debouncedQuery = debounced(this.query, { delay: 300 });
// 空关键词返回 undefined → 资源 idle,不发请求
readonly results = httpResource<SearchResult[]>(() => {
const q = this.debouncedQuery.value();
return q === '' ? undefined : `/api/search?q=${encodeURIComponent(q)}`;
});
}
这个示例的完整链路:输入信号 → debounced() 防抖 → URL 函数依赖防抖值 → httpResource 自动请求、自动取消过期请求、状态直通模板。全程零手动订阅、零 unsubscribe。
HttpClient 与 resource 怎么选
| 维度 | HttpClient Observable | resource / httpResource |
|---|---|---|
| 定位 | 底层原语、服务层封装 | 组件层声明式数据源 |
| 状态管理 | 手动(loading/error 字段) | 内建状态机 |
| 取消 | 手动 unsubscribe / takeUntil | request 变化自动 abort |
| SSR | 支持 transfer cache | 支持,且有 id 缓存选项 |
| 组合复杂流 | 强(RxJS 操作符) | 简单依赖用 chain() |
| 表单联动 | 手写 | validateHttp 直接联动 |
经验法则:组件模板直接消费的数据一律 httpResource/resource;被多个模块复用、需要复杂变换的请求逻辑沉淀到服务里,内部仍可用 HttpClient + RxJS,对外暴露 rxResource() 或信号。