CHARLIE SAYS

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

Angular 22+ 教程 23:HttpClient(Fetch 默认)与 resource / httpResource

和服务端打交道占了前端一半的工作量。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 Observableresource / httpResource
定位底层原语、服务层封装组件层声明式数据源
状态管理手动(loading/error 字段)内建状态机
取消手动 unsubscribe / takeUntilrequest 变化自动 abort
SSR支持 transfer cache支持,且有 id 缓存选项
组合复杂流强(RxJS 操作符)简单依赖用 chain()
表单联动手写validateHttp 直接联动

经验法则:组件模板直接消费的数据一律 httpResource/resource;被多个模块复用、需要复杂变换的请求逻辑沉淀到服务里,内部仍可用 HttpClient + RxJS,对外暴露 rxResource() 或信号。

系列导航

← 算法 023:图:遍历(BFS & DFS) 目录 设计模式 023:状态(State) →
← 返回文章列表