基於 React 和 Redux 的 API 集成解決方案

在前端開發的過程中,我們可能會花不少的時間去集成 API、與 API 聯調、或者解決 API 變動帶來的問題。如果你也希望減輕這部分負擔,提高團隊的開發效率,那麼這篇文章一定會對你有所幫助。

文章中使用到的技術棧主要有:

  • React 全家桶
  • TypeScript
  • RxJS

文章中會講述集成 API 時遇到的一些複雜場景,並給出對應解決方案。通過自己寫的小工具,自動生成 API 集成的代碼,極大提升團隊開發效率。

本文的所有代碼都在這個倉庫:request

自動生成代碼的工具在這裏:ts-codegen

1. 統一處理 HTTP 請求

1.1 爲什麼要這樣做?

我們可以直接通過 fetch 或者 XMLHttpRequest 發起 HTTP 請求。但是,如果在每個調用 API 的地方都採用這種方式,可能會產生大量模板代碼,而且很難應對一些業務場景:

  • 如何爲所有的請求添加 loading 動畫?
  • 如何統一顯示請求失敗之後的錯誤信息?
  • 如何實現 API 去重?
  • 如何通過 Google Analytics 追蹤請求?

因此,爲了減少模板代碼並應對各種複雜業務場景,我們需要對 HTTP 請求進行統一處理。

1.2 如何設計和實現?

通過 redux,我們可以將 API 請求 「action 化」。換句話說,就是將 API 請求轉化成 redux 中的 action。通常來說,一個 API 請求會轉化爲三個不同的 action: request action、request start action、request success/fail action。分別用於發起 API 請求,記錄請求開始、請求成功響應和請求失敗的狀態。然後,針對不同的業務場景,我們可以實現不同的 middleware 去處理這些 action。

1.2.1 Request Action

redux 的 dispatch 是一個同步方法,默認只用於分發 action (普通對象)。但通過 middleware,我們可以 dispatch 任何東西,比如 function (redux-thunk) 和 observable,只要確保它們被攔截即可。

要實現異步的 HTTP 請求,我們需要一種特殊的 action,本文稱之爲 request action 。request action 會攜帶請求參數的信息,以便之後發起 HTTP 請求時使用。與其他 action 不同的是,它需要一個 request 屬性作爲標識。其定義如下:

interface IRequestAction<T = any> {
  type: T
  meta: {
    request: true // 標記 request action
  };
  payload: AxiosRequestConfig; // 請求參數
}

redux 的 action 一直飽受詬病的一點,就是會產生大量模板代碼而且純字符串的 type 也很容易寫錯。所以官方不推薦我們直接使用 action 對象,而是通過 action creator 函數來生成相應的 action。比如社區推出的 redux-actions,就能夠幫助我們很好地創建 action creator。參考它的實現,我們可以實現一個函數 createRequestActionCreator ,用於創建如下定義的 action creator:

interface IRequestActionCreator<TReq, TResp = any, TMeta = any> {
  (args: TReq, extraMeta?: TMeta): IRequestAction;

  TReq: TReq;   // 請求參數的類型
  TResp: TResp; // 請求響應的類型
  $name: string; // request action creator 函數的名字
  toString: () => string;
  start: {
    toString: () => string;
  };
  success: {
    toString: () => string;
  };
  fail: {
    toString: () => string;
  };
}

在上面的代碼中,TReq 和 TResp 分別表示 請求參數的類型請求響應的類型。它們保存在 request action creator 函數的原型上。這樣,通過 request action creator,我們就能迅速知道一個 API 請求參數的類型和響應數據的類型。

const user: typeof getUser.TResp = { name: "Lee", age: 10 };

對於 API 請求來說,請求開始、請求成功和請求失敗這幾個節點非常重要。因爲每一個節點都有可能觸發 UI 的改變。我們可以定義三種特定 type 的 action 來記錄每個異步階段。也就是我們上面提到的 request start action、request success action 和 request fail action,其定義如下:

interface IRequestStartAction<T = any> {
  type: T; // xxx_START
  meta: {
    prevAction: IRequestAction; // 保存其對應的 reqeust action
  };
}

interface IRequestSuccessAction<T = any, TResp = any> {
  type: T; // xxx_SUCCESS
  payload: AxiosResponse<TResp>; // 保存 API Response
  meta: {
    prevAction: IRequestAction; 
  };
}

interface IRequestFailAction<T = any> {
  type: T; // xxx_FAIL
  error: true;
  payload: AxiosError; // 保存 Error
  meta: {
    prevAction: IRequestAction; 
  };
}

在上面的代碼中,我們在 request action creator 的原型上綁定了 toString 方法,以及 startsuccessfail 屬性。因爲 action type 是純字符串,手寫很容易出錯,所以我們希望通過 request action creator 直接獲取它們的 type,就像下面這樣:

`${getData}` // "GET_DATA"
`${getData.start}` // "GET_DATA_START"
`${getData.success}` // "GET_DATA_SUCCESS"
`${getData.fail}`  // "GET_DATA_FAIL"

1.2.2 Request Middleware

接下來,我們需要創建一個 middleware 來統一處理 request action。middleware 的邏輯很簡單,就是攔截所有的 request action,然後發起 HTTP 請求:

  • 請求開始:dispatch xxx_STAT action,方便顯示 loading
  • 請求成功:攜帶 API Response,dispatch xxx_SUCCESS action
  • 請求失敗:攜帶 Error 信息,dispatch xxx_FAIL action

這裏需要注意的是,request middleware 需要「吃掉」request action,也就是說不把這個 action 交給下游的 middleware 進行處理。一是因爲邏輯已經在這個 middleware 處理完成了,下游的 middleware 無需處理這類 action。二是因爲如果下游的 middleware 也 dispatch request action,會造成死循環,引發不必要的問題。

1.3 如何使用?

我們可以通過分發 request action 來觸發請求的調用。然後在 reducer 中去處理 request success action,將請求的響應數據存入 redux store。

但是,很多時候我們不僅要發起 API 請求,還要在 請求成功請求失敗 的時候去執行一些邏輯。這些邏輯不會對 state 造成影響,因此不需要在 reducer 中去處理。比如:用戶填寫了一個表單,點擊 submit 按鈕時發起 API 請求,當 API 請求成功後執行頁面跳轉。這個問題用 Promise 很好解決,你只需要將邏輯放到它的 then 和 catch 中即可。然而,將請求 「action化」之後,我們不能像 Promise 一樣,在調用請求的同時註冊請求成功和失敗的回調。

如何解決這個問題呢?我們可以實現一種類似 Promise 的調用方式,允許我們在分發 request action 的同時去註冊請求成功和失敗的回調。也就是我們即將介紹的 useRequest。

1.3.1 useRequest: 基於 React Hooks 和 RXJS 調用請求

爲了讓發起請求、請求成功和請求失敗這幾個階段不再割裂,我們設計了 onSuccess 和 onFail 回調。類似於 Promise 的 then 和 catch。希望能夠像下面這樣去觸發 API 請求的調用:

// 僞代碼

useRequest(xxxActionCreator, {
  onSuccess: (requestSuccessAction) => {
    // do something when request success
  },
  onFail: (requestFailAction) => {
    // do something when request fail
  },
});

通過 RxJS 處理請求成功和失敗的回調

Promise 和 callback 都像「潑出去的水」,正所謂「覆水難收」,一旦它們開始執行便無法取消。如果遇到需要「取消」的場景就會比較尷尬。雖然可以通過一些方法繞過這個問題,但始終覺得代碼不夠優雅。因此,我們引入了 RxJS,嘗試用一種新的思路去探索並解決這個問題。

我們可以改造 redux 的 dispatch 方法,在每次 dispatch 一個 action 之前,再 dispatch 一個 subject$ (觀察者)。接着,在 middleware 中創建一個 rootSubject$ (可觀察對象),用於攔截 dispatch 過來的 subject$,並讓它成爲 rootSubject$ 的觀察者。rootSubject$ 會把 dispatch 過來的 action 推送給它的所有觀察者。因此,只需要觀察請求成功和失敗的 action,執行對應的 callback 即可。

image.png

利用 Rx 自身的特性,我們可以方便地控制複雜的異步流程,當然也包括取消。

實現 useRequest Hook

useRequest 提供用於分發 request action 的函數,同時在請求成功或失敗時,執行相應的回調函數。它的輸入和輸出大致如下:

interface IRequestCallbacks<TResp> {
  onSuccess?: (action: IRequestSuccessAction<TResp>) => void;
  onFail?: (action: IRequestFailAction) => void;
}

export enum RequestStage {
  START = "START",
  SUCCESS = "SUCCESS",
  FAILED = "FAIL",
}

const useRequest = <T extends IRequestActionCreator<T["TReq"], T["TResp"]>>(
  actionCreator: T,
  options: IRequestCallbacks<T["TResp"]> = {},
  deps: DependencyList = [],
) => {
  
  // ...
  
  return [request, requestStage$] as [typeof request, BehaviorSubject<RequestStage>];
};

它接收 actionCreator 作爲第一個參數,並返回一個 request 函數,當你調用這個函數時,就可以分發相應的 request action從而發起 API 請求

同時它也會返回一個可觀察對象 requestStage$(可觀察對象) ,用於推送當前請求所處的階段。其中包括:請求開始、成功和失敗三個階段。這樣,在發起請求之後,我們就能夠輕鬆地追蹤到它的狀態。這在一些場景下非常有用,比如當請求開始時,在頁面上顯示 loading 動畫,請求結束時關閉這個動畫。

爲什麼返回可觀察對象 requestStage$ 而不是返回 requestStage 狀態呢?如果返回狀態,意味着在請求開始、請求成功和請求失敗時都需要去 setState。但並不是每一個場景都需要這個狀態。對於不需要這個狀態的組件來說,就會造成一些浪費(re-render)。因此,我們返回一個可觀察對象,當你需要用到這個狀態時,去訂閱它就好了。

 options 作爲它的第二個參數,你可以通過它來指定 onSuccess 和 onFail 回調。onSuccess 會將 request success action 作爲參數提供給你,你可以通過它拿到請求成功響應之後的數據。然後,你可以選擇將數據存入 redux store,或是 local state,又或者你根本不在乎它的響應數據,只是爲了在請求成功時去跳轉頁面。但無論如何,通過 useRequest,我們都能更加便捷地去實現需求。

const [getBooks] = useRequest(getBooksUsingGET, {
  success: (action) => {
    saveBooksToStore(action.payload.data); // 將 response 數據存入 redux store
  },
});

const onSubmit = (values: { name: string; price: number }) => {
  getBooks(values);
};

複雜場景

useRequest 封裝了調用請求的邏輯,通過組合多個 useRequest ,可以應對很多複雜場景。

處理多個相互獨立的 Request Action

同時發起多個不同的 request action,這些 request action 之間相互獨立,並無關聯。這種情況很簡單,使用多個 useRequest 即可。

const [requestA] = useRequest(A);
const [requestB] = useRequest(B);
const [requestC] = useRequest(C);

useEffect(() => {
  requestA();
  requestB();
  requestC();
}, []);
處理多個相互關聯的 Request Action

同時發起多個不同的 request action,這些 request action 之間有先後順序。比如發起 A 請求,A 請求成功了之後發起 B 請求,B 請求成功了之後再發起 C 請求。

由於 useRequest 會創建發起請求的函數,並在請求成功之後執行 onSuccess 回調。因此,我們可以通過 useRequest 創建多個 request 函數,並預設它們成功響應之後的邏輯。就像 RXJS 中「預鋪設管道」一樣,當事件發生之後,系統會按照預設的管道運作。

// 預先創建所有的 request 函數,並預設 onSuccess 的邏輯
const [requestC] = useRequest(C);

const [requestB] = useRequest(B, {
  onSuccess: () => {
    requestC();
  },
});
const [requestA] = useRequest(A, {
  onSuccess: () => {
    requestB();
  },
});

// 當 requestA 真正調用之後,程序會按照預設的邏輯執行。

<form onSubmit={requestA}>
處理多個相同的 request action

同時發起多個完全相同的 request action,但是出於性能的考慮,我們通常會「吃掉」相同的 action,只有最後一個 action 會發起 API 請求。也就是我們前面提到過的 API 去重。但是對於 request action 的回調函數來說,可能會有下面兩種不同的需求:

  1. 每個相同 request action 所對應的 onSuccess/onFail 回調在請求成功時都會被執行。
  2. 只執行真正發起請求的這個 action 所對應的 onSuccess/onFail 回調。

對於第一個場景來說,我們可以判斷 action 的 type 和 payload 是否一致,如果一致就執行對應的 callback,這樣相同 action 的回調都可以被執行。對於第二個場景,我們可以從 action 的 payload 上做點「手腳」,action 的 payload 放置的是我們發起請求時需要的 request config,通過添加一個 UUID,可以讓這個和其他 action「相同」的 action 變得「不同」,這樣就只會執行這個 request action 所對應的回調函數。

組件卸載

通常我們會使用 Promise 或者 XMLHttpRequest 發起 API 請求,但由於 API 請求是異步的,在組件卸載之後,它們的回調函數仍然會被執行。這就可能導致一些問題,比如在已卸載的組件裏執行 setState。

組件被卸載之後,組件內部的邏輯應該隨之「銷燬」,我們不應該再執行任何組件內包含的任何邏輯。利用 RxJS,useRequest 能夠在組件銷燬時自動取消所有邏輯。換句話說,就是不再執行請求成功或者失敗的回調函數。

2. 存儲並使用請求響應的數據

對於 API Response 這一類數據,我們應該如何存儲呢?由於不同的 API Response 數據對應用有着不同的作用,因此我們可以抽象出對應的數據模型,然後分類存儲。就像我們收納生活用品一樣,第一個抽屜放餐具,第二個抽屜放零食......

image.png

按照數據變化的頻率,或者說數據的存活時間,我們可以將 API response 大致歸爲兩類:

一類是變化頻率非常高的數據,比如排行榜列表,可能每一秒都在發生變化,這一類數據沒有緩存價值,我們稱之爲臨時數據(temporary data)。臨時數據用完之後會被銷燬。

另一類是不常發生變化的數據,我們稱之爲實體數據(entity),比如國家列表、品牌列表。這一類數據很多時候需要緩存到本地,將它們歸爲一類更易於做數據持久化。

2.1 useTempData

2.1.2 背景

通過 useRequest 我們已經能夠非常方便的去調用 API 請求了。但是對於大部分業務場景來說,還是會比較繁瑣。試想一個非常常見的需求:將 API 數據渲染到頁面上。我們通常需要以下幾個步驟:

Step1: 組件 mount 時,dispatch 一個 request action。這一步可以通過 useRequest 實現。

Step2: 處理 request success action,並將數據存入 store 中。

Step3: 從 store 的 state 中 pick 出對應的數據,並將其提供給組件。

Step4: 組件拿到數據並渲染頁面。

Step5: 執行某些操作之後,用新的 request 參數重新發起請求。

Step6: 重複 Step2、Step3、Step4。

如果每一次集成 API 都要通過上面的這些步驟才能完成,不僅會浪費大量時間,也會生產大量模板代碼。並且,由於邏輯非常地分散,我們無法爲它們統一添加測試,因此需要在每個使用的地方單獨去測。可想而知,開發效率一定會大打折扣。

爲了解決這個問題,我們抽象了 useTempData。之前也提到過 temp data 的概念,其實它就是指頁面上的臨時數據,通常都是「閱後即焚」。我們項目上通過 API 請求獲取的數據大部分都是這一類。useTempData 主要用於在組件 mount 時自動獲取 API 數據,並在組件 unmount 時自動銷燬它們。

2.1.3 輸入和輸出

useTempData 會在組件 mount 時自動分發 request action,當請求成功之後將響應數據存入 redux  store,然後從 store 提取出響應數據,將響應數據提供給外部使用。當然,你也可以通過配置,讓 useTempData 響應請求參數的變化,當請求參數發生變化時,useTempData 會攜帶新的請求參數重新發起請求。

其核心的輸入輸出如下:

export const useTempData = <T extends IRequestActionCreator<T["TReq"], T["TResp"]>>(
  actionCreator: T,
  args?: T["TReq"],
  deps: DependencyList = [],
) => {
  // ...
  return [data, requestStage, fetchData] as [
    typeof actionCreator["TResp"],
    typeof requestStage,
    typeof fetchData,
  ];
};

它接收 actionCreator 作爲第一個參數,用於創建相應的 request action。當組件 mount 時,會自動分發 request action。args 作爲第二個參數,用於設置請求參數。 deps 作爲第三個參數,當它發生變化時,會重新分發 request action。

同時,它會返回 API 響應的數據 data、表示請求當前所處階段的 requestStage  以及用於分發 request action 的函數 fetchData 。

使用起來也非常方便,如果業務場景比較簡單,集成 API 就是一行代碼的事:

const [books] = useTempData(getBooksUsingGET, { bookType }, [bookType]);

// 拿到 books 數據,渲染 UI

2.1.4 實現思路

useTempData 基於 useRequest 實現。在組件 mount 時分發 request action,然後在請求成功的回調函數 onSuccess 中再分發另一個 action,將請求響應的數據存入 redux store。

const [fetchData] = useRequest(actionCreator, {
  success: (action) => {
    dispatch(updateTempData(groupName, reducer(dataRef.current, action))),
  },
});

useEffect(() => {
  fetchData(args as any);
}, deps);

2.1.5 組件卸載

當組件卸載時,如果 store 的 state 已經保存了這個 request action 成功響應的數據,useTempData 會自動將它清除。發起 API 請求之後,如果組件已經卸載,useTempData 就不會將請求成功響應的數據存入 redux store。

2.2 useEntity

基於 useTempData 的設計,我們可以封裝 useEntity, 用於統一處理 entity 這類數據。這裏不再贅述。

3. 自動生成代碼

利用代碼生成工具,我們可以通過 swagger 文檔自動生成 request action creator 以及接口定義。並且,每一次都會用服務端最新的 swagger json 來生成代碼。這在接口變更時非常有用,只需要一行命令,就可以更新接口定義,然後通過 TypeScript 的報錯提示,依次修改使用的地方即可。

同一個 swagger 生成的代碼我們會放到同一個文件裏。在多人協作時,爲了避免衝突,我們會將生成的 request action creator 以及接口定義按照字母順序進行排序,並且每一次生成的文件都會覆蓋之前的文件。因此,我們在項目上還硬性規定了:生成的文件只能自動生成,不能夠手動修改。

4. 最後

自動生成代碼工具爲我們省去了很大一部分工作量,再結合我們之前講過的 useRequest、useTempData 和 useEntity,集成 API 就變成了一項非常輕鬆的工作。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章