Browse Source

feat(demo): hooks useRequest 异步数据管理 (#3447)

luocong2016 1 year ago
parent
commit
d6d1120d00
37 changed files with 2357 additions and 65 deletions
  1. 1 0
      packages/hooks/package.json
  2. 1 0
      packages/hooks/src/index.ts
  3. 147 0
      packages/hooks/src/useRequest/Fetch.ts
  4. 30 0
      packages/hooks/src/useRequest/index.ts
  5. 52 0
      packages/hooks/src/useRequest/plugins/useAutoRunPlugin.ts
  6. 127 0
      packages/hooks/src/useRequest/plugins/useCachePlugin.ts
  7. 71 0
      packages/hooks/src/useRequest/plugins/useDebouncePlugin.ts
  8. 45 0
      packages/hooks/src/useRequest/plugins/useLoadingDelayPlugin.ts
  9. 71 0
      packages/hooks/src/useRequest/plugins/usePollingPlugin.ts
  10. 37 0
      packages/hooks/src/useRequest/plugins/useRefreshOnWindowFocusPlugin.ts
  11. 54 0
      packages/hooks/src/useRequest/plugins/useRetryPlugin.ts
  12. 63 0
      packages/hooks/src/useRequest/plugins/useThrottlePlugin.ts
  13. 124 0
      packages/hooks/src/useRequest/types.ts
  14. 49 0
      packages/hooks/src/useRequest/useRequestImplement.ts
  15. 48 0
      packages/hooks/src/useRequest/utils/cache.ts
  16. 23 0
      packages/hooks/src/useRequest/utils/cachePromise.ts
  17. 22 0
      packages/hooks/src/useRequest/utils/cacheSubscribe.ts
  18. 5 0
      packages/hooks/src/useRequest/utils/isBrowser.ts
  19. 8 0
      packages/hooks/src/useRequest/utils/isDocumentVisible.ts
  20. 2 0
      packages/hooks/src/useRequest/utils/isFunction.ts
  21. 8 0
      packages/hooks/src/useRequest/utils/isOnline.ts
  22. 12 0
      packages/hooks/src/useRequest/utils/limit.ts
  23. 30 0
      packages/hooks/src/useRequest/utils/subscribeFocus.ts
  24. 25 0
      packages/hooks/src/useRequest/utils/subscribeReVisible.ts
  25. 38 65
      pnpm-lock.yaml
  26. 79 0
      src/router/routes/modules/hooks/request.ts
  27. 328 0
      src/views/hooks/request/base.tsx
  28. 318 0
      src/views/hooks/request/cache.tsx
  29. 62 0
      src/views/hooks/request/debounce.tsx
  30. 61 0
      src/views/hooks/request/loading-delay.tsx
  31. 27 0
      src/views/hooks/request/mock-api.ts
  32. 96 0
      src/views/hooks/request/polling.tsx
  33. 86 0
      src/views/hooks/request/ready.tsx
  34. 50 0
      src/views/hooks/request/refresh-on-window-focus.tsx
  35. 43 0
      src/views/hooks/request/refresy-deps.tsx
  36. 53 0
      src/views/hooks/request/retry.tsx
  37. 61 0
      src/views/hooks/request/throttle.tsx

+ 1 - 0
packages/hooks/package.json

@@ -30,6 +30,7 @@
   },
   "dependencies": {
     "@vueuse/core": "^10.2.1",
+    "lodash-es": "^4.17.21",
     "vue": "^3.3.4"
   },
   "devDependencies": {

+ 1 - 0
packages/hooks/src/index.ts

@@ -1,6 +1,7 @@
 export * from './onMountedOrActivated';
 export * from './useAttrs';
 export * from './useRefs';
+export * from './useRequest';
 export * from './useScrollTo';
 export * from './useWindowSizeFn';
 export { useTimeoutFn } from '@vueuse/core';

+ 147 - 0
packages/hooks/src/useRequest/Fetch.ts

@@ -0,0 +1,147 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import { reactive } from 'vue';
+
+import type { FetchState, PluginReturn, Service, Subscribe, UseRequestOptions } from './types';
+import { isFunction } from './utils/isFunction';
+
+export default class Fetch<TData, TParams extends any[]> {
+  pluginImpls: PluginReturn<TData, TParams>[] = [];
+
+  count: number = 0;
+
+  state: FetchState<TData, TParams> = reactive({
+    loading: false,
+    params: undefined,
+    data: undefined,
+    error: undefined,
+  });
+
+  constructor(
+    public serviceRef: Service<TData, TParams>,
+    public options: UseRequestOptions<TData, TParams>,
+    public subscribe: Subscribe,
+    public initState: Partial<FetchState<TData, TParams>> = {},
+  ) {
+    this.setState({ loading: !options.manual, ...initState });
+  }
+
+  setState(s: Partial<FetchState<TData, TParams>> = {}) {
+    Object.assign(this.state, s);
+    this.subscribe();
+  }
+
+  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);
+  }
+
+  async runAsync(...params: TParams): Promise<TData> {
+    this.count += 1;
+    const currentCount = this.count;
+
+    const {
+      stopNow = false,
+      returnNow = false,
+      ...state
+    } = this.runPluginHandler('onBefore', params);
+
+    // stop request
+    if (stopNow) {
+      return new Promise(() => {});
+    }
+
+    this.setState({
+      loading: true,
+      params,
+      ...state,
+    });
+
+    // return now
+    if (returnNow) {
+      return Promise.resolve(state.data);
+    }
+
+    this.options.onBefore?.(params);
+
+    try {
+      // replace service
+      let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef, params);
+
+      if (!servicePromise) {
+        servicePromise = this.serviceRef(...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;
+
+      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);
+      }
+
+      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;
+    }
+  }
+
+  run(...params: TParams) {
+    this.runAsync(...params).catch((error) => {
+      if (!this.options.onError) {
+        console.error(error);
+      }
+    });
+  }
+
+  cancel() {
+    this.count += 1;
+    this.setState({ loading: false });
+
+    this.runPluginHandler('onCancel');
+  }
+
+  refresh() {
+    // @ts-ignore
+    this.run(...(this.state.params || []));
+  }
+
+  refreshAsync() {
+    // @ts-ignore
+    return this.runAsync(...(this.state.params || []));
+  }
+
+  mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {
+    const targetData = isFunction(data) ? data(this.state.data) : data;
+    this.runPluginHandler('onMutate', targetData);
+    this.setState({ data: targetData });
+  }
+}

+ 30 - 0
packages/hooks/src/useRequest/index.ts

@@ -0,0 +1,30 @@
+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 { Service, UseRequestOptions, UseRequestPlugin } from './types';
+import { useRequestImplement } from './useRequestImplement';
+
+export { clearCache } from './utils/cache';
+
+export function useRequest<TData, TParams extends any[]>(
+  service: Service<TData, TParams>,
+  options?: UseRequestOptions<TData, TParams>,
+  plugins?: UseRequestPlugin<TData, TParams>[],
+) {
+  return useRequestImplement<TData, TParams>(service, options, [
+    ...(plugins || []),
+    useDebouncePlugin,
+    useLoadingDelayPlugin,
+    usePollingPlugin,
+    useRefreshOnWindowFocusPlugin,
+    useThrottlePlugin,
+    useAutoRunPlugin,
+    useCachePlugin,
+    useRetryPlugin,
+  ] as UseRequestPlugin<TData, TParams>[]);
+}

+ 52 - 0
packages/hooks/src/useRequest/plugins/useAutoRunPlugin.ts

@@ -0,0 +1,52 @@
+import { ref, unref, watch } from 'vue';
+
+import type { UseRequestPlugin } from '../types';
+
+// support refreshDeps & ready
+const useAutoRunPlugin: UseRequestPlugin<any, any[]> = (
+  fetchInstance,
+  { manual, ready = true, defaultParams = [], refreshDeps = [], refreshDepsAction },
+) => {
+  const hasAutoRun = ref(false);
+
+  watch(
+    () => unref(ready),
+    (readyVal) => {
+      if (!unref(manual) && readyVal) {
+        hasAutoRun.value = true;
+        fetchInstance.run(...defaultParams);
+      }
+    },
+  );
+
+  if (refreshDeps.length) {
+    watch(refreshDeps, () => {
+      if (hasAutoRun.value) {
+        return;
+      }
+      if (!manual) {
+        if (refreshDepsAction) {
+          refreshDepsAction();
+        } else {
+          fetchInstance.refresh();
+        }
+      }
+    });
+  }
+
+  return {
+    onBefore: () => {
+      if (!unref(ready)) {
+        return { stopNow: true };
+      }
+    },
+  };
+};
+
+useAutoRunPlugin.onInit = ({ ready = true, manual }) => {
+  return {
+    loading: !unref(manual) && unref(ready),
+  };
+};
+
+export default useAutoRunPlugin;

+ 127 - 0
packages/hooks/src/useRequest/plugins/useCachePlugin.ts

@@ -0,0 +1,127 @@
+import { onUnmounted, ref, watchEffect } from 'vue';
+
+import type { UseRequestPlugin } from '../types';
+import type { CachedData } from '../utils/cache';
+import { getCache, setCache } from '../utils/cache';
+import { getCachePromise, setCachePromise } from '../utils/cachePromise';
+import { subscribe, trigger } from '../utils/cacheSubscribe';
+
+const useCachePlugin: UseRequestPlugin<any, any[]> = (
+  fetchInstance,
+  {
+    cacheKey,
+    cacheTime = 5 * 60 * 1000,
+    staleTime = 0,
+    setCache: customSetCache,
+    getCache: customGetCache,
+  },
+) => {
+  const unSubscribeRef = ref<() => void>();
+  const currentPromiseRef = ref<Promise<any>>();
+
+  const _setCache = (key: string, cachedData: CachedData) => {
+    customSetCache ? customSetCache(cachedData) : setCache(key, cacheTime, cachedData);
+    trigger(key, cachedData.data);
+  };
+
+  const _getCache = (key: string, params: any[] = []) => {
+    return customGetCache ? customGetCache(params) : getCache(key);
+  };
+
+  watchEffect(() => {
+    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.value = subscribe(cacheKey, (data) => {
+      fetchInstance.setState({ data });
+    });
+  });
+
+  onUnmounted(() => {
+    unSubscribeRef.value?.();
+  });
+
+  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.value) {
+        return { servicePromise };
+      }
+
+      servicePromise = service(...args);
+      currentPromiseRef.value = servicePromise;
+      setCachePromise(cacheKey, servicePromise);
+
+      return { servicePromise };
+    },
+    onSuccess: (data, params) => {
+      if (cacheKey) {
+        // cancel subscribe, avoid trgger self
+        unSubscribeRef.value?.();
+
+        _setCache(cacheKey, { data, params, time: new Date().getTime() });
+
+        // resubscribe
+        unSubscribeRef.value = subscribe(cacheKey, (d) => {
+          fetchInstance.setState({ data: d });
+        });
+      }
+    },
+    onMutate: (data) => {
+      if (cacheKey) {
+        // cancel subscribe, avoid trigger self
+        unSubscribeRef.value?.();
+
+        _setCache(cacheKey, {
+          data,
+          params: fetchInstance.state.params,
+          time: new Date().getTime(),
+        });
+
+        // resubscribe
+        unSubscribeRef.value = subscribe(cacheKey, (d) => {
+          fetchInstance.setState({ data: d });
+        });
+      }
+    },
+  };
+};
+
+export default useCachePlugin;

+ 71 - 0
packages/hooks/src/useRequest/plugins/useDebouncePlugin.ts

@@ -0,0 +1,71 @@
+import type { DebouncedFunc, DebounceSettings } from 'lodash-es';
+import { debounce } from 'lodash-es';
+import { computed, ref, watchEffect } from 'vue';
+
+import type { UseRequestPlugin } from '../types';
+
+const useDebouncePlugin: UseRequestPlugin<any, any[]> = (
+  fetchInstance,
+  { debounceWait, debounceLeading, debounceTrailing, debounceMaxWait },
+) => {
+  const debouncedRef = ref<DebouncedFunc<any>>();
+
+  const options = computed(() => {
+    const ret: DebounceSettings = {};
+
+    if (debounceLeading !== undefined) {
+      ret.leading = debounceLeading;
+    }
+    if (debounceTrailing !== undefined) {
+      ret.trailing = debounceTrailing;
+    }
+    if (debounceMaxWait !== undefined) {
+      ret.maxWait = debounceMaxWait;
+    }
+
+    return ret;
+  });
+
+  watchEffect(() => {
+    if (debounceWait) {
+      const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance);
+
+      debouncedRef.value = debounce(
+        (callback) => {
+          callback();
+        },
+        debounceWait,
+        options.value,
+      );
+
+      // debounce runAsync should be promise
+      // https://github.com/lodash/lodash/issues/4400#issuecomment-834800398
+      fetchInstance.runAsync = (...args) => {
+        return new Promise((resolve, reject) => {
+          debouncedRef.value?.(() => {
+            _originRunAsync(...args)
+              .then(resolve)
+              .catch(reject);
+          });
+        });
+      };
+
+      return () => {
+        debouncedRef.value?.cancel();
+        fetchInstance.runAsync = _originRunAsync;
+      };
+    }
+  });
+
+  if (!debounceWait) {
+    return {};
+  }
+
+  return {
+    onCancel: () => {
+      debouncedRef.value?.cancel();
+    },
+  };
+};
+
+export default useDebouncePlugin;

+ 45 - 0
packages/hooks/src/useRequest/plugins/useLoadingDelayPlugin.ts

@@ -0,0 +1,45 @@
+import { ref, unref } from 'vue';
+
+import type { UseRequestPlugin, UseRequestTimeout } from '../types';
+
+const useLoadingDelayPlugin: UseRequestPlugin<any, any[]> = (
+  fetchInstance,
+  { loadingDelay, ready },
+) => {
+  const timerRef = ref<UseRequestTimeout>();
+
+  if (!loadingDelay) {
+    return {};
+  }
+
+  const cancelTimeout = () => {
+    if (timerRef.value) {
+      clearTimeout(timerRef.value);
+    }
+  };
+
+  return {
+    onBefore: () => {
+      cancelTimeout();
+
+      // Two cases:
+      // 1. ready === undefined
+      // 2. ready === true
+      if (unref(ready) !== false) {
+        timerRef.value = setTimeout(() => {
+          fetchInstance.setState({ loading: true });
+        }, loadingDelay);
+      }
+
+      return { loading: false };
+    },
+    onFinally: () => {
+      cancelTimeout();
+    },
+    onCancel: () => {
+      cancelTimeout();
+    },
+  };
+};
+
+export default useLoadingDelayPlugin;

+ 71 - 0
packages/hooks/src/useRequest/plugins/usePollingPlugin.ts

@@ -0,0 +1,71 @@
+import { ref, watch } from 'vue';
+
+import type { UseRequestPlugin, UseRequestTimeout } from '../types';
+import { isDocumentVisible } from '../utils/isDocumentVisible';
+import subscribeReVisible from '../utils/subscribeReVisible';
+
+const usePollingPlugin: UseRequestPlugin<any, any[]> = (
+  fetchInstance,
+  { pollingInterval, pollingWhenHidden = true, pollingErrorRetryCount = -1 },
+) => {
+  const timerRef = ref<UseRequestTimeout>();
+  const unsubscribeRef = ref<() => void>();
+  const countRef = ref<number>(0);
+
+  const stopPolling = () => {
+    if (timerRef.value) {
+      clearTimeout(timerRef.value);
+    }
+    unsubscribeRef.value?.();
+  };
+
+  watch(
+    () => pollingInterval,
+    () => {
+      if (!pollingInterval) {
+        stopPolling();
+      }
+    },
+  );
+
+  if (!pollingInterval) {
+    return {};
+  }
+
+  return {
+    onBefore: () => {
+      stopPolling();
+    },
+    onError: () => {
+      countRef.value += 1;
+    },
+    onSuccess: () => {
+      countRef.value = 0;
+    },
+    onFinally: () => {
+      if (
+        pollingErrorRetryCount === -1 ||
+        // When an error occurs, the request is not repeated after pollingErrorRetryCount retries
+        (pollingErrorRetryCount !== -1 && countRef.value <= pollingErrorRetryCount)
+      ) {
+        timerRef.value = setTimeout(() => {
+          // if pollingWhenHidden = false && document is hidden, then stop polling and subscribe revisible
+          if (!pollingWhenHidden && !isDocumentVisible()) {
+            unsubscribeRef.value = subscribeReVisible(() => {
+              fetchInstance.refresh();
+            });
+          } else {
+            fetchInstance.refresh();
+          }
+        }, pollingInterval);
+      } else {
+        countRef.value = 0;
+      }
+    },
+    onCancel: () => {
+      stopPolling();
+    },
+  };
+};
+
+export default usePollingPlugin;

+ 37 - 0
packages/hooks/src/useRequest/plugins/useRefreshOnWindowFocusPlugin.ts

@@ -0,0 +1,37 @@
+import { onUnmounted, ref, watchEffect } from 'vue';
+
+import type { UseRequestPlugin } from '../types';
+import { limit } from '../utils/limit';
+import subscribeFocus from '../utils/subscribeFocus';
+
+const useRefreshOnWindowFocusPlugin: UseRequestPlugin<any, any[]> = (
+  fetchInstance,
+  { refreshOnWindowFocus, focusTimespan = 5000 },
+) => {
+  const unsubscribeRef = ref<() => void>();
+
+  const stopSubscribe = () => {
+    unsubscribeRef.value?.();
+  };
+
+  watchEffect(() => {
+    if (refreshOnWindowFocus) {
+      const limitRefresh = limit(fetchInstance.refresh.bind(fetchInstance), focusTimespan);
+      unsubscribeRef.value = subscribeFocus(() => {
+        limitRefresh();
+      });
+    }
+
+    return () => {
+      stopSubscribe();
+    };
+  });
+
+  onUnmounted(() => {
+    stopSubscribe();
+  });
+
+  return {};
+};
+
+export default useRefreshOnWindowFocusPlugin;

+ 54 - 0
packages/hooks/src/useRequest/plugins/useRetryPlugin.ts

@@ -0,0 +1,54 @@
+import { ref } from 'vue';
+
+import type { UseRequestPlugin, UseRequestTimeout } from '../types';
+
+const useRetryPlugin: UseRequestPlugin<any, any[]> = (
+  fetchInstance,
+  { retryInterval, retryCount },
+) => {
+  const timerRef = ref<UseRequestTimeout>();
+  const countRef = ref(0);
+
+  const triggerByRetry = ref(false);
+
+  if (!retryCount) {
+    return {};
+  }
+
+  return {
+    onBefore: () => {
+      if (!triggerByRetry.value) {
+        countRef.value = 0;
+      }
+      triggerByRetry.value = false;
+
+      if (timerRef.value) {
+        clearTimeout(timerRef.value);
+      }
+    },
+    onSuccess: () => {
+      countRef.value = 0;
+    },
+    onError: () => {
+      countRef.value += 1;
+      if (retryCount === -1 || countRef.value <= retryCount) {
+        // Exponential backoff
+        const timeout = retryInterval ?? Math.min(1000 * 2 ** countRef.value, 30000);
+        timerRef.value = setTimeout(() => {
+          triggerByRetry.value = true;
+          fetchInstance.refresh();
+        }, timeout);
+      } else {
+        countRef.value = 0;
+      }
+    },
+    onCancel: () => {
+      countRef.value = 0;
+      if (timerRef.value) {
+        clearTimeout(timerRef.value);
+      }
+    },
+  };
+};
+
+export default useRetryPlugin;

+ 63 - 0
packages/hooks/src/useRequest/plugins/useThrottlePlugin.ts

@@ -0,0 +1,63 @@
+import type { DebouncedFunc, ThrottleSettings } from 'lodash-es';
+import { throttle } from 'lodash-es';
+import { ref, watchEffect } from 'vue';
+
+import type { UseRequestPlugin } from '../types';
+
+const useThrottlePlugin: UseRequestPlugin<any, any[]> = (
+  fetchInstance,
+  { throttleWait, throttleLeading, throttleTrailing },
+) => {
+  const throttledRef = ref<DebouncedFunc<any>>();
+
+  const options: ThrottleSettings = {};
+  if (throttleLeading !== undefined) {
+    options.leading = throttleLeading;
+  }
+  if (throttleTrailing !== undefined) {
+    options.trailing = throttleTrailing;
+  }
+
+  watchEffect(() => {
+    if (throttleWait) {
+      const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance);
+
+      throttledRef.value = 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.value?.(() => {
+            _originRunAsync(...args)
+              .then(resolve)
+              .catch(reject);
+          });
+        });
+      };
+
+      return () => {
+        fetchInstance.runAsync = _originRunAsync;
+        throttledRef.value?.cancel();
+      };
+    }
+  });
+
+  if (!throttleWait) {
+    return {};
+  }
+
+  return {
+    onCancel: () => {
+      throttledRef.value?.cancel();
+    },
+  };
+};
+
+export default useThrottlePlugin;

+ 124 - 0
packages/hooks/src/useRequest/types.ts

@@ -0,0 +1,124 @@
+import type { MaybeRef, Ref, WatchSource } from 'vue';
+
+import type Fetch from './Fetch';
+import type { CachedData } from './utils/cache';
+
+export type Service<TData, TParams extends any[]> = (...args: TParams) => Promise<TData>;
+export type Subscribe = () => void;
+
+// for Fetch
+export interface FetchState<TData, TParams extends any[]> {
+  loading: boolean;
+  params?: TParams;
+  data?: TData;
+  error?: Error;
+}
+
+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;
+}
+
+// for useRequestImplement
+export interface UseRequestOptions<TData, TParams extends any[]> {
+  manual?: MaybeRef<boolean>;
+
+  onBefore?: (params: TParams) => void;
+  onSuccess?: (data: TData, params: TParams) => void;
+  onError?: (e: Error, params: TParams) => void;
+  // formatResult?: (res: any) => TData;
+  onFinally?: (params: TParams, data?: TData, e?: Error) => void;
+
+  defaultParams?: TParams;
+
+  // refreshDeps
+  refreshDeps?: WatchSource<any>[];
+  refreshDepsAction?: () => void;
+
+  // loading delay
+  loadingDelay?: number;
+
+  // polling
+  pollingInterval?: number;
+  pollingWhenHidden?: boolean;
+  pollingErrorRetryCount?: number;
+
+  // refresh on window focus
+  refreshOnWindowFocus?: boolean;
+  focusTimespan?: number;
+
+  // debounce
+  debounceWait?: number;
+  debounceLeading?: boolean;
+  debounceTrailing?: boolean;
+  debounceMaxWait?: number;
+
+  // throttle
+  throttleWait?: number;
+  throttleLeading?: boolean;
+  throttleTrailing?: boolean;
+
+  // cache
+  cacheKey?: string;
+  cacheTime?: number;
+  staleTime?: number;
+  setCache?: (data: CachedData<TData, TParams>) => void;
+  getCache?: (params: TParams) => CachedData<TData, TParams> | undefined;
+
+  // retry
+  retryCount?: number;
+  retryInterval?: number;
+
+  // ready
+  ready?: MaybeRef<boolean>;
+
+  // [key: string]: any;
+}
+
+export interface UseRequestPlugin<TData, TParams extends any[]> {
+  // eslint-disable-next-line prettier/prettier
+  (fetchInstance: Fetch<TData, TParams>, options: UseRequestOptions<TData, TParams>): PluginReturn<
+    TData,
+    TParams
+  >;
+  onInit?: (options: UseRequestOptions<TData, TParams>) => Partial<FetchState<TData, TParams>>;
+}
+
+// for index
+// export type OptionsWithoutFormat<TData, TParams extends any[]> = Omit<Options<TData, TParams>, 'formatResult'>;
+
+// export interface OptionsWithFormat<TData, TParams extends any[], TFormated, TTFormated extends TFormated = any> extends Omit<Options<TTFormated, TParams>, 'formatResult'> {
+//   formatResult: (res: TData) => TFormated;
+// };
+
+export interface UseRequestResult<TData, TParams extends any[]> {
+  loading: Ref<boolean>;
+  data: Ref<TData>;
+  error: Ref<Error>;
+  params: Ref<TParams | []>;
+  cancel: Fetch<TData, TParams>['cancel'];
+  refresh: Fetch<TData, TParams>['refresh'];
+  refreshAsync: Fetch<TData, TParams>['refreshAsync'];
+  run: Fetch<TData, TParams>['run'];
+  runAsync: Fetch<TData, TParams>['runAsync'];
+  mutate: Fetch<TData, TParams>['mutate'];
+}
+
+export type UseRequestTimeout = ReturnType<typeof setTimeout>;

+ 49 - 0
packages/hooks/src/useRequest/useRequestImplement.ts

@@ -0,0 +1,49 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import { onMounted, onUnmounted, toRefs } from 'vue';
+
+import Fetch from './Fetch';
+import type { Service, UseRequestOptions, UseRequestPlugin, UseRequestResult } from './types';
+
+export function useRequestImplement<TData, TParams extends any[]>(
+  service: Service<TData, TParams>,
+  options: UseRequestOptions<TData, TParams> = {},
+  plugins: UseRequestPlugin<TData, TParams>[] = [],
+) {
+  const { manual = false, ...rest } = options;
+  const fetchOptions = { manual, ...rest };
+
+  const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean);
+
+  const fetchInstance = new Fetch<TData, TParams>(
+    service,
+    fetchOptions,
+    () => {},
+    Object.assign({}, ...initState),
+  );
+
+  fetchInstance.options = fetchOptions;
+  // run all plugins hooks
+  fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));
+
+  onMounted(() => {
+    if (!manual) {
+      const params = fetchInstance.state.params || options.defaultParams || [];
+      // @ts-ignore
+      fetchInstance.run(...params);
+    }
+  });
+
+  onUnmounted(() => {
+    fetchInstance.cancel();
+  });
+
+  return {
+    ...toRefs(fetchInstance.state),
+    cancel: fetchInstance.cancel.bind(fetchInstance),
+    mutate: fetchInstance.mutate.bind(fetchInstance),
+    refresh: fetchInstance.refresh.bind(fetchInstance),
+    refreshAsync: fetchInstance.refreshAsync.bind(fetchInstance),
+    run: fetchInstance.run.bind(fetchInstance),
+    runAsync: fetchInstance.runAsync.bind(fetchInstance),
+  } as UseRequestResult<TData, TParams>;
+}

+ 48 - 0
packages/hooks/src/useRequest/utils/cache.ts

@@ -0,0 +1,48 @@
+type Timer = ReturnType<typeof setTimeout>;
+type CachedKey = string | number;
+
+export interface CachedData<TData = any, TParams = any> {
+  data: TData;
+  params: TParams;
+  time: number;
+}
+
+interface RecordData extends CachedData {
+  timer: Timer | undefined;
+}
+
+const cache = new Map<CachedKey, RecordData>();
+
+export 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) {
+    // if cache out, clear it
+    timer = setTimeout(() => {
+      cache.delete(key);
+    }, cacheTime);
+  }
+
+  cache.set(key, {
+    ...cachedData,
+    timer,
+  });
+};
+
+export const getCache = (key: CachedKey) => {
+  return cache.get(key);
+};
+
+export const clearCache = (key?: string | string[]) => {
+  if (key) {
+    const cacheKeys = Array.isArray(key) ? key : [key];
+    cacheKeys.forEach((cacheKey) => cache.delete(cacheKey));
+  } else {
+    cache.clear();
+  }
+};

+ 23 - 0
packages/hooks/src/useRequest/utils/cachePromise.ts

@@ -0,0 +1,23 @@
+type CachedKey = string | number;
+
+const cachePromise = new Map<CachedKey, Promise<any>>();
+
+export const getCachePromise = (cacheKey: CachedKey) => {
+  return cachePromise.get(cacheKey);
+};
+
+export const setCachePromise = (cacheKey: CachedKey, promise: Promise<any>) => {
+  // Should cache the same promise, cannot be promise.finally
+  // Because the promise.finally will change the reference of the promise
+  cachePromise.set(cacheKey, promise);
+
+  // no use promise.finally for compatibility
+  promise
+    .then((res) => {
+      cachePromise.delete(cacheKey);
+      return res;
+    })
+    .catch(() => {
+      cachePromise.delete(cacheKey);
+    });
+};

+ 22 - 0
packages/hooks/src/useRequest/utils/cacheSubscribe.ts

@@ -0,0 +1,22 @@
+type Listener = (data: any) => void;
+
+const listeners: Record<string, Listener[]> = {};
+
+export const trigger = (key: string, data: any) => {
+  if (listeners[key]) {
+    listeners[key].forEach((item) => item(data));
+  }
+};
+
+export const subscribe = (key: string, listener: Listener) => {
+  if (!listeners[key]) {
+    listeners[key] = [];
+  }
+
+  listeners[key].push(listener);
+
+  return function unsubscribe() {
+    const index = listeners[key].indexOf(listener);
+    listeners[key].splice(index, 1);
+  };
+};

+ 5 - 0
packages/hooks/src/useRequest/utils/isBrowser.ts

@@ -0,0 +1,5 @@
+export const isBrowser = !!(
+  typeof window !== 'undefined' &&
+  window.document &&
+  window.document.createElement
+);

+ 8 - 0
packages/hooks/src/useRequest/utils/isDocumentVisible.ts

@@ -0,0 +1,8 @@
+import { isBrowser } from './isBrowser';
+
+export function isDocumentVisible(): boolean {
+  if (isBrowser) {
+    return document.visibilityState !== 'hidden';
+  }
+  return true;
+}

+ 2 - 0
packages/hooks/src/useRequest/utils/isFunction.ts

@@ -0,0 +1,2 @@
+export const isFunction = (value: unknown): value is (...args: any) => any =>
+  typeof value === 'function';

+ 8 - 0
packages/hooks/src/useRequest/utils/isOnline.ts

@@ -0,0 +1,8 @@
+import { isBrowser } from './isBrowser';
+
+export function isOnline(): boolean {
+  if (isBrowser && typeof navigator.onLine !== 'undefined') {
+    return navigator.onLine;
+  }
+  return true;
+}

+ 12 - 0
packages/hooks/src/useRequest/utils/limit.ts

@@ -0,0 +1,12 @@
+export function limit(fn: any, timespan: number) {
+  let pending = false;
+
+  return (...args: any[]) => {
+    if (pending) return;
+    pending = true;
+    fn(...args);
+    setTimeout(() => {
+      pending = false;
+    }, timespan);
+  };
+}

+ 30 - 0
packages/hooks/src/useRequest/utils/subscribeFocus.ts

@@ -0,0 +1,30 @@
+import { isBrowser } from './isBrowser';
+import { isDocumentVisible } from './isDocumentVisible';
+import { isOnline } from './isOnline';
+
+type Listener = () => void;
+
+const listeners: Listener[] = [];
+
+if (isBrowser) {
+  const revalidate = () => {
+    if (!isDocumentVisible() || !isOnline()) return;
+    for (let i = 0; i < listeners.length; i++) {
+      const listener = listeners[i];
+      listener();
+    }
+  };
+  window.addEventListener('visibilitychange', revalidate, false);
+  window.addEventListener('focus', revalidate, false);
+}
+
+export default function subscribe(listener: Listener) {
+  listeners.push(listener);
+
+  return function unsubscribe() {
+    const index = listeners.indexOf(listener);
+    if (index > -1) {
+      listeners.splice(index, 1);
+    }
+  };
+}

+ 25 - 0
packages/hooks/src/useRequest/utils/subscribeReVisible.ts

@@ -0,0 +1,25 @@
+import { isBrowser } from './isBrowser';
+import { isDocumentVisible } from './isDocumentVisible';
+
+type Listener = () => void;
+
+const listeners: Listener[] = [];
+
+if (isBrowser) {
+  const revalidate = () => {
+    if (!isDocumentVisible()) return;
+    for (let i = 0; i < listeners.length; i++) {
+      const listener = listeners[i];
+      listener();
+    }
+  };
+  window.addEventListener('visibilitychange', revalidate, false);
+}
+
+export default function subscribe(listener: Listener) {
+  listeners.push(listener);
+  return function unsubscribe() {
+    const index = listeners.indexOf(listener);
+    listeners.splice(index, 1);
+  };
+}

+ 38 - 65
pnpm-lock.yaml

@@ -461,6 +461,9 @@ importers:
       '@vueuse/core':
         specifier: ^10.2.1
         version: 10.2.1(vue@3.3.4)
+      lodash-es:
+        specifier: ^4.17.21
+        version: 4.17.21
       vue:
         specifier: ^3.3.4
         version: 3.3.4
@@ -537,6 +540,7 @@ packages:
   /@babel/code-frame@7.23.4:
     resolution: {integrity: sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==}
     engines: {node: '>=6.9.0'}
+    requiresBuild: true
     dependencies:
       '@babel/highlight': 7.23.4
       chalk: 2.4.2
@@ -563,7 +567,7 @@ packages:
       '@babel/types': 7.22.5
       '@nicolo-ribaudo/semver-v6': 6.3.3
       convert-source-map: 1.9.0
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       gensync: 1.0.0-beta.2
       json5: 2.2.3
     transitivePeerDependencies:
@@ -714,6 +718,7 @@ packages:
   /@babel/helper-validator-identifier@7.22.20:
     resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==}
     engines: {node: '>=6.9.0'}
+    requiresBuild: true
     dev: true
     optional: true
 
@@ -936,7 +941,7 @@ packages:
       '@babel/helper-split-export-declaration': 7.22.6
       '@babel/parser': 7.22.6
       '@babel/types': 7.22.5
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       globals: 11.12.0
     transitivePeerDependencies:
       - supports-color
@@ -1572,7 +1577,7 @@ packages:
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
     dependencies:
       ajv: 6.12.6
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       espree: 9.6.1
       globals: 13.20.0
       ignore: 5.2.4
@@ -1617,7 +1622,7 @@ packages:
     engines: {node: '>=10.10.0'}
     dependencies:
       '@humanwhocodes/object-schema': 1.2.1
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       minimatch: 3.1.2
     transitivePeerDependencies:
       - supports-color
@@ -2141,7 +2146,7 @@ packages:
     dependencies:
       '@iconify/iconify': 2.1.2
       axios: 0.26.1(debug@4.3.4)
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       fast-glob: 3.3.0
       fs-extra: 10.1.0
     transitivePeerDependencies:
@@ -2750,7 +2755,7 @@ packages:
     dependencies:
       '@typescript-eslint/typescript-estree': 6.6.0(typescript@5.2.2)
       '@typescript-eslint/utils': 6.6.0(eslint@8.48.0)(typescript@5.2.2)
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       eslint: 8.48.0
       ts-api-utils: 1.0.3(typescript@5.2.2)
       typescript: 5.2.2
@@ -2774,7 +2779,7 @@ packages:
     dependencies:
       '@typescript-eslint/types': 6.6.0
       '@typescript-eslint/visitor-keys': 6.6.0
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       globby: 11.1.0
       is-glob: 4.0.3
       semver: 7.5.4
@@ -3266,7 +3271,7 @@ packages:
     resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
     engines: {node: '>= 6.0.0'}
     dependencies:
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
     transitivePeerDependencies:
       - supports-color
 
@@ -4553,6 +4558,17 @@ packages:
     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
     dev: true
 
+  /debug@2.6.9:
+    resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+    dependencies:
+      ms: registry.npmmirror.com/ms@2.0.0
+    dev: true
+
   /debug@3.2.7(supports-color@5.5.0):
     resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
     peerDependencies:
@@ -5237,7 +5253,7 @@ packages:
   /eslint-import-resolver-node@0.3.7:
     resolution: {integrity: sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==}
     dependencies:
-      debug: registry.npmmirror.com/debug@3.2.7
+      debug: 3.2.7(supports-color@5.5.0)
       is-core-module: 2.13.0
       resolve: 1.22.2
     transitivePeerDependencies:
@@ -5266,7 +5282,7 @@ packages:
         optional: true
     dependencies:
       '@typescript-eslint/parser': 6.6.0(eslint@8.48.0)(typescript@5.2.2)
-      debug: registry.npmmirror.com/debug@3.2.7
+      debug: 3.2.7(supports-color@5.5.0)
       eslint: 8.48.0
       eslint-import-resolver-node: 0.3.7
     transitivePeerDependencies:
@@ -5554,7 +5570,7 @@ packages:
     resolution: {integrity: sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==}
     engines: {node: '>=0.10.0'}
     dependencies:
-      debug: registry.npmmirror.com/debug@2.6.9
+      debug: 2.6.9
       define-property: 0.2.5
       extend-shallow: 2.0.1
       posix-character-classes: 0.1.1
@@ -5711,7 +5727,7 @@ packages:
     resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
     engines: {node: '>= 0.8'}
     dependencies:
-      debug: registry.npmmirror.com/debug@2.6.9
+      debug: 2.6.9
       encodeurl: 1.0.2
       escape-html: 1.0.3
       on-finished: 2.3.0
@@ -5950,7 +5966,7 @@ packages:
     dependencies:
       '@tootallnate/once': 1.1.2
       data-uri-to-buffer: 3.0.1
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       file-uri-to-path: 2.0.0
       fs-extra: 8.1.0
       ftp: 0.3.10
@@ -6344,7 +6360,7 @@ packages:
     dependencies:
       '@tootallnate/once': 1.1.2
       agent-base: 6.0.2
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
     transitivePeerDependencies:
       - supports-color
 
@@ -6353,7 +6369,7 @@ packages:
     engines: {node: '>= 6'}
     dependencies:
       agent-base: 6.0.2
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
     transitivePeerDependencies:
       - supports-color
 
@@ -6868,7 +6884,7 @@ packages:
     resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
     engines: {node: '>=10'}
     dependencies:
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       istanbul-lib-coverage: 3.2.0
       source-map: registry.npmmirror.com/source-map@0.6.1
     transitivePeerDependencies:
@@ -8720,7 +8736,7 @@ packages:
     dependencies:
       '@tootallnate/once': 1.1.2
       agent-base: 6.0.2
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       get-uri: 3.0.2
       http-proxy-agent: 4.0.1
       https-proxy-agent: 5.0.1
@@ -9262,7 +9278,7 @@ packages:
     engines: {node: '>= 8'}
     dependencies:
       agent-base: 6.0.2
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       http-proxy-agent: 4.0.1
       https-proxy-agent: 5.0.1
       lru-cache: 5.1.1
@@ -9509,7 +9525,7 @@ packages:
     resolution: {integrity: sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==}
     engines: {node: '>=6'}
     dependencies:
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       module-details-from-path: 1.0.3
       resolve: 1.22.2
     transitivePeerDependencies:
@@ -9970,7 +9986,7 @@ packages:
     engines: {node: '>=0.10.0'}
     dependencies:
       base: 0.11.2
-      debug: registry.npmmirror.com/debug@2.6.9
+      debug: 2.6.9
       define-property: 0.2.5
       extend-shallow: 2.0.1
       map-cache: 0.2.2
@@ -9986,7 +10002,7 @@ packages:
     engines: {node: '>= 6'}
     dependencies:
       agent-base: 6.0.2
-      debug: registry.npmmirror.com/debug@4.3.4
+      debug: 4.3.4
       socks: 2.7.1
     transitivePeerDependencies:
       - supports-color
@@ -12247,7 +12263,7 @@ packages:
       dom-align: registry.npmmirror.com/dom-align@1.12.4
       dom-scroll-into-view: registry.npmmirror.com/dom-scroll-into-view@2.0.1
       lodash: registry.npmmirror.com/lodash@4.17.21
-      lodash-es: registry.npmmirror.com/lodash-es@4.17.21
+      lodash-es: 4.17.21
       resize-observer-polyfill: registry.npmmirror.com/resize-observer-polyfill@1.5.1
       scroll-into-view-if-needed: registry.npmmirror.com/scroll-into-view-if-needed@2.2.31
       shallow-equal: registry.npmmirror.com/shallow-equal@1.2.1
@@ -12325,32 +12341,6 @@ packages:
       ms: registry.npmmirror.com/ms@2.0.0
     dev: true
 
-  registry.npmmirror.com/debug@3.2.7:
-    resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz}
-    name: debug
-    version: 3.2.7
-    peerDependencies:
-      supports-color: '*'
-    peerDependenciesMeta:
-      supports-color:
-        optional: true
-    dependencies:
-      ms: registry.npmmirror.com/ms@2.1.3
-    dev: true
-
-  registry.npmmirror.com/debug@4.3.4:
-    resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz}
-    name: debug
-    version: 4.3.4
-    engines: {node: '>=6.0'}
-    peerDependencies:
-      supports-color: '*'
-    peerDependenciesMeta:
-      supports-color:
-        optional: true
-    dependencies:
-      ms: registry.npmmirror.com/ms@2.1.2
-
   registry.npmmirror.com/dom-align@1.12.4:
     resolution: {integrity: sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz}
     name: dom-align
@@ -12410,12 +12400,6 @@ packages:
     name: js-tokens
     version: 4.0.0
 
-  registry.npmmirror.com/lodash-es@4.17.21:
-    resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz}
-    name: lodash-es
-    version: 4.17.21
-    dev: false
-
   registry.npmmirror.com/lodash@4.17.21:
     resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz}
     name: lodash
@@ -12445,17 +12429,6 @@ packages:
     version: 2.0.0
     dev: true
 
-  registry.npmmirror.com/ms@2.1.2:
-    resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz}
-    name: ms
-    version: 2.1.2
-
-  registry.npmmirror.com/ms@2.1.3:
-    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz}
-    name: ms
-    version: 2.1.3
-    dev: true
-
   registry.npmmirror.com/nanopop@2.3.0:
     resolution: {integrity: sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==, registry: https://registry.npm.taobao.org/, tarball: https://registry.npmmirror.com/nanopop/-/nanopop-2.3.0.tgz}
     name: nanopop

+ 79 - 0
src/router/routes/modules/hooks/request.ts

@@ -0,0 +1,79 @@
+import type { AppRouteModule } from '@/router/types';
+
+import { LAYOUT } from '@/router/constant';
+
+const charts: AppRouteModule = {
+  path: '/useRequest',
+  name: 'useRequest',
+  component: LAYOUT,
+  redirect: '/useRequest/base',
+  meta: {
+    orderNo: 900,
+    icon: 'ant-design:api-outlined',
+    title: 'useRequest',
+  },
+  children: [
+    {
+      path: 'base',
+      name: 'useRequest-base',
+      meta: { title: '基础用法' },
+      component: () => import('@/views/hooks/request/base'),
+    },
+    {
+      path: 'loading-delay',
+      name: 'useRequest-loading-delay',
+      meta: { title: 'Loading Delay' },
+      component: () => import('@/views/hooks/request/loading-delay'),
+    },
+    {
+      path: 'polling',
+      name: 'useRequest-polling',
+      meta: { title: '轮询' },
+      component: () => import('@/views/hooks/request/polling'),
+    },
+    {
+      path: 'ready',
+      name: 'useRequest-ready',
+      meta: { title: 'Ready' },
+      component: () => import('@/views/hooks/request/ready'),
+    },
+    {
+      path: 'refresy-deps',
+      name: 'useRequest-refresy-deps',
+      meta: { title: '依赖刷新' },
+      component: () => import('@/views/hooks/request/refresy-deps'),
+    },
+    {
+      path: 'refresh-on-window-focus',
+      name: 'useRequest-refresh-on-window-focus',
+      meta: { title: '屏幕聚焦重新请求' },
+      component: () => import('@/views/hooks/request/refresh-on-window-focus'),
+    },
+    {
+      path: 'debounce',
+      name: 'useRequest-debounce',
+      meta: { title: '防抖' },
+      component: () => import('@/views/hooks/request/debounce'),
+    },
+    {
+      path: 'throttle',
+      name: 'useRequest-throttle',
+      meta: { title: '节流' },
+      component: () => import('@/views/hooks/request/throttle'),
+    },
+    {
+      path: 'cache',
+      name: 'useRequest-cache',
+      meta: { title: '缓存&SWR' },
+      component: () => import('@/views/hooks/request/cache'),
+    },
+    {
+      path: 'retry',
+      name: 'useRequest-retry',
+      meta: { title: '错误重试' },
+      component: () => import('@/views/hooks/request/retry'),
+    },
+  ],
+};
+
+export default charts;

+ 328 - 0
src/views/hooks/request/base.tsx

@@ -0,0 +1,328 @@
+import { defineComponent, onMounted, ref, unref } from 'vue';
+import { Card, Spin, Typography, message, Input, Button, Space } from 'ant-design-vue';
+import { imitateApi } from './mock-api';
+import { useRequest } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+
+const Demo1 = defineComponent({
+  setup() {
+    const { data, error, loading } = useRequest(imitateApi);
+
+    return () => (
+      <Card title="默认用法">
+        <Typography>
+          <Typography.Paragraph>
+            <Typography.Text type="danger">useRequest </Typography.Text>
+            的第一个参数是一个异步函数,在组件初次加载时,会自动触发该函数执行。同时自动管理该异步函数的
+            <Typography.Text code>loading</Typography.Text>
+            <Typography.Text code>data</Typography.Text>
+            <Typography.Text code>error</Typography.Text>
+            等状态。
+          </Typography.Paragraph>
+          <Typography.Text code>
+            {`const { data, error, loading } = useRequest(imitateApi);`}
+          </Typography.Text>
+        </Typography>
+
+        {/* 基础案例 */}
+        <Spin spinning={loading.value}>
+          <div class="mt-4">{error.value ? 'failed to load' : `Username: ${data.value}`}</div>
+        </Spin>
+      </Card>
+    );
+  },
+});
+
+const Demo2 = defineComponent({
+  setup() {
+    const search = ref('');
+    const setSearch = (value: string) => {
+      search.value = value;
+    };
+
+    const { loading, run } = useRequest(imitateApi, {
+      manual: true,
+      onSuccess: (result, params) => {
+        if (result) {
+          setSearch('');
+          message.success(`The username was changed to "${params[0]}" !`);
+        }
+      },
+    });
+
+    return () => (
+      <Card title="手动触发" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            如果设置了
+            <Typography.Text type="danger"> options.manual = true </Typography.Text>
+            ,则 useRequest 不会默认执行,需要通过
+            <Typography.Text type="danger"> run </Typography.Text>来触发执行。
+          </Typography.Paragraph>
+          <Typography.Text code>
+            {`const { loading, run } = useRequest(imitateApi, { manual: true });`}
+          </Typography.Text>
+        </Typography>
+
+        {/* 手动触发 */}
+        <Space class="mt-4">
+          <Input v-model={[search.value, 'value']} placeholder="Please enter username" />
+          <Button type="primary" disabled={loading.value} onClick={() => run(search.value)}>
+            {loading.value ? 'Loading' : 'Edit'}
+          </Button>
+        </Space>
+      </Card>
+    );
+  },
+});
+
+const Demo3 = defineComponent({
+  setup() {
+    const search = ref('');
+    const setSearch = (value: string) => {
+      search.value = value;
+    };
+
+    const { loading, run } = useRequest(imitateApi, {
+      manual: true,
+      onBefore: (params) => {
+        message.info(`Start Request: ${params[0]}`);
+      },
+      onSuccess: (result, params) => {
+        if (result) {
+          setSearch('');
+          message.success(`The username was changed to "${params[0]}" !`);
+        }
+      },
+      onError: (error) => {
+        message.error(error.message);
+      },
+      onFinally: () => {
+        message.info(`Request finish`);
+      },
+    });
+
+    return () => (
+      <Card title="生命周期" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            <Typography.Text type="danger">useRequest </Typography.Text>
+            提供了以下几个生命周期配置项,供你在异步函数的不同阶段做一些处理。
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            <Typography.Text code>onBefore</Typography.Text>
+            请求之前触发
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            <Typography.Text code>onSuccess</Typography.Text>
+            请求成功触发
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            <Typography.Text code>onError</Typography.Text>
+            请求失败触发
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            <Typography.Text code>onFinally</Typography.Text>
+            请求完成触发
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* 生命周期 */}
+        <Space>
+          <Input v-model={[search.value, 'value']} placeholder="Please enter username" />
+          <Button type="primary" disabled={loading.value} onClick={() => run(search.value, true)}>
+            {loading.value ? 'Loading' : 'Edit'}
+          </Button>
+          <Button danger disabled={loading.value} onClick={() => run(search.value, false)}>
+            {loading.value ? 'Loading' : 'Error Edit'}
+          </Button>
+        </Space>
+      </Card>
+    );
+  },
+});
+
+const Demo4 = defineComponent({
+  setup() {
+    const { data, loading, run, refresh } = useRequest(imitateApi, {
+      manual: true,
+    });
+
+    onMounted(() => run('lutz'));
+
+    const changeData = () => {
+      data.value = `${Date.now()}`;
+    };
+
+    return () => (
+      <Card title="刷新(重复上一次请求)" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            <Typography.Text type="danger">useRequest </Typography.Text>
+            提供了
+            <Typography.Text type="danger"> refresh </Typography.Text>和
+            <Typography.Text type="danger"> refreshAsync </Typography.Text>
+            方法,使我们可以使用上一次的参数,重新发起请求。
+          </Typography.Paragraph>
+        </Typography>
+
+        <Spin spinning={loading.value}>
+          <Space>
+            <div>Username: {data.value}</div>
+            <Button type="primary" onClick={changeData}>
+              Change data
+            </Button>
+            <Button onClick={refresh}>Refresh</Button>
+          </Space>
+        </Spin>
+      </Card>
+    );
+  },
+});
+
+const Demo5 = defineComponent({
+  setup() {
+    const search = ref('');
+    const setSearch = (value: string) => {
+      search.value = value;
+    };
+
+    const { loading, run, cancel } = useRequest(imitateApi, {
+      manual: true,
+      onSuccess: (result, params) => {
+        if (result) {
+          setSearch('');
+          message.success(`The username was changed to "${params[0]}" !`);
+        }
+      },
+    });
+
+    return () => (
+      <Card title="取消响应" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            <Typography.Text type="danger"> useRequest </Typography.Text>提供了
+            <Typography.Text type="danger"> cancel </Typography.Text>函数,用于忽略当前 promise
+            返回的数据和错误
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* 取消响应 */}
+        <Space>
+          <Input v-model={[search.value, 'value']} placeholder="Please enter username" />
+          <Button type="primary" disabled={loading.value} onClick={() => run(search.value)}>
+            Edit
+          </Button>
+          <Button type="dashed" disabled={!loading.value} onClick={cancel}>
+            Cancel
+          </Button>
+        </Space>
+      </Card>
+    );
+  },
+});
+
+const Demo6 = defineComponent({
+  setup() {
+    const search = ref('');
+
+    const {
+      data: username,
+      loading,
+      run,
+      params,
+    } = useRequest(imitateApi, {
+      defaultParams: ['lutz'],
+    });
+
+    const onChange = () => {
+      run(search.value);
+    };
+
+    return () => (
+      <Card title="管理参数" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            <Typography.Text type="danger"> useRequest </Typography.Text>返回的
+            <Typography.Text type="danger"> params </Typography.Text>会记录当次调用
+            <Typography.Text type="danger"> service </Typography.Text>的参数数组。比如你触发了
+            <Typography.Text code>run(1, 2, 3)</Typography.Text>,则
+            <Typography.Text type="danger"> params </Typography.Text> 等于
+            <Typography.Text code> [1, 2, 3] </Typography.Text>
+          </Typography.Paragraph>
+          <Typography.Paragraph>
+            如果我们设置了
+            <Typography.Text type="danger"> options.manual = false </Typography.Text>,则首次调用
+            <Typography.Text type="danger"> service </Typography.Text>
+            的参数可以通过<Typography.Text type="danger"> options.defaultParams </Typography.Text>
+            来设置。
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* 管理参数 */}
+        <Space>
+          <Input v-model={[search.value, 'value']} placeholder="Please enter username" />
+          <Button disabled={loading.value} onClick={onChange}>
+            {loading.value ? 'Loading' : 'Edit'}
+          </Button>
+        </Space>
+        <div>
+          <div>UserId: {unref(params)?.[0]}</div>
+          <div>Username: {unref(username)}</div>
+        </div>
+      </Card>
+    );
+  },
+});
+
+export default defineComponent({
+  setup() {
+    return () => (
+      <PageWrapper
+        v-slots={{
+          headerContent: () => (
+            <Typography>
+              <Typography.Link
+                href="https://ahooks.js.org/zh-CN/hooks/use-request/index"
+                target="_blank"
+              >
+                ahooks{' '}
+              </Typography.Link>
+              useRequest 的 vue 版本,是一个强大的异步数据管理的 Hooks。
+              <Typography.Paragraph>
+                <ul>
+                  {[
+                    '自动请求/手动请求',
+                    '轮询',
+                    '防抖',
+                    '节流',
+                    '屏幕聚焦重新请求',
+                    '错误重试',
+                    'loading delay',
+                    'SWR(stale-while-revalidate)',
+                    '缓存',
+                  ].map((item) => (
+                    <li>
+                      <Typography.Text>{item}</Typography.Text>
+                    </li>
+                  ))}
+                </ul>
+              </Typography.Paragraph>
+            </Typography>
+          ),
+        }}
+      >
+        <Demo1 />
+        <Demo2 />
+        <Demo3 />
+        <Demo4 />
+        <Demo5 />
+        <Demo6 />
+      </PageWrapper>
+    );
+  },
+});

+ 318 - 0
src/views/hooks/request/cache.tsx

@@ -0,0 +1,318 @@
+import { defineComponent, ref, unref } from 'vue';
+import { Card, Typography, Button, Input, Space, message } from 'ant-design-vue';
+import { getArticle } from './mock-api';
+import { useRequest, clearCache } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+
+const Article1 = defineComponent({
+  props: {
+    cacheKey: {
+      type: String,
+      default: 'cacheKey-demo',
+    },
+  },
+  setup(props) {
+    const { loading, data } = useRequest(getArticle, {
+      cacheKey: props.cacheKey,
+    });
+
+    return () => (
+      <>
+        <p>Background loading: {loading.value ? 'true' : 'false'}</p>
+        <p>Latest request time: {unref(data)?.time}</p>
+        <p>{unref(data)?.data}</p>
+      </>
+    );
+  },
+});
+
+const Demo1 = defineComponent({
+  setup() {
+    const state = ref(false);
+    const toggle = (bool?: boolean) => {
+      state.value = bool ?? !state.value;
+    };
+
+    return () => (
+      <Card title="SWR">
+        <Typography>
+          <Typography.Paragraph>
+            下面的示例,我们设置了
+            <Typography.Text type="danger"> cacheKey </Typography.Text>
+            ,在组件第二次加载时,会优先返回缓存的内容,然后在背后重新发起请求。你可以通过点击按钮来体验效果。
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* SWR */}
+        <div class="mt-4">
+          <Button type="primary" onClick={() => toggle()}>
+            {state.value ? 'hidden' : 'show'}
+          </Button>
+          {state.value && <Article1 />}
+        </div>
+      </Card>
+    );
+  },
+});
+
+const Article2 = defineComponent({
+  setup() {
+    const { loading, data } = useRequest(getArticle, {
+      cacheKey: 'staleTime-demo',
+      staleTime: 5000,
+    });
+
+    return () => (
+      <>
+        <p>Background loading: {loading.value ? 'true' : 'false'}</p>
+        <p>Latest request time: {unref(data)?.time}</p>
+        <p>{unref(data)?.data}</p>
+      </>
+    );
+  },
+});
+
+const Demo2 = defineComponent({
+  setup() {
+    const state = ref(false);
+    const toggle = (bool?: boolean) => {
+      state.value = bool ?? !state.value;
+    };
+
+    return () => (
+      <Card title="数据保持新鲜" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            通过设置
+            <Typography.Text type="danger"> staleTime </Typography.Text>
+            ,我们可以指定数据新鲜时间,在这个时间内,不会重新发起请求。下面的示例设置了 5s
+            的新鲜时间,你可以通过点击按钮来体验效果
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* 数据保持新鲜 */}
+        <div class="mt-4">
+          <Button type="primary" onClick={() => toggle()}>
+            {state.value ? 'hidden' : 'show'}
+          </Button>
+          {state.value && <Article2 />}
+        </div>
+      </Card>
+    );
+  },
+});
+
+const Article3 = defineComponent({
+  setup() {
+    const { loading, data, refresh } = useRequest(getArticle, {
+      cacheKey: 'cacheKey-share',
+    });
+
+    return () => (
+      <>
+        <p>Background loading: {loading.value ? 'true' : 'false'}</p>
+        <Button type="primary" onClick={refresh}>
+          更新
+        </Button>
+        <p>Latest request time: {unref(data)?.time}</p>
+        <p>{unref(data)?.data}</p>
+      </>
+    );
+  },
+});
+
+const Demo3 = defineComponent({
+  setup() {
+    return () => (
+      <Card title="数据共享" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            同一个<Typography.Text type="danger"> cacheKey </Typography.Text>
+            的内容,在全局是共享的,这会带来以下几个特性
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            <ul>
+              <li>
+                请求 Promise 共享,相同的<Typography.Text type="danger"> cacheKey </Typography.Text>
+                同时只会有一个在发起请求,后发起的会共用同一个请求 Promise
+              </li>
+              <li>
+                数据同步,任何时候,当我们改变其中某个 cacheKey 的内容时,其它相同
+                <Typography.Text type="danger"> cacheKey </Typography.Text>
+                的内容均会同步
+              </li>
+            </ul>
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* 数据共享 */}
+        <div class="mt-4">
+          <h2>Article 1</h2>
+          <Article3 />
+          <h2>Article 2</h2>
+          <Article3 />
+        </div>
+      </Card>
+    );
+  },
+});
+
+const Article4 = defineComponent({
+  setup() {
+    const { loading, data, params, run } = useRequest(getArticle, {
+      cacheKey: 'cacheKey-share4',
+    });
+
+    const keyword = ref(params.value?.[0] || '');
+
+    return () => (
+      <>
+        <Space>
+          <Input v-model={[keyword.value, 'value']} />
+          <Button onClick={() => run(keyword.value)}>Get</Button>
+        </Space>
+        <p>Background loading: {loading.value ? 'true' : 'false'}</p>
+        <p>Latest request time: {unref(data)?.time}</p>
+        <p>Latest request data: {unref(data)?.data}</p>
+        <p>keyword: {keyword.value}</p>
+      </>
+    );
+  },
+});
+
+const Demo4 = defineComponent({
+  setup() {
+    const state = ref(false);
+    const toggle = (bool?: boolean) => {
+      state.value = bool ?? !state.value;
+    };
+
+    return () => (
+      <Card title="参数缓存" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            缓存的数据包括 data 和 params,通过 params
+            缓存机制,我们可以记忆上一次请求的条件,并在下次初始化
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* 参数缓存 */}
+        <div class="mt-4">
+          <Button type="primary" onClick={() => toggle()}>
+            {state.value ? 'hidden' : 'show'}
+          </Button>
+          <div class="mt-2">{state.value && <Article4 />}</div>
+        </div>
+      </Card>
+    );
+  },
+});
+
+const Demo5 = defineComponent({
+  setup() {
+    const state = ref(false);
+    const toggle = (bool?: boolean) => {
+      state.value = bool ?? !state.value;
+    };
+
+    const clear = (cacheKey?: string | string[]) => {
+      clearCache(cacheKey);
+      const tips = Array.isArray(cacheKey) ? cacheKey.join('、') : cacheKey;
+      message.success(`Clear ${tips ?? 'All'} finished`);
+    };
+
+    return () => (
+      <Card title="删除缓存" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            useRequest 提供了一个 clearCache 方法,可以清除指定 cacheKey 的缓存数据。
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* 删除缓存 */}
+        <div class="mt-4">
+          <Space>
+            <Button type="primary" onClick={() => toggle()}>
+              {state.value ? 'hidden' : 'show'}
+            </Button>
+            <Button onClick={() => clear('Article1')}>Clear Article1</Button>
+            <Button onClick={() => clear('Article2')}>Clear Article2</Button>
+            <Button onClick={() => clear(['Article2', 'Article3'])}>
+              Clear Article2 and Article3
+            </Button>
+            <Button onClick={() => clear()}>Clear All</Button>
+          </Space>
+          <h2>Article 1</h2>
+          {state.value && <Article1 cacheKey="Article1" />}
+          <h2>Article 2</h2>
+          {state.value && <Article1 cacheKey="Article2" />}
+          <h2>Article 3</h2>
+          {state.value && <Article1 cacheKey="Article3" />}
+        </div>
+      </Card>
+    );
+  },
+});
+
+const Article6 = defineComponent({
+  setup() {
+    const cacheKey = 'setCache-demo6';
+    const { loading, data } = useRequest(getArticle, {
+      cacheKey,
+      setCache: (data) => localStorage.setItem(cacheKey, JSON.stringify(data)),
+      getCache: () => JSON.parse(localStorage.getItem(cacheKey) || '{}'),
+    });
+
+    return () => (
+      <>
+        <p>Background loading: {loading.value ? 'true' : 'false'}</p>
+        <p>Latest request time: {unref(data)?.time}</p>
+        <p>{unref(data)?.data}</p>
+      </>
+    );
+  },
+});
+
+const Demo6 = defineComponent({
+  setup() {
+    const state = ref(false);
+    const toggle = (bool?: boolean) => {
+      state.value = bool ?? !state.value;
+    };
+
+    return () => (
+      <Card title="自定义缓存" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            通过配置 setCache 和 getCache,可以自定义数据缓存,比如可以将数据存储到
+            localStorage、IndexDB 等。
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* 自定义缓存 */}
+        <div class="mt-4">
+          <Button type="primary" onClick={() => toggle()}>
+            {state.value ? 'hidden' : 'show'}
+          </Button>
+          <div class="mt-2">{state.value && <Article6 />}</div>
+        </div>
+      </Card>
+    );
+  },
+});
+
+export default defineComponent({
+  setup() {
+    return () => (
+      <PageWrapper>
+        <Demo1 />
+        <Demo2 />
+        <Demo3 />
+        <Demo4 />
+        <Demo5 />
+        <Demo6 />
+      </PageWrapper>
+    );
+  },
+});

+ 62 - 0
src/views/hooks/request/debounce.tsx

@@ -0,0 +1,62 @@
+import { defineComponent, ref } from 'vue';
+import { Card, Typography, Input, Spin, Space } from 'ant-design-vue';
+import { imitateApi } from './mock-api';
+import { useRequest } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+
+const Demo1 = defineComponent({
+  setup() {
+    const search = ref('');
+
+    const { data, loading } = useRequest(imitateApi, {
+      debounceWait: 1000,
+      refreshDeps: [search],
+    });
+
+    return () => (
+      <Card title="防抖">
+        <Typography>
+          <Typography.Paragraph>
+            通过设置<Typography.Text type="danger"> options.debounceWait </Typography.Text>
+            ,进入防抖模式,此时如果频繁触发
+            <Typography.Text code> run </Typography.Text>
+            或者
+            <Typography.Text code> runAsync </Typography.Text>
+            则会以防抖策略进行请求。
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            <Typography.Text code>
+              {`const { data, run } = useRequest(imitateApi, { debounceWait: 300, manual: true });`}
+            </Typography.Text>
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            如上示例代码,频繁触发
+            <Typography.Text code> run </Typography.Text>, 300ms 执行一次。
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>你可以在下面 input 框中快速输入文本,体验效果</Typography.Paragraph>
+        </Typography>
+
+        {/* 防抖 */}
+        <Spin spinning={loading.value}>
+          <Space direction="vertical">
+            <Input v-model={[search.value, 'value']} placeholder="Please enter username" />
+            <div>Username: {data.value}</div>
+          </Space>
+        </Spin>
+      </Card>
+    );
+  },
+});
+
+export default defineComponent({
+  setup() {
+    return () => (
+      <PageWrapper>
+        <Demo1 />
+      </PageWrapper>
+    );
+  },
+});

+ 61 - 0
src/views/hooks/request/loading-delay.tsx

@@ -0,0 +1,61 @@
+import { defineComponent, unref } from 'vue';
+import { Card, Typography, Button, Space } from 'ant-design-vue';
+import { useRequest } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+import { imitateApi } from './mock-api';
+
+export default defineComponent({
+  setup() {
+    const action = useRequest(imitateApi);
+
+    const withLoadingDelayAction = useRequest(imitateApi, {
+      loadingDelay: 300,
+    });
+
+    const trigger = () => {
+      action.run('lutz');
+      withLoadingDelayAction.run('lutz');
+    };
+
+    return () => (
+      <PageWrapper>
+        <Card title="Loading Delay">
+          <Typography>
+            <Typography.Paragraph>
+              通过设置
+              <Typography.Text type="danger"> options.loadingDelay </Typography.Text>
+              可以延迟 <Typography.Text code>loading</Typography.Text> 变成
+              <Typography.Text code>true</Typography.Text>
+              的时间,有效防止闪烁。
+            </Typography.Paragraph>
+
+            <Typography.Paragraph>
+              <Typography.Text code>
+                {`const { loading, data } = useRequest(imitateApi, { loadingDelay: 300 });`}
+              </Typography.Text>
+            </Typography.Paragraph>
+
+            <Typography.Paragraph>
+              例如上面的场景,假如 imitateApi 在 300ms 内返回,则{' '}
+              <Typography.Text code>loading</Typography.Text> 不会变成{' '}
+              <Typography.Text code>true</Typography.Text> Loading... 的情况。
+            </Typography.Paragraph>
+          </Typography>
+
+          <Space direction="vertical">
+            <Button onClick={trigger}>Run</Button>
+
+            <div>Username: {unref(action.loading) ? 'Loading...' : unref(action.data)}</div>
+
+            <div>
+              Username:{' '}
+              {unref(withLoadingDelayAction.loading)
+                ? 'Loading...'
+                : unref(withLoadingDelayAction.data)}
+            </div>
+          </Space>
+        </Card>
+      </PageWrapper>
+    );
+  },
+});

+ 27 - 0
src/views/hooks/request/mock-api.ts

@@ -0,0 +1,27 @@
+import Mock from 'mockjs';
+
+export async function imitateApi(username?: string, pass: boolean = true): Promise<string> {
+  return new Promise((resolve, reject) => {
+    setTimeout(() => {
+      if (pass) {
+        resolve(username ?? Mock.mock('@name'));
+      } else {
+        reject(new Error(`Failed to modify username: ${username}`));
+      }
+    }, 1250);
+  });
+}
+
+export async function getArticle(
+  keyword?: string,
+): Promise<{ data: string; time: number; keyword?: string }> {
+  return new Promise((resolve) => {
+    setTimeout(() => {
+      resolve({
+        data: Mock.mock('@paragraph'),
+        time: new Date().getTime(),
+        keyword,
+      });
+    }, 1000);
+  });
+}

+ 96 - 0
src/views/hooks/request/polling.tsx

@@ -0,0 +1,96 @@
+import { defineComponent } from 'vue';
+import { Card, Typography, Button, Space, message } from 'ant-design-vue';
+import { imitateApi } from './mock-api';
+import { useRequest } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+
+const Demo1 = defineComponent({
+  setup() {
+    const { data, loading, run, cancel } = useRequest(imitateApi, {
+      pollingInterval: 1000,
+      pollingWhenHidden: false,
+      // onSuccess() {
+      //   console.log('不可见是否运行呢'); // 测试不可见时,是否还在执行
+      // },
+    });
+
+    return () => (
+      <Card title="默认用法">
+        <Typography>
+          <Typography.Paragraph>
+            通过设置
+            <Typography.Text type="danger"> options.pollingInterval </Typography.Text>
+            ,进入轮询模式,useRequest 会定时触发 service 执行。
+          </Typography.Paragraph>
+          <Typography.Paragraph>
+            <Typography.Text code>
+              {`const { data, run, cancel } = useRequest(imitateApi, { pollingInterval: 3000 });`}
+            </Typography.Text>
+          </Typography.Paragraph>
+        </Typography>
+
+        <div>
+          <div>Username: {loading.value ? 'Loading' : data.value}</div>
+          <Space>
+            <Button onClick={() => run()}>start</Button>
+            <Button type="dashed" onClick={cancel}>
+              stop
+            </Button>
+          </Space>
+        </div>
+      </Card>
+    );
+  },
+});
+
+const Demo2 = defineComponent({
+  setup() {
+    const { data, loading, run, cancel } = useRequest(imitateApi, {
+      manual: true,
+      pollingInterval: 3000,
+      pollingErrorRetryCount: 3,
+      pollingWhenHidden: false,
+      onError: (error) => {
+        message.error(error.message);
+      },
+    });
+
+    return () => (
+      <Card title="轮询错误重试" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            通过
+            <Typography.Text type="danger"> options.pollingErrorRetryCount </Typography.Text>
+            轮询错误重试次数。
+          </Typography.Paragraph>
+          <Typography.Paragraph>
+            <Typography.Text code>
+              {`const { data, run, cancel } = useRequest(imitateApi, { pollingInterval: 3000,  pollingErrorRetryCount: 3 });`}
+            </Typography.Text>
+          </Typography.Paragraph>
+        </Typography>
+
+        <div>
+          <div>Username: {loading.value ? 'Loading' : data.value}</div>
+          <Space>
+            <Button onClick={() => run('lutz', false)}>start</Button>
+            <Button type="dashed" onClick={cancel}>
+              stop
+            </Button>
+          </Space>
+        </div>
+      </Card>
+    );
+  },
+});
+
+export default defineComponent({
+  setup() {
+    return () => (
+      <PageWrapper>
+        <Demo1 />
+        <Demo2 />
+      </PageWrapper>
+    );
+  },
+});

+ 86 - 0
src/views/hooks/request/ready.tsx

@@ -0,0 +1,86 @@
+import { defineComponent, ref, unref } from 'vue';
+import { Card, Typography, Button, Space } from 'ant-design-vue';
+import { imitateApi } from './mock-api';
+import { useRequest } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+
+const Demo1 = defineComponent({
+  setup() {
+    const ready = ref(false);
+    const toggle = (bool?: boolean) => {
+      ready.value = bool ?? !ready.value;
+    };
+    const { data, loading } = useRequest(imitateApi, { ready });
+
+    return () => (
+      <Card title="自动模式">
+        <Typography>
+          <Typography.Paragraph>
+            以下示例演示了自动模式下
+            <Typography.Text type="danger"> ready </Typography.Text> 的行为。每次
+            <Typography.Text type="danger"> ready </Typography.Text> 从 false 变为 true
+            时,都会重新发起请求。
+          </Typography.Paragraph>
+        </Typography>
+
+        <div>
+          <Space>
+            <div>Ready: {JSON.stringify(unref(ready))}</div>
+            <Button onClick={() => toggle()}>Toggle Ready</Button>
+          </Space>
+          <div>Username: {loading.value ? 'Loading' : unref(data)}</div>
+        </div>
+      </Card>
+    );
+  },
+});
+
+const Demo2 = defineComponent({
+  setup() {
+    const ready = ref(false);
+    const toggle = (bool?: boolean) => {
+      ready.value = bool ?? !ready.value;
+    };
+    const { data, loading, run } = useRequest(imitateApi, { manual: true, ready });
+
+    return () => (
+      <Card title="手动模式" class="mt-2">
+        <Typography>
+          <Typography.Paragraph>
+            以下示例演示了手动模式下
+            <Typography.Text type="danger"> ready </Typography.Text>
+            的行为。只有当
+            <Typography.Text type="danger"> ready </Typography.Text>
+            等于 true 时,run 才会执行。
+          </Typography.Paragraph>
+        </Typography>
+
+        <div>
+          <Space>
+            <div>Ready: {JSON.stringify(unref(ready))}</div>
+            <Button onClick={() => toggle()}>Toggle Ready</Button>
+          </Space>
+          <div class="mt-2">
+            <Space>
+              <div>Username: {loading.value ? 'Loading' : unref(data)}</div>
+              <Button type="primary" disabled={!ready.value} onClick={() => run()}>
+                Run
+              </Button>
+            </Space>
+          </div>
+        </div>
+      </Card>
+    );
+  },
+});
+
+export default defineComponent({
+  setup() {
+    return () => (
+      <PageWrapper>
+        <Demo1 />
+        <Demo2 />
+      </PageWrapper>
+    );
+  },
+});

+ 50 - 0
src/views/hooks/request/refresh-on-window-focus.tsx

@@ -0,0 +1,50 @@
+import { defineComponent } from 'vue';
+import { Card, Typography, Spin } from 'ant-design-vue';
+import { imitateApi } from './mock-api';
+import { useRequest } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+
+const Demo1 = defineComponent({
+  setup() {
+    const { data, loading } = useRequest(imitateApi, {
+      refreshOnWindowFocus: true,
+    });
+
+    return () => (
+      <Card title="屏幕聚焦重新请求">
+        <Typography>
+          <Typography.Paragraph>
+            通过设置<Typography.Text type="danger"> options.refreshOnWindowFocus </Typography.Text>
+            ,在浏览器窗口 refocus 和 revisible 时, 会重新发起请求。
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            <Typography.Text code>
+              {`const { data, run } = useRequest(imitateApi, { refreshOnWindowFocus: true });`}
+            </Typography.Text>
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            你可以点击浏览器外部,再点击当前页面来体验效果(或者隐藏当前页面,重新展示),如果和上一次请求间隔大于
+            5000ms, 则会重新请求一次。
+          </Typography.Paragraph>
+        </Typography>
+
+        {/* 屏幕聚焦重新请求 */}
+        <Spin spinning={loading.value}>
+          <div>Username: {data.value}</div>
+        </Spin>
+      </Card>
+    );
+  },
+});
+
+export default defineComponent({
+  setup() {
+    return () => (
+      <PageWrapper>
+        <Demo1 />
+      </PageWrapper>
+    );
+  },
+});

+ 43 - 0
src/views/hooks/request/refresy-deps.tsx

@@ -0,0 +1,43 @@
+import { defineComponent, ref, unref } from 'vue';
+import { Card, Typography, Select } from 'ant-design-vue';
+import { imitateApi } from './mock-api';
+import { useRequest } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+
+const options = [
+  { label: 'Jack', value: 'Jack' },
+  { label: 'Lucy', value: 'Lucy' },
+  { label: 'Lutz', value: 'Lutz' },
+];
+
+const Demo1 = defineComponent({
+  setup() {
+    const select = ref('Lutz');
+    const { data, loading } = useRequest(() => imitateApi(select.value), { refreshDeps: [select] });
+
+    return () => (
+      <Card title="依赖刷新">
+        <Typography>
+          <Typography.Paragraph>
+            useRequest 提供了一个
+            <Typography.Text type="danger"> options.refreshDeps </Typography.Text>
+            参数,当它的值变化后,会重新触发请求。
+          </Typography.Paragraph>
+        </Typography>
+
+        <Select v-model={[select.value, 'value']} options={options} style="width: 220px" />
+        <p>Username: {loading.value ? 'Loading' : unref(data)}</p>
+      </Card>
+    );
+  },
+});
+
+export default defineComponent({
+  setup() {
+    return () => (
+      <PageWrapper>
+        <Demo1 />
+      </PageWrapper>
+    );
+  },
+});

+ 53 - 0
src/views/hooks/request/retry.tsx

@@ -0,0 +1,53 @@
+import { defineComponent, ref } from 'vue';
+import { Card, Typography, Input, Button, Space, message } from 'ant-design-vue';
+import { imitateApi } from './mock-api';
+import { useRequest } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+
+const Demo1 = defineComponent({
+  setup() {
+    let count = 0;
+    const search = ref('');
+
+    const { loading, run } = useRequest(imitateApi, {
+      manual: true,
+      retryCount: 3,
+      onError: (error) => {
+        message.error(error.message + ` count: ${count++}.`);
+      },
+    });
+
+    return () => (
+      <Card title="错误重试">
+        <Typography>
+          <Typography.Paragraph>
+            通过设置
+            <Typography.Text type="danger"> options.retryCount </Typography.Text>
+            ,指定错误重试次数,则 useRequest 在失败后会进行重试。
+          </Typography.Paragraph>
+          <Typography.Text code>
+            {`const { data, run } = useRequest(imitateApi, { retryCount: 3 });`}
+          </Typography.Text>
+        </Typography>
+
+        {/* 错误重试 */}
+        <Space class="mt-4">
+          <Input v-model={[search.value, 'value']} placeholder="Please enter username" />
+          <Button type="primary" disabled={loading.value} onClick={() => run(search.value, false)}>
+            {loading.value ? 'Loading' : 'Edit'}
+          </Button>
+        </Space>
+      </Card>
+    );
+  },
+});
+
+export default defineComponent({
+  setup() {
+    return () => (
+      <PageWrapper>
+        <Demo1 />
+      </PageWrapper>
+    );
+  },
+});

+ 61 - 0
src/views/hooks/request/throttle.tsx

@@ -0,0 +1,61 @@
+import { defineComponent, ref } from 'vue';
+import { Card, Typography, Input, Spin, Space } from 'ant-design-vue';
+import { imitateApi } from './mock-api';
+import { useRequest } from '@vben/hooks';
+import { PageWrapper } from '@/components/Page';
+
+const Demo1 = defineComponent({
+  setup() {
+    const search = ref('');
+
+    const { data, loading } = useRequest(imitateApi, {
+      throttleWait: 1000,
+      refreshDeps: [search],
+    });
+
+    return () => (
+      <Card title="节流">
+        <Typography>
+          <Typography.Paragraph>
+            通过设置
+            <Typography.Text type="danger"> options.throttleWait </Typography.Text>
+            ,进入节流模式,此时如果频繁触发
+            <Typography.Text code> run </Typography.Text>或者
+            <Typography.Text code> runAsync </Typography.Text>, 则会以节流策略进行请求。
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            <Typography.Text code>
+              {`const { data, run } = useRequest(imitateApi, { throttleWait: 300, manual: true });`}
+            </Typography.Text>
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>
+            如上示例代码,频繁触发
+            <Typography.Text code> run </Typography.Text>, 300ms 执行一次。
+          </Typography.Paragraph>
+
+          <Typography.Paragraph>你可以在下面 input 框中快速输入文本,体验效果</Typography.Paragraph>
+        </Typography>
+
+        {/* 节流 */}
+        <Spin spinning={loading.value}>
+          <Space direction="vertical">
+            <Input v-model={[search.value, 'value']} placeholder="Please enter username" />
+            <div>Username: {data.value}</div>
+          </Space>
+        </Spin>
+      </Card>
+    );
+  },
+});
+
+export default defineComponent({
+  setup() {
+    return () => (
+      <PageWrapper>
+        <Demo1 />
+      </PageWrapper>
+    );
+  },
+});