Observable 防腐層項目實戰

在 基於 Observable 構建前端防腐策略 發佈後,有一些讀者留言對文章中使用 Observable 構建防腐層的典型應用感到困惑,覺得代碼中的例子過於簡單,不少可以通過 Promise 解決,引入 Observable 會提高而非降低複雜度。

這種顧慮是完全有道理的,在 RxJS 中可以由 Promise 操作符來替代的場景還有很多,事實上,所有能由 Observable 實現的場景理論上都可以 Promise 來實現,畢竟 RxJS 是基於 JavaScript 構建,整個 Observable 的核心實現也不過只有 100 行代碼。

然而上文的例子只是用來說明防腐層的場景,而並非複雜到一定要使用防腐層的情況。既然我們需要構建防腐層,實際業務中的場景不可能只有 2 個接口,3 個組件這樣簡單。

在複雜的業務場景下,基於 Observable 構建的防腐層可以提升我們的代碼開發效率,更好的抽象和封裝底層接口。以下舉幾個項目中防腐層的實戰場景 ,每個場景均附加了在線示例。

1. 接口穩定提升

在線示例: https://stackblitz.com/edit/rxjs-stable-improvement

操作符: retry / retryWhen / delay

有些時候後端接口的成功率較低,但是前端爲了保證視圖層穩定,需要對這些接口的成功率進行增強。這裏,我們使用 Promise 模擬一個成功率只有 50% 的接口,代碼如下:

// 成功率 50% 的接口
function unstableAPI(): Promise<boolean> {
  return new Promise((resolve, reject) => {
    if (Math.random() < 0.5) {
      resolve(true);
    } else {
      reject('error');
    }
  });
}

通過 RxJS 的 retry 操作符,我們可以很容易將 50% 成功率的接口組裝爲成功率 99.9% 的接口,即每次當接口失敗時,自動重試最多 10 次。

function stabilizedAPI(): Promise<boolean> {
  return lastValueFrom(
    from(defer(() => unstableAPI())).pipe(retry(10))
  );
}

實際的業務中,以上代碼會導致代碼短時間內多次重試,可能會導致接口雪崩。在 RxJS 中我們可以輕鬆實現錯誤回退機制,以花費更多時間的代價來獲得更大的成功幾率。

我們將 stabilizedAPI 的代碼修改爲以下代碼,當發生錯誤時,等待 1s 後重新發起請求,最多發送 10 次。

function stabilizedAPI(): Promise<boolean> {
  return lastValueFrom(
    from(defer(() => unstableAPI())).pipe(
      retryWhen((errors) => errors.pipe(delay(1000), take(10)))
    )
  );
}

2. 接口時序調整

在線示例: https://stackblitz.com/edit/rxjs-minimal-response-time

操作符: forkJoin / delay

絕大部分的前端應用都會有啓動屏幕,啓動屏幕中可能包含廣告、加載動畫或者應用 logo 信息等內容,應用啓動屏幕的展示時間通常由以下兩個因素決定:

  1. 網絡加載耗時 networkDelay: 應用加載所需的關鍵數據接口,例如用戶個人信息的最長返回時間

  2. 頁面最小展示時間 minimalDelay: 啓動屏幕包含的有效信息需要有一個最短展示時間,防止屏幕閃爍

啓動屏幕的展示時間應當由以下邏輯計算:當網絡加載耗時小於頁面最小展示時間時,將以頁面最小展示時間爲準,當大於頁面最小展示時間時,將網絡加載耗時爲準。簡化公式爲:

啓動屏幕展示時間 = Max(關鍵接口加載時間,最短加載時間)

我們使用 Promise 來模擬關鍵數據返回,其中網絡接口延時由 setTimeout 來模擬

function initData(): Promise<{ name: string }> {
  return new Promise((resolve) => {
    const networkDelay = Math.random() * 3000;
    setTimeout(() => {
      resolve({ name: 'lucy' });
    }, networkDelay);
  });
}

通過 forkJoin delay 等 operator,我們可以組裝出給啓動屏幕使用的最短返回時間接口

// 初始化數據,返回時間必定大於 minimalDelay ms
function initDataWithMinimalDelay(minimalDelay: number): Promise<{ name: string }> {
  return lastValueFrom(
    forkJoin([
      from(defer(() => initData())),
      of(true).pipe(delay(minimalDelay)),
    ]).pipe(map(([data]) => data))
  );
}

在以上代碼中,當 networkDelay 調用時間小於 minimalDelay 時,將以 minimalDelay 爲準,當大於 minimalDelay 時,將以 networkDelay 爲準。

3. 接口擇優使用

在線示例: https://stackblitz.com/edit/rxjs-race-query

操作符: raceWith

有時相同的數據可以從後端多個接口中獲取,我們使用 Promise 模擬快慢兩個接口

// 快速接口
function fastAPI(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('fast data');
    }, 1000);
  });
}
// 慢速接口
function slowAPI(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('slow data');
    }, 3000);
  });
}

在實際的使用中,我們無法提前知曉接口的網絡情況,通過 raceWith 操作符,我們可以對任意個接口進行封裝,自動獲取其中最快的那個

function getFasterOne(): Promise<string> {
  return lastValueFrom(
    from(defer(() => fastAPI())).pipe(raceWith(from(defer(() => slowAPI()))))
  );
}

4. 接口競態處理

在線示例:https://stackblitz.com/edit/rxjs-race-condition

操作符:exhaustMap / switchMap / concatMap

接口的請求結果返回的順序不能保證一致,這就要求我們在業務中需要對接口的競態問題進行處理。Dan Abramov 在 useEffect 完整指南使用了布爾值來對數據進行處理。但是如果你使用 Observable 構建了防腐層,就會有更簡單的方法來處理競態問題。

我們使用 randomuser.me 的服務與 fromFetch operator 構建一個簡單的數據層

function getData() {
  return fromFetch('https://api.randomuser.me/?page=1&results=10').pipe(
    map((data) => data.json())
  );
}

4.1 以第一次請求爲準

由於防腐層 Observable 的特性,使用 Observable 與 exhaustMap 結合就可以獲得與 flag 標註相同的效果,即當前一次請求未返回時,下一次請求會被直接拋棄。

fromEvent(document.getElementById('button'), 'click')
  .pipe(exhaustMap(() => getData()));

4.2 以最後一次請求爲準

我們也可以選擇以最後一次請求爲基準,將之前所有的請求都拋棄,在組件內直接使用 switchMap operator 來保證請求順序與返回數據一致,fromFetch 中內置了 AbortController 可以將過期但仍未返回的接口置爲 canceled 狀態。

fromEvent(document.getElementById('button'), 'click')
  .pipe(switchMap(() => getData()));

4.3 所有請求排隊處理

將所有發出的請求排隊處理,不丟棄任何一次請求,當上一次請求未返回時,下一次請求進入隊列排隊。

fromEvent(document.getElementById('button'), 'click')
  .pipe(concatMap(() => getData()));

5. 高階數據組裝

在線示例:https://stackblitz.com/edit/rxjs-high-order-query

操作符:mergeMap / map / forkJoin

有些時候需要二次請求才能獲得視圖層的數據,例如下圖中的數據可能由 getList 與 getStatus 兩個接口才能完整獲取。當我們需要同步渲染這些數據時,在防腐層中抽象出 getListWithStatus 會是更好的選擇。

我們使用 Promise 模擬出兩個接口的內容

// 模擬獲取列表數據的接口
function getList(): Promise<
  Array<{
    name: string;
    id: string;
  }>
> {
  return new Promise((resolve) => {
    resolve([
      {
        name: 'John Brown',
        id: '1',
      },
      {
        name: 'Jim Green',
        id: '2',
      }
    ]);
  });
}
// 模擬獲取狀態接口
function getStatus(id: string) {
  return new Promise((resolve) => {
    if (id === '2') {
      resolve('old');
    } else {
      resolve('young');
    }
  });
}

通過 mergeMap 與 forkJoin,我們可以將高階的請求直接打平爲一階數組,獲得含有 status 列表數據的接口抽象

// 抽象後含有 status 的列表數據
function getListWithStatus() {
  const getList$ = from(defer(() => getList()));
  const getStatus$ = (id: string) => from(defer(() => from(getStatus(id))));
  const data$ = getList$.pipe(
    mergeMap((list) => {
      const queryList = list.map((item) =>
        getStatus$(item.id).pipe(map((status) => ({ ...item, status })))
      );
      return forkJoin(queryList);
    })
  );
  return lastValueFrom(data$);
}

調用 getListWithStatus 返回的數據爲

[
    {
        "name": "John Brown",
        "id": "1",
        "status": "young"
    },
    {
        "name": "Jim Green",
        "id": "2",
        "status": "old"
    }
]

總結

本文給出了 Observable 防腐層在實際項目中的一些相對複雜的例子,通過 Observable 防腐層的引入可以使用較少代碼來實現上述複雜功能。

複雜業務的有效設計對於簡單場景來說很可能是過度設計。不建議在看不到應用場景的時候強行引入 Observable,工程領域實踐中沒有銀彈,感謝大家的閱讀。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/g5PANaoZmm3fkxCiIZ-ZxA