ahooks源码系列(二):useRequest,万字长文,看完收益很大

useRequest 架构

首先在阅读源码前,我们得先清楚 useRequest 的架构,请看下图:

image.png

它分为两大核心和两个辅助模块

两大核心是:Fetch类、plugin插件化机制

两个辅助模块是:type.ts类型定义、utils工具方法

而重点就是 Fetch类 和 plugin插件化机制,通过插件化机制降低了每个功能之间的耦合度,也降低了其本身的复杂度,而通过 Fetch 类处理了整个请求的生命周期流程。他们两个之间的关系就是 Fetch 作为核心和主流程,plugin 给 Fetch 打辅助拓展额外的功能

接下来,我们一步一步来剖析 useRequest 的源码

入口函数

useRequset

首先,useRequset 作为主入口函数,接受三个参数

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;

其中:

  • service:请求接口的函数
  • options:配置项,比如 manual、onBefore 等,详情看官方文档 ahooks
  • plugins:插件列表。注意:这个参数在官方文档里面并没有被暴露出来,或许目前还不打算给开发者使用,但是我们可以发现 plugins 参数会被拓展到 useRequestImplement 函数里面去,估计以后可以按照特定的规则支持开发者自定义插件

我们接着看 useRequestImplement

useRequestImplement

useRequestImplement 的源码也不算多,可以直接分成三部分

function useRequestImplement<TData, TParams extends any[]>(
  service: Service<TData, TParams>,
  options: Options<TData, TParams> = {},
  plugins: Plugin<TData, TParams>[] = [],
) {
  //第一部分:处理参数
  //1.1
  const { manual = false, ...rest } = options;
  const fetchOptions = {
    manual,
    ...rest,
  };
  //1.2
  const serviceRef = useLatest(service);
  const update = useUpdate();
  
  //第二部分:创建 Fetch 实例对象
  const fetchInstance = useCreation(() => {
    //2.1
    const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean);
    return new Fetch<TData, TParams>(
      serviceRef,
      fetchOptions,
      update,
      Object.assign({}, ...initState),
    );
  }, []);
  fetchInstance.options = fetchOptions;
  //2.2
  // 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();
  });

  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;

我们来梳理一下每一部分做了什么:

  • 第一部分:就是处理了一下入参 options,默认 manual 为 false(也就是自动发起请求),然后用 useLatest 保证每次的 service 都是最新的,然后记录了一个 useUpdate 函数,这个函数的代码如下:
import { useCallback, useState } from 'react';

const useUpdate = () => {
  const [, setState] = useState({});

  return useCallback(() => setState({}), []);
};

export default useUpdate;

其实很简单,它类似 class 组件的 forceUpdate,就是通过 setState,让组件强行渲染

  • 第二部分:创建 Fetch 实例对象,然后需要注意两部分

    • 步骤 2.1:它遍历了传入的 plugins 插件数组,如果当前插件有 onInit 方法,就执行,然后存储执行的结果赋值给 initState。而在所有内置的 plugin 中,目前只有 useAutoRunPluginonInit 方法,这个方法返回的就是一个包含 loading 属性的对象

    image.png

    • 步骤2.2:这一步,也遍历 plugins 数组,然后传入 fetchInstance 实例对象和 fecthOptions 配置项,将每个插件的执行结果存入到数组中,然后把这个结果数组挂载到 fetchInstance.pluginImpls 属性上。而每一个插件返回的类型如下:
    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;
    }
    

    返回的是个对象,包含 onBefore、onRequest、onSuccess、onError、onFinally、onCancel、onMutate 这几个钩子,从名字上看,对应的是请求流程的各个生命周期。也就是说 pluginImpls 里面存的是一堆含有各个钩子函数的对象的数组,而这其实是发布订阅模式,他们会在请求的不同生命周期中被调用,我们稍后在后面 Fetch 流程里面再细说

    • 第三部分:处理一下请求参数,因为可能有缓存之类的考虑,然后就看是自动请求还是手动请求,最后返回数据对象

到这里,useRequestImplement 也梳理完了。接下来,我们看重点 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>, // 这个就是用户传入的 options
    public subscribe: Subscribe,  //这个 subscribe 就是之前那个 useUpdate,强制刷新组件的
    public initState: Partial<FetchState<TData, TParams>> = {},  // 这个就是 { loading: !manual && ready }
  ) {
    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 用于处理请求,只不过像 run、runAsync、cancel、refresh 等是暴露给开发者的,而 setState、runPluginHandler是内部的 API。然后维护的数据通过 setState 方法设置数据,设置完成通过 subscribe(也就是 useUpdate) 调用通知 useRequestImplement 组件重新渲染,从而获取最新值。(所以这里状态更新的时候,都会导致组件重新渲染)。而我们重点关注 runAsync、runPluginHandler,因为其他的 API 基本都会调用这两个方法

我们先来看 runPluginHandler

runPluginHandler

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

它会去遍历 pluginImpls,先前我们说了,pluginImpls 是个数组,里面每个元素是个包含了一个或多个钩子的对象,也就是 { onBefore?, onRequest?, onSuccess?, onError?, onFinally?, onCancel?、onMutate? },然后 event 其实就是这些钩子的 key,即 keyof PluginReturn<TData, TParams>,也就是说,如果当前这个插件支持这个 event,那就去执行对应的钩子,其中某些钩子可能有返回值,就记录下来,最后通过 Object.assign 合并结果。

通过插件化的方式,Fetch 类只需要完成整体流程的功能,所有额外的功能(比如重试、轮询等等)都交给插件去实现。这么做符合职责单一原则:一个 Plugin 只做一件事,相互之间不相关。整体的可维护性更高,并且拥有更好的可测试性。

通过上面的分析可以看出:基本所有的插件功能都是在一个请求的一个或者多个生命周期中实现的,也就是说我们只需要在请求的相应阶段,执行插件的逻辑,就能执行和完成插件的功能

图解如下:

image.png

runAsync

runAsync 就是整个 Fetch 流程的主心骨了,我们来看看它的代码实现

async runAsync(...params: TParams): Promise<TData> {
    //1.1
    this.count += 1;
    const currentCount = this.count;
    
    //1.2
    const {
      stopNow = false,
      returnNow = false,
      ...state
    } = this.runPluginHandler('onBefore', params);

    //1.3 stop request
    if (stopNow) {
      return new Promise(() => {});
    }
  
    //1.4
    this.setState({
      loading: true,
      params,
      ...state,
    });

    //1.5 return now
    if (returnNow) {
      return Promise.resolve(state.data);
    }

    //1.6
    this.options.onBefore?.(params);

    try {
      //1.7 replace service
      let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params);
      if (!servicePromise) {
        servicePromise = this.serviceRef.current(...params);
      }
      const res = await servicePromise;

      //1.8
      if (currentCount !== this.count) {
        // prevent run.then when request is canceled
        return new Promise(() => {});
      }
      this.setState({
        data: res,
        error: undefined,
        loading: false,
      });

      //1.9
      this.options.onSuccess?.(res, params);
      this.runPluginHandler('onSuccess', res, params);

      //1.10
      this.options.onFinally?.(params, res, undefined);
      if (currentCount === this.count) {
        this.runPluginHandler('onFinally', params, res, undefined);
      }

      return res;
    } catch (error) {
      //1.11
      if (currentCount !== this.count) {
        // prevent run.then when request is canceled
        return new Promise(() => {});
      }
      this.setState({
        error,
        loading: false,
      });

      //1.12
      this.options.onError?.(error, params);
      this.runPluginHandler('onError', error, params);

      //1.13
      this.options.onFinally?.(params, undefined, error);
      if (currentCount === this.count) {
        this.runPluginHandler('onFinally', params, undefined, error);
      }

      throw error;
    }
  }

这一部分代码看起来可能有点多,但一句话概括就是:调用我们请求接口数据的方法,然后拿到成功或者失败的结果,对数据进行处理,然后更新 state,然后执行插件的各回调钩子,还有我们通过 options 传入的回调函数

接下来我们来稍微分析一下:

步骤1.1:

我们可以看到 1.1 有 this.countcurrentCount 两个变量,这两个变量其实和 cancel() 取消请求有关系,你看 1.8、1.10、1.11 都会判断 this.count 和 currentCount 是否相等决定是否取消请求

步骤1.2:

这一步可以发现是去调 runPluginHandler,然后执行的是 onBefore 钩子。我们先看看哪些插件返回的对象里包含 onBefore 钩子:

image.png

有五个插件会返回包含 onBefore 钩子的对象,但在 useRequestImplement 中的 fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions)); 这一步时,插件一定会返回包含 onBefore 钩子的对象吗?

我们来看看这几个插件的内部实现:

useAutoRunPlugin: image.png

useCachePlugin:

image.png

useLoadingDelayPlugin: image.png

剩下两个插件一样的,只有在满足条件的时候才会返回包含 onBefore 的对象。现在我们回到步骤 1.2 来:

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

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

假如现在我只传了 loadingDelay 参数,只做一个延时效果,这个效果由useLoadingDelayPlugin 插件完成,那么我在 useRequestImplement 入口函数时,fetchInstance.pluginImpls 数组中也就只有一个对象会包含 onBefore 钩子(该对象由 useLoadingDelayPlugin 返回),然后在步骤 1.2 遍历 pluginImpls 时,也就只会执行延时效果的 onBefore 钩子

image.png

步骤1.3

就是看要不要停止请求

步骤1.4

不停止请求就设置参数、组件状态

步骤1.5

这个应该跟缓存有关,要不要立即返回数据

步骤1.6

执行我们传入的 options.onBefore

剩余步骤

剩下的步骤就是执行插件的钩子,然后执行 options 传入的回调,最终返回结果

错误抓捕

这里提一嘴,runAsync 用了 try catch 捕获异常,返回的是个 Promise,当出错时,需要自己捕获异常

run 方法之所以不需要我们自己捕获异常是因为 run 方法其实调用的就是 runAsync 方法,然后帮我们做了异常处理

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

总结

分析之后,Fetch 做的事很简单,就是在请求的生命周期中,调用插件相关的钩子,然后更新数据到 state,如果有 options 的回调函数,调用即可

Plugins

在前面 步骤 1.2 里面我们分析了插件是如何能在请求的过程中被执行的,这里我们就来看看插件源码的逻辑。因为插件比较多,我们就拿一个简单的插件 useLoadingDelayPlugin 来看看

图解如下:

image.png

总之,一个插件只做一件事,负责单一功能。

最后

useRequest 的核心流程在 Fetch 类中,在该类中通过生命周期流程的形式,执行不同插件对应的钩子,实现不同的功能,把特定功能交给特定的插件去实现,自己只负责主流程,解耦功能之间的关系,降低本身维护的复杂度,提高可测试性

其实 useRequest 的这种插件化机制很值得我们学习,比如我们自己封装通用 hook 的时候,要往对外简单隐藏内部复杂的方向去做,这种思想好像涉及到深模块的概念,这个我还是第一次接触,以后有空了去学学

猜你喜欢

转载自juejin.im/post/7246963275955454009