Code前端首页关于Code前端联系我们

Vue3项目里,Axios怎么用才不算搬砖式套模板?还要兼顾拦截、统一处理、Token刷新这些常用需求

terry 1小时前 阅读数 22 #Vue
文章标签 Vue3Axios高阶封装

很多人第一次接触Vue3+Axios,可能会直接在每个组件里import axios,然后写重复的请求URL前缀、错误提示、Loading状态,这样写出来的代码既乱又难维护,稍微改个接口地址或者添加个全局参数,就得翻遍所有组件修改——这就是典型的“搬砖式套模板”,今天咱们就从基础配置、分层封装、常见痛点解决三个维度,一步步搭一套能用、好用、能扩展的Vue3 Axios架构。

第一步:基础配置,把重复的公共部分先拎出来

首先得理清楚,一个成熟的前端请求里,哪些是100%不会变或者很少变的?请求URL的基础前缀baseURL肯定算一个,毕竟一般开发环境、测试环境、生产环境的接口地址不一样,不能每次上线前手动替换;超时时间timeout也很重要,避免网络差的时候页面一直死等;默认的请求头,比如Content-Type,大多数RESTful接口都是application/json,除非上传文件这种特殊情况,也可以先统一设置。

这些公共配置,单独放一个文件会更清晰,比如在src目录下建个utils文件夹,里面加个request.js作为Axios的主入口,安装axios和配套的类型提示库@types/axios(如果用TypeScript的话,强烈建议加上,IDE的补全和报错会很方便),可以用npm或者pnpm:

// 这是JavaScript版本的基础配置,TypeScript的话后面会提
import axios from 'axios';
// 先创建一个axios实例,避免污染全局的axios
const instance = axios.create({
  // 基础前缀,开发环境可以用.env.development里的VITE_APP_API_BASE_URL(如果用Vite脚手架)
  // 或者Vue CLI的话是process.env.VUE_APP_API_BASE_URL
  baseURL: import.meta.env.VITE_APP_API_BASE_URL || 'http://localhost:3000/api',
  // 超时时间,一般设10-30秒就行,看项目接口响应速度
  timeout: 15000,
  // 默认请求头
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  }
});

这里提一下,用环境变量管理基础前缀是必须的,因为不同环境的接口地址切换不需要改代码,Vite和Vue CLI的环境变量规则不一样,要注意前缀:Vite的环境变量必须以VITE_开头,Vue CLI必须以VUE_APP_开头,这样才能在前端代码里通过import.meta.env或者process.env访问到,同时避免把后端敏感的环境变量泄露出来。

第二步:分层封装,不是简单的加个拦截器

很多人觉得Axios封装就是加两个拦截器——请求拦截和响应拦截,但如果只这么做,还是不够灵活,比如有的接口需要特殊的超时时间,有的不需要Loading,有的不需要错误提示,有的甚至要跳过Token校验,这时候就需要在拦截器的基础上,再加一层“请求方法封装”,给每个请求加配置参数,满足个性化需求。

先写请求和响应拦截器

请求拦截器主要做两件事:一是在请求发送前给Header加Token(如果有的话,Token一般存到localStorage或者sessionStorage,或者Pinia/Vuex的状态管理里),二是开启全局Loading(如果需要的话)。

响应拦截器就复杂一点了,首先要处理HTTP状态码,比如200是正常响应,401是未授权(需要跳转登录页或者刷新Token),403是禁止访问,404是接口不存在,500是服务器内部错误;然后还要处理业务状态码,因为很多后端不会直接把错误抛在HTTP状态码里,而是会在响应体里加个code字段,比如code=200是成功,code=400是参数错误,code=401是Token过期;最后还要关闭全局Loading,并且统一处理成功和失败的返回值,让组件里的代码不用再判断HTTP状态码,直接拿业务数据或者错误信息就行。

这里要注意的是,Pinia/Vuex的状态管理不能直接在request.js里import useStore然后调用,因为Pinia的store是在Vue应用挂载之后才初始化的,而request.js可能会在应用挂载之前就被其他文件引用,这时候直接调用useStore会报错,解决办法是在请求发送前,通过window.PINIA(Pinia的全局实例)来获取store,或者把store的实例保存到一个全局变量里。

响应拦截器里的全局Loading,也可以用Element Plus、Ant Design Vue这类UI库的Loading服务,或者自己写一个简单的Loading组件,通过Pinia/Vuex来控制显示和隐藏。

再加一层请求方法封装

这一步是为了满足个性化需求,我们可以封装get、post、put、delete这四个常用的请求方法,每个方法接受三个参数:url(接口路径)、data(请求参数)、config(个性化配置),个性化配置可以包括:不需要Loading、不需要错误提示、不需要Token、特殊的超时时间、特殊的请求头等等。

比如我们可以给config加一个noLoading字段,如果是true的话,就不开启全局Loading;加一个noErrorToast字段,如果是true的话,就不显示全局错误提示;加一个noToken字段,如果是true的话,就不在Header里加Token。

// 继续写request.js,假设我们用的是Element Plus的ElMessage和ElLoading
import { ElMessage, ElLoading } from 'element-plus';
import { useUserStore } from '@/stores/user'; // TypeScript里先import,不过JavaScript里后面可以通过window.__PINIA__获取
// 全局Loading的实例
let loadingInstance = null;
// Loading的计数器,避免多个请求同时触发时,一个请求结束就关闭Loading
let loadingCount = 0;
// 开启Loading
const startLoading = () => {
  if (loadingCount === 0) {
    loadingInstance = ElLoading.service({
      lock: true,
      text: '加载中...',
      background: 'rgba(0, 0, 0, 0.7)'
    });
  }
  loadingCount++;
};
// 关闭Loading
const endLoading = () => {
  loadingCount--;
  if (loadingCount <= 0) {
    loadingInstance?.close();
  }
};
// 请求拦截器
instance.interceptors.request.use(
  (config) => {
    // 获取个性化配置
    const customConfig = config.custom || {};
    // 如果没有设置noLoading,就开启Loading
    if (!customConfig.noLoading) {
      startLoading();
    }
    // 获取Token,这里用window.__PINIA__获取userStore,TypeScript的话要类型断言
    const pinia = window.__PINIA__;
    if (pinia) {
      const userStore = useUserStore(pinia);
      const token = userStore.token;
      // 如果有Token,并且没有设置noToken,就加到Header里
      if (token && !customConfig.noToken) {
        config.headers.Authorization = `Bearer ${token}`;
      }
    }
    // 如果有特殊的超时时间,就覆盖默认的
    if (customConfig.timeout) {
      config.timeout = customConfig.timeout;
    }
    // 如果有特殊的请求头,就合并
    if (customConfig.headers) {
      config.headers = { ...config.headers, ...customConfig.headers };
    }
    return config;
  },
  (error) => {
    // 请求发送失败,关闭Loading
    endLoading();
    return Promise.reject(error);
  }
);
// 响应拦截器
instance.interceptors.response.use(
  (response) => {
    // 先关闭Loading
    endLoading();
    // 获取响应数据
    const res = response.data;
    // 获取个性化配置
    const customConfig = response.config.custom || {};
    // 先判断业务状态码,这里假设后端返回的code=200是成功
    if (res.code === 200) {
      // 成功的话,直接返回业务数据
      return res.data || true;
    } else if (res.code === 401) {
      // Token过期或者未授权,先清空用户信息,然后跳转登录页
      const pinia = window.__PINIA__;
      if (pinia) {
        const userStore = useUserStore(pinia);
        userStore.logout();
      }
      // 如果是单页应用,这里用router.push('/login'),router也要通过全局变量获取,比如window.__VUE_ROUTER__
      const router = window.__VUE_ROUTER__;
      if (router) {
        router.push('/login');
      }
      // 显示错误提示,除非设置了noErrorToast
      if (!customConfig.noErrorToast) {
        ElMessage.error(res.message || '登录已过期,请重新登录');
      }
      return Promise.reject(res);
    } else {
      // 其他业务错误,显示错误提示
      if (!customConfig.noErrorToast) {
        ElMessage.error(res.message || '请求失败,请稍后重试');
      }
      return Promise.reject(res);
    }
  },
  (error) => {
    // HTTP错误,关闭Loading
    endLoading();
    // 获取个性化配置
    const customConfig = error.config?.custom || {};
    // 判断HTTP状态码
    let message = '';
    if (error.response) {
      switch (error.response.status) {
        case 400:
          message = '请求参数错误';
          break;
        case 403:
          message = '禁止访问';
          break;
        case 404:
          message = '接口不存在';
          break;
        case 500:
          message = '服务器内部错误';
          break;
        case 502:
          message = '网关错误';
          break;
        case 503:
          message = '服务器暂时不可用';
          break;
        case 504:
          message = '网关超时';
          break;
        default:
          message = `请求失败,状态码:${error.response.status}`;
      }
    } else if (error.request) {
      // 请求已发送,但没有收到响应
      message = '网络连接失败,请检查网络';
    } else {
      // 请求配置出错
      message = error.message || '请求配置出错';
    }
    // 显示错误提示,除非设置了noErrorToast
    if (!customConfig.noErrorToast) {
      ElMessage.error(message);
    }
    return Promise.reject(error);
  }
);
// 封装请求方法
const request = {
  get: (url, params = {}, custom = {}) => {
    return instance.get(url, {
      params,
      custom
    });
  },
  post: (url, data = {}, custom = {}) => {
    return instance.post(url, data, {
      custom
    });
  },
  put: (url, data = {}, custom = {}) => {
    return instance.put(url, data, {
      custom
    });
  },
  delete: (url, params = {}, custom = {}) => {
    return instance.delete(url, {
      params,
      custom
    });
  },
  // 上传文件的方法,单独封装,因为Content-Type要改成multipart/form-data
  upload: (url, formData, custom = {}) => {
    return instance.post(url, formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      custom
    });
  }
};
// 导出请求方法
export default request;

这里还有一个小技巧,就是Loading的计数器,避免多个请求同时触发时,一个请求结束就关闭Loading,比如我们同时请求A、B、C三个接口,loadingCount会从0变成1、2、3,当A结束时,loadingCount变成2,不关闭Loading,当B结束时变成1,也不关闭,只有当C结束时变成0,才会关闭Loading。

第三步:常见痛点解决,比如Token刷新、接口取消、跨域

刚才的封装已经能满足大部分日常需求了,但还有几个常见的痛点需要解决:比如Token过期了,能不能不用用户重新登录,直接刷新Token?比如用户快速点击两次提交按钮,能不能取消第一次请求?比如开发环境下的跨域问题怎么解决?

Token刷新的实现

Token刷新的逻辑是这样的:当接口返回401(Token过期)时,我们先检查有没有正在刷新的Token请求,如果有的话,就把后续的请求都缓存起来,等Token刷新成功后,再重新发送这些缓存的请求;如果没有正在刷新的Token请求,就发起刷新Token的请求,刷新成功后,更新Pinia/Vuex里的Token和刷新Token,然后清空缓存的请求,重新发送,刷新失败的话,就清空缓存的请求,清空用户信息,跳转登录页。

要实现这个逻辑,我们需要加几个变量:比如isRefreshing(是否正在刷新Token),requestQueue(缓存的请求队列),然后在响应拦截器的401分支里处理。

// 在request.js的开头加变量
import { ElMessage, ElLoading } from 'element-plus';
import { useUserStore } from '@/stores/user';
let loadingInstance = null;
let loadingCount = 0;
let isRefreshing = false; // 是否正在刷新Token
let requestQueue = []; // 缓存的请求队列,每个元素是一个函数,接收新的Token作为参数
// 响应拦截器的401分支修改一下
    } else if (res.code === 401) {
      const customConfig = response.config.custom || {};
      // 如果是刷新Token的接口返回401,说明刷新Token也过期了,直接跳转登录页
      if (response.config.url === '/api/auth/refresh') {
        const pinia = window.__PINIA__;
        if (pinia) {
          const userStore = useUserStore(pinia);
          userStore.logout();
        }
        const router = window.__VUE_ROUTER__;
        if (router) {
          router.push('/login');
        }
        if (!customConfig.noErrorToast) {
          ElMessage.error('登录已过期,请重新登录');
        }
        isRefreshing = false;
        requestQueue = [];
        return Promise.reject(res);
      }
      // 如果没有正在刷新的Token请求,就发起刷新
      if (!isRefreshing) {
        isRefreshing = true;
        const pinia = window.__PINIA__;
        if (pinia) {
          const userStore = useUserStore(pinia);
          const refreshToken = userStore.refreshToken;
          // 发起刷新Token的请求,这里要跳过Token校验,并且不显示错误提示,不开启Loading
          request.post('/api/auth/refresh', { refreshToken }, { noToken: true, noErrorToast: true, noLoading: true })
            .then((newToken) => {
              // 刷新成功,更新Token
              userStore.setToken(newToken);
              // 重新发送缓存的请求
              requestQueue.forEach((callback) => callback(newToken));
              // 清空缓存的请求
              requestQueue = [];
            })
            .catch(() => {
              // 刷新失败,清空缓存的请求,清空用户信息,跳转登录页
              requestQueue = [];
              userStore.logout();
              const router = window.__VUE_ROUTER__;
              if (router) {
                router.push('/login');
              }
              if (!customConfig.noErrorToast) {
                ElMessage.error('登录已过期,请重新登录');
              }
            })
            .finally(() => {
              isRefreshing = false;
            });
        }
      }
      // 把当前请求缓存起来,返回一个Promise
      return new Promise((resolve) => {
        requestQueue.push((newToken) => {
          // 更新当前请求的Authorization头
          response.config.headers.Authorization = `Bearer ${newToken}`;
          // 重新发送当前请求
          resolve(instance(response.config));
        });
      });

这里要注意的是,刷新Token的接口返回的应该是新的Token和新的刷新Token,这样Token可以无限刷新(只要刷新Token不过期),刷新Token的请求要跳过Token校验,并且不显示错误提示,不开启Loading,避免和其他请求冲突。

接口取消的实现

接口取消的逻辑是这样的:每个请求都生成一个CancelToken,然后把CancelToken的cancel函数保存到一个Map里,Map的key可以是请求的URL+方法+参数的组合,也可以是接口的唯一标识,当需要取消请求时,就从Map里找到对应的cancel函数,调用它,然后从Map里删除。

比如我们要实现用户快速点击两次提交按钮,取消第一次请求,就可以在提交按钮的点击事件里,先取消上次的请求,然后再发起新的请求。

// 在request.js的开头加一个Map
import axios, { CancelToken } from 'axios'; // 注意要导入CancelToken
let loadingInstance = null;
let loadingCount = 0;
let isRefreshing = false;
let requestQueue = [];
const cancelTokenMap = new Map(); // 保存cancel函数的Map
// 请求拦截器里修改,生成CancelToken
instance.interceptors.request.use(
  (config) => {
    const customConfig = config.custom || {};
    // 如果没有设置noLoading,就开启Loading
    if (!customConfig.noLoading) {
      startLoading();
    }
    // 获取唯一标识,默认是URL+方法
    const cancelKey = customConfig.cancelKey || `${config.method}-${config.url}`;
    // 如果Map里已经有这个cancelKey,就取消上次的请求
    if (cancelTokenMap.has(cancelKey)) {
      const cancel = cancelTokenMap.get(cancelKey);
      cancel('重复请求,已取消');
    }
    // 生成新的CancelToken
    config.cancelToken = new CancelToken((cancel) => {
      cancelTokenMap.set(cancelKey, cancel);
    });
    // 其他代码不变
    const pinia = window.__PINIA__;
    if (pinia) {
      const userStore = useUserStore(pinia);
      const token = userStore.token;
      if (token && !customConfig.noToken) {
        config.headers.Authorization = `Bearer ${token}`;
      }
    }
    if (customConfig.timeout) {
      config.timeout = customConfig.timeout;
    }
    if (customConfig.headers) {
      config.headers = { ...config.headers, ...customConfig.headers };
    }
    return config;
  },
  (error) => {
    endLoading();
    return Promise.reject(error);
  }
);
// 响应拦截器里修改,请求成功或失败后,从Map里删除cancelKey
instance.interceptors.response.use(
  (response) => {
    endLoading();
    const customConfig = response.config.custom || {};
    const cancelKey = customConfig.cancelKey || `${response.config.method}-${response.config.url}`;
    cancelTokenMap.delete(cancelKey);
    // 其他代码不变
    const res = response.data;
    if (res.code === 200) {
      return res.data || true;
    } else if (res.code === 401) {
      // 401的处理不变
    } else {
      if (!customConfig.noErrorToast) {
        ElMessage.error(res.message || '请求失败,请稍后重试');
      }
      return Promise.reject(res);
    }
  },
  (error) => {
    endLoading();
    const customConfig = error.config?.custom || {};
    // 如果是取消的请求,不显示错误提示
    if (axios.isCancel(error)) {
      return Promise.reject(error);
    }
    const cancelKey = customConfig?.cancelKey || `${error.config?.method}-${error.config?.url}`;
    if (cancelKey) {
      cancelTokenMap.delete(cancelKey);
    }
    // 其他代码不变
    let message = '';
    if (error.response) {
      // ...
    } else if (error.request) {
      // ...
    } else {
      // ...
    }
    if (!customConfig.noErrorToast) {
      ElMessage.error(message);
    }
    return Promise.reject(error);
  }
);
// 封装一个手动取消请求的方法
request.cancel = (cancelKey) => {
  if (cancelTokenMap.has(cancelKey)) {
    const cancel = cancelTokenMap.get(cancelKey);
    cancel('手动取消请求');
    cancelTokenMap.delete(cancelKey);
  }
};
// 封装一个取消所有请求的方法
request.cancelAll = () => {
  cancelTokenMap.forEach((cancel) => {
    cancel('页面卸载,取消所有请求');
  });
  cancelTokenMap.clear();
};

这样,在组件里使用的时候,就可以通过设置cancelKey来取消重复请求,或者手动调用request.cancel(cancelKey)来取消指定请求,或者在页面卸载的onUnmounted钩子函数里调用request.cancelAll()来取消所有请求,避免内存泄漏。

开发环境下的跨域问题解决

跨域是浏览器的同源策略导致的,同源策略要求协议、域名、端口都相同,否则就会被浏览器拦截,开发环境下的跨域问题,可以通过Vite或者Vue CLI的代理功能来解决。

如果用的是Vite,在vite.config.js里加一个proxy配置:

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5173,
    open: true,
    proxy: {
      // 当请求的路径以/api开头时,就代理到http://localhost:3000
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true, // 改变请求的origin为target的origin,避免后端因为origin不一致而拒绝请求
        rewrite: (path) => path.replace(/^\/api/, '') // 如果后端的接口路径没有/api前缀,就把/api去掉
      }
    }
  }
});

如果用的是Vue CLI,在vue.config.js里加一个devServer.proxy配置:

module.exports = {
  devServer: {
    port: 8080,
    open: true,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        }
      }
    }
  }
};

这里要注意的是,代理功能只在开发环境下有效,生产环境下的跨域问题,需要后端配置CORS(跨域资源共享),或者用Nginx反向代理。

第四步:TypeScript版本的优化

如果用的是TypeScript,可以给Axios的配置、响应数据、个性化配置都加类型提示,这样IDE的补全和报错会更友好,代码也更规范。

定义响应数据的类型:

// src/types/api.ts
// 基础响应类型
export interface BaseResponse<T = any> {
  code: number;
  message: string;
  data: T;
}
// 用户信息类型
export interface UserInfo {
  id: number;
  username: string;
  avatar: string;
}
// 登录请求参数类型
export interface LoginParams {
  username: string;
  password: string;
}
// 登录响应数据类型
export interface LoginData {
  token: string;
  refreshToken: string;
  userInfo: UserInfo;
}

给Axios的配置加custom字段的类型:

// src/utils/request.ts
import axios, { AxiosRequestConfig, CancelToken } from 'axios';
import { ElMessage, ElLoading } from 'element-plus';
import { useUserStore } from '@/stores/user';
import type { BaseResponse } from '@/types/api';
// 定义个性化配置的类型
interface CustomConfig {
  noLoading?: boolean;
  noErrorToast?: boolean;
  noToken?: boolean;
  timeout?: number;
  headers?: Record<string, string>;
  cancelKey?: string;
}
// 扩展AxiosRequestConfig,添加custom字段
interface RequestConfig extends AxiosRequestConfig {
  custom?: CustomConfig;
}
// 扩展AxiosInstance,使用RequestConfig
const instance = axios.create({
  baseURL: import.meta.env.VITE_APP_API_BASE_URL || 'http://localhost:3000/api',
  timeout: 15000,
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  }
}) as unknown as {
  // 扩展instance的request方法,使用RequestConfig
  request<T = any>(config: RequestConfig): Promise<T>;
  get<T = any>(url: string, config?: RequestConfig): Promise<T>;
  post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T>;
  put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T>;
  delete<T = any>(url: string, config?: RequestConfig): Promise<T>;
};
// 其他代码和JavaScript版本差不多,只是要加类型断言
// 比如获取Pinia和userStore的时候
const pinia = window.__PINIA__ as any;
if (pinia) {
  const userStore = useUserStore(pinia);
  // ...
}
// 比如获取router的时候
const router = window.__VUE_ROUTER__ as any;
if (router) {
  router.push('/login');
}

在组件里使用的时候,就可以指定响应数据的类型:

// src/views/Login.vue
<script setup lang="ts">
import { ref } from 'vue';
import request from '@/utils/request';
import type { LoginParams, LoginData, BaseResponse } from '@/types/api';
const username = ref('');
const password = ref('');
const handleLogin = async () => {
  try {
    // 指定响应数据的类型为LoginData
    const data = await request.post<LoginData>('/api/auth/login', {
      username: username.value,
      password: password.value
    } as LoginParams);
    console.log(data);
    // 这里的data会有类型提示,比如data.token、data.refreshToken、data.userInfo
  } catch (error) {
    console.error(error);
  }
};
</script>

今天咱们从基础配置、分层封装、常见痛点解决三个维度,搭了一套能用、好用、能扩展的Vue3 Axios架构,这套架构不仅解决了搬砖式套模板的问题,还兼顾了拦截、统一处理、Token刷新、接口取消、跨域这些常用需求,不管是Vue3新手还是初中级开发,都可以直接拿来用,或者根据自己的项目需求进行修改。

Axios的封装没有绝对的标准答案,不同的项目有不同的需求,比如有的项目需要签名校验,有的项目需要加密传输,这些都可以在这套架构的基础上进行扩展,希望这篇文章能帮到大家,如果有什么问题或者建议,欢迎在评论区留言讨论。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门