天天看點

基于 React 和 Redux 的 API 內建解決方案

基于 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 方法,以及 start、 success 和 fail 屬性。因為 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 即可。

利用 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 資料對應用有着不同的作用,是以我們可以抽象出對應的資料模型,然後分類存儲。就像我們收納生活用品一樣,第一個抽屜放餐具,第二個抽屜放零食......

按照資料變化的頻率,或者說資料的存活時間,我們可以将 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 就變成了一項非常輕松的工作。

本文完~

基于 React 和 Redux 的 API 內建解決方案