如何在 Angular 中優雅地傳送 Ajax 請求

語言: CN / TW / HK

這篇文章同樣來自 張庭岑 同學的分享,他介紹瞭如何在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 有公用配置項, 又都放在同一個檔案中, 導致重新組織拆分也困難) | | 難以適配非標準的 ajax | Axios 多例項 + 配置優先順序 | | 與 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> 以及錯誤

// 標準化的輸入, 是不可變物件 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. 上千處介面呼叫的批量替換, 可能因為執行人的因素出現差錯;
    • 分攤到各個模組負責人, 對責任田的介面呼叫進行修改
    • 少量多次修改替換, 邊修改邊驗證
    • 不要求兩週內, 單力爭在一個季度內整改完畢 ​

您說, 優雅不優雅