Vue3 教你实现公司级网络请求的 Hook

5,703 阅读9分钟

🌍 背景

前后端分离项目中,我们很多场景需要通过异步请求获取数据,在此过程中会有很多的处理。例如请求的loading、错误捕获、防抖等。

开发过程中往往需要编写大量重复的代码来实现这些逻辑处理,最近vue3掀起了国内的浪潮,对ts兼容也不错,由于我是react转vue,使用vue的axios时候回调的问题,让我觉得不优雅和不适应,此文章来讲解如何封装一个类似于ahooks/useRequest的简单的vue3的异步请求hook来满足我们对这些场景的需求。也让大家直观的、易懂的了解这一过程。

🌟 功能

  • 手动请求/自动请求/重新请求
  • 条件请求/依赖请求
  • 轮询/防抖/节流
  • 请求数据缓存/数据保鲜
  • 格式化数据/立即变更数据
  • 状态间的回调
  • 优雅的响应式数据

使用方式

const { data, run, loading } = useAsync(此处接收一个promise的异步请求函数,{...配置项})

useAsync第一个参数为promise的异步请求,可以是带参形式的()=>request(params)的,也可以是待传参形式的request需要配合run使用,run(params)。使用方法参考ahooks/useRequest

一、结果项

参数说明类型
dataservice 返回的数据TDataundefined
errorservice 抛出的异常Errorundefined
loadingservice 是否正在执行boolean
params当次执行的 service 的参数数组TParams[]
run手动触发 service 执行,参数会传递给 service异常自动处理,通过 onError 反馈(...params: TParams) => void
refresh使用上一次的 params,重新调用 run() => void
mutate直接修改 data(data?: TData / ((oldData?: TData) => (TData / undefined))) => void
cancel取消当前正在进行的请求() => void

二、配置项Options(基础功能)

参数说明类型默认值
manual默认 false。 即在初始化时自动执行 service。如果设置为 true,则需要手动调用 run 触发执行。booleanfalse
defaultParams首次默认执行时,传递给 service 的参数TParams-
onBeforeservice 执行前触发(params: TParams) => void-
onSuccessservice resolve 时触发(data: TData, params: TParams) => void-
onErrorservice reject 时触发(e: Error, params: TParams) => void-
onFinallyservice 执行完成时触发(params: TParams, data?: TData, e?: Error) => void-

更多的高级功能↓

📡 基本实现流程

vue3响应式的数据声明,从结果项中我们得知,不是函数类型的结果我们都应该声明一个响应式的数据。简单数据类型采用ref,复杂的数据类型如对象可以采用reactive。

一、首先我们需要有这些基础的数据

  const params = ref<TParams>(defaultParams as TParams) as Ref<TParams>;
  const lastSuccessParams = ref<TParams>(defaultParams as TParams) as Ref<TParams>;
  const state: StateType<TData> = reactive({
    data: initialData || null,
    error: null,
    loading: false,
  });

二、其次我们取到传入的service,service都围绕着run进行的

  const run = async (...args: TParams) => {
    // ...逻辑
    try {
      // 请求更新数据
      const result = await service(...args);
      // 由于reactive解构会失去响应式,只能单个赋值
      state.data = result;
      state.loading = false;
    } catch (err: Error) {
      onError(err);
      state.error = err;
      state.loading = false;
    }
  };

三、return,至此完成了基本的流程

export function useAsync<TParams = any, TParams extends any[] = any>(
  service: Service<TData, TParams>,
  options: OptionsType<TData, TParams> = {}
){
  const state: StateType<TData> = reactive({
    data: initialData || null,
    error: null,
    loading: false,
  });  
  const run = async (...args: TParams) => {
    // ...逻辑
    try {
      // 请求更新数据
      const result = await service(...args);
      // 由于reactive解构会失去响应式,只能单个赋值
      state.data = result;
      state.loading = false;
    } catch (err: Error) {
      onError(err);
      state.error = err;
      state.loading = false;
    }
  };
    return {
    ...toRefs(state),
    run
}  
}
​

上述代码就能够完成在vue3中基本的异步请求hook。定义好state,当开始异步请求的时候,进入run,异步请求状态变化的时候更新state状态,当有错误异常的时候也能及时的更新状态。拓展功能见↓

☄️ useAsync的拓展功能

  • manual自动执行

    在Mounted进行判断,如果manual为true则发送请求。此处可以将manual传入一个ref,这样此处可以使用watchEffect进行依赖收集监听,个人认为此方案也优。

  onMounted(() => {
    if (!manual) run(...(defaultParams as TParams));
  });
  • 数据格式化

    在onSuccess回调前formatResult变更数据。formatResult将data做为参数回调,由外部返回修改后的值,将onSuccess回调值改变和结果项的改变

       state.data = formatResult?.(state.data);
       onSuccess?.(state.data);
    
  • 防抖

    创建一个防抖的函数做为run的中间件,需要经过防抖加工后再进行run。防抖debounce属于lodash

    serviceRun做为一个待被处理的run

      let serviceRun = run;
      if (debounceInterval) {
        const debounceRun = debounce(run, debounceInterval);
        serviceRun = (...args: TParams) => {
          state.loading = true;
          return Promise.resolve(debounceRun(...args)!);
        };
      }
    
  • 节流

    创建一个节流的函数做为run的中间件,需要经过节流加工后再进行run。节流debounce属于lodash

    serviceRun同上

     if (throttleInterval) {
        const throttleRun = throttle(run, throttleInterval);
        serviceRun = (...args: TParams) => {
          return Promise.resolve(throttleRun(...args)!);
        };
      }
    ​
    
  • 轮询

    创建一个轮询的函数做为run的中间件,需要轮询加工后再进行run。

    serviceRun同上

    let pollingTimer: Timer | undefined;
      if (pollingInterval) {
        serviceRun = (...args: P) => {
          if (pollingTimer) {
            clearInterval(pollingTimer);
          }
          pollingTimer = setInterval(() => {
            // 此处可添加轮询不处理的逻辑,如页面隐藏的时候不发送轮询,可自行定义字段处理
            run(...args);
          }, pollingInterval);
          return run(...args);
        };
      }
    
  • 取消请求

    取消定时器等逻辑,loading状态改变。

      function cancel = () => {
        if (pollingTimer) {
          clearInterval(pollingTimer);
        }
        state.loading = false;
      };
    ​
    // 可定义一个变量count记录当前请求次数,在run请求前累计当前次数和赋值一个currentCount时候赋值,相等的时候赋值。如果取消请求可以在此处将count加1,造成异步请求时候count不一致,从而达到取消请求的目的
    ​
    
  • 重新请求

    重新发送请求,并且携带上一次请求的params

     function refresh() {
        return run(...params.value);
      }
    
  • 立即变更数据-突变

    提供函数从外部任意位置变更数据

      function mutate(data: TData) {
        state.data = data;
      }
    
  • 条件请求

    ready传入一个ref响应式的数据,当为true的时候发送请求

      watch(ready, (val) => {
        if (val === true) {
          run(...params.value);
        }
      });
    
  • 依赖请求

    refreshDeps为一个ref的数组,watch监听依赖的改变,如果发生改变,则会寻找其是否是params依赖项,如果是则会替换该params里属性的值发生请求,否则不改变的重新发送请求。

      // 依赖更新数据
      watch(refreshDeps, (value, prevValue) => {
        params.value = (params.value as any[])?.map((item) => {
          const obj = item;
          (prevValue as any[]).forEach((v, index) => {
            Object.keys(item).forEach((key) => {
              if (item[key] === v) {
                obj[key] = value[index];
              }
            });
          });
          return {
            params.value,
            ...obj,
          };
        }) as TParams;
        run(...params.value);
      });
    
  • loading延迟

    loading延迟防止数据闪烁

    // 使用loadingDelay
    if (loadingDelay !== undefined) {
      timerRef.value = setTimeout(() => {
        state.loading = true;
      }, loadingDelay);
      state.loading = false;
    }
    
  • 请求数据缓存

    需配合cacheKey一起使用,这将缓存请求数据,在下一次请求的时候优先使用缓存数据,并且会在背后偷偷发送数据,更新缓存。

    可指定cacheTime设置缓存到期时间,到期会被清除。

      const run = async (...args: TParams) => {   
        // run前立即使用缓存
        if (cacheKey) {
          state.data = getCache(cacheKey)?.data;
        }
          // .....
          // 请求成功后更新缓存
          if (cacheKey) {
            setCache(cacheKey, cacheTime, state.data, cloneDeep(args));
          }
            onSuccess?.(state.data);
      } 
    ​
    

    cache.ts文件中

    type Timer = ReturnType<typeof setTimeout>;
    type CachedKey = string | number;
    type CachedData = {
      data: any;
      params: any;
      timer: Timer | undefined;
      time: number;
    };
    ​
    type Listener = (data: any) => void;
    ​
    // 缓存是一个Map对象
    const cache = new Map<CachedKey, CachedData>();
    ​
    const listeners: Record<string, Listener[]> = {};
    ​
    // 设置缓存
    const setCache = (
      key: CachedKey,
      cacheTime: number,
      data: any,
      params: any
    ) => {
      const currentCache = cache.get(key);
      
        // 如果缓存有延时器,则清除延时器
      if (currentCache?.timer) {
        clearTimeout(currentCache.timer);
      }
    ​
      let timer: Timer | undefined = undefined;
        
        // cacheTime为-1,不缓存
      if (cacheTime > -1) {
        timer = setTimeout(() => {
          cache.delete(key);
        }, cacheTime);
      }
      if (listeners[key]) {
        listeners[key].forEach((item) => item(data));
      }
      cache.set(key, {
        data,
        params,
        timer,
        time: new Date().getTime(),
      });
    };
    ​
    const getCache = (key: CachedKey) => {
      return cache.get(key);
    };
    ​
    // 订阅模式,用于监听cache数据
    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);
      };
    };
    ​
    // 清除缓存
    const clearCache = (key?: string | string[]) => {
      if (key) {
        const cacheKeys = Array.isArray(key) ? key : [key];
        cacheKeys.forEach((cacheKey) => cache.delete(cacheKey));
      } else {
        cache.clear();
      }
    };
    ​
    export { getCache, setCache, subscribe, clearCache };
    
  • 并行请求

    需配合fetchKey使用

    fetches类似state的集合,存储相同的请求的状态(主要是同一请求参数不一致的状态收集)

      const fetches = reactive<
        Record<
          string,
          StateType<any> & {
            params: any;
          }
        >
      >({});
    ​
    // 比较简单粗暴直接取第一个参数,大家可以改进。将params回调出去,由外部传入唯一的key值
     const fetchKeyPersist = fetchKey({ ...args }?.[0] ?? "default_key");
    //....
    //run里面请求前
    // 收集同一请求不同key下的状态
      if (fetchKeyPersist) {
          fetches[fetchKeyPersist as string] = {
            ...state,
            loading: true,
            params: cloneDeep(args),
          };
        }
    ​
    // 请求成功的时候
      if (fetchKeyPersist) {
            fetches[fetchKeyPersist as string] = {
              ...state,
              loading: false,
              params: { ...args },
            };
          }    
    ​
    // 请求失败的时候
      if (fetchKeyPersist) {
            fetches[fetchKeyPersist as string] = {
              ...state,
              data: null,
              params: cloneDeep(args),
            };
          }
    ​
    

以上为全部高级拓展功能,类似与一个hook一样穿插在各个生命节点执行对应的逻辑。

💫 附上源码

语雀useAsync

👬 vue的好搭档axios

import axios, { AxiosRequestConfig } from "axios";
import { Toast } from "vant";
import { routers } from "../routers";

//post请求头
axios.defaults.headers.post["Content-Type"] =
  "application/x-www-form-urlencoded;charset=UTF-8";

//设置超时
// axios.defaults.timeout = 10_000;

const axiosInstance = axios.create({
  timeout: 10000,
});

axiosInstance.interceptors.request.use(
  (config) => {
    const accessToken = sessionStorage.getItem("access_token");
    if (accessToken) {
      return {
        ...config,
        headers: {
          ...config.headers,
          Authorization: accessToken ? `Bearer ${accessToken}` : "",
        },
      };
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

axiosInstance.interceptors.response.use(
  (response) => {
    if (response?.status === 200) {
      return Promise.resolve(response);
    } else {
      return Promise.reject(response);
    }
  },
  (error) => {
    if (error?.message?.includes?.("timeout")) {
      Toast.fail("请求超时");
    } else {
      Toast.fail("网络错误,请重试");
      routers.push("/403");
    }
    Promise.reject(error);
  }
);

const request = <ResponseType = unknown>(
  url: string,
  options?: AxiosRequestConfig<unknown>
): Promise<ResponseType> => {
  return new Promise((resolve, reject) => {
    axiosInstance({
      url,
      ...options,
    })
      .then((res) => {
        resolve(res.data.data);
      })
      .catch((err) => reject(err));
  });
};

export { axiosInstance, request };

🌰 使用

  • 返回一个promise请求请求,request为⬆️ 导出的

iShot2022-01-04 11.40.11.png

  • 为非必填只作展示功能,可根据自身需求进行删减增和加逻辑 iShot2022-01-04 11.39.48.png

👩🏻‍💻 总结

这个hook可以帮助我们省去比较多的重复逻辑,并且难度不高,适合那些需要优雅请求和用于学习的使用,目前项目上使用也是没问题的。我们也能根据自己的场景进行添加和改进。总体还是借鉴了ahooks的思想。

🌚 不足

这是一个简易版的请求hook,尽管它简洁功能多,但弊端也比较明显,不可否认它确实能完成我们大部分的场景需求,随着后续的优化改进和加其他通用逻辑,整个hook代码会挤到一个文件里,代码繁多,乱,不利于管理。但其还是满足大部分的场景的,因此改动不会很大。

🤩 展望

useAsync提供插件式的逻辑功能拓展,彻底解决代码乱,易于管理。每个请求实例存在一个独立的实例。我们需要拓展功能,只需要自己根据规范,合理使用hook暴露出来的回调和实例进行书写即可。强大的插件式异步管理工具 🔧 开发中...

使用前言

浅谈我为什么不使用vueuse

全新的vue hooks开发完成,展望的useRequest插件式请求工具也在其中,见👇🏻链接

文档地址

GitHub地址

大家 star star 🌟🌟 ,感谢支持。

iShot2022-09-09 18.02.36.png

iShot2022-09-09 17.55.01.png