大家會發(fā)現(xiàn),自從 React v16.8 推出了 Hooks API,前端框架圈并開啟了新的邏輯復用的時代,從此無需在意 HOC 的無限套娃導致性能差的問題,同時也解決了 mixin 的可閱讀性差的問題。這里也有對于 React 最大的變化是函數(shù)式組件可以有自己的狀態(tài),扁平化的邏輯組織方式,更加友好地支持 TS 類型聲明。
在運用Hooks的時候,除了 React 官方提供的,同時也支持我們能根據(jù)自己的業(yè)務場景自定義 Hooks,還有一些通用的 Hooks,例如用于請求的useRequest,用于定時器的useTimeout,用于節(jié)流的useThrottle等。于是出現(xiàn)了大量的 Hooks 庫,ahooks是其中比較受歡迎的 Hooks 庫之一,其提供了大量的 Hooks,基本滿足了大多數(shù)場景的需求。又是國人開發(fā),中文文檔友好,在我們團隊的一些項目中就使用了 ahooks。
其中最常用的 hooks 就是useRequest,用于從后端請求數(shù)據(jù)的業(yè)務場景,除了簡單的數(shù)據(jù)請求,它還支持:
輪詢
防抖和節(jié)流
錯誤重試
SWR(stale-while-revalidate)
緩存
等功能,這樣看起來是不是基本上滿足了我們請求后端數(shù)據(jù)需要考慮的大多數(shù)場景,其中還有 loading-delay、頁面 foucs 重新刷新數(shù)據(jù)等這些功能,就個人看法上面的功能才是使用比較頻繁的功能點。
一個 Hooks 實現(xiàn)這么多功能,不禁感嘆它的強大,所以本文就從源碼的角度帶大家了解 useRequest 的實現(xiàn)。
架構圖
下面是關于了解其模塊設計,對于一個功能復雜的 API,如果不使用合適的架構和方式組織代碼,其擴展性和可維護性肯定比較差。功能點實現(xiàn)和核心代碼混在一起,閱讀代碼的人也無從下手,也帶來更大的測試難度。雖然 useRequest 只是一個 Hook,但是實際上其設計還是有清晰的架構,我們來看看 useRequest 的架構圖:
將 useRequest 的模塊劃分為三大塊:Core、Plugins、utils,然后 useRequest 將這些模塊組合在一起實現(xiàn)核心功能。
先看插件部分,看到每個插件的命名,如果了解 useRequest 的功能就會發(fā)現(xiàn),基本上每個功能點對應一個插件。這也是 useRequest 設計比較巧妙的一點,通過插件化機制降低了每個功能之間的耦合度,也降低了其本身的復雜度。這些點我們在分析具體的源碼的時候會再詳細介紹。
另外一部分核心的代碼我將其歸類為 Core(在 useRequest 的源碼中沒有這個名詞),主要實現(xiàn)了一個 Fetch 類,這個類是 useRequest 的插件化機制實現(xiàn)和其它功能的核心實現(xiàn)。
下面我們深入源碼,看下其實現(xiàn)原理。
源碼解析
先看 Core 部分的源碼,主要是 Fetch 這個類的實現(xiàn)。
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,然它也有區(qū)分的做法,但從封裝的來說設計感并不好。
重點關注下幾個 Fetch 類的屬性,一個是 state,它的類型是FetchState<TData, TParams>,一個是 pluginImpls,它是PluginReturn<TData, TParams>數(shù)組,實際上這個屬性就用來存所有插件執(zhí)行后返回的結果。還有一個 count 屬性,是number類型,這個不看源代碼,是無法知道做什么的。這點useRequest 開發(fā)者做的不夠好。注釋也很少,全靠閱讀者深入到源碼,去看使用的地方,才能知道一些方法和屬性的作用。
那我們先來看下FetchState<TData, TParams>的定義,它定義在 src/type.ts 里面:
export interface FetchState<TData, TParams extends any[]> { loading: boolean; params?: TParams; data?: TData; error?: Error; }
這個定義就十分簡單了,就是存一個請求結果的上下文信息,其實這些信息需要暴露給外部用戶的,例如loading、data、errors等不就是我們使用 useRequest 經常需要拿到的數(shù)據(jù)信息:
const { data, error, loading } = useRequest(service);
而對應的 Fetch 封裝了 setState API,實際上就是用來更新 state 的數(shù)據(jù):
setState(s: Partial<FetchState<TData, TParams>> = {}) { this.state = { ...this.state, ...s, }; // ? 未知 this.subscribe(); }
除了更新 state,這里還調用了一個 subscribe 方法,這是初始化 Fetch 類的時候傳進來的一個參數(shù),它的類型是Subscribe,等后面將到調用的地方再看這個方法是怎么實現(xiàn)的,以及它的作用。
再看下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 里面存的是一堆含有各個鉤子函數(shù)的對象集合,如果技術敏銳的同學,可能很容易就想到發(fā)布訂閱模式,這不就是存了一系列的 subscribe 回調,這不過這是一個回調的集合,里面有各種不同請求階段的回調。那么到底是不是這樣,我們繼續(xù)往下看。
要搞清楚 Fetch 的運作方式,我們需要看兩個核心 API 的實現(xiàn):runPluginHandler和runAsync,其它所有的 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 參數(shù),它的類型就是keyof PluginReturn<TData, TParams>,也就是:onBefore | onRequest | onSuccess | onError | onFinally | onCancel | onMutate的聯(lián)合類型,以及其它額外的參數(shù),然后從 pluginImpls 中找出所有對應的 event 回調鉤子函數(shù),然后執(zhí)行回調函數(shù),拿到結果并返回。
再看runAsync的實現(xiàn):
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; } }
現(xiàn)在我們先說下上面代碼,這個函數(shù)實際上做的事就是調用我們傳入的獲取數(shù)據(jù)的方法,然后拿到成功或者失敗的結果,進行一系列的數(shù)據(jù)處理,然后更新到 state,執(zhí)行插件的各回調鉤子,還有就是我們通過 options 傳入的回調函數(shù)。
這樣說文字不知道大家是否聽的懂,現(xiàn)在我分請求階段分析代碼。
首先前兩行是對 count 屬性的累加處理,詳細不在這里說,等后面看到 currentCount 的使用的地方,我們再說。
onBefore
接下來 5~27 行實際上是對 onBefore 回調鉤子的執(zhí)行,這樣就可以拿到結果做的一些邏輯處理。這里調用的就是 runPluginHandler 方法,傳入的參數(shù)是 onBefore 和外部用戶定義的 params 參數(shù)。然后執(zhí)行完所有的 onBefore 鉤子函數(shù),拿到最后的結果,如果 stopNow 的 flag 是 true,則直接返回沒有結果的 Promise。看注釋,我們知道這里實際上做的是取消請求的處理,當我們在 onBefore 的鉤子里實現(xiàn)了取消的邏輯,符合條件后并會真正的阻斷請求。
當然如果沒有取消,然后接著更新 state 數(shù)據(jù),如果立即返回的 returnNow flag 為 true,則立馬將更新后的 state 返回,否則執(zhí)行用戶傳入的 options 中的 onBefore 回調,也就是說在調用 useRequest 的時候,我們可以通過 options 參數(shù)傳入 onBefore 函數(shù),進行請求之前的一些邏輯處理。
onRequest
現(xiàn)在就是真正執(zhí)行請求數(shù)據(jù)的方法了,這里就會執(zhí)行所有的 onRequest 鉤子。實際上,通過 onRequest 鉤子我們是可以重寫傳入的獲取數(shù)據(jù)的方法,因為最后執(zhí)行的是 onRequest 回調返回的servicePromise。
拿到最后執(zhí)行的請求數(shù)據(jù)方法,就開始發(fā)起請求。在這里發(fā)現(xiàn)了前面的 currentCount 的使用,它會去對比當前最新的 count 和執(zhí)行這個方法時定義的 currentCount 是否相等,如果不相等,則會做類似于取消請求的處理。這里大概知道 count 的作用類似于一個”鎖“的作用,我的理解是,如果在執(zhí)行這些代碼過程有產生一些比這里優(yōu)先級更高的處理邏輯或者請求操作,是需要 cancel 掉這次的請求,以最新的請求為準。當然,最后還是要看哪些地方可能會修改 count。
onSuccess
執(zhí)行完請求后,如果請求成功,則拿到請求返回的數(shù)據(jù),更新到 state,執(zhí)行用戶傳入的成功回調和各插件的成功回調鉤子。
onFinally
成功之后,執(zhí)行 onFinally 鉤子,這里也很嚴謹,也會比較 count 的值,確保一致之后,才會執(zhí)行各插件的回調鉤子,預發(fā)一些”競態(tài)“情況的發(fā)生。
onError
如果請求失敗,就會進入到 catch 分支,執(zhí)行一些處理錯誤的邏輯,更新 error 信息到 state 中。同樣這里也會有 count 的對比,然后執(zhí)行 onError 的回調。執(zhí)行完 onError 也會同樣執(zhí)行 onFinally 的回調,因為一個請求要么成功,要么失敗,都會需要執(zhí)行最后的 onFinally 回調。
其它 API
其它的例如 run、cancel、refresh 等 API,實際上調用的是runPluginHandler和runAsyncAPI,例如 run:
run(...params: TParams) { this.runAsync(...params).catch((error) => { if (!this.options.onError) { console.error(error); } }); }
代碼很容易看懂,就不過多介紹。
我們來看看 cancel 的實現(xiàn):
cancel() { this.count += 1; this.setState({ loading: false, }); this.runPluginHandler('onCancel'); }
最后的 runPluginHandler 調用的作用我們十分明白,要注意的是對 count 的修改。前面我們提到每次 runAsync 一些核心階段會判斷 count 是否和 currentCount 能對得上,看到這里我們就徹底明白了 count 的作用了。實際上在我們執(zhí)行了 run 的操作,如果在本次 runAsync 方法執(zhí)行過程中,我們就調用了 cancel 方法,那么無論是在請求發(fā)起前還是后,都會把本次執(zhí)行當做 cancel 處理,返回空的數(shù)據(jù)。也就是說,這個 count 就是為了實現(xiàn)請求取消功能的一個標識。
小結
其實這里了解runAsync的實現(xiàn),實際基本上整個的 Fetch 的核心邏輯也看的清楚。從一個請求的生命周期角度來看,這里主要做兩件事:
執(zhí)行各階段的鉤子回調;
更新數(shù)據(jù)到 state。
其實這都歸功于 useRequest 的巧妙設計,我們看這部分源碼,只要看懂了類型和兩個核心的方法,都不用關心具體每個插件的實現(xiàn)。它將每個功能點的復雜度和核心的邏輯通過插件機制隔離開來,從而每個插件只需要按一定的契約實現(xiàn)好自己的功能就行,然后 Fetch 不管有多少插件,只負責在合適的時間點調用插件鉤子,做到了完全的解耦。
plugins
其實看完了 Fetch,還沒看插件,你腦子里就大概知道怎么去實現(xiàn)一個插件。因為插件比較多,限于篇幅原因,這里就以 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(); }, }; };
它接受兩個參數(shù),一個是 fetchInstance,也就是前面提到的 Fetch 實例,第二個參數(shù)是 options,支持傳入 pollingInterval、pollingWhenHidden 兩個屬性。這兩個屬性從命名上比較容易理解,一個就是輪詢的時間間隔,另外一個猜測應該是可以在某種場景下通過設置這個 flag 停止輪詢。在真實的場景中,確實有比如要求用戶在切換到其它 tab 頁時停止輪詢等這樣的需求。所以這個配置,還比較好理解。
而每個插件的作用就是在請求的各個階段進行定制化的邏輯處理,以輪詢?yōu)槔?,其最核心的邏輯在?onFinally 的回調,在每次請求結束后,設置一個 setTimeout,然后按用戶傳入的 pollingInterval 進行定時執(zhí)行 Fetch 的 refresh 方法。
還有就是停止輪詢的時機,每次用戶主動取消請求,在 onCancel 的回調停止輪詢。如果已經開始了輪詢,在每次新的請求調用的時候先停止上一次的輪詢,避免重復。當然包括,如果組件修改了 pollingInterval 等的時候,需要先停止掉之前的輪詢。
useRetryPlugin
假設讓你去設計一個 retry 的插件,那么你的設計思路是什么了?需要關注的核心邏輯是什么?還是前面那句話: 每個插件的作用就是在請求的各個階段進行定制化的邏輯處理,那如果要實現(xiàn) retry 肯定你首要關注的是,什么時候才需要 retry?答案顯而易見,那就是請求失敗的時候,也就是需要在 onError 回調實現(xiàn) retry 的邏輯??紤]得周全一點,你還需要知道 retry 的次數(shù),因為第二次也可能失敗了。當然還有就是 retry 的時間間隔,失敗后多久 retry?這些是外部使用者關心的,所以應該將它們設計成配置項。
分析好了需求,我們看下 retry 插件的實現(xiàn):
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 指數(shù)補償 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); } }, }; };
第一個參數(shù)跟 usePollingPlugin 的插件一樣,都是接收 Fetch 實例,第二個參數(shù)是 options,支持 retryInterval、retryCount 等選型,從命名上看跟我們剛開始分析需求的時候想的差不多。
看代碼,核心的邏輯主要是在 onError 的回調中。首先前面定義了一個 countRef,記錄 retry 的次數(shù)。執(zhí)行了 onError 回調,代表新的一次請求錯誤發(fā)生,然后判斷如果 retryCount 為 -1,或者當前 retry 的次數(shù)還小于用戶自定義的次數(shù),則通過一個定時器設置下次 retry 的時間,否則將 countRef 重置。
還需要注意的是其它的一些回調的處理,比如當請求成功或者被取消,需要重置 countRef,取消的時候還需要清理可能存在的下一次 retry 的定時器。
這里 onBefore 的邏輯處理怎么理解了?首先這里會有一個 triggerByRetry 的 flag,如果 flag 是 false。則會清空 countRef。然后會將 triggerByRetry 設置為 false,然后清理掉上一次可能存在的 retry 定時器。我個人的理解是這里設置一個 flag 是為了避免如果 useRequest 重新執(zhí)行,導致請求重新發(fā)起,那么在 onBefore 的時候需要做一些重置處理,以防和上一次的 retry 定時器撞車。
小結
其它插件的設計思路是類似的,關鍵是要分析出你需要實現(xiàn)的功能是作用在請求的哪個階段,那么就需要在這個鉤子里實現(xiàn)核心的邏輯處理。然后再考慮其它鉤子的一些重置處理,取消處理等,所以在優(yōu)秀合理的設計下實現(xiàn)某個功能它的成本是很低的,而且也不需要關心其它插件的邏輯,這樣每個插件也是可以獨立測試的。
useRequest
分析了核心的兩塊源碼,我們來看下,怎么組裝最后的 useRequest。首先在 useRequest 之前,還有一層抽象叫 useRequestImplement,看下是怎么實現(xiàn)的:
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 循環(huán)去執(zhí)行每個插件 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>; }
前面兩個參數(shù)如果使用過 useRequest 的都知道,就是我們通常傳給 useRequest 的參數(shù),一個是請求 api,一個就是 options。這里還多了個插件參數(shù),大概可以知道,內置的一些插件應該會在更上層的地方傳進來,做一些參數(shù)初始化的邏輯。
然后通過 useLatest 構造一個 serviceRef,保證能拿到最新的 service。接下來,使用 useUpdate Hook 創(chuàng)建了update 方法,然后再創(chuàng)建 fetchInstance 的時候作為第三個參數(shù)傳遞給 Fetch,這里就是我們前面提到過的 subscribe。那我們要看下 useUpdate 做了什么:
const useUpdate = () => { const [, setState] = useState({}); return useCallback(() => setState({}), []); };
原來是個”黑科技“,類似 class 組件的 $forceUpdate API,就是通過 setState,讓組件強行渲染一次。
接著就是使用 useMount,如果發(fā)現(xiàn)用戶沒有設置 manual 或者將其設置為 false,立馬會執(zhí)行一次請求。當組件被銷毀的時候,在 useUnMount 中進行請求的取消。最后返回暴露給用戶的數(shù)據(jù)和 API。
最后看下 useRequest 的實現(xiàn):
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 是支持用戶自定義插件的,這又突出了插件化設計的必要性。除了能降低本身自己的功能之間的復雜度,也能提供更多的靈活度給到用戶,如果你覺得功能不夠,實現(xiàn)自定義插件吧。
對自定義 hook 的思考
面向對象編程里面有一個原則叫職責單一原則, 我個人理解它的含義是我們在設計一個類或者一個方法時,它的職責應該盡量單一。如果一個類的抽象不在一個層次,那么這個類注定會越來越膨脹,難以維護。一個方法職責越單一,它的復用性就可能越高,可測試性也越好。
其實我們在設計一個 hooks,也是需要參照這個原則的。Hooks API 出現(xiàn)的一個重大意義,就是解決我們在編寫組件時的邏輯復用問題。沒有 Hooks,之前是使用 HOC、Render props或者 Mixin 等解決邏輯復用的問題,然而每一種方式在大量實踐后都發(fā)現(xiàn)有明顯的缺點。所以,我們在自定義一個 Hook 時,總是應該朝著提高復用性的角度出發(fā)。
光說太抽象,舉個之前我在業(yè)務開發(fā)中遇到的一個例子。在一個項目中,我們封裝了一個計算預算的 Hook 叫useBudgetValidate,不方便貼所有代碼,下面通過偽代碼列下這個 Hook 做的事:
export default function useBudgetValidate({ id, dailyBudgetType, mode }: Options) { const [dailyBudgetSetting, setDailyBudgetSetting] = useState<BudgetSetting | null>(null); // 從后端獲取某個數(shù)據(jù) 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 就是從后端獲取數(shù)據(jù),然后根據(jù)不同的傳參進行預算計算,然后返回預算信息。可現(xiàn)在有個問題影響,因為計算預算是項目通用的邏輯。在另外一個頁面也需要這段計算邏輯,但是那個頁面已經從后端其它的接口獲取了預算信息,或者通過其它方式構造了計算預算需要的數(shù)據(jù)。因此核心矛盾點在于很多頁面依賴這段計算邏輯,但是數(shù)據(jù)來源是不一致的。將獲取預算配置和其它信息的接口邏輯放在這個 Hook 里面就會導致它的職責不單一,所以沒法很容易在其它場景復用。
現(xiàn)在就說說重構的思路,就是將數(shù)據(jù)請求的邏輯抽離,多帶帶封裝一個 Hook,或者把職責交給組件去做。這個 Hook 只做一件事,那就是接收配置和其它參數(shù),進行預算計算,將結果返回給外面。
現(xiàn)在有個復雜又難解的就是useRequest的功能Hook,從功能上看,感覺它既做了一般請求數(shù)據(jù)的功能,但同時又做了輪詢,做了緩存,做了重試,做了。。。簡而言之就是很多的職責。
但他們都依賴請求這個關鍵點,這也就表明它們的抽象是在同一層次上。而且 useRquest 是一個更加通用的 Hook,它作為一個 package 給大量的用戶使用。如果你是一個使用者,你想要什么能力,它就可以實現(xiàn)什么,超級爽。
在Philosophy of Software Design一書中提到一個概念叫:深模塊,它的意思是:深模塊是那些既提供了強大功能但又有著簡單接口的模塊。在設計一些模塊或者 API 的時候,比如像 useRequest 這種,那么就要符合這個原則,用戶只需要少量的配置,就能使用各插件帶來的豐富功能。
所以最后,總結下:如果我們在日常業(yè)務開發(fā)封裝一些 Hook,要記住應該盡量保證職責單一,以提高其復用性。如果我們需要設計一個抽象程度很高,然后給多個項目使用的 Hook,那么在設計的時候,應該符合深模塊的特點,接口盡量簡單,又需要滿足各需求場景,將功能復雜度隱藏在 Hook 內部。
總結
我們現(xiàn)在降的就是從 Fetch 類的實現(xiàn)和 plugins 的設計詳細解析了 useRequest 的源碼。
useRequest 核心源碼主要在 Fetch 類的實現(xiàn)中,主要是通過巧妙的將請求劃分為各個階段的設計,之后將豐富的功能交給每個插件去實現(xiàn),解耦功能之間的關系,降低本身維護的復雜度,提高可測試性;
useRequest 雖然只是一個代碼千行左右的 Hook,但是通過插件化機制,使得各個功能之間完全解耦,提高了代碼的可維護性和可測試性,同時也提供了用戶自定義插件的能力;
職責單一的原則在任何場景下引用都不會過時,我們在設計一些 Hook 的時候應該也要考慮單一原則。但是在設計一些跨多項目通用的 Hook,應該朝著深模塊的角度設計,提供簡單的接口,把復雜度隱藏在模塊內部。
知識點都已講述了,只看每個人自己的理解。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉載請注明本文地址:http://systransis.cn/yun/128265.html
起因 社會在不斷的向前,技術也在不斷的完善進步。從 React Hooks 正式發(fā)布到現(xiàn)在,越來越多的項目正在使用 Function Component 替代 Class Component,Hooks 這一新特性也逐漸被廣泛的使用。 這樣的解析是不是很熟悉,在日常中時常都有用到,但也有一個可以解決這樣重復的就是對數(shù)據(jù)請求的邏輯處理,對防抖節(jié)流的邏輯處理等。 另一方面,由于 Hoo...
我們今天來講講關于ahooks 源碼,我們目標主要有以下幾點: 深入了解 React hooks?! ∶靼兹绾纬橄笞远x hooks,且可以構建屬于自己的 React hooks 工具庫?! ⌒〗ㄗh:培養(yǎng)閱讀學習源碼的習慣,工具庫是一個對源碼閱讀不錯的選擇。 列表頁常見元素 后臺管理系統(tǒng)中常見典型列表頁包括篩選表單項、Table表格、Pagination分頁這三部分?! ♂槍κ褂?Ant...
我們講下 ahooks 的核心 hook —— useRequest?! seRequest 簡介 根據(jù)官方文檔的介紹,useRequest 是一個強大的異步數(shù)據(jù)管理的 Hooks,React 項目中的網絡請求場景使用 useRequest ,這就可以?! seRequest通過插件式組織代碼,核心代碼極其簡單,并且可以很方便的擴展出更高級的功能。目前已有能力包括: 自動請求/手動請求 ...
這是講 ahooks 源碼的第一篇文章,簡要就是以下幾點: 加深對 React hooks 的理解?! W習如何抽象自定義 hooks。構建屬于自己的 React hooks 工具庫?! ∨囵B(yǎng)閱讀學習源碼的習慣,工具庫是一個對源碼閱讀不錯的選擇?! ∽ⅲ罕鞠盗袑?ahooks 的源碼解析是基于v3.3.13。自己 folk 了一份源碼,主要是對源碼做了一些解讀,可見詳情?! 〉谝黄饕榻B a...
我們講述的是關于 ahooks 源碼系列文章的第七篇,總結主要講述下面幾點: 鞏固 React hooks 的理解。 學習如何抽象自定義 hooks。構建屬于自己的 React hooks 工具庫?! ∨囵B(yǎng)閱讀學習源碼的習慣,工具庫是一個對源碼閱讀不錯的選擇。 注:本系列對 ahooks 的源碼解析是基于v3.3.13。自己 folk 了一份源碼,主要是對源碼做了一些解讀,可見詳情?! ?..
閱讀 566·2023-03-27 18:33
閱讀 755·2023-03-26 17:27
閱讀 656·2023-03-26 17:14
閱讀 608·2023-03-17 21:13
閱讀 541·2023-03-17 08:28
閱讀 1829·2023-02-27 22:32
閱讀 1324·2023-02-27 22:27
閱讀 2207·2023-01-20 08:28