源码共读:ahooks 的 useRequest 实现解析

avatar
前端 @阿巴阿巴

image.png

ahooks 想必大家都用过,目前还有了 vue 的移植版,他是一个高质量的 hooks 库,其中 useRequest 应该大家都用过或听说过,这里就从这个为切入点,源码解析一下它的实现原理。

基本使用

声明:

const getImageInfoService = (params) => {
    return GetImageInfo({ ImageIds: params.imageIds }); // promise
  };
  
const { run, cancel } = useRequest(getImageInfoService, {
  manual: true,
  pollingInterval: 3000,
  pollingErrorRetryCount: 3,
  onSuccess: (resp) => {
    // 请求返回的值
  },
  onError: (e) => {
    console.log(e);
  }
});

调用:

run({ imageIds });

上面举了一个获取图片信息的例子,可以看到他可以用于轮询(polling),使用很方便。

源码解析

来看一下源码的目录结构:

image.png

从入口开始看

// useRequest.ts
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 是内部的实现。

useRequestImplement 中,首先接收 option 的参数:

const { manual = false, ready = true, ...rest } = options;

然后是最核心的代码,请求实例的声明:

// useLatest 就是 useref 的一个封装,将传入的 service 放在 useRef 的 current 里再返回,这样写有什么用?这样写可以避免闭包问题和props重渲染
const serviceRef = useLatest(service);

// useUpdate 只是返回一个 useCallback 包裹的空函数
const update = useUpdate();

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

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

其中工具函数 useCreation 定义如下:

export default function useCreation<T>(factory: () => T, deps: DependencyList) {
  const { current } = useRef({
    deps,
    obj: undefined as undefined | T,
    initialized: false,
  });
  
  if (current.initialized === false || !depsAreSame(current.deps, deps)) {
    current.deps = deps;
    current.obj = factory();
    current.initialized = true;
  }
  return current.obj as T;
}

depsAreSame 函数是利用 Object.is 来判断依赖是否相同,与react的原理相似。

可以看到,useCreation 在初始化时,或者在依赖更新时,调用 factory 函数;回到 useCreation 这里,就是说初始化时调用一次内置函数,而这个函数,处理了所有的 插件(plugins),初始化插件(onInit) 后,使用了 new Fetch 来创建一个 Fetch 对象,然后将 options 和 pluginImpls 参数挂载在这个对象上。

这个new Fetch我们暂时先不管,我们接着往下看看 useRequestImplement 还有哪些实现:

在加载和卸载时触发:

useMount(() => {
    if (!manual && ready) {
      // 将请求参数放在Fetch实例的state
      const params = fetchInstance.state.params || options.defaultParams || [];
      // @ts-ignore
      fetchInstance.run(...params);
    }
});

useUnmount(() => {
    fetchInstance.cancel();
});

可以看到,在初始化时,如果不是手动调用,则自动将 fetchInstance 设置状态后 run !具体怎么 run 的,一会看一下 Fetch 的实现。

先继续 useRequestImplement 函数,其返回:

 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吐给用户的参数。


接下来就只剩 Fetch 对象的实现了,源码中使用 react class 声明方式,看来是刻意将其设置为一个对象,其构造器中初始化了state和loading的值。

看一下他的全部声明:

image.png

Run 的实现

先看最重要的 run,他负责跑通这个 promise:

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

runAsync 又调用了 runPluginHandler:

this.count += 1;
const currentCount = this.count;

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

...
// options 就是 new Fetch 传入的 fetchOptions
this.options.onBefore?.(params);

看一下 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);
}

他这里是遍历插件来调用,不同的插件有相同的事件名称,都会被调用,有点AOP的感觉... 这就要求给 useRequest 写插件时,需要有相同的生命周期函数。

可以看到,插件执行的 runPluginHandler 函数,执行了传递进来的 event,所以具体的请求内容(axios、fetch等)还得业务代码自己来实现,他只是负责执行你传入的函数并配置了很多的调度功能而已。

这里是在 run 之前,执行插件的 onBefore 钩子。

继续往下看 runAsync:

拿到用户定义的 promise:

// 插件有onRequest,则使用插件返回的 promise,否则使用一开始传进来的 serviceRef
let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params);

if (!servicePromise) {
  servicePromise = this.serviceRef.current(...params);
}

// 执行
const res = await servicePromise;

hook 里一直都有计数器 count,这里有判断了一下:

if (currentCount !== this.count) {
    // prevent run.then when request is canceled
    return new Promise(() => {});
}

这个仅作参考,保证计数一致是为了保证操作的原子性。每一个操作,run/cancel等都+1

然后将 promise 返回的值设置到state (这个data 也会返回给用户):

// error 在 catch 里,这里就不解读了,流程一致
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);
}

此时 回调里的 option 的 onSuccess 就能接收到响应值了。

Polling & Cancel 的实现

有请求就有取消,我们看一下他是怎么取消 ajax 请求的:

cancel() {
    this.count += 1;
    this.setState({
      loading: false,
    });

    // 所有的插件都会执行 onCancel,需要注意
    this.runPluginHandler('onCancel');
}

可以看到,取消是使用的插件的能力,传入了 onCancel 来接收消息。我们回头查一下,发现有个插件叫 usePollingPlugin,看来取消和轮询是一体的,我们就一起看了。

上面可以知道,runPluginHandler 会调用插件中传入的方法,我们就重点看一下 usePollingPlugin 的事件声明。

我们总揽一下这个插件:

image.png

不出所料,确实有个 onCancel,他调用了 stopPolling 函数。

const stopPolling = () => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    unsubscribeRef.current?.();
};

还记得 Fetch 组件声明时调用了 onFinally 事件么,这里看一下:

onFinally: () => {
    ...
    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);
  } 
}

可以看到他启动了一个定时器,不停的执行实例上的 refresh 方法。然后在调用 cancel 方法时,触发了 onCancel 事件,进而关闭这个定时器,从而达到了取消轮询的功能。

Refresh 实现

最后我们再来看一下这个 Refresh 方法:

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

哦吼,没有什么新鲜的,就是 run 重新调用。

总结

最后我们来总结一下 useRequest 的执行流程。

image.png


完!源码阅读其实并没有那么难,只要沉下心来,从使用作为切入点来看就能理解了,你学废了嘛!