首页 / 文章

适用于 Angular HttpClient 的不可变、基于闭包的查询构建器

用基于闭包的小型请求构建器替代重复的 HttpParams 代码模板,该构建器通过 InjectionToken 进行连接,具备不可变性、类型安全且易于测试。

发布者:REACTAPP.TOP阅读约 9 分钟2004 词

来源: Stop Hand-Wiring HttpParams: Build a Fluent, Type-Safe Request Builder in Angular

大型 Angular 项目往往会在数十个服务中重复使用相同的几行代码:创建一个 HttpParams 对象,设置过滤器、排序规则和页码,最后发起请求。每个版本的代码略有不同,而这些细微差别会导致不同团队之间查询参数名称和排序规则不一致。

本指南将介绍如何用仅由闭包构成的简洁请求构建器来替代这种重复代码。它既不需要类,也不需要装饰器或继承结构;只需通过 InjectionToken 暴露一个工厂函数,再使用 inject() 进行解析即可。您将了解其工作原理、为何从设计上就是不可变的、如何在不使用 TestBed 的情况下对其进行测试,以及何时无需添加此类功能。

将被替代的样板代码

以下是手动编写的典型列表接口代码。

getUsers(status: string, sortField: string, page: number, size: number) {
  let params = new HttpParams()
    .set('status', status)
    .set('sort', sortField)
    .set('direction', 'asc')
    .set('page', page)
    .set('size', size);

  return this.http.get<User[]>('/api/users', { params });
}

这种方式本身没有问题,但扩展性较差。某个服务将页面大小命名为pageSize,另一个则使用size;一个团队通过sort和direction来排序,另一个团队则将两者编码在单个参数中。每个新接口都会在实际处理内容之前增加一大段HttpParams调用。理想的情况是,调用位置应能像查询描述一样清晰易读:

loadUsers() {
  return this.request<User[]>('/api/users')
    .where('status', 'active')
    .sortBy('name')
    .paginate(0, 20)
    .execute();
}

该链中的每次调用都会返回一个全新的独立值,最终通过execute()生成可观察的结果。本指南的其余部分将介绍实现这一功能的各个组件。

一个返回自身的工厂类

整个构建器仅约二十行代码。

import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';

type SortDirection = 'asc' | 'desc';

interface RequestBuilder<T> {
  where(key: string, value: string | number | boolean): RequestBuilder<T>;
  sortBy(field: string, direction?: SortDirection): RequestBuilder<T>;
  paginate(page: number, size: number): RequestBuilder<T>;
  execute(): Observable<T>;
}

export function createRequest(http: HttpClient) {
  const build = <T>(url: string, params = new HttpParams()): RequestBuilder<T> => ({
    where: (key, value) => build<T>(url, params.set(key, String(value))),

    sortBy: (field, direction = 'asc') =>
      build<T>(url, params.set('sort', field).set('direction', direction)),

    paginate: (page, size) =>
      build<T>(url, params.set('page', page).set('size', size)),

    execute: () => http.get<T>(url, { params }),
  });

  return build;
}

核心技巧在于:调用build方法会生成一个对象,该对象上的每个方法都会再次调用build。链式处理中的每一步都会创建一个新的构建器,该构建器会使用已更新的HttpParams。之前的构建器则不会被修改。

这种方法之所以如此简洁,是因为 HttpParams 本身就是不可变的:params.set() 不会修改原始实例,而是返回一个包含新增条目的新实例。构建器只是将这一特性提升到更高层级,从而使构建器对象也具备同样的不可变性。where 会将值转换为字符串,而 sortBy 会设置 sort 和 direction 键(默认为升序),paginate 则会设置 page 和 size 键。只有 execute() 会通过使用累积的参数调用 http.get 来与网络进行交互。

同样值得注意的是其中缺失的内容。这里没有 this,也没有私有字段或 class。所有状态都存储在由闭包捕获的函数参数中,因此既不会意外发生状态变更,也无需在当前调用之外进行跟踪。如果对捕获参数的闭包概念不熟悉,关于 JavaScript 的作用域链如何解析变量的相关解释可以说明其底层机制。

通过依赖注入暴露构建器

一个接受 HttpClient 参数的函数固然有用,但组件应能像处理其他依赖项一样注入它。基于工厂模式的 InjectionToken 正是为此设计的。

import { InjectionToken, inject } from '@angular/core';

export const REQUEST_BUILDER = new InjectionToken<ReturnType<typeof createRequest>>(
  'REQUEST_BUILDER',
  {
    providedIn: 'root',
    factory: () => createRequest(inject(HttpClient)),
  }
);

有两个细节值得关注。

让编译器自动推导类型

ReturnType<typeof createRequest> 表示构建器的类型无需手动书写。当 createRequest 添加了方法后,该令牌的类型会自动随之变化,所有使用方都能看到这一改动。

在存在注入上下文时解析 HttpClient

inject(HttpClient) 会在令牌的工厂内部执行,而该工厂始终在注入上下文中运行,这正是调用它的合适位置。常见的错误是在普通方法或不在任何注入上下文中的回调函数中调用 inject(),这样 Angular 会在运行时抛出 NG0203 错误。在创建令牌时一次性解析 HttpClient 并将其保存在闭包中,这样之后无论是在效果处理、RxJS 运算符还是普通工具函数中,都可以使用该构建器。

在组件中使用它

某个组件将令牌注入到某个字段中,然后像调用函数一样使用它。

@Component({
  selector: 'app-users',
  template: `...`,
})
export class UsersComponent {
  private readonly request = inject(REQUEST_BUILDER);

  loadUsers() {
    return this.request<User[]>('/api/users')
      .where('status', 'active')
      .sortBy('name')
      .paginate(0, 20)
      .execute();
  }
}

在开头指定的泛型参数 User[] 会贯穿整个处理链,因此 execute() 会直接返回 Observable<User[]>,而无需进行任何类型转换。

违背不可变性原则的模式

基于信号的响应式搜索

由于构建器是一种成本低廉且不可变的值,因此每当响应式输入发生变化时都可以创建一个新的构建器。在这里,由信号驱动的资源会根据当前的搜索文本和页面重新生成查询。

export class ProductSearchComponent {
  private readonly request = inject(REQUEST_BUILDER);

  readonly query = signal('');
  readonly page = signal(0);

  readonly products = rxResource({
    params: () => ({ q: this.query(), page: this.page() }),
    stream: ({ params }) =>
      this.request<Product[]>('/api/products')
        .where('q', params.q)
        .where('inStock', true)
        .sortBy('price', 'desc')
        .paginate(params.page, 24)
        .execute(),
  });
}

每当 query 或 page 发生变化时,params 函数会生成一个新的对象,而 stream 函数则会基于该对象构建新的请求。各次执行之间并没有可重用的构建器。需要注意的是,rxResource 及其选项名称在近期的 Angular 版本中已经发生了变化(早期版本使用的是不同的属性名),因此请查阅你所使用版本的 API 文档。

条件链式调用

由于每一步都是返回值而非直接修改现有数据,因此可以通过重新赋值局部变量来应用可选的过滤条件。

loadOrders(filter: OrderFilter) {
  let builder = this.request<Order[]>('/api/orders')
    .paginate(filter.page, filter.size);

  if (filter.status) {
    builder = builder.where('status', filter.status);
  }
  if (filter.sortField) {
    builder = builder.sortBy(filter.sortField, filter.direction);
  }

  return builder.execute();
}

只有 builder 变量会被重新赋值;构建器对象本身始终不会改变。每个分支只是替换为一个新的、经过扩展的构建器。

不使用 TestBed 进行测试

createRequest只是HttpClient的一个普通函数,因此单元测试完全不需要Angular测试模块,只需一个能记录接收参数的简单桩函数即可。

it('accumulates params immutably', () => {
  const captured: HttpParams[] = [];
  const httpStub = {
    get: (_url: string, opts: { params: HttpParams }) => {
      captured.push(opts.params);
      return of([]);
    },
  } as unknown as HttpClient;

  const request = createRequest(httpStub);
  const base = request<unknown[]>('/api/items').where('a', 1);

  base.where('b', 2).execute();
  base.where('c', 3).execute();

  expect(captured[0].get('b')).toBe('2');
  expect(captured[0].get('c')).toBeNull(); // branches don't leak into each other
  expect(captured[1].get('c')).toBe('3');
});

最终的断言展示了不可变性的优势。从同一个base构建器分支出来的不同链之间是完全隔离的:第一个链中的b参数永远不会出现在第二个链中,反之亦然。如果使用可变构建器,第二次调用会悄悄继承第一次调用的过滤规则。

该设计为何可行

  • 天生不可变。不存在可能被破坏的内部状态。例如,重复使用部分已构建的请求——一个用于活跃用户的基础查询,另一个用于归档用户的基础查询——在默认情况下是安全的。而基于类的可变构建器则会在毫无预警的情况下出现此类问题。
  • 统一的查询参数词汇表。 sort、direction、page 和 size 这些键都在同一个地方定义。当后端更改其中某个键的名称时,只需修改一行代码,而无需在四十个服务中逐一查找。这样的构建器实际上成为了前端与 API 查询规范之间的契约。
  • 可贯穿整个流程的推导机制。 在入口处使用一个通用的类型定义即可涵盖所有步骤,包括 execute()。而基于类的构建器往往要求在每一层继承中都重新传递泛型类型。
  • 依赖解析在正确的位置仅执行一次。令牌工厂会在受保障的注入上下文中解析HttpClient,从而避免因在回调中延迟创建构建器而出现的“在注入上下文之外调用inject()”系列错误。
  • 可进行代码剪枝且易于测试。当没有组件使用标记为providedIn: 'root'的工厂令牌时,它会被从程序包中移除。其核心仅为普通函数,因此测试无需使用TestBed.configureTestingModule或HttpTestingController,除非你需要集成级别的测试覆盖。
  • 拦截器依然有效。 execute() 最终会调用 HttpClient.get,因此拦截器链中的身份验证标头、重试逻辑和错误处理机制都不会发生变化。该构建器只是建立在 HttpClient 之上,并不会替代它。
  • 需要权衡的利弊

    • 键为普通字符串。 where('status', 'active') 不会针对 API 进行验证。如果键中存在拼写错误,代码仍能编译通过,只有在运行时才会出错。使用泛型如 where<K extends keyof UserQuery> 来限制键的类型可以解决这个问题,但代价是类型会更复杂;需根据团队需求决定是否采用。
  • 重复调用会覆盖之前的值。 HttpParams.set 会替换现有的值,因此多次调用 sortBy() 只会保留最后一次的排序结果。这通常是排序时所期望的行为,但对于那些期望支持多值参数的人来说却会感到意外。对于类似 ?tag=a&tag=b 这样的重复键,可以添加一个使用 params.append 的 whereAll 方法。
  • 刻意仅支持 GET 请求。 该构建器用于组合查询字符串,不处理请求体、头部信息以及响应类型相关选项。虽然可以添加 withHeaders 或 post(body) 这类方法,但每添加一种功能都在逐渐偏离 HttpClient 已经具备的选项对象功能。需明确知道何时该停止添加功能。
  • 每步创建一个新对象。 每次链式调用都会分配一个构建器和一个HttpParams对象。这些都是体积极小、生命周期短暂的对象,JavaScript引擎可以高效地回收它们,因此在实际使用中其成本可以忽略不计,但了解这一点仍是必要的。
  • 需要学习另一层抽象概念。 新成员必须了解REQUEST_BUILDER的存在及其功能。在只有两个端点的应用中,直接调用HttpClient更为简单。而当代码库中存在大量具有相同分页和排序规则的列表及表格端点时,这种模式的优势就会显现出来,这类应用在企业级的Angular项目中十分常见。
  • 扩展方法

    一旦基本版本确定,再进行一些扩展就显得很自然了。

    为每个端点定义类型化的查询架构

    第二个泛型可以指定端点接受的键和值,从而使拼写错误的键在编译时引发错误。

    interface UserQuery {
      status: 'active' | 'archived';
      role: string;
    }
    
    const users = this.request<User[], UserQuery>('/api/users')
      .where('status', 'active'); // 'staus' would now be a compile error
    

    这段代码展示了预期的调用位置。为支持该功能,createRequest和RequestBuilder接口需要第二个类型参数,同时where必须以查询类型及其值类型的keyof形式来指定。

    特定于环境的基础URL

    也在令牌工厂中解析BASE_API_URL令牌,并将其添加到构建器接收的每个路径的前面。各个组件始终传递相对路径,而不处理环境配置。

    响应适配器

    map阶段可以在构建器内部重新塑造服务器响应,这样组件就能获得可直接展示的模型,而非原始的API格式。

    总结

    基于闭包的请求构建器仅需少量代码即可实现强大功能。大约二十行代码就能创建出一个不可变、有类型约束且可注入的层,从而规范整个应用程序与分页端点的交互方式。它完全依赖 Angular 已有的功能:HttpParams的不可变性、基于工厂的注入机制以及inject()函数,无需任何额外的运行时组件。

    与任何抽象概念一样,这种方式也存在一个适用临界点。对于少数几个端点,直接使用HttpClient调用更为直观。而当端点数量增多时,该构建器就能将人们需要记忆的查询字符串规则转化为编译器会自动检查的契约。