精讀《@umijs/use-request》源碼

1 引言

與組件生命週期綁定的 Utils 很是適合基於 React Hooks 來作,好比能夠將 「發請求」 這個功能與組件生命週期綁定,實現一些便捷的功能。前端

此次以 @umijs/use-request 爲例子,分析其功能思路與源碼。git

2 簡介

@umijs/use-request 支持如下功能:github

  • 默認自動請求:在組件初次加載時自動觸發請求函數,並自動管理 loading, data , error 狀態。
  • 手動觸發請求:設置 options.manual = true , 則手動調用 run 時纔會取數。
  • 輪詢請求:設置 options.pollingInterval 則進入輪詢模式,可經過 run / cancel 開始與中止輪詢。
  • 並行請求:設置 options.fetchKey 能夠對請求狀態隔離,經過 fetches 拿到全部請求狀態。
  • 請求防抖:設置 options.debounceInterval 開啓防抖。
  • 請求節流:設置 options.throttleInterval 開啓節流。
  • 請求緩存 & SWR:設置 options.cacheKey 後開啓對請求結果緩存機制,下次請求前會優先返回緩存並在後臺從新取數。
  • 請求預加載:因爲 options.cacheKey 全局共享,能夠提早執行 run 實現預加載效果。
  • 屏幕聚焦從新請求:設置 options.refreshOnWindowFocus = true 在瀏覽器 refocusrevisible 時從新請求。
  • 請求結果突變:能夠經過 mutate 直接修改取數結果。
  • 加載延遲:設置 options.loadingDelay 能夠延遲 loading 變成 true 的時間,有效防止閃爍。
  • 自定義請求依賴:設置 options.refreshDeps 能夠在依賴變更時從新觸發請求。
  • 分頁:設置 options.paginated 可支持翻頁場景。
  • 加載更多:設置 options.loadMore 可支持加載更多場景。

一切 Hooks 的功能拓展都要基於 React Hooks 生命週期,咱們能夠利用 Hooks 作下面幾件與組件相關的事:數組

  1. 存儲與當前組件實例綁定的 mutable、immutable 數據。
  2. 主動觸發調用組件 rerender。
  3. 訪問到組件初始化、銷燬時機的鉤子。

上面這些功能就能夠基於這些基礎能力拓展了:瀏覽器

默認自動請求緩存

在組件初始時機取數。因爲和組件生命週期綁定,能夠很方便實現各組件相互隔離的取數順序強保證:能夠利用取數閉包存儲 requestIndex,取數結果返回後與當前最新 requestIndex 進行比對,丟棄不一致的取數結果。微信

手動觸發請求閉包

將觸發取數的函數抽象出來並在 CustomHook 中 return。async

輪詢請求函數

在取數結束後設定 setTimeout 從新觸發下一輪取數。

並行請求

每次取數時先獲取當前請求惟一標識 fetchKey,僅更新這個 key 下的狀態。

請求防抖、請求節流

這個實現方式能夠挺通用化,即取數調用函數處替換爲對應 debouncethrottle 函數。

請求預加載

這個功能只要實現全局緩存就天然支持了。

屏幕聚焦從新請求

這個能夠統一監聽 window action 事件,並觸發對應組件取數。能夠全局統一監聽,也能夠每一個組件分別監聽。

請求結果突變

因爲取數結果存儲在 CustomHook 中,直接修改數據 data 值便可。

加載延遲

有加載延遲時,能夠先將 loading 設置爲 false,等延遲到了再設置爲 true,若是此時取數提早完畢則銷燬定時器,實現無 loading 取數。

自定義請求依賴

利用 useEffect 和自帶的 deps 便可。

分頁

基於通用取數 Hook 封裝,本質上是多帶了一些取數參數與返回值參數,並遵循 Antd Table 的 API。

加載更多

和分頁相似,區別是加載更多不會清空已有數據,而且須要根據約定返回結構 noMore 判斷是否能繼續加載。

3 精讀

接下來是源碼分析。

首先定義了一個類 Fetch,這是由於一個 useRequestfetchKey 特性能夠經過多實例解決。

Class 的生命週期不依賴 React Hooks,因此將不依賴生命週期的操做收斂到 Class 中,不只提高了代碼抽象程度,也提高了可維護性。

class Fetch<R, P extends any[]> {
  // ...
  // 取數狀態存儲處
  state: FetchResult<R, P> = {
    loading: false,
    params: [] as any,
    data: undefined,
    error: undefined,
    run: this.run.bind(this.that),
    mutate: this.mutate.bind(this.that),
    refresh: this.refresh.bind(this.that),
    cancel: this.cancel.bind(this.that),
    unmount: this.unmount.bind(this.that),
  };

  constructor(
    service: Service<R, P>,
    config: FetchConfig<R, P>,
    // 外部經過這個回調訂閱 state 變化
    subscribe: Subscribe<R, P>,
    initState?: { data?: any; error?: any; params?: any; loading?: any }
  ) {}

  // 此 setState 非彼 setState,做用是更新 state 並通知訂閱
  setState(s = {}) {
    this.state = {
      ...this.state,
      ...s,
    };
    this.subscribe(this.state);
  }

  // 實際取數函數,但下劃線命名的帶有一些歷史氣息啊
  _run(...args: P) {}

  // 對外暴露的取數函數,對防抖和節流作了分發處理
  run(...args: P) {
    if (this.debounceRun) {
      // return ..
    }
    if (this.throttleRun) {
      // return ..
    }
    return this._run(...args);
  }

  // 取消取數,考慮到了防抖、節流兼容性
  cancel() {}

  // 以上次取數參數從新取數
  refresh() {}

  // 輪詢 starter
  rePolling() {}

  // 對應 mutate 函數
  mutate(data: any) {}

  // 銷燬訂閱
  unmount() {}
}
複製代碼

默認自動請求

經過 useEffect 零依賴實現,須要:

  1. 有緩存則不需響應,當對應緩存結束後會通知,同時也支持了請求預加載功能。
  2. 爲支持並行請求,全部請求都經過 fetches 獨立管理。
// 第一次默認執行
useEffect(() => {
  if (!manual) {
    // 若是有緩存
    if (Object.keys(fetches).length > 0) {
      /* 從新執行全部的 */
      Object.values(fetches).forEach((f) => {
        f.refresh();
      });
    } else {
      // 第一次默認執行,能夠經過 defaultParams 設置參數
      run(...(defaultParams as any));
    }
  }
}, []);
複製代碼

默認執行第 11 行,並根據當前的 fetchKey 生成對應 fetches,若是初始化已經存在 fetches,則行爲改成從新執行全部 已存在的 並行請求。

手動觸發請求

上一節已經在初始請求時禁用了 manual 開啓時的默認取數。下一步只要將封裝的取數函數 run 定義出來並暴露給用戶:

const run = useCallback(
  (...args: P) => {
    if (fetchKeyPersist) {
      const key = fetchKeyPersist(...args);
      newstFetchKey.current = key === undefined ? DEFAULT_KEY : key;
    }
    const currentFetchKey = newstFetchKey.current;
    // 這裏必須用 fetchsRef,而不能用 fetches。
    // 不然在 reset 完,當即 run 的時候,這裏拿到的 fetches 是舊的。
    let currentFetch = fetchesRef.current[currentFetchKey];
    if (!currentFetch) {
      const newFetch = new Fetch(
        servicePersist,
        config,
        subscribe.bind(null, currentFetchKey),
        {
          data: initialData,
        }
      );
      currentFetch = newFetch.state;
      setFeches((s) => {
        // eslint-disable-next-line no-param-reassign
        s[currentFetchKey] = currentFetch;
        return { ...s };
      });
    }
    return currentFetch.run(...args);
  },
  [fetchKey, subscribe]
);
複製代碼

主動取數函數與內部取數函數共享一個,因此 run 函數要考慮多種狀況,其中之一就是並行取數的狀況,所以須要拿到當前取數的 fetchKey,並建立一個 Fetch 的實例,最終調用 Fetch 實例的 run 函數取數。

輪詢請求

輪詢取數在 Fetch 實際取數函數 _fetch 中定義,當取數函數 fetchService(對多種形態的取數方法進行封裝後)執行完後,不管正常仍是報錯,都要進行輪詢邏輯,所以在 .finally 時機裏判斷:

fetchService.then().finally(() => {
  if (!this.unmountedFlag && currentCount === this.count) {
    if (this.config.pollingInterval) {
      // 若是屏幕隱藏,而且 !pollingWhenHidden, 則中止輪詢,並記錄 flag,等 visible 時,繼續輪詢
      if (!isDocumentVisible() && !this.config.pollingWhenHidden) {
        this.pollingWhenVisibleFlag = true;
        return;
      }
      this.pollingTimer = setTimeout(() => {
        this._run(...args);
      }, this.config.pollingInterval);
    }
  }
});
複製代碼

輪詢還要考慮到屏幕是否隱藏,若是能夠觸發輪詢則觸發定時器再次調用 _run,注意這個定時器須要正常銷燬。

並行請求

每一個 fetchKey 對應一個 Fetch 實例,這個邏輯在 手動觸發請求 介紹的 run 函數中已經實現。

這塊的封裝思路能夠品味一下,從外到內分別是 React Hooks 的 fetch -> Fetch 類的 run -> Fetch 類的 _run,並行請求作在 React Hooks 這一層。

請求防抖、請求節流

這個實現就在 Fetch 類的 run 函數中:

function run(...args: P) {
  if (this.debounceRun) {
    this.debounceRun(...args);
    return Promise.resolve(null as any);
  }
  if (this.throttleRun) {
    this.throttleRun(...args);
    return Promise.resolve(null as any);
  }
  return this._run(...args);
}
複製代碼

因爲防抖和節流是 React 無關的,也不是最終取數無關的,所以實如今 run 這個夾層函數進行分發。

這裏實現的比較簡化,防抖後 run 拿到的 Promise 再也不是有效的取數結果了,其實這塊仍是能夠進一步對 Promise 進行封裝,不管在防抖仍是正常取數的場景都返回 Promise,只需 resolve 的時機由 Fetch 這個類靈活把控便可。

請求預加載

預加載就是緩存機制,首先利用 useEffect 同步緩存:

// cache
useEffect(() => {
  if (cacheKey) {
    setCache(cacheKey, {
      fetches,
      newstFetchKey: newstFetchKey.current,
    });
  }
}, [cacheKey, fetches]);
複製代碼

在初始化 Fetch 實例時優先採用緩存:

const [fetches, setFeches] = useState<Fetches<U, P>>(() => {
  // 若是有 緩存,則從緩存中讀數據
  if (cacheKey) {
    const cache = getCache(cacheKey);
    if (cache) {
      newstFetchKey.current = cache.newstFetchKey;
      /* 使用 initState, 從新 new Fetch */
      const newFetches: any = {};
      Object.keys(cache.fetches).forEach((key) => {
        const cacheFetch = cache.fetches[key];
        const newFetch = new Fetch();
        // ...
        newFetches[key] = newFetch.state;
      });
      return newFetches;
    }
  }
  return [];
});
複製代碼

屏幕聚焦從新請求

Fetch 構造函數實現監聽並調用 refresh 便可,源碼裏採起全局統一監聽的方式:

function subscribe(listener: () => void) {
  listeners.push(listener);
  return function unsubscribe() {
    const index = listeners.indexOf(listener);
    listeners.splice(index, 1);
  };
}

let eventsBinded = false;
if (typeof window !== "undefined" && window.addEventListener && !eventsBinded) {
  const revalidate = () => {
    if (!isDocumentVisible()) return;
    for (let i = 0; i < listeners.length; i++) {
      // dispatch 每一個 listener
      const listener = listeners[i];
      listener();
    }
  };
  window.addEventListener("visibilitychange", revalidate, false);
  // only bind the events once
  eventsBinded = true;
}
複製代碼

Fetch 構造函數裏註冊:

this.limitRefresh = limit(this.refresh.bind(this), this.config.focusTimespan);

if (this.config.pollingInterval) {
  this.unsubscribe.push(subscribeVisible(this.rePolling.bind(this)));
}
複製代碼

並經過 limit 封裝控制調用頻率,並 push 到 unsubscribe 數組,一邊監聽能夠隨組件一塊兒銷燬。

請求結果突變

這個函數只要更新 data 數據結果便可:

function mutate(data: any) {
  if (typeof data === "function") {
    this.setState({
      data: data(this.state.data) || {},
    });
  } else {
    this.setState({
      data,
    });
  }
}
複製代碼

值得注意的是,cancelrefreshmutate 都必須在初次請求完成後纔有意義,因此初次返回的函數是一個拋錯:

const noReady = useCallback(
  (name: string) => () => {
    throw new Error(`Cannot call ${name} when service not executed once.`);
  },
  []
);

return {
  loading: !manual || defaultLoading,
  data: initialData,
  error: undefined,
  params: [],
  cancel: noReady("cancel"),
  refresh: noReady("refresh"),
  mutate: noReady("mutate"),
  ...(fetches[newstFetchKey.current] || {}),
} as BaseResult<U, P>;
複製代碼

等取數完成後會被 ...(fetches[newstFetchKey.current] || {}) 這一段覆蓋爲正常函數。

加載延遲

若是設置了加載延遲,請求發動時就不該該當即設置爲 loading,這個邏輯寫在 _run 函數中:

function _run(...args: P) {
  // 取消 loadingDelayTimer
  if (this.loadingDelayTimer) {
    clearTimeout(this.loadingDelayTimer);
  }
  this.setState({
    loading: !this.config.loadingDelay,
    params: args,
  });

  if (this.config.loadingDelay) {
    this.loadingDelayTimer = setTimeout(() => {
      this.setState({
        loading: true,
      });
    }, this.config.loadingDelay);
  }
}
複製代碼

啓動一個 setTimeout 將 loading 設爲 true 便可,這個 timeout 在下次執行 _run 時被 clearTimeout 清空。

自定義請求依賴

最明智的作法是利用 useEffect 實現,實際代碼作了組件 unmount 保護:

//  refreshDeps 變化,從新執行全部請求
useUpdateEffect(() => {
  if (!manual) {
    /* 所有從新執行 */
    Object.values(fetchesRef.current).forEach((f) => {
      f.refresh();
    });
  }
}, [...refreshDeps]);
複製代碼

非手動條件下,依賴變化全部已存在的 fetche 執行 refresh 便可。

分頁和加載更多就不解析了,原理是在 useAsync 這個基礎請求 Hook 基礎上再包一層 Hook,拓展取數參數與返回結果。

4 總結

目前還有 錯誤重試、請求超時管理、Suspense 沒有支持,看完這篇精讀後,相信你已經能夠提 PR 了。

討論地址是:精讀《@umijs/use-request》源碼 · Issue #249 · dt-fe/weekly

若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公衆號

版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章
相關標籤/搜索