useRequest
作用
useRequest 是一个强大的异步数据管理的 Hooks,React 项目中的网络请求场景使用 useRequest 就够了。
useRequest 通过插件式组织代码,核心代码极其简单,并且可以很方便的扩展出更高级的功能。目前已有能力包括:
- 自动请求/手动请求
- 轮询
- 防抖
- 节流
- 屏幕聚焦重新请求
- 错误重试
- loading delay
- SWR(stale-while-revalidate)
- 缓存
源码
这个 hooks 相对来说比较复杂,先来看看入口主文件的源码
ts
import useAutoRunPlugin from './plugins/useAutoRunPlugin'
import useCachePlugin from './plugins/useCachePlugin'
import useDebouncePlugin from './plugins/useDebouncePlugin'
import useLoadingDelayPlugin from './plugins/useLoadingDelayPlugin'
import usePollingPlugin from './plugins/usePollingPlugin'
import useRefreshOnWindowFocusPlugin from './plugins/useRefreshOnWindowFocusPlugin'
import useRetryPlugin from './plugins/useRetryPlugin'
import useThrottlePlugin from './plugins/useThrottlePlugin'
import type { Options, Plugin, Service } from './types'
import useRequestImplement from './useRequestImplement'
// function useRequest<TData, TParams extends any[], TFormated, TTFormated extends TFormated = any>(
// service: Service<TData, TParams>,
// options: OptionsWithFormat<TData, TParams, TFormated, TTFormated>,
// plugins?: Plugin<TData, TParams>[],
// ): Result<TFormated, TParams>
// function useRequest<TData, TParams extends any[]>(
// service: Service<TData, TParams>,
// options?: OptionsWithoutFormat<TData, TParams>,
// plugins?: Plugin<TData, TParams>[],
// ): Result<TData, TParams>
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,
useAutoRunPlugin,
useCachePlugin,
useRetryPlugin
] as Plugin<TData, TParams>[])
}
export default useRequest
从源码中可以看出,这个 hook 导出的是一个 useRequestImplement,这个函数接收三个参数,分别是 service、options、plugins,其中 service 是必传的,options 和 plugins 是可选的。从源码可以看出,这个是基于插件的架构,可以通过插件来扩展功能。
useRequestImplement
ts
import useCreation from '../../useCreation'
import useLatest from '../../useLatest'
import useMemoizedFn from '../../useMemoizedFn'
import useMount from '../../useMount'
import useUnmount from '../../useUnmount'
import useUpdate from '../../useUpdate'
import isDev from '../../utils/isDev'
import Fetch from './Fetch'
import type { Options, Plugin, Result, Service } from './types'
function useRequestImplement<TData, TParams extends any[]>(
service: Service<TData, TParams>,
options: Options<TData, TParams> = {},
plugins: Plugin<TData, TParams>[] = []
) {
// manual是否手动触发
const { manual = false, ...rest } = options
// 判断是否是开发环境
if (isDev) {
if (options.defaultParams && !Array.isArray(options.defaultParams)) {
console.warn(`expected defaultParams is array, got ${typeof options.defaultParams}`)
}
}
// 请求参数
const fetchOptions = {
manual,
...rest
}
// 获取最新的service
const serviceRef = useLatest(service)
// 定义更新方法
const update = useUpdate()
// 实例化Fetch类,使用useCreation来缓存实例
const fetchInstance = useCreation(() => {
// 初始化状态,调用插件的onInit方法,只有useAutoRunPlugin插件有这个方法。
const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean)
// 返回一个Fetch类的实例
return new Fetch<TData, TParams>(
serviceRef,
fetchOptions,
update,
Object.assign({}, ...initState)
)
}, [])
fetchInstance.options = fetchOptions
// run all plugins 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()
})
// 返回一个对象,这个对象包含了一些方法和状态,这些方法和状态都是通过Fetch类实例化出来的。
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>
}
export default useRequestImplement
从源码可以看出,这个 hook 函数主要是对 Fetch 类的实例化,然后对插件进行初始化,最后返回一个对象,这个对象包含了一些方法和状态,这些方法和状态都是通过 Fetch 类实例化出来的。然后去看下 Fetch 类的源码。
Fetch
ts
/* eslint-disable @typescript-eslint/no-parameter-properties */
import { isFunction } from '../../utils'
import type { MutableRefObject } from 'react'
import type { FetchState, Options, PluginReturn, Service, Subscribe } from './types'
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, // 通过是否手动执行来判断是否loading
...initState // 初始化状态值
}
}
// 更新状态值
setState(s: Partial<FetchState<TData, TParams>> = {}) {
this.state = {
...this.state,
...s
}
this.subscribe()
}
// 执行插件中的某个事件(event)
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)
}
// 如果设置了 options.manual = true,则 useRequest 不会默认执行,需要通过 run 或者 runAsync 来触发执行。
// runAsync 是一个返回 Promise 的异步函数,如果使用 runAsync 来调用,则意味着你需要自己捕获异常。
async runAsync(...params: TParams): Promise<TData> {
this.count += 1
const currentCount = this.count
// 执行插件中的onBefore方法并返回结果
const {
stopNow = false,
returnNow = false,
...state
} = this.runPluginHandler('onBefore', params)
// stop request
// 如果插件中的onBefore方法返回stopNow为true,则不会执行请求
if (stopNow) {
return new Promise(() => {})
}
// 设置请求为loading状态
this.setState({
loading: true,
params,
...state
})
// return now
// 如果插件中的onBefore方法返回returnNow为true,则直接返回data
if (returnNow) {
return Promise.resolve(state.data)
}
this.options.onBefore?.(params)
try {
// replace service
// 执行插件中的onRequest方法并返回结果
let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params)
// 如果没有servicePromise,则执行serviceRef.current方法
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;
// 更新state
this.setState({
data: res,
error: undefined,
loading: false
})
// 执行插件中的onSuccess方法
this.options.onSuccess?.(res, params)
this.runPluginHandler('onSuccess', res, params)
// 执行插件中的onFinally方法
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
}
}
// 同步调用runAsync方法
run(...params: TParams) {
this.runAsync(...params).catch((error) => {
if (!this.options.onError) {
console.error(error)
}
})
}
// 取消进行中的请求
cancel() {
this.count += 1
this.setState({
loading: false
})
// 执行插件中的onCancel方法
this.runPluginHandler('onCancel')
}
// 使用上一次的参数重新调用run方法
refresh() {
// @ts-ignore
this.run(...(this.state.params || []))
}
// 使用上一次的参数重新调用runAsync方法
refreshAsync() {
// @ts-ignore
return this.runAsync(...(this.state.params || []))
}
// 修改data数据
mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {
const targetData = isFunction(data) ? data(this.state.data) : data
this.runPluginHandler('onMutate', targetData)
this.setState({
data: targetData
})
}
}
插件的源码
useAutoRunPlugin
作用
是否自动执行方法发起请求,支持手动触发和自动触发,支持 ready 和 refreshDeps 两种触发条件。
ts
import { useRef } from 'react'
import useUpdateEffect from '../../../useUpdateEffect'
import type { Plugin } from '../types'
// support refreshDeps & ready
const useAutoRunPlugin: Plugin<any, any[]> = (
fetchInstance,
{ manual, ready = true, defaultParams = [], refreshDeps = [], refreshDepsAction }
) => {
const hasAutoRun = useRef(false)
hasAutoRun.current = false
useUpdateEffect(() => {
if (!manual && ready) {
hasAutoRun.current = true
fetchInstance.run(...defaultParams)
}
}, [ready])
useUpdateEffect(() => {
if (hasAutoRun.current) {
return
}
if (!manual) {
hasAutoRun.current = true
if (refreshDepsAction) {
refreshDepsAction()
} else {
fetchInstance.refresh()
}
}
}, [...refreshDeps])
return {
onBefore: () => {
if (!ready) {
return {
stopNow: true
}
}
}
}
}
useAutoRunPlugin.onInit = ({ ready = true, manual }) => {
return {
loading: !manual && ready
}
}
export default useAutoRunPlugin
useCachePlugin
ts
import { useRef } from 'react'
import useCreation from '../../../useCreation'
import useUnmount from '../../../useUnmount'
import type { Plugin } from '../types'
import { setCache, getCache } from '../utils/cache'
import type { CachedData } from '../utils/cache'
import { setCachePromise, getCachePromise } from '../utils/cachePromise'
import { trigger, subscribe } from '../utils/cacheSubscribe'
const useCachePlugin: Plugin<any, any[]> = (
fetchInstance,
{
cacheKey,
cacheTime = 5 * 60 * 1000,
staleTime = 0,
setCache: customSetCache,
getCache: customGetCache
}
) => {
const unSubscribeRef = useRef<() => void>()
const currentPromiseRef = useRef<Promise<any>>()
const _setCache = (key: string, cachedData: CachedData) => {
if (customSetCache) {
customSetCache(cachedData)
} else {
setCache(key, cacheTime, cachedData)
}
trigger(key, cachedData.data)
}
const _getCache = (key: string, params: any[] = []) => {
if (customGetCache) {
return customGetCache(params)
}
return getCache(key)
}
useCreation(() => {
if (!cacheKey) {
return
}
// get data from cache when init
const cacheData = _getCache(cacheKey)
if (cacheData && Object.hasOwnProperty.call(cacheData, 'data')) {
fetchInstance.state.data = cacheData.data
fetchInstance.state.params = cacheData.params
if (staleTime === -1 || new Date().getTime() - cacheData.time <= staleTime) {
fetchInstance.state.loading = false
}
}
// subscribe same cachekey update, trigger update
unSubscribeRef.current = subscribe(cacheKey, (data) => {
fetchInstance.setState({ data })
})
}, [])
useUnmount(() => {
unSubscribeRef.current?.()
})
if (!cacheKey) {
return {}
}
return {
onBefore: (params) => {
const cacheData = _getCache(cacheKey, params)
if (!cacheData || !Object.hasOwnProperty.call(cacheData, 'data')) {
return {}
}
// If the data is fresh, stop request
if (staleTime === -1 || new Date().getTime() - cacheData.time <= staleTime) {
return {
loading: false,
data: cacheData?.data,
error: undefined,
returnNow: true
}
} else {
// If the data is stale, return data, and request continue
return {
data: cacheData?.data,
error: undefined
}
}
},
onRequest: (service, args) => {
let servicePromise = getCachePromise(cacheKey)
// If has servicePromise, and is not trigger by self, then use it
if (servicePromise && servicePromise !== currentPromiseRef.current) {
return { servicePromise }
}
servicePromise = service(...args)
currentPromiseRef.current = servicePromise
setCachePromise(cacheKey, servicePromise)
return { servicePromise }
},
onSuccess: (data, params) => {
if (cacheKey) {
// cancel subscribe, avoid trgger self
unSubscribeRef.current?.()
_setCache(cacheKey, {
data,
params,
time: new Date().getTime()
})
// resubscribe
unSubscribeRef.current = subscribe(cacheKey, (d) => {
fetchInstance.setState({ data: d })
})
}
},
onMutate: (data) => {
if (cacheKey) {
// cancel subscribe, avoid trigger self
unSubscribeRef.current?.()
_setCache(cacheKey, {
data,
params: fetchInstance.state.params,
time: new Date().getTime()
})
// resubscribe
unSubscribeRef.current = subscribe(cacheKey, (d) => {
fetchInstance.setState({ data: d })
})
}
}
}
}
export default useCachePlugin
useDebouncePlugin
ts
import type { DebouncedFunc, DebounceSettings } from 'lodash-es'
import { debounce } from 'lodash-es'
import { useEffect, useMemo, useRef } from 'react'
import type { Plugin } from '../types'
const useDebouncePlugin: Plugin<any, any[]> = (
fetchInstance,
{ debounceWait, debounceLeading, debounceTrailing, debounceMaxWait }
) => {
const debouncedRef = useRef<DebouncedFunc<any>>()
const options = useMemo(() => {
const ret: DebounceSettings = {}
if (debounceLeading !== undefined) {
ret.leading = debounceLeading
}
if (debounceTrailing !== undefined) {
ret.trailing = debounceTrailing
}
if (debounceMaxWait !== undefined) {
ret.maxWait = debounceMaxWait
}
return ret
}, [debounceLeading, debounceTrailing, debounceMaxWait])
useEffect(() => {
if (debounceWait) {
const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance)
debouncedRef.current = debounce(
(callback) => {
callback()
},
debounceWait,
options
)
// debounce runAsync should be promise
// https://github.com/lodash/lodash/issues/4400#issuecomment-834800398
fetchInstance.runAsync = (...args) => {
return new Promise((resolve, reject) => {
debouncedRef.current?.(() => {
_originRunAsync(...args)
.then(resolve)
.catch(reject)
})
})
}
return () => {
debouncedRef.current?.cancel()
fetchInstance.runAsync = _originRunAsync
}
}
}, [debounceWait, options])
if (!debounceWait) {
return {}
}
return {
onCancel: () => {
debouncedRef.current?.cancel()
}
}
}
export default useDebouncePlugin
useLoadingDelayPlugin
ts
import { useRef } from 'react'
import type { Plugin, Timeout } from '../types'
const useLoadingDelayPlugin: Plugin<any, any[]> = (fetchInstance, { loadingDelay, ready }) => {
const timerRef = useRef<Timeout>()
if (!loadingDelay) {
return {}
}
const cancelTimeout = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
return {
onBefore: () => {
cancelTimeout()
// Two cases:
// 1. ready === undefined
// 2. ready === true
if (ready !== false) {
timerRef.current = setTimeout(() => {
fetchInstance.setState({
loading: true
})
}, loadingDelay)
}
return {
loading: false
}
},
onFinally: () => {
cancelTimeout()
},
onCancel: () => {
cancelTimeout()
}
}
}
export default useLoadingDelayPlugin
usePollingPlugin
ts
import { useRef } from 'react'
import useUpdateEffect from '../../../useUpdateEffect'
import type { Plugin, Timeout } from '../types'
import isDocumentVisible from '../utils/isDocumentVisible'
import subscribeReVisible from '../utils/subscribeReVisible'
const usePollingPlugin: Plugin<any, any[]> = (
fetchInstance,
{ pollingInterval, pollingWhenHidden = true, pollingErrorRetryCount = -1 }
) => {
const timerRef = useRef<Timeout>()
const unsubscribeRef = useRef<() => void>()
const countRef = useRef<number>(0)
const stopPolling = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
unsubscribeRef.current?.()
}
useUpdateEffect(() => {
if (!pollingInterval) {
stopPolling()
}
}, [pollingInterval])
if (!pollingInterval) {
return {}
}
return {
onBefore: () => {
stopPolling()
},
onError: () => {
countRef.current += 1
},
onSuccess: () => {
countRef.current = 0
},
onFinally: () => {
if (
pollingErrorRetryCount === -1 ||
// When an error occurs, the request is not repeated after pollingErrorRetryCount retries
(pollingErrorRetryCount !== -1 && countRef.current <= pollingErrorRetryCount)
) {
timerRef.current = setTimeout(() => {
// if pollingWhenHidden = false && document is hidden, then stop polling and subscribe revisible
if (!pollingWhenHidden && !isDocumentVisible()) {
unsubscribeRef.current = subscribeReVisible(() => {
fetchInstance.refresh()
})
} else {
fetchInstance.refresh()
}
}, pollingInterval)
} else {
countRef.current = 0
}
},
onCancel: () => {
stopPolling()
}
}
}
export default usePollingPlugin
useRefreshOnWindowFocusPlugin
ts
import { useEffect, useRef } from 'react'
import useUnmount from '../../../useUnmount'
import type { Plugin } from '../types'
import limit from '../utils/limit'
import subscribeFocus from '../utils/subscribeFocus'
const useRefreshOnWindowFocusPlugin: Plugin<any, any[]> = (
fetchInstance,
{ refreshOnWindowFocus, focusTimespan = 5000 }
) => {
const unsubscribeRef = useRef<() => void>()
const stopSubscribe = () => {
unsubscribeRef.current?.()
}
useEffect(() => {
if (refreshOnWindowFocus) {
const limitRefresh = limit(fetchInstance.refresh.bind(fetchInstance), focusTimespan)
unsubscribeRef.current = subscribeFocus(() => {
limitRefresh()
})
}
return () => {
stopSubscribe()
}
}, [refreshOnWindowFocus, focusTimespan])
useUnmount(() => {
stopSubscribe()
})
return {}
}
export default useRefreshOnWindowFocusPlugin
useRetryPlugin
ts
import { useRef } from 'react'
import type { Plugin, Timeout } from '../types'
const useRetryPlugin: Plugin<any, any[]> = (fetchInstance, { retryInterval, retryCount }) => {
const timerRef = useRef<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)
}
}
}
}
export default useRetryPlugin
useThrottlePlugin
ts
import type { DebouncedFunc, ThrottleSettings } from 'lodash-es'
import { throttle } from 'lodash-es'
import { useEffect, useRef } from 'react'
import type { Plugin } from '../types'
const useThrottlePlugin: Plugin<any, any[]> = (
fetchInstance,
{ throttleWait, throttleLeading, throttleTrailing }
) => {
const throttledRef = useRef<DebouncedFunc<any>>()
const options: ThrottleSettings = {}
if (throttleLeading !== undefined) {
options.leading = throttleLeading
}
if (throttleTrailing !== undefined) {
options.trailing = throttleTrailing
}
useEffect(() => {
if (throttleWait) {
const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance)
throttledRef.current = throttle(
(callback) => {
callback()
},
throttleWait,
options
)
// throttle runAsync should be promise
// https://github.com/lodash/lodash/issues/4400#issuecomment-834800398
fetchInstance.runAsync = (...args) => {
return new Promise((resolve, reject) => {
throttledRef.current?.(() => {
_originRunAsync(...args)
.then(resolve)
.catch(reject)
})
})
}
return () => {
fetchInstance.runAsync = _originRunAsync
throttledRef.current?.cancel()
}
}
}, [throttleWait, throttleLeading, throttleTrailing])
if (!throttleWait) {
return {}
}
return {
onCancel: () => {
throttledRef.current?.cancel()
}
}
}
export default useThrottlePlugin