如何在 Angular 中优雅地发送 Ajax 请求

1,821 阅读12分钟

这篇文章同样来自 张庭岑 同学的分享,他介绍了如何在Angular中优雅地发送Ajax,这也一定程度上解释了Rxjs、洋葱模型的优点所在。请耐心看到最后, 优雅不优雅, 大家说了算

背景

前端通过浏览器 api 发起 ajax 请求和后台进行沟通,例如 XMLHttpRequest 和 fetch api。

在实际生产实践中, XMLHttpRequest 和 fetch api 的基本功能往往是不够的:

  1. 主动构造一个 XMLHttpRequest 的成本过高, 必须得监听处理各种事件;
  2. 项目中的 ajax 请求往往具有统一的属性和行为, 进行封装可维护性可以大幅提升, 同时对开发人员的研发体验也有较大提升。

真实 Angular 项目的实践 (反面教材)

简化 api, 封装通用能力

在项目初期, 我们直接使用原生 api 发送请求, 对 fetch api 进行了简单封装 fetchHttp() , 它具有以下提升:

  1. 简化了 api, 降低了构造 ajax 请求的成本fetchHttp(url, options): Promise<any>
  2. 它具有一些公共能力, 比如:
    1. 异常处理, 只包括 401, 403, 500 的场景
    2. 标准化请求头
    3. 标准化处理 response

套娃封装, 丰富能力

随着项目持续迭代, 需求场景膨胀, 初始的 fetchHttp 能力逐渐显得不够了, 但它原来的逻辑修改成本较高, 于是项目组采取 wrap 的形式, 对 fetchHttp 进行套娃封装:

export async function wrapFetchHttp(url, options) {
    // .... do something before ajax send
    const res = await fetchHttp()
    // .... do something after ajax respond
}

并且这种套娃层次可不止一层, 并且代码组织较为不规范, 导致维护成本变得非常高。

问题暴露

在项目初期以上封装思路正在良好运转, 但是随着场景变得复杂, 上述封装手段的维护成本直线上升:

问题简述详细描述
Api 使用变得复杂包装层次变多, 配置项不断进行拓展, 虽然有 TS 类型检查, 但由于没有文档, 且配置项的命名比较随意, 导致使用成本变高, 或在部分场景没有正确使用 api,或配置项的行为不可预测等。
拓展难度大不知道新功能是应该再 wrap 一层, 还是在原来的哪一层进行拓展, 每一层 warp 的逻辑不一定很清晰, 做功能拓展或修改的时候成本很高,每一次功能拓展, 都讲维护成本推到了新的高度。(这有很大一部分是代码组织结构太差劲)
难以适配非标准的 ajax出现了不能遵照 fetchHttp 处理逻辑的 ajax 请求, 可能新增一个 options 属性, 并在每一层 wrap 都进行 if else 判断, 进一步腐化代码。
与 Angular 交互不方便简单封装导出的 function, 并不是 Angular 的可注入对象, 不论怎么 wrap 都不可能使用到 Angular 的全局 Service 实例;将 Service实例暴露到全局, 进一步腐化架构简单包裹, fetchHttp 改成一个 Service; (我们确实这么做了, 不仅耗费了批量修改代码的成本, 还要承担批量修改可能造成的风险)

明明在项目初期运转良好, 十分够用的工具, 却经过 1 年就已经变得腐朽难以维护。

  1. 迭代过程质量看护投入不够, 没有及时为腐朽代码除锈,导致优化成本逐步提高;
  2. 研发过程采用极为简单暴力的方式快速满足当下需求, 缺少对未来可能产生后果的思考;

敏捷 , 效率软件质量 就是存在矛盾, 不同的场景要做出取舍, 当团队目标是短期商业目的的时候敏捷优先, 当软件稳定盈利的时候, 再沉下心提升质量, 软件研发的真相往往就是后人去填前人坑,坑太大我只能开个新坑(重写)。

Axios 的封装思路简述

简化 Api 并标准化输入输出

一个好用的 api 是封装的最基本目的, 通过 axios.request 构造一个 ajax 对象, 并通过 axios.request().then(...) 获得请求响应. 这已经是简化 api 的最优解了。

Axios 的源码虽然不是 TS 写的, 但是也提供了 TS 支持 index.d.ts , 其中提供了输入 AxiosRequestConfig, 输出 Promise<AxiosResponse>, 错误 AxiosError 的类型定义.

export interface AxiosRequestConfig<D = any> {
    url?: string;
    method?: Method | string;
    baseURL?: string;
    transformRequest?: AxiosRequestTransformer | AxiosRequestTransformer[];
    transformResponse?: AxiosResponseTransformer | AxiosResponseTransformer[];
    headers?: AxiosRequestHeaders;
    params?: any;
    paramsSerializer?: (params: any) => string;
    data?: D;
    timeout?: number;
    timeoutErrorMessage?: string;
    withCredentials?: boolean;
    adapter?: AxiosAdapter;
    auth?: AxiosBasicCredentials;
    responseType?: ResponseType;
    responseEncoding?: responseEncoding | string;
    xsrfCookieName?: string;
    xsrfHeaderName?: string;
    onUploadProgress?: (progressEvent: any) => void;
    onDownloadProgress?: (progressEvent: any) => void;
    maxContentLength?: number;
    validateStatus?: ((status: number) => boolean) | null;
    maxBodyLength?: number;
    maxRedirects?: number;
    beforeRedirect?: (options: Record<string, any>, responseDetails: {headers:
    Record<string, string>}) => void;
    socketPath?: string | null;
    httpAgent?: any;
    httpsAgent?: any;
    proxy?: AxiosProxyConfig | false;
    cancelToken?: CancelToken;
    decompress?: boolean;
    transitional?: TransitionalOptions;
    signal?: GenericAbortSignal;
    insecureHTTPParser?: boolean;
    env?: {
    FormData?: new (...args: any[]) => object;
    };
    formSerializer?: FormSerializerOptions;
}
export interface AxiosResponse<T = any, D = any> {
    data: T;
    status: number;
    statusText: string;
    headers: AxiosResponseHeaders;
    config: AxiosRequestConfig<D>;
    request?: any;
}
export class AxiosError<T = unknown, D = any> extends Error {
    constructor(
    message?: string,
    code?: string,
    config?: AxiosRequestConfig<D>,
    request?: any,
    response?: AxiosResponse<T, D>
    );
    config?: AxiosRequestConfig<D>;
    code?: string;
    request?: any;
    response?: AxiosResponse<T, D>;
    isAxiosError: boolean;
    status?: number;
    toJSON: () => object;
    static readonly ERR_FR_TOO_MANY_REDIRECTS = "ERR_FR_TOO_MANY_REDIRECTS";
    static readonly ERR_BAD_OPTION_VALUE = "ERR_BAD_OPTION_VALUE";
    static readonly ERR_BAD_OPTION = "ERR_BAD_OPTION";
    static readonly ERR_NETWORK = "ERR_NETWORK";
    static readonly ERR_DEPRECATED = "ERR_DEPRECATED";
    static readonly ERR_BAD_RESPONSE = "ERR_BAD_RESPONSE";
    static readonly ERR_BAD_REQUEST = "ERR_BAD_REQUEST";
    static readonly ERR_CANCELED = "ERR_CANCELED";
    static readonly ECONNABORTED = "ECONNABORTED";
    static readonly ETIMEDOUT = "ETIMEDOUT";
}

拦截器机制 - 请求行为标准化

image_1652862927918.png 有很多例子表明使用拦截器是多么简单易用, 上图的拦截器运行示意图很好的展示了拦截器的用法,我们用 axios.interceptors.request.use(onFulfilled, onRejected) 在请求被触发之前对其进行修改,并用 axios.interceptors.response.use(onFulfilled, onRejected) 在响应返回到调用方位置之前对其进行处理,

我们可以以一定的顺序, 注册多个拦截器, 这些拦截器会按照注册顺序被依次序调用。

这让我们可以轻易定制项目中 ajax 请求的标准行为, 例如图中:

  1. 发送前配置统一的请求头;
  2. 发送前进行登录态检查;
  3. 发送前进行日志打印;
  4. 请求响应后进行日志打印;
  5. 403 错误将会导致重新登录并重试请求;
  6. Domain 匹配等等;

多实例与配置优先级 - 支持差异化场景

Axios 的全局配置以及拦截器, 都是注册在 Axios 的实例上的, 所以我们可以保存多个差异化的实例再支持:

  1. 多实例则全局设置不止一份, 在多 backend 场景非常有用;
  2. 配置优先级机制, 可以轻松处理项目中的非标准请求

实际生产过程中的非标准场景往往因为架构问题难以差异处理, 不得不采用很不优雅的方式进行处理, 相比之下, 使用 axios 真是一场优雅体验。

这个特性很好理解, 就不多说了。

其他最佳实践

  1. 基础的安全设置 - CSRF
  2. Cancelable 功能 这些也不展开讲了, 完全可以使用拦截器实现

总结

我觉得我们项目的所有问题都可以用 axios 解决:

问题简述解决方案
Api 使用变得复杂每一个新增的 options 配置项, 对应一个拦截器, 并且配合 TS 类型检查, 配置项的行为一定是可以预期的。
拓展难度大Axios 拦截器 (多层 wrap 其实已经有了拦截器的雏形, 但是多层 wrap 有公用配置项, 又都放在同一个文件中, 导致重新组织拆分也困难)
难以适配非标准的 ajaxAxios 多实例 + 配置优先级
与 Angular 交互不方便可以简单地讲 axios 实例放到一个 Angular Service 里面, 那么拦截器中也能使用 Angular 的可注入对象了

但 Axios 是满分答案吗? 对Angular来说,不是的。Angular 相对于 React 和 Vue ,它是一个更庞大的全场景解决方案, 因为它内置了一个@angular/common/http 模块。

@angular/common/http 模块

简化的 api 与标准化的输入输出

Angular 使用全局 Service HttpClient 发送请求, HttpClient 只有一个关键 api——HttpClient.request, 其他 api 都是别名。

class HttpClient {
  request(first: string | HttpRequest<any>, url?: string, options: { body?: any; headers?: HttpHeaders | { [header: string]: string | string[]; }; context?: HttpContext; observe?: "body" | "events" | "response"; params?: HttpParams | { [param: string]: string | number | boolean | ReadonlyArray<...>; }; reportProgress?: boolean; responseType?: "arraybuffer" | ... 2 more ... | "json"... = {}): Observable<any>
  ....
}

标准化的输入 HttpRequest, 输出 Observable<HttpEvent> 以及错误

// 标准化的输入, 是不可变对象
export class HttpRequest<T> { ... }
// 标准化的输出, 是不可变对象
export type HttpEvent<T> =
    HttpSentEvent|HttpHeaderResponse|HttpResponse<T>|HttpProgressEvent|HttpUserEvent<T>;
// 标准化的 error, 是标准输出的一种
export class HttpErrorResponse extends HttpResponseBase implements Error {...}

洋葱模型拦截器机制 + HttpContext - 差异化场景行为可预测

Angular 的拦截器执行顺序, 和 express 中间件是一样的洋葱模型, 一个拦截器同时可以拦截请求也可以拦截响应, 是成对存在的, 虽然大部分场景只使用到了 1 个。

这是和 axios 存在一点差异的地方, 但是不论 Angular Http 还是 axios, 拦截器的执行顺序都是至关重要的, Angular 的拦截器成对存在, 相对更容易组织代码, 更容易进行管理。

image_1652863138560.png

HttpContext 是请求执行上下文, 也就是差异化配置, 在 HttpRequest 中定义并赋值, 在整个拦截器的执行过程中都能被获取到, 我们可以用它来差异化拦截器的行为。

HttpContext 是一个简单的 Map, 但它是类型严格的 Map, 它的每一键都必须定义为 HttpContextToken 类型并包含默认值, 这种强制类型检查, 让每一个请求的配置项被严格定义, 通过合适的代码组织, 可以让每一个 HttpContextToken 和拦截器进行组合, 让请求配置项的行为变得可预测。

下面展示一个简单的例子 - 自动展示 loading 的拦截器:

  1. 在拦截器文件中, 定义一个 HttpContextToken, 行为表示是否开关拦截器的行为。
  2. 声明拦截器
    • 获取 HttpContextToken: HTTP_INTERCEPTOR_PARAM_AUTO_LOADING
    • 如果配置为 false, 则拦截器什么也不做 (既不拦截请求, 也不拦截响应)
    • 如果配置不为 false, 那么在请求阶段 autoShowLoading, 在请求结束的时候 autoHideLoading
export const HTTP_INTERCEPTOR_PARAM_AUTO_LOADING = new HttpContextToken<boolean | string>(
    () => false
);
​
@Injectable()
export class AutoLoadingHttpInterceptor implements HttpInterceptor {
    constructor(private loading: OfsLoadingService) {}
​
    intercept(
        req: HttpRequest<any>,
        next: HttpHandler
    ): Observable<HttpEvent<any>> {
        const shouldAutoLoading = req.context.get(HTTP_INTERCEPTOR_PARAM_AUTO_LOADING);
        if (!shouldAutoLoading) {
            return next.handle(req)
        }
​
        this.autoShowLoading(shouldAutoLoading);
        return next.handle(req).pipe(
            finalize(() => {
                this.autoHideLoading(shouldAutoLoading);
            })
        );
    }
}

天然支持 cancelable

Angular Http 是基于 rxjs 进行封装的, HttpClient.request 方法返回的是一个 Observable, 它显著不同于 Promise:

  1. Observable 是惰性的, 如果它没有被订阅, 那么它不会被执行;
  2. Observable 在创建后已经包含了他执行所需要的所有设置, 如果它被订阅两次, 将会发送两次请求;
  3. 这个 Observable 被订阅后会产生一个 Subscription, 当这个 subscription 被 unsubscribe 的时候, 会销毁它拥有的一切资源, 包括 pending 中的 ajax 请求, 实际效果就是控制台 network 面板展示 canceled:

image_1652863204055.png

详情可以查看 Angular 官方文档

image_1652863229249.png

总结

Angular Http 模块是一个内核功能纯粹且强大 - 基于 rxjs 封装 http 请求, 可以无限拓展 - 洋葱模型拦截器, 并且在拓展的时候进行强制类型检查 - HttpContext, Angular Http 从研发者的研发体验, 到项目功能的拓展性和差异性, 到有效降低接入成本和维护成本等方面, 都是足够优秀的方案。

从 rxjs vs promise, 到天然支持 cancelable, 到强制类型检查, 我认为都比 axios 更优秀, 在多实例方面 axios 设计确实考虑到了更多的场景, 但是 Angular 的 provider 也可以实现。

因此在 Angular 技术栈中使用 @angular/common/http 是更优雅的方案。

实际项目的逆袭

方案选定 @angular/common/http, 开始将项目原始封装的 fetchHttp 的功能, 使用拦截器复刻一遍

image_1652863289429.png

拦截器设计

拦截器名称行为逻辑
CacheHttpInterceptor是否缓存请求的响应, 以及缓存的有效时长 (只缓存 get 请求)
AutoLoadingHttpInterceptor使用全局的 loadingService, 在请求发出前自动开始 loading, 在请求响应后自动关闭 loading
BlockHttpInterceptor阻塞拦截器, 存在特殊 A 请求在发送期间, 其他所有 ajax 请求都将被阻塞, 在 A 请求响应后, 被阻塞的请求自动开始发送。 1. 处理该请求是否会收到阻塞的影响 2. 处理该请求是否会阻塞其他请求
AutoCancelHttpInterceptor切换路由的时候, 会自动将所有 pending 的请求丢弃, 子路由模块内的请求不可应用这个拦截器
PrepareUrlHttpInterceptor标准化请求 url
PrepareHeaderHttpInterceptor标准化请求 header
AuthHttpInterceptor标准化需要登录信息请求的行为, 包括相关的请求头, 报错登录过期处理等
ErrorHandlerHttpInterceptor标准的错误处理
RetryHttpInterceptor自动重试, 用处较少, 可以去掉
SuccessHttpInterceptor成功拦截器, 可以对自定义数据解析的方式等等

拦截器逻辑已经列出, 业务代码库不方便展示, 请谅解

最终效果

  • 每个拦截器一个文件, 每一种报错都有通用的处理手段

image_1652863471638.png

  • 每个拦截器都有相应的 HttpContextToken 进行跳过

image_1652863492472.png

  • 实际的使用场景
// 不需要新增 Service, 只需要引入 Angular 的 HttpClient
constructor(private http: HttpClient) {}
​
public getInfo(
    managementEscalationId: string
): Observable<any> {
    ......
    // 直接发起请求, 注意, 若想要浏览器发送请求, 这个 Observable 必须被 subscribe 
    return this.http.get('/xxx');
}
​
public sendInfo(
    id: string,
    body: any
): Observable<any> {
    ......
    return this.http.post(
        '/xxx/,
        body,
        {   // 通过 context 的配置开启或关闭拦截器
            context: new HttpContext().set(
                    HTTP_INTERCEPTOR_PARAM_AUTO_LOADING,
                    true
                ),
        }
    );
}
​
handleSaveNew() {
    return this.mecService
        .saveNewMember({
        ...
    })
        .subscribe(() => {// subscribe 将会使接口开发发送, 拦截器开始执行; 并订阅请求结果
        this.setRowsEditable(PageState.viewing);
    })
        .add(() => {// Subscription.add 相当于 Promise.finall, Subscription.unsubscribe 相当于 XMLHttpRequest.abort
        this.handleSearch();
    });
}

推行落地

当前方案已经输出, 并在两个模块试点转测, 测试内容为通用的报错逻辑, block 拦截器的逻辑等。 在测试通过后将全面铺开, 力争两周内将项目内上千处接口调用一次性替换掉。

风险削减:

  1. 方案可靠性已经提前一个版本验证, 方案本身不会出现问题;
  2. 上千处接口调用的批量替换, 可能因为执行人的因素出现差错;
    • 分摊到各个模块负责人, 对责任田的接口调用进行修改
    • 少量多次修改替换, 边修改边验证
    • 不要求两周内, 单力争在一个季度内整改完毕 ​

您说, 优雅不优雅