【解读 ahooks 源码系列】探究如何实现 useRequest

前言

本文是 ahooks 源码(v3.7.4)系列的第十四篇——【解读 ahooks 源码系列】探究如何实现 useRequest

往期文章:

本文主要解读 useRequest 的源码实现。

useRequest

useRequest 是一个强大的异步数据管理的 Hooks,React 项目中的网络请求场景使用 useRequest 就够了。

useRequest 通过插件式组织代码,核心代码极其简单,并且可以很方便的扩展出更高级的功能。目前已有能力包括:

  • 自动请求/手动请求

  • 轮询

  • 防抖

  • 节流

  • 屏幕聚焦重新请求

  • 错误重试

  • loading delay

  • SWR(stale-while-revalidate)

  • 缓存

  • 官方文档

基本用法

useRequest 的第一个参数是一个异步函数,在组件初次加载时,会自动触发该函数执行。同时自动管理该异步函数的 loading , data , error 等状态。

官方在线 Demo

import { useRequest } from 'ahooks';
import Mock from 'mockjs';
import React from 'react';

function getUsername(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(Mock.mock('@name'));
    }, 1000);
  });
}

export default () => {
  const { data, error, loading } = useRequest(getUsername);

  if (error) {
    return <div>failed to load</div>;
  }
  if (loading) {
    return <div>loading...</div>;
  }
  return <div>Username: {data}</div>;
};

核心实现

主调用流程

  • useRequest 负责初始化和处理数据,最后将结果返回
  • useRequest 方法的入口文件 useRequest.ts 封装了 useRequestImplement 这个 Hook,并引入了默认插件。
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>[]);
}

useRequestImplement.ts:实例化 Fetch 类——fetchInstance,并针对实例的(卸载)生命周期相关进行逻辑处理

function useRequestImplement<TData, TParams extends any[]>(
  service: Service<TData, TParams>,
  options: Options<TData, TParams> = {},
  plugins: Plugin<TData, TParams>[] = [],
) {
  // manual:初始化时是否自动执行 service,默认为false
  const { manual = false, ...rest } = options;

  const fetchOptions = {
    manual,
    ...rest,
  };

  const serviceRef = useLatest(service);

  const update = useUpdate();

  // fetch 请求实例
  // useCreation, useMemo/useCallback 替代品,可以保证被 memo 的值不会被重新计算
  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
  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>;
}

可以看出,我们平时使用 useRequest 暴露出来的方法,几乎都是 fetchInstance 实例暴露出来的,所以源代码核心,也就是 Fetch 类了

核心 Fetch 类

Fetch 类是整个 Hook 的核心代码,它封装了暴露给外部的具体数据和方法,只需完成整体请求流程的功能即可。

先来看看这个类的大致结构

class Fetch<TData, TParams extends any[]> {
  // 所有插件执行完成后的返回结果数组
  pluginImpls: PluginReturn<TData, TParams>[];

  count: number = 0;

  state: FetchState<TData, TParams> = {
    loading: false, // service 是否正在执行
    params: undefined, // 当次执行的 service 的参数数组
    data: undefined, // service 返回的数据
    error: undefined, // service 抛出的异常
  };

  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[]) {
    // ...
  }

  // 与 run 用法一致,但返回的是 Promise,需要自行处理异常
  async runAsync(...params: TParams): Promise<TData> {
    // ...
  }

  // 手动触发 service 执行,异常自动处理
  run(...params: TParams) {
    // ...
  }

  // 忽略当前 Promise 的响应
  cancel() {
    // ...
  }

  // 使用上一次的 params,重新调用 run
  refresh() {
    // ...
  }

  // 使用上一次的 params,重新调用 runAsync
  refreshAsync() {
    // ...
  }

  // 直接修改 data
  mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {
    // ...
  }
}

setState

该方法保存于请求参数和请求结果相关的信息,而这些是我们使用 useRequest 需要用的信息,如下:

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

setState 实现如下,其中 subscribe 在 Fetch 实例化的时候传入的参数为 useUpdate 执行后返回的 update 方法

setState(s: Partial<FetchState<TData, TParams>> = {}) {
  this.state = {
    ...this.state,
    ...s,
  };
  this.subscribe(); // 强制组件重新渲染
}

runPluginHandler

runPluginHandler 用于特定的生命周期钩子执行插件方法。该方法通过pluginImpls 数组循环找出所有对应 event 的钩子函数去执行并返回结果。

pluginImpls 是一个数组,用来存储所有插件 hooks 的返回结果。它的赋值是在 useRequestImplement.ts 实例化 Fetch 类后执行 fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));

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);
}

这个运行插件的方法具体怎么理解作用呢?当我们想实现一个 useRequest 插件类实现特定功能时,想在接口请求成功时做一些事情,那在插件类里面要怎么知道呢?一种比较好的方式就是生命周期,我只要在插件类里写下 onSuccess 方法,在这里写逻辑就能轻松实现想要的功能而不用考虑其它。

核心思想就是,Fetch 类只需完成请求整体的流程,其它功能交给插件去实现。那如何把请求前后的流程分享给插件,那就是定义固定的生命周期钩子。如插件想要在接口请求成功后做些事情,则这里的处理只需通过插件数组循环找出哪个插件有写 onSuccess 这个钩子,有就找出来把它执行了。

来看下 PluginReturn 这个 TS 定义,也就是插件支持的钩子函数了

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;
}

runAsync

这个是 Fetch 类请求的核心方法,下面介绍的 run/refresh/refreshAsync 底层都是调用该方法,在该方法不同的时机也会调用插件的不同生命周期钩子函数。

onBefore 请求前

发起请求前,会调用插件的 onBefore 方法

async runAsync(...params: TParams): Promise<TData> {
  this.count += 1;
  const currentCount = this.count;

  // onBefore 函数的返回值(如需实现特定功能插件,字段还可继续扩展)
  const {
    stopNow = false, // 是否停止请求(可选)- useAutoRunPlugin 插件使用
    returnNow = false, // 是否立即返回(可选)- useCachePlugin 插件使用
    ...state
  } = this.runPluginHandler('onBefore', params); // 执行每个插件的 onBefore 钩子函数

  // stop request
  if (stopNow) {
    return new Promise(() => {});
  }

  this.setState({
    loading: true, // 请求前把 loading 设置为 true
    params,
    ...state,
  });

  // 是否立即返回
  if (returnNow) {
    return Promise.resolve(state.data);
  }

  // 执行外部传入的 onBefore 回调
  this.options.onBefore?.(params);

  // ...
}

onRequest 请求中

请求阶段

这个阶段只有 useCachePlugin 执行了 onRequest 方法,执行后返回 service Promise(有可能是缓存的结果),从而达到缓存 Promise 的效果

// 替换 service。目前只有 useCachePlugin 插件内部实现该方法,可实现缓存 Promise 的功能
let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params);

// 其它插件走这个逻辑
if (!servicePromise) {
  servicePromise = this.serviceRef.current(...params);
}

const res = await servicePromise;

onSuccess/onError/onFinally 请求结果

整个请求过程是包在 try catch 里面的

下文代码有个判断 currentCount !== this.count,调用 runAsync 方法的时候默认 currentCount === this.count,区分是否为正常流程的当前请求。当调用取消请求方法的时候,this.count += 1;,此时两者不相等,直接返回空的 Promise

try {
  // onRequest ...

  // 区分是否为正常流程的当前请求(调用取消请求方法的时候 this.count 会加1)
  if (currentCount !== this.count) {
    // prevent run.then when request is canceled
    return new Promise(() => {});
  }

  this.setState({
    data: res,
    error: undefined,
    loading: false,
  });
  // 执行外部传入的 onSuccess 回调
  this.options.onSuccess?.(res, params);
  // 执行插件 onSuccess 的生命周期钩子
  this.runPluginHandler('onSuccess', res, params);
  // 执行外部传入的 onFinally 回调
  this.options.onFinally?.(params, res, undefined);

  // 执行插件 onFinally 的生命周期钩子
  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,
  });
  // 执行外部传入的 onError 回调
  this.options.onError?.(error, params);
  // 执行插件 onError 的生命周期钩子
  this.runPluginHandler('onError', error, params);
  // 执行外部传入的 onFinally 回调
  this.options.onFinally?.(params, undefined, error);

  // 执行插件 onFinally 的生命周期钩子
  if (currentCount === this.count) {
    this.runPluginHandler('onFinally', params, undefined, error);
  }

  throw error;
}

cancel

取消请求

cancel() {
  this.count += 1; // 标识加1,区分是否为正常流程的当前请求
  this.setState({
    loading: false,
  });
  // 执行插件 onCancel 的生命周期钩子
  this.runPluginHandler('onCancel');
}

run

实现是直接调用 runAsync 方法,跟 runAsync 区别是 run 会自动 catch 异常且无返回值

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

refresh

使用上一次的 params,重新调用 run

refresh() {
  // @ts-ignore
  this.run(...(this.state.params || []));
}

refreshAsync

与 run 用法一致,但返回的是 Promise,需要自行处理异常

refreshAsync() {
  // @ts-ignore
  return this.runAsync(...(this.state.params || []));
}

mutate

直接修改 data,onMutate 这个钩子是唯一不算在请求的生命周期里

mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {
  // data 支持传入函数
  const targetData = isFunction(data) ? data(this.state.data) : data;
  // 执行插件 onMutate 的生命周期钩子
  this.runPluginHandler('onMutate', targetData);
  // 更新 state 中的 data
  this.setState({
    data: targetData,
  });
}

插件功能实现

讲完 useRequest 的核心实现,接下来就来讲 useRequest 支持的多种功能是如何实现的。

Loading Delay

首先第一个是 Loading Delay

通过设置 options.loadingDelay ,可以延迟 loading 变 e 成 true 的时间,有效防止闪烁。

用法

假如 getUsername 在 300ms 内返回,则 loading 不会变成 true,避免了页面展示 Loading... 的情况。

Demo

const { loading, data } = useRequest(getUsername, {
  loadingDelay: 300
});

实现

该功能是通过 useLoadingDelayPlugin 插件实现,通过 onBefore 请求前设置 loading 状态来实现

暴露给外部使用的 loading 状态实际是 Fetch 实例维护的 state 状态。

async runAsync(...params: TParams): Promise<TData> {
  const {
    stopNow = false,
    returnNow = false,
    ...state // 此时 useLoadingDelayPlugin 返回的 loading 为 false
  } = this.runPluginHandler('onBefore', params);

  // ...

  this.setState({
    loading: true,
    params,
    ...state, // 这里覆盖 loading 状态,置为 false
  });
}

useLoadingDelayPlugin 实现:

const useLoadingDelayPlugin: Plugin<any, any[]> = (fetchInstance, { loadingDelay }) => {
  const timerRef = useRef<Timeout>();

  if (!loadingDelay) {
    return {};
  }

  const cancelTimeout = () => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
  };

  return {
    onBefore: () => {
      cancelTimeout();
      // loadingDelay 时间范围内请求未完成,则才将 loading 状态设为 false
      timerRef.current = setTimeout(() => {
        fetchInstance.setState({
          loading: true,
        });
      }, loadingDelay);
      // loading 状态直接返回 false
      return {
        loading: false,
      };
    },
    // 请求完成或取消清除定时器
    onFinally: () => {
      cancelTimeout();
    },
    onCancel: () => {
      cancelTimeout();
    },
  };
};

轮询

通过设置 options.pollingInterval,进入轮询模式,useRequest 会定时触发 service 执行。

用法

每隔 3000ms 请求一次 getUsername。同时你可以通过 cancel 来停止轮询,通过 run/runAsync 来启动轮询,通过 options.pollingErrorRetryCount 轮询错误重试次数

Demo

const { data, run, cancel } = useRequest(getUsername, {
  pollingInterval: 3000, // 轮询间隔,单位为毫秒
  pollingErrorRetryCount: 3, // 轮询错误重试次数
});

实现

usePollingPlugin 插件实现。

实现原理:在 onFinally 生命周期里调用 refresh 方法,核心的几行代码如下:

// 每当完成一次请求,通过定时器在 `pollingInterval` ms 后再次发起请求
onFinally: () => {
  // ...
  timerRef.current = setTimeout(() => {
    fetchInstance.refresh();
  }, pollingInterval);
},

如何实现 pollingErrorRetryCount(轮询错误重试次数。如果设置为 -1,则无限次)这个功能?

  1. 请求失败设置轮询次数。在 onError 生命周期钩子记录一个轮询错误次数 countRef.current,每次被调用就加 1。
  2. 请求成功重置轮询次数。在 onSuccess 钩子里重置 countRef.current 为 0
  3. 轮询之前判断是否满足条件。
const countRef = useRef<number>(0);

// 判断轮询重试次数
if (
  pollingErrorRetryCount === -1 ||
  (pollingErrorRetryCount !== -1 && countRef.current <= pollingErrorRetryCount)
) {
  // setTimeout 轮询
} else {
  // 重置
  countRef.current = 0;
}

如何实现 pollingWhenHidden(在页面隐藏时,是否继续轮询。如果设置为 false,在页面隐藏时会暂时停止轮询,页面重新显示时继续上次轮询)功能?

  1. 判断页面是否可见:isDocumentVisible 方法
  2. 主要针对 pollingWhenHidden 参数为 false 的情况,订阅页面可见状态的变化 visibilitychange
  3. 每次一次新的请求需要在 onBefore 取消订阅
// 判断页面是否处于可见状态
function isDocumentVisible(): boolean {
  if (isBrowser) {
    return document.visibilityState !== 'hidden';
  }
  return true;
}

timerRef.current = setTimeout(() => {
  // !pollingWhenHidden && 页面不可见
  if (!pollingWhenHidden && !isDocumentVisible()) {
    // 通过 subscribeReVisible 方法进行订阅监听页面可见状态的变化
    unsubscribeRef.current = subscribeReVisible(() => {
      fetchInstance.refresh();
    });
  } else {
    fetchInstance.refresh();
  }
}, pollingInterval);

来看看 subscribeReVisible 方法的实现,实际就类似于发布订阅:

// 事件监听队列
const listeners: Listener[] = [];

// 订阅事件
function subscribe(listener: Listener) {
  // 将监听函数添加到事件队列
  listeners.push(listener);
  // 返回取消事件订阅的方法
  return function unsubscribe() {
    const index = listeners.indexOf(listener);
    listeners.splice(index, 1);
  };
}

if (isBrowser) {
  const revalidate = () => {
    // 页面不可见时不处理
    if (!isDocumentVisible()) return;
    // 页面可见,循环执行事件监听队列的事件
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i];
      listener();
    }
  };
  // 监听 visibilitychange
  window.addEventListener('visibilitychange', revalidate, false);
}

Ready

useRequest 提供了一个 options.ready 参数,当其值为 false 时,请求永远都不会发出。

其具体行为如下:

manual=false 自动请求模式时,每次 ready 从 false 变为 true 时,都会自动发起请求,会带上参数 options.defaultParams
manual=true 手动请求模式时,只要 ready=false,则通过 run/runAsync 触发的请求都不会执行。

用法

手动模式下 ready 的行为。只有当 ready 等于 true 时,run 才会执行。

Demo

export default () => {
  const [ready, { toggle }] = useToggle(false);

  const { data, loading, run } = useRequest(getUsername, {
    ready,
    manual: true,
  });
};

实现

const useAutoRunPlugin: Plugin<any, any[]> = (
  fetchInstance,
  { manual, ready = true, defaultParams = [], refreshDeps = [], refreshDepsAction },
) => {
  const hasAutoRun = useRef(false);
  hasAutoRun.current = false;

  // 只在依赖更新时执行
  useUpdateEffect(() => {
    // manual 为 false 表示自动请求模式
    if (!manual && ready) {
      hasAutoRun.current = true;
      fetchInstance.run(...defaultParams); // 自动执行请求
    }
  }, [ready]);

  return {
    // 请求前的钩子
    onBefore: () => {
      // ready 为 false,返回 stopNow 为 true,表示请求不会发出
      if (!ready) {
        return {
          stopNow: true,
        };
      }
    },
  };
};

在 Fetch 类中执行请求前:

const {
  stopNow = false,
  returnNow = false,
  ...state
} = this.runPluginHandler('onBefore', params);

// 为 true,则停止发送请求,返回空的 promise
if (stopNow) {
  return new Promise(() => {});
}

依赖刷新

useRequest 提供了一个 options.refreshDeps 参数,当它的值变化后,会重新触发请求。

用法

const [userId, setUserId] = useState('1');

const { data, run } = useRequest(() => getUserSchool(userId), {
refreshDeps: [userId],
});

上面的示例代码,useRequest 会在初始化和 userId 变化时,触发函数执行。

与下面代码实现功能完全一致:

const [userId, setUserId] = useState('1');

const { data, refresh } = useRequest(() => getUserSchool(userId));

useEffect(() => {
  refresh();
}, [userId]);

实现

这个也是由 useAutoRunPlugin 插件实现,主要就是监听 refreshDeps 依赖

const hasAutoRun = useRef(false);
hasAutoRun.current = false; // 每一次重新执行都重置为 false

useUpdateEffect(() => {
  // manual 默认值为 false,ready 默认值为true
  if (!manual && ready) {
    hasAutoRun.current = true; // 请求前设置为 true
    fetchInstance.run(...defaultParams); // 自动执行请求
  }
}, [ready]);

useUpdateEffect(() => {
  if (hasAutoRun.current) {
    return;
  }
  if (!manual) {
    hasAutoRun.current = true;
    // 依赖变化的时候的回调,有传就执行
    if (refreshDepsAction) {
      refreshDepsAction();
    } else {
      fetchInstance.refresh(); // 重新发起请求
    }
  }
}, [...refreshDeps]);

屏幕聚焦重新请求

通过设置 options.refreshOnWindowFocus,在浏览器窗口 refocus 和 revisible 时,会重新发起请求。

用法

const { data } = useRequest(getUsername, {
  refreshOnWindowFocus: true,
});

你可以点击浏览器外部,再点击当前页面来体验效果(或者隐藏当前页面,重新展示),如果和上一次请求间隔大于 5000ms,则会重新请求一次。Demo

实现

该功能由 useRefreshOnWindowFocusPlugin 插件实现。通过监听 visibilitychangefocus 事件,监听变化的时候执行判断逻辑

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 {};
};

limit 实际就是节流函数

function limit(fn: any, timespan: number) {
  let pending = false;
  return (...args: any[]) => {
    if (pending) return; // pending 阶段不进行请求,直接返回
    pending = true;
    fn(...args);
    setTimeout(() => {
      // 超过 timespan 时间范围,重置 pending
      pending = false;
    }, timespan);
  };
}

接下来的关键就是这个 subscribeFocus 方法了,跟我们上面写的 subscribeReVisible 差不多

// 事件监听队列
const listeners: Listener[] = [];
// 订阅事件
function subscribe(listener: Listener) {
  // 将监听函数添加到事件队列
  listeners.push(listener);
  // 返回取消事件订阅的方法
  return function unsubscribe() {
    const index = listeners.indexOf(listener);
    if (index > -1) {
      listeners.splice(index, 1);
    }
  };
}

if (isBrowser) {
  const revalidate = () => {
    // 页面不可见的时候和没网络的时候不处理
    if (!isDocumentVisible() || !isOnline()) return;
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i];
      listener();
    }
  };
  // 监听 `visibilitychange` 和 `focus` 事件
  window.addEventListener('visibilitychange', revalidate, false);
  window.addEventListener('focus', revalidate, false);
}

防抖

通过设置 options.debounceWait,进入防抖模式,此时如果频繁触发 run 或者 runAsync,则会以防抖策略进行请求。

用法

const { data, run } = useRequest(getUsername, {
  debounceWait: 300,
  manual: true
});

如上示例代码,频繁触发 run,只会在最后一次触发结束后等待 300ms 执行。

实现

该功能是通过 useDebouncePlugin 插件实现的,实际底层调用的是 lodash/debounce,所以也支持参数 options.debounceWaitoptions.debounceLeadingoptions.debounceTrailingoptions.debounceMaxWait

下面只放出关键代码:

useEffect(() => {
  if (debounceWait) {
    // 保存 runAsync 原方法
    const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance);
    // 基于lodash 的 debounce 封装,执行完的返回结果有 cancel 方法
    debouncedRef.current = debounce(
      (callback) => {
        callback();
      },
      debounceWait,
      options,
    );

    // debounce runAsync should be promise
    // https://github.com/lodash/lodash/issues/4400#issuecomment-834800398
    fetchInstance.runAsync = (...args) => {
      // 返回 Promise
      return new Promise((resolve, reject) => {
        debouncedRef.current?.(() => {
          // 执行原函数
          _originRunAsync(...args)
            .then(resolve)
            .catch(reject);
        });
      });
    };

    return () => {
      // 取消函数调用
      debouncedRef.current?.cancel();
      // 还原成原来的 runAsync 函数
      fetchInstance.runAsync = _originRunAsync;
    };
  }
}, [debounceWait, options]);

节流

通过设置 options.throttleWait,进入节流模式,此时如果频繁触发 run 或者 runAsync,则会以节流策略进行请求。

用法

const { data, run } = useRequest(getUsername, {
  throttleWait: 300,
  manual: true
});

如上示例代码,频繁触发 run,只会每隔 300ms 执行一次。

实现

该功能使用 useThrottlePlugin 实现,与 useDebouncePlugin 的思路一致,只不过换成了 lodash/throttle 去实现

useEffect(() => {
  if (throttleWait) {
    // 保存 runAsync 原方法
    const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance);
    // 基于lodash 的 throttle 封装,执行完的返回结果有 cancel 方法
    throttledRef.current = throttle(
      (callback) => {
        callback();
      },
      throttleWait,
      options,
    );

    // throttle runAsync should be promise
    // https://github.com/lodash/lodash/issues/4400#issuecomment-834800398
    fetchInstance.runAsync = (...args) => {
      // 返回 Promise
      return new Promise((resolve, reject) => {
        // 执行原函数
        throttledRef.current?.(() => {
          _originRunAsync(...args)
            .then(resolve)
            .catch(reject);
        });
      });
    };

    return () => {
      // 还原成原来的 runAsync 函数
      fetchInstance.runAsync = _originRunAsync;
      // 取消函数调用
      throttledRef.current?.cancel();
    };
  }
}, [throttleWait, throttleLeading, throttleTrailing]);

缓存 & SWR

  • 设置了 options.cacheKey,useRequest 会将当前请求成功的数据缓存起来。下次组件初始化时,如果有缓存数据,我们会优先返回缓存数据,然后在背后发送新请求,也就是 SWR 的能力。
  • 通过 options.staleTime 设置数据保持新鲜时间,在该时间内,我们认为数据是新鲜的,不会重新发起请求。
  • 通过 options.cacheTime 设置数据缓存时间,超过该时间,我们会清空该条缓存数据。

用法

const { data, loading, refresh } = useRequest(getArticle, {
  cacheKey: 'cacheKey-share',
});

实现

缓存主要由 useCachePlugin 插件实现。在看主逻辑前,先看看涉及该插件有关的三个 utils 文件。

utils/cache.ts

先来看看缓存是如何管理的,ahooks 是抽到一个 packages/hooks/src/useRequest/src/utils/cache.ts 里面,暴露三个 utils 方法:getCachesetCacheclearCache 方法,通过 Map 数据结构来管理缓存数据

// 通过 Map 数据结构来缓存
const cache = new Map<CachedKey, RecordData>();

// 设置缓存
const setCache = (key: CachedKey, cacheTime: number, cachedData: CachedData) => {
  // 获取当前缓存
  const currentCache = cache.get(key);
  // 设置缓存之前,先清除缓存定时器
  if (currentCache?.timer) {
    clearTimeout(currentCache.timer);
  }

  let timer: Timer | undefined = undefined;

  // 有设置缓存时间
  if (cacheTime > -1) {
    // 有设置超过缓存时间 cacheTime,则删除该条缓存数据
    timer = setTimeout(() => {
      cache.delete(key);
    }, cacheTime);
  }

  // 设置该条缓存数据
  cache.set(key, {
    ...cachedData,
    timer,
  });
};

// 读取缓存
const getCache = (key: CachedKey) => {
  return cache.get(key);
};

// 支持清空单个缓存,或一组缓存。在自定义缓存模式下不会生效
const clearCache = (key?: string | string[]) => {
  if (key) {
    const cacheKeys = Array.isArray(key) ? key : [key];
    cacheKeys.forEach((cacheKey) => cache.delete(cacheKey));
  } else {
    cache.clear();
  }
};

export { getCache, setCache, clearCache };

utils/cacheSubscribe.ts

实现了发布订阅,主要暴露两个方法:

  • subscribe:订阅事件
  • trigger 触发指定缓存 key 对应的所有事件
// 事件监听器
const listeners: Record<string, Listener[]> = {};

// 触发指定缓存 key 对应的所有事件
const trigger = (key: string, data: any) => {
  if (listeners[key]) {
    listeners[key].forEach((item) => item(data));
  }
};

// 订阅事件
const subscribe = (key: string, listener: Listener) => {
  // 一个 key 值可对应多个事件
  if (!listeners[key]) {
    listeners[key] = [];
  }
  // 添加到事件监听数组
  listeners[key].push(listener);

  // 返回清除订阅的方法
  return function unsubscribe() {
    const index = listeners[key].indexOf(listener);
    listeners[key].splice(index, 1);
  };
};

utils/cachePromise.ts

主要暴露两个方法:

  • getCachePromise:获取缓存 promise
  • setCachePromise:设置 promise 缓存

代码概览

  1. 插件初始化时先尝试获取缓存,有缓存且没过期则使用缓存的 data 和 params 进行替换,订阅同一 cacheKey 更新
  2. 实现住逻辑用到了钩子函数:onBeforeonRequestonSuccessonMutate
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 {
      cache.setCache(key, cacheTime, cachedData);
    }
    // 触发指定缓存 key 对应的所有事件。key 值相同的话 data 数据是共享的
    cacheSubscribe.trigger(key, cachedData.data);
  };

  // 读取缓存
  const _getCache = (key: string, params: any[] = []) => {
    // 有传入自定义读取缓存则优先使用自定义方法
    if (customGetCache) {
      return customGetCache(params);
    }
    return cache.getCache(key);
  };

  useCreation(() => {
    if (!cacheKey) {
      return;
    }

    // 初始化时尝试从缓存获取数据
    const cacheData = _getCache(cacheKey);
    // 有缓存
    if (cacheData && Object.hasOwnProperty.call(cacheData, 'data')) {
      // 使用缓存的 data 和 params 替换
      fetchInstance.state.data = cacheData.data;
      fetchInstance.state.params = cacheData.params;
      // staleTime 为-1表示数据永远新鲜 或 当前时间还在新鲜时间范围内
      if (staleTime === -1 || new Date().getTime() - cacheData.time <= staleTime) {
        // 直接使用缓存的话无需 loading
        fetchInstance.state.loading = false;
      }
    }

    // 订阅 cacheKey 更新(同个 cacheKey 缓存数据相同)
    unSubscribeRef.current = cacheSubscribe.subscribe(cacheKey, (data) => {
      fetchInstance.setState({ data });
    });
  }, []);

  // 页面卸载时取消订阅
  useUnmount(() => {
    unSubscribeRef.current?.();
  });

  // 没有设置 cacheKey 则直接返回空对象
  if (!cacheKey) {
    return {};
  }

  // 核心逻辑
  return {
    onBefore: (params) => {},
    onRequest: (service, args) => {},
    onSuccess: (data, params) => {},
    onMutate: (data) => {}
  }
}

onBefore 阶段

主要逻辑:判断是否有缓存数据,有则返回数据,关键是 returnNow: true;没有也返回数据,但不会阻塞请求进行

returnNow 参数在 runAsync 方法会用到:

async runAsync(...params: TParams): Promise<TData> {
  const {
      returnNow = false,
  } = this.runPluginHandler('onBefore', params);

  // 返回停止请求
  if (returnNow) {
    return Promise.resolve(state.data);
  }
}

useCachePluginonBefore 实现:

onBefore: (params) => {
  // 读取 cacheKey 缓存
  const cacheData = _getCache(cacheKey, params);
  // 无缓存数据
  if (!cacheData || !Object.hasOwnProperty.call(cacheData, 'data')) {
    return {};
  }

  // staleTime 为-1表示数据永远新鲜 或 当前时间还在新鲜时间范围内
  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 阶段

主要逻辑:缓存 promise。通过记录本地请求 promise 比对的方式保证同一时间同个 cacheKey 的请求只能发起一个

onRequest: (service, args) => {
  // 获取 promise 缓存
  let servicePromise = cachePromise.getCachePromise(cacheKey);

  // 有缓存 promise && 不等于上一次触发的请求,保证统
  if (servicePromise && servicePromise !== currentPromiseRef.current) {
    // 返回 servicePromise
    return { servicePromise };
  }

  servicePromise = service(...args);
  // 保存当前触发的 promise
  currentPromiseRef.current = servicePromise;
  // 设置 promise 缓存
  cachePromise.setCachePromise(cacheKey, servicePromise);
  return { servicePromise };
},

onSuccess & onMutate 阶段

这两个钩子里面做的事情是一样的,分别是:

  1. 先取消订阅
  2. _setCache 更新缓存值
  3. 最后重新订阅 cacheKey
onSuccess: (data, params) => {
  // 有缓存 cacheKey
  if (cacheKey) {
    // 先取消订阅
    unSubscribeRef.current?.();
    // 更新缓存值
    _setCache(cacheKey, {
      data,
      params,
      time: new Date().getTime(),
    });
    // 重新订阅
    unSubscribeRef.current = cacheSubscribe.subscribe(cacheKey, (d) => {
      fetchInstance.setState({ data: d });
    });
  }
},

错误重试

该功能是通过 useRetryPlugin 插件实现的。

  • 通过设置 options.retryCount,指定错误重试次数,则 useRequest 在失败后会进行重试。
  • 还可以设置 options.retryInterval 指定重试时间间隔。

用法

const { data, run } = useRequest(getUsername, {
  retryCount: 3,
});

如上示例代码,在请求异常后,会做 3 次重试。

实现

核心实现:在 onError 生命周期钩子计数错误次数,然后通过定时器重试。

onError: () => {
  countRef.current += 1; // 累计错误次数
  // retryCount 为 -1 或 重试次数小于等于 retryCount,则执行
  if (retryCount === -1 || countRef.current <= retryCount) {
    // 如果不设置 retryInterval,默认采用简易的指数退避算法,取 1000 * 2 ** retryCount,也就是第一次重试等待 2s,第二次重试等待 4s,以此类推,如果大于 30s,则取 30s
    const timeout = retryInterval ?? Math.min(1000 * 2 ** countRef.current, 30000);
    timerRef.current = setTimeout(() => {
      triggerByRetry.current = true; // 标记触发错误重试
      fetchInstance.refresh(); // 重新请求
    }, timeout);
  } else {
    countRef.current = 0;
  }
},

完整源码

结语

useRequest 的源码写到这了,文章比较长,有些不是特别关键的逻辑会遗漏,各位有兴趣的可以自己去看看完整源码

原文链接:https://juejin.cn/post/7244818537776709687 作者:JackySummer

(0)
上一篇 2023年6月16日 上午10:05
下一篇 2023年6月16日 上午10:15

相关推荐

发表回复

登录后才能评论