ahooks useRequest源碼精讀解析

前言

自從 React v16.8 推出瞭 Hooks API,前端框架圈並開啟瞭新的邏輯復用的時代,不再需要在意 HOC 的無限套娃導致性能差的問題,也解決瞭 mixin 的可閱讀性差的問題。當然對於 React 最大的變化是函數式組件可以有自己的狀態,扁平化的邏輯組織方式,更加友好地支持 TS 類型聲明。

除瞭 React 官方提供的一些 Hooks,也支持我們能根據自己的業務場景自定義 Hooks,還有一些通用的 Hooks,例如用於請求的 useRequest,用於定時器的 useTimeout,用於節流的 useThrottle 等。於是出現瞭大量的 Hooks 庫,ahooks 是其中比較受歡迎的 Hooks 庫之一,其提供瞭大量的 Hooks,基本滿足瞭大多數場景的需求。又是國人開發,中文文檔友好,在我們團隊的一些項目中就使用瞭 ahooks。

其中最常用的 hooks 就是 useRequest,用於從後端請求數據的業務場景,除瞭簡單的數據請求,它還支持:

  • 輪詢
  • 防抖和節流
  • 錯誤重試
  • SWR(stale-while-revalidate)
  • 緩存

等功能,基本上滿足瞭我們請求後端數據需要考慮的大多數場景,當然還有 loading-delay、頁面 foucs 重新刷新數據等這些功能,但是個人理解上面列的功能才是使用比較頻繁的功能點。

一個 Hooks 實現這麼多功能,我還是對其內部的實現比較好奇的,所以本文就從源碼的角度帶大傢瞭解 useRequest 的實現。

架構圖

我們從一張圖開始瞭解其模塊設計,對於一個功能復雜的 API,如果不使用合適的架構和方式組織代碼,其擴展性和可維護性肯定比較差。功能點實現和核心代碼混在一起,閱讀代碼的人也無從下手,也帶來更大的測試難度。雖然 useRequest 隻是一個 Hook,但是實際上其設計還是有清晰的架構,我們來看看 useRequest 的架構圖:

我把 useRequest 的模塊劃分為三大塊:Core、Plugins、utils,然後 useRequest 將這些模塊組合在一起實現核心功能。

先看插件部分,看到每個插件的命名,如果瞭解 useRequest 的功能就會發現,基本上每個功能點對應一個插件。這也是 useRequest 設計比較巧妙的一點,通過插件化機制降低瞭每個功能之間的耦合度,也降低瞭其本身的復雜度。這些點我們在分析具體的源碼的時候會再詳細介紹。

另外一部分核心的代碼我將其歸類為 Core(在 useRequest 的源碼中沒有這個名詞),主要實現瞭一個 Fetch 類,這個類是 useRequest 的插件化機制實現和其它功能的核心實現。

下面我們深入源碼,看下其實現原理。

源碼解析

先看 Core 部分的源碼,主要是 Fetch 這個類的實現。

Fetch

先貼代碼:

export default class Fetch<TData, TParams extends any[]> {
  pluginImpls: PluginReturn<TData, TParams>[];
  count: number = 0;
  state: FetchState<TData, TParams> = {
    loading: false,
    params: undefined,
    data: undefined,
    error: undefined,
  };
  constructor(
    public serviceRef: MutableRefObject<Service<TData, TParams>>,
    public options: Options<TData, TParams>,
    public subscribe: Subscribe,
    public initState: Partial<FetchState<TData, TParams>> = {},
  ) {
    this.state = {
      ...this.state,
      loading: !options.manual,
      ...initState,
    };
  }
  setState(s: Partial<FetchState<TData, TParams>> = {}) {
    // 省略一些代碼
  }
  runPluginHandler(event: keyof PluginReturn<TData, TParams>, ...rest: any[]) {
    // 省略一些代碼
  }
  async runAsync(...params: TParams): Promise<TData> {
    // 省略一些代碼
  }
  run(...params: TParams) {
    // 省略一些代碼
  }
  cancel() {
    // 省略一些代碼
  }
  refresh() {
    // 省略一些代碼
  }
  refreshAsync() {
    // 省略一些代碼
  }
  mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {
    // 省略一些代碼
  }
}

Fetch 類 API 的設計還是比較簡潔的,而且也不是特別多,實際上有些 API 就是直接從 useRequest 暴露給外部用戶使用的,比如 run、runAsync、cancel、refresh、refreshAsync、mutate 等。像 runPluginHandler、setState 等 API 主要是給內部用的 API,不過它也沒有做區分,從封裝的角度上來說,這一點個人感覺設計得不夠好。

重點關註下幾個 Fetch 類的屬性,一個是 state,它的類型是 FetchState<TData, TParams>,一個是 pluginImpls,它是 PluginReturn<TData, TParams> 數組,實際上這個屬性就用來存所有插件執行後返回的結果。還有一個 count 屬性,是 number 類型,不看具體源碼,完全不知道這個屬性是做什麼用的。這點也是 useRequest 開發者做得感覺不是很好的地方,很少有註釋,純靠閱讀者深入到源碼,去看使用的地方,才能知道一些方法和屬性的作用。

那我們先來看下 FetchState<TData, TParams> 的定義,它定義在 src/type.ts 裡面:

export interface FetchState<TData, TParams extends any[]> {
  loading: boolean;
  params?: TParams;
  data?: TData;
  error?: Error;
}

它的定義還是比較簡單,看起來是存一個請求結果的上下文信息,這些信息其實都是需要暴露給外部用戶的,例如 loadingdataerrors 等不就是我們使用 useRequest 經常需要拿到的數據信息:

const { data, error, loading } = useRequest(service);

而對應的 Fetch 封裝瞭 setState API,實際上就是用來更新 state 的數據:

setState(s: Partial&lt;FetchState&lt;TData, TParams&gt;&gt; = {}) {
    this.state = {
      ...this.state,
      ...s,
    };
  	// ? 未知
    this.subscribe();
  }

除瞭更新 state,這裡還調用瞭一個 subscribe 方法,這是初始化 Fetch 類的時候傳進來的一個參數,它的類型是 Subscribe,等後面將到調用的地方再看這個方法是怎麼實現的,以及它的作用。

再看下 PluginReturn<TData, TParams> 的類型定義:

export interface PluginReturn<TData, TParams extends any[]> {
  onBefore?: (params: TParams) =>
    | ({
        stopNow?: boolean;
        returnNow?: boolean;
      } & Partial<FetchState<TData, TParams>>)
    | void;
  onRequest?: (
    service: Service<TData, TParams>,
    params: TParams,
  ) => {
    servicePromise?: Promise<TData>;
  };
  onSuccess?: (data: TData, params: TParams) => void;
  onError?: (e: Error, params: TParams) => void;
  onFinally?: (params: TParams, data?: TData, e?: Error) => void;
  onCancel?: () => void;
  onMutate?: (data: TData) => void;
}

實際上都是一些回調鉤子,從名字對應上來看,對應瞭請求的各個階段,除瞭 onMutate 是其內部擴展的一個鉤子。

也就是說 pluginImpls 裡面存的是一堆含有各個鉤子函數的對象集合,如果技術敏銳的同學,可能很容易就想到發佈訂閱模式,這不就是存瞭一系列的 subscribe 回調,這不過這是一個回調的集合,裡面有各種不同請求階段的回調。那麼到底是不是這樣,我們繼續往下看。

要搞清楚 Fetch 的運作方式,我們需要看兩個核心 API 的實現:runPluginHandlerrunAsync,其它所有的 API 實際上都在調用這兩個 API,然後做一些額外的特殊邏輯處理。

先看 runPluginHandler

runPluginHandler(event: keyof PluginReturn<TData, TParams>, ...rest: any[]) {
	// @ts-ignore
  const r = this.pluginImpls.map((i) => i[event]?.(...rest)).filter(Boolean);
  return Object.assign({}, ...r);
}

這個方法實現還是比較簡單,隻有兩行代碼。跟我們之前猜測的大致差不多,這個方法就是接收一個 event 參數,它的類型就是 keyof PluginReturn<TData, TParams>,也就是:onBefore | onRequest | onSuccess | onError | onFinally | onCancel | onMutate 的聯合類型,以及其它額外的參數,然後從 pluginImpls 中找出所有對應的 event 回調鉤子函數,然後執行回調函數,拿到結果並返回。

再看 runAsync 的實現:

async runAsync(...params: TParams): Promise<TData> {
    this.count += 1;
    const currentCount = this.count;
    const {
      stopNow = false,
      returnNow = false,
      ...state
    } = this.runPluginHandler('onBefore', params);
    // stop request
    if (stopNow) {
      return new Promise(() => {});
    }
    this.setState({
      loading: true,
      params,
      ...state,
    });
    // return now
    if (returnNow) {
      return Promise.resolve(state.data);
    }
    this.options.onBefore?.(params);
    try {
      // replace service
      let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params);
      if (!servicePromise) {
        servicePromise = this.serviceRef.current(...params);
      }
      const res = await servicePromise;
      if (currentCount !== this.count) {
        // prevent run.then when request is canceled
        return new Promise(() => {});
      }
      // const formattedResult = this.options.formatResultRef.current ? this.options.formatResultRef.current(res) : res;
      this.setState({
        data: res,
        error: undefined,
        loading: false,
      });
      this.options.onSuccess?.(res, params);
      this.runPluginHandler('onSuccess', res, params);
      this.options.onFinally?.(params, res, undefined);
      if (currentCount === this.count) {
        this.runPluginHandler('onFinally', params, res, undefined);
      }
      return res;
    } catch (error) {
      if (currentCount !== this.count) {
        // prevent run.then when request is canceled
        return new Promise(() => {});
      }
      this.setState({
        error,
        loading: false,
      });
      this.options.onError?.(error, params);
      this.runPluginHandler('onError', error, params);
      this.options.onFinally?.(params, undefined, error);
      if (currentCount === this.count) {
        this.runPluginHandler('onFinally', params, undefined, error);
      }
      throw error;
    }
  }

看著代碼挺多的,其實看下來很好理解。 這個函數實際上做的事就是調用我們傳入的獲取數據的方法,然後拿到成功或者失敗的結果,進行一系列的數據處理,然後更新到 state,執行插件的各回調鉤子,還有就是我們通過 options 傳入的回調函數。

可能直接用文字直接描述比較抽象,下面我們分請求階段分析代碼。

首先前兩行是對 count 屬性的累加處理,之前我們不知道這個屬性的作用,看到這裡可能猜測大概是跟請求相關的,後面看到 currentCount 的使用的地方,我們再說。

onBefore

接下來 5~27 行實際上是對 onBefore 回調鉤子的執行,然後拿到結果做的一些邏輯處理。這裡調用的就是 runPluginHandler 方法,傳入的參數是 onBefore 和外部用戶定義的 params 參數。然後執行完所有的 onBefore 鉤子函數,拿到最後的結果,如果 stopNow 的 flag 是 true,則直接返回沒有結果的 Promise。看註釋,我們知道這裡實際上做的是取消請求的處理,當我們在 onBefore 的鉤子裡實現瞭取消的邏輯,符合條件後並會真正的阻斷請求。

如果沒有取消,然後接著更新 state 數據,如果立即返回的 returnNow flag 為 true,則立馬將更新後的 state 返回,否則執行用戶傳入的 options 中的 onBefore 回調,也就是說在調用 useRequest 的時候,我們可以通過 options 參數傳入 onBefore 函數,進行請求之前的一些邏輯處理。

onRequest

接下來後面的代碼就是真正執行請求數據的方法瞭,這裡就會執行所有的 onRequest 鉤子。實際上,通過 onRequest 鉤子我們是可以重寫傳入的獲取數據的方法,因為最後執行的是 onRequest 回調返回的 servicePromise

拿到最後執行的請求數據方法,就開始發起請求。在這裡發現瞭前面的 currentCount 的使用,它會去對比當前最新的 count 和執行這個方法時定義的 currentCount 是否相等,如果不相等,則會做類似於取消請求的處理。這裡大概知道 count 的作用類似於一個”鎖“的作用,我的理解是,如果在執行這些代碼過程有產生一些比這裡優先級更高的處理邏輯或者請求操作,是需要 cancel 掉這次的請求,以最新的請求為準。當然,最後還是要看哪些地方可能會修改 count。

onSuccess

執行完請求後,如果請求成功,則拿到請求返回的數據,更新到 state,執行用戶傳入的成功回調和各插件的成功回調鉤子。

onFinally

成功之後,執行 onFinally 鉤子,這裡也很嚴謹,也會比較 count 的值,確保一致之後,才會執行各插件的回調鉤子,預發一些”競態“情況的發生。

onError

如果請求失敗,就會進入到 catch 分支,執行一些處理錯誤的邏輯,更新 error 信息到 state 中。同樣這裡也會有 count 的對比,然後執行 onError 的回調。執行完 onError 也會同樣執行 onFinally 的回調,因為一個請求要麼成功,要麼失敗,都會需要執行最後的 onFinally 回調。

其它 API

其它的例如 run、cancel、refresh 等 API,實際上調用的是 runPluginHandlerrunAsync API,例如 run:

run(...params: TParams) {
    this.runAsync(...params).catch((error) => {
      if (!this.options.onError) {
        console.error(error);
      }
    });
  }

代碼很容易看懂,就不過多介紹。

我們來看看 cancel 的實現:

cancel() {
    this.count += 1;
    this.setState({
      loading: false,
    });
    this.runPluginHandler('onCancel');
  }

最後的 runPluginHandler 調用我們已經很清楚它的作用瞭,這裡值得註意的是對 count 的修改。前面我們提到每次 runAsync 一些核心階段會判斷 count 是否和 currentCount 能對得上,看到這裡我們就徹底明白瞭 count 的作用瞭。實際上在我們執行瞭 run 的操作,如果在本次 runAsync 方法執行過程中,我們就調用瞭 cancel 方法,那麼無論是在請求發起前還是後,都會把本次執行當做 cancel 處理,返回空的數據。也就是說,這個 count 就是為瞭實現請求取消功能的一個標識。

小結

看完瞭 runAsync 的實現,實際上就代表我們看完瞭 Fetch 的核心邏輯。從一個請求的生命周期角度來看,其實它的實現就很容易理解,主要做兩件事:

  • 執行各階段的鉤子回調;
  • 更新數據到 state。

這歸功於 useRequest 的巧妙設計,我們看這部分源碼,隻要看懂瞭類型和兩個核心的方法,都不用關心具體每個插件的實現。它將每個功能點的復雜度和核心的邏輯通過插件機制隔離開來,從而每個插件隻需要按一定的契約實現好自己的功能就行,然後 Fetch 不管有多少插件,隻負責在合適的時間點調用插件鉤子,做到瞭完全的解耦。

plugins

其實看完瞭 Fetch,還沒看插件,你腦子裡就大概知道怎麼去實現一個插件。因為插件比較多,限於篇幅原因,這裡就以 usePollingPlugin 和 useRetryPlugin 兩個插件為例,進行詳細的源碼介紹。

usePollingPlugin

首先需要清楚一點每個插件實際也是一個 Hook,所以在它內部可以使用任何 Hook 的功能或者調用其它 Hook。先看 usePollingPlugin:

const usePollingPlugin: Plugin<any, any[]> = (
  fetchInstance,
  { pollingInterval, pollingWhenHidden = true },
) => {
  const timerRef = useRef<NodeJS.Timeout>();
  const unsubscribeRef = useRef<() => void>();
  const stopPolling = () => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    unsubscribeRef.current?.();
  };
  useUpdateEffect(() => {
    if (!pollingInterval) {
      stopPolling();
    }
  }, [pollingInterval]);
  if (!pollingInterval) {
    return {};
  }
  return {
    onBefore: () => {
      stopPolling();
    },
    onFinally: () => {
      // if pollingWhenHidden = false && document is hidden, then stop polling and subscribe revisible
      if (!pollingWhenHidden && !isDocumentVisible()) {
        unsubscribeRef.current = subscribeReVisible(() => {
          fetchInstance.refresh();
        });
        return;
      }
      timerRef.current = setTimeout(() => {
        fetchInstance.refresh();
      }, pollingInterval);
    },
    onCancel: () => {
      stopPolling();
    },
  };
};

它接受兩個參數,一個是 fetchInstance,也就是前面提到的 Fetch 實例,第二個參數是 options,支持傳入 pollingInterval、pollingWhenHidden 兩個屬性。這兩個屬性從命名上比較容易理解,一個就是輪詢的時間間隔,另外一個猜測應該是可以在某種場景下通過設置這個 flag 停止輪詢。在真實的場景中,確實有比如要求用戶在切換到其它 tab 頁時停止輪詢等這樣的需求。所以這個配置,還比較好理解。

而每個插件的作用就是在請求的各個階段進行定制化的邏輯處理,以輪詢為例,其最核心的邏輯在於 onFinally 的回調,在每次請求結束後,設置一個 setTimeout,然後按用戶傳入的 pollingInterval 進行定時執行 Fetch 的 refresh 方法。

還有就是停止輪詢的時機,每次用戶主動取消請求,在 onCancel 的回調停止輪詢。如果已經開始瞭輪詢,在每次新的請求調用的時候先停止上一次的輪詢,避免重復。當然包括,如果組件修改瞭 pollingInterval 等的時候,需要先停止掉之前的輪詢。

useRetryPlugin

假設讓你去設計一個 retry 的插件,那麼你的設計思路是什麼瞭?需要關註的核心邏輯是什麼?還是前面那句話: 每個插件的作用就是在請求的各個階段進行定制化的邏輯處理,那如果要實現 retry 肯定你首要關註的是,什麼時候才需要 retry?答案顯而易見,那就是請求失敗的時候,也就是需要在 onError 回調實現 retry 的邏輯。考慮得周全一點,你還需要知道 retry 的次數,因為第二次也可能失敗瞭。當然還有就是 retry 的時間間隔,失敗後多久 retry?這些是外部使用者關心的,所以應該將它們設計成配置項。

分析好瞭需求,我們看下 retry 插件的實現:

const useRetryPlugin: Plugin<any, any[]> = (fetchInstance, { retryInterval, retryCount }) => {
  const timerRef = useRef<NodeJS.Timeout>();
  const countRef = useRef(0);
  const triggerByRetry = useRef(false);
  if (!retryCount) {
    return {};
  }
  return {
    onBefore: () => {
      if (!triggerByRetry.current) {
        countRef.current = 0;
      }
      triggerByRetry.current = false;
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    },
    onSuccess: () => {
      countRef.current = 0;
    },
    onError: () => {
      countRef.current += 1;
      if (retryCount === -1 || countRef.current <= retryCount) {
        // Exponential backoff 指數補償
        const timeout = retryInterval ?? Math.min(1000 * 2 ** countRef.current, 30000);
        timerRef.current = setTimeout(() => {
          triggerByRetry.current = true;
          fetchInstance.refresh();
        }, timeout);
      } else {
        countRef.current = 0;
      }
    },
    onCancel: () => {
      countRef.current = 0;
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    },
  };
};

第一個參數跟 usePollingPlugin 的插件一樣,都是接收 Fetch 實例,第二個參數是 options,支持 retryInterval、retryCount 等選型,從命名上看跟我們剛開始分析需求的時候想的差不多。

看代碼,核心的邏輯主要是在 onError 的回調中。首先前面定義瞭一個 countRef,記錄 retry 的次數。執行瞭 onError 回調,代表新的一次請求錯誤發生,然後判斷如果 retryCount 為 -1,或者當前 retry 的次數還小於用戶自定義的次數,則通過一個定時器設置下次 retry 的時間,否則將 countRef 重置。

還需要註意的是其它的一些回調的處理,比如當請求成功或者被取消,需要重置 countRef,取消的時候還需要清理可能存在的下一次 retry 的定時器。

這裡 onBefore 的邏輯處理怎麼理解瞭?首先這裡會有一個 triggerByRetry 的 flag,如果 flag 是 false。則會清空 countRef。然後會將 triggerByRetry 設置為 false,然後清理掉上一次可能存在的 retry 定時器。我個人的理解是這裡設置一個 flag 是為瞭避免如果 useRequest 重新執行,導致請求重新發起,那麼在 onBefore 的時候需要做一些重置處理,以防和上一次的 retry 定時器撞車。

小結

其它插件的設計思路是類似的,關鍵是要分析出你需要實現的功能是作用在請求的哪個階段,那麼就需要在這個鉤子裡實現核心的邏輯處理。然後再考慮其它鉤子的一些重置處理,取消處理等,所以在優秀合理的設計下實現某個功能它的成本是很低的,而且也不需要關心其它插件的邏輯,這樣每個插件也是可以獨立測試的。

useRequest

分析瞭核心的兩塊源碼,我們來看下,怎麼組裝最後的 useRequest。首先在 useRequest 之前,還有一層抽象叫 useRequestImplement,看下是怎麼實現的:

function useRequestImplement<TData, TParams extends any[]>(
  service: Service<TData, TParams>,
  options: Options<TData, TParams> = {},
  plugins: Plugin<TData, TParams>[] = [],
) {
  const { manual = false, ...rest } = options;
  const fetchOptions = {
    manual,
    ...rest,
  };
  const serviceRef = useLatest(service);
  const update = useUpdate();
  const fetchInstance = useCreation(() => {
    const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean);
    return new Fetch<TData, TParams>(
      serviceRef,
      fetchOptions,
      update,
      Object.assign({}, ...initState),
    );
  }, []);
  fetchInstance.options = fetchOptions;
  // run all plugins hooks
  // 這裡為什麼可以使用 map 循環去執行每個插件 hooks
  fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));
  useMount(() => {
    if (!manual) {
      // useCachePlugin can set fetchInstance.state.params from cache when init
      const params = fetchInstance.state.params || options.defaultParams || [];
      // @ts-ignore
      fetchInstance.run(...params);
    }
  });
  useUnmount(() => {
    fetchInstance.cancel();
  });
  return {
    loading: fetchInstance.state.loading,
    data: fetchInstance.state.data,
    error: fetchInstance.state.error,
    params: fetchInstance.state.params || [],
    cancel: useMemoizedFn(fetchInstance.cancel.bind(fetchInstance)),
    refresh: useMemoizedFn(fetchInstance.refresh.bind(fetchInstance)),
    refreshAsync: useMemoizedFn(fetchInstance.refreshAsync.bind(fetchInstance)),
    run: useMemoizedFn(fetchInstance.run.bind(fetchInstance)),
    runAsync: useMemoizedFn(fetchInstance.runAsync.bind(fetchInstance)),
    mutate: useMemoizedFn(fetchInstance.mutate.bind(fetchInstance)),
  } as Result<TData, TParams>;
}

前面兩個參數如果使用過 useRequest 的都知道,就是我們通常傳給 useRequest 的參數,一個是請求 api,一個就是 options。這裡還多瞭個插件參數,大概可以知道,內置的一些插件應該會在更上層的地方傳進來,做一些參數初始化的邏輯。

然後通過 useLatest 構造一個 serviceRef,保證能拿到最新的 service。接下來,使用 useUpdate Hook 創建瞭update 方法,然後再創建 fetchInstance 的時候作為第三個參數傳遞給 Fetch,這裡就是我們前面提到過的 subscribe。那我們要看下 useUpdate 做瞭什麼:

const useUpdate = () =&gt; {
  const [, setState] = useState({});
  return useCallback(() =&gt; setState({}), []);
};

原來是個”黑科技“,類似 class 組件的 $forceUpdate API,就是通過 setState,讓組件強行渲染一次。

接著就是使用 useMount,如果發現用戶沒有設置 manual 或者將其設置為 false,立馬會執行一次請求。當組件被銷毀的時候,在 useUnMount 中進行請求的取消。最後返回暴露給用戶的數據和 API。

最後看下 useRequest 的實現:

function useRequest<TData, TParams extends any[]>(
  service: Service<TData, TParams>,
  options?: Options<TData, TParams>,
  plugins?: Plugin<TData, TParams>[],
) {
  return useRequestImplement<TData, TParams>(service, options, [
    ...(plugins || []),
    useDebouncePlugin,
    useLoadingDelayPlugin,
    usePollingPlugin,
    useRefreshOnWindowFocusPlugin,
    useThrottlePlugin,
    useRefreshDeps,
    useCachePlugin,
    useRetryPlugin,
    useReadyPlugin,
  ] as Plugin<TData, TParams>[]);
}

這裡就會把內置的插件傳入進去,當然還有用戶自定義的插件。實際上 useRequest 是支持用戶自定義插件的,這又突出瞭插件化設計的必要性。除瞭能降低本身自己的功能之間的復雜度,也能提供更多的靈活度給到用戶,如果你覺得功能不夠,實現自定義插件吧。

對自定義 hook 的思考

面向對象編程裡面有一個原則叫職責單一原則, 我個人理解它的含義是我們在設計一個類或者一個方法時,它的職責應該盡量單一。如果一個類的抽象不在一個層次,那麼這個類註定會越來越膨脹,難以維護。一個方法職責越單一,它的復用性就可能越高,可測試性也越好。

其實我們在設計一個 hooks,也是需要參照這個原則的。Hooks API 出現的一個重大意義,就是解決我們在編寫組件時的邏輯復用問題。沒有 Hooks,之前是使用 HOC、Render props或者 Mixin 等解決邏輯復用的問題,然而每一種方式在大量實踐後都發現有明顯的缺點。所以,我們在自定義一個 Hook 時,總是應該朝著提高復用性的角度出發。

光說太抽象,舉個之前我在業務開發中遇到的一個例子。在一個項目中,我們封裝瞭一個計算預算的 Hook 叫 useBudgetValidate,不方便貼所有代碼,下面通過偽代碼列下這個 Hook 做的事:

export default function useBudgetValidate({ id, dailyBudgetType, mode }: Options) {
  const [dailyBudgetSetting, setDailyBudgetSetting] = useState<BudgetSetting | null>(null);
  // 從後端獲取某個數據
  const { data: adSetCountRes } = useRequest(
    (campaign: ReactText) => getSomeData({ params: { id } }));
  // 從後端獲取預算配置
  useRequest(
    () => {
      return getBudgetSetting();
    },
    {
      onSuccess: result => setDailyBudgetSetting(result),
    },
  );
  /**
   * 對於傳入的預算的類型, 返回的預算設置
   */
  const currentDailyBudgetSetting: DailyBudgetSetting | undefined = useMemo(() => {
    if (dailyBudgetType === BudgetTypeEnum.AdSet) {
      return dailyBudgetSetting?.adset;
    }
    if (dailyBudgetType === BudgetTypeEnum.Smart) {
      return dailyBudgetSetting?.smart;
    }
    const campaignBudget = dailyBudgetSetting?.campaign;
    // 這裡有大量的計算邏輯,得到最後的 campaignBudget
    return campaignBudget;
  }, []);
  return {
    currentDailyBudgetSetting,
    dailyBudgetSetting,
  };
}

初一看,這個 Hook 沒有太大的問題,不就是從後端獲取數據,然後根據不同的傳參進行預算計算,然後返回預算信息。但是現在有個問題,因為計算預算是項目通用的邏輯。在另外一個頁面也需要這段計算邏輯,但是那個頁面已經從後端其它的接口獲取瞭預算信息,或者通過其它方式構造瞭計算預算需要的數據。所以這裡的核心矛盾點在於很多頁面依賴這段計算邏輯,但是數據來源是不一致的。將獲取預算配置和其它信息的接口邏輯放在這個 Hook 裡面就會導致它的職責不單一,所以沒法很容易在其它場景復用。

重構的思路很簡單,就是將數據請求的邏輯抽離,單獨封裝一個 Hook,或者把職責交給組件去做。這個 Hook 隻做一件事,那就是接收配置和其它參數,進行預算計算,將結果返回給外面。

但是對於 useRequest 這樣功能很復雜的 Hook 又怎麼理解瞭?從功能上看,感覺它既做瞭一般請求數據的功能,又做瞭輪詢,做瞭緩存,做瞭重試,做瞭。。。反正很多很多的職責。

但是,如果你認真思考,發現這些功能又是依賴請求這個關鍵點,也就是說從這個角度來看,它們的抽象是在同一層次上。而且 useRquest 是一個更加通用的 Hook,它作為一個 package 給大量的用戶使用。如果你是一個使用者,你八成希望它是什麼能力都有,你需要的它有,你暫時不需要的,它也幫你想好瞭。

在 Philosophy of Software Design 一書中提到一個概念叫:深模塊,它的意思是:深模塊是那些既提供瞭強大功能但又有著簡單接口的模塊。在設計一些模塊或者 API 的時候,比如像 useRequest 這種,那麼就要符合這個原則,用戶隻需要少量的配置,就能使用各插件帶來的豐富功能。

所以最後,總結下:如果我們在日常業務開發封裝一些 Hook,我們應該盡量保證職責單一,以提高其復用性。如果我們需要設計一個抽象程度很高,然後給多個項目使用的 Hook,那麼在設計的時候,應該符合深模塊的特點,接口盡量簡單,又需要滿足各需求場景,將功能復雜度隱藏在 Hook 內部。

總結

本文主要從 Fetch 類的實現和 plugins 的設計詳細解析瞭 useRequest 的源碼,看完源碼,我們知道瞭:

  • useRequest 核心源碼主要在 Fetch 類的實現中,通過巧妙的將請求劃分為各個階段的設計,然後把豐富的功能交給每個插件去實現,解耦功能之間的關系,降低本身維護的復雜度,提高可測試性;
  • useRequest 雖然隻是一個代碼千行左右的 Hook,但是通過插件化機制,使得各個功能之間完全解耦,提高瞭代碼的可維護性和可測試性,同時也提供瞭用戶自定義插件的能力;
  • 職責單一的原則在任何場景下引用都不會過時,我們在設計一些 Hook 的時候應該也要考慮單一原則。但是在設計一些跨多項目通用的 Hook,應該朝著深模塊的角度設計,提供簡單的接口,把復雜度隱藏在模塊內部。

以上就是ahooks useRequest源碼精讀解析的詳細內容,更多關於ahooks useRequest源碼的資料請關註WalkonNet其它相關文章!

推薦閱讀: