Skip to content

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

如有转载或 CV 的请标注本站原文地址