最近在写后台管理,这里分享一下我的登录模块的实现,我是使用react+typescript实现的,主要是登录的逻辑和双token的处理方式,请求接口的二次封装aixos
1.首先我们需要渲染登录界面的窗口,这个很简单就不详细讲解了,然后主要就是关于点击登录按钮的接口的调用
封装我们的接口(封装是非常有必要的):
下面是我们的登录接口,然后request就是我二次封装的axios
export function login(data: LoginData) {return request.post<LoginResult>('/backstage/login', data);
}
这里详细讲解一下关于axios的封装,对于每一个要写项目的时候,只要有后端请求接口,我们都需要封装axios,这一步很重要,下面是对axios封装的主要实现,主要是基于双token的axios二次封装请求
创建axios的封装核心文件(request)讲解:
1.双token的实现逻辑?
1.1长短token是什么:
当用户登录成功之后会返回一个json数据,里面有连个token,一个是短token,access_token,一个是长token,refresh_token
access_token是访问令牌,因为在请求具有权限接口的时候需要请求头,里面需要放用户token,这个请求头里面的token就是我们的access_token,access_token的存在时间很短
refresh_token是刷新令牌,用于生成短token
1.2双token的更新更新逻辑:
当access_token过期时,需要使用refresh_token来生成一个新的access_token,那么什么时候会触发这个刷新机制呢,其实就是当调用权限接口的时候,如果access_token过期了,服务器教会返回一个401 unauthorized,前端的响应拦截器会获取所有的api请求,当它获取到401的时候,就知道access_token过期了,然后就刷新token了
当获取到了access_token之后,需要存储access_token,这是为了确保后续所有新发起的 API 请求都能使用这个最新的access_token。之前失败的请求并没有被直接抛弃,而是被暂存到了一个“待重试队列”(requestsToRetry)中。重新发送新的请求
2.axios二次封装实现思路:
实现引入我们的核心库,第一行就不进行解释了,第二行就是自己封装的一下存储,获取,清除token的自己封装的一些方法,见名知意
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { getAccessToken, getRefreshToken, setTokens, clearTokens } from './token';
2.1创建队列和函数:
isRefreshing,用于确保在多个请求同时 401 时,只有一个 token刷新请求被发送,防止重复刷新。
failedQueue队列:当isRefreshing为true时(表示已经有刷新 token 的请求在进行中),所有后续因 401 失败的请求都会被推入failedQueue。这些请求会返回一个新的 Promise,等待 token 刷新成功后被解决。
processQueue 函数:负责在 token 刷新成功或失败后,处理 failedQueue 中的所有请求。如果成功,用新的 token 重新发送;如果失败,则拒绝这些请求。
let isRefreshing: boolean = false;
// 存储因 token 过期而失败的请求队列
// 定义队列中每个元素的类型
interface FailedRequest {resolve: (value?: string | PromiseLike<string>) => void;reject: (reason?: any) => void;
}
let failedQueue: FailedRequest[] = []const processQueue = (error: Error | null, token: string | null = null): void => {failedQueue.forEach(prom => {if (error) {prom.reject(error);} else {prom.resolve(token as string); // 确保在没有错误时 token 不为 null}});failedQueue = [];
};
2.2创建axios实例:
使用 axios.create() 创建一个独立的 axios 实例,避免污染全局 axios
const request: AxiosInstance = axios.create({// 在 .env 文件中配置 中的请求配置baseURL: api基础路径,timeout: 10000, // 请求超时时间
});
2.3创建请求拦截器:
- 动态添加access_token,从存储位置获取access_token,并将其添加到请求头 Authorization 中。通常格式为 Bearer ${token}。
- 除了上面的请求头,还可以添加其他请求头,如 Content-Type。
- 可以实现全局的请求 Loading 动画。
// --- 请求拦截器 ---
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {const accessToken = getAccessToken();if (accessToken) {// 在请求头中添加 Authorization 字段if (!config.headers) {config.headers = new axios.AxiosHeaders();}config.headers['Authorization'] = `Bearer ${accessToken}`;}return config;},(error: AxiosError) => {return Promise.reject(error);},
);
2.4创建响应拦截器:
后端返回的数据通常会包裹在data当中,可以直接返回response.data
处理 HTTP 状态码非 2xx 的错误
无感刷新 token (核心):
- 捕获401 Unauthorized错误:这是最关键的一步。当后端因为access_token过期而返回401 时,拦截此错误。
- 调用刷新接口:使用refresh_token去请求新的access_token。
- 重发失败请求:获取到新的access_token后,将刚才失败的请求(error.config)用新 token重新发送一次。
- 并发请求处理:当多个请求同时因为 token 过期而失败时,要确保刷新 token的接口只被调用一次。后续失败的请求应被“暂存”,等待新 token 获取后再统一重发。
// --- 响应拦截器 ---
request.interceptors.response.use(// 响应成功 (HTTP 状态码为 2xx)(response: AxiosResponse<any>) => {// 通常后端会把数据包裹在 data 中,这里直接返回 data,简化业务代码return response.data;}, // 响应失败 (HTTP 状态码非 2xx)async (error: AxiosError) => {const originalRequest = error.config as| (InternalAxiosRequestConfig & { _retry?: boolean })| undefined;// 如果没有config,直接返回错误if (!originalRequest) {console.error('Request Error: No config available');return Promise.reject(error);} // 检查是否是 401 Unauthorized 错误,并且不是刷新 token 的请求本身if (error.response?.status === 401 && !originalRequest._retry) {// 如果正在刷新 token,则将当前失败的请求加入队列if (isRefreshing) {return new Promise<string>((resolve, reject) => {failedQueue.push({resolve: (value?: string | PromiseLike<string>) => resolve(value as string),reject,});}).then((token) => {if (!originalRequest.headers) {originalRequest.headers = new axios.AxiosHeaders();}originalRequest.headers['Authorization'] = `Bearer ${token}`;return request(originalRequest); // 使用新 token 重新发送请求}).catch((err) => {return Promise.reject(err);});}originalRequest._retry = true; // 标记此请求已尝试过重试isRefreshing = true;const refreshToken = getRefreshToken();if (!refreshToken) {// 如果没有 refresh_token,直接跳转到登录页console.error('No refresh token available.');clearTokens(); // window.location.href = '/login'; // 或使用 router.push('/login')return Promise.reject(new Error('No refresh token, redirect to login.'));}try {// --- 调用刷新 Token 的 API ---// 注意:这里需要使用一个不带拦截器的 axios 实例来发请求,避免循环调const response = await axios.post<{data: {access_token: string;refresh_token: string;};}>('登录接口api', {refresh_token: refreshToken,});const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data.data; // 1. 更新本地存储的 tokensetTokens(newAccessToken, newRefreshToken); // 2. 处理并重发等待队列中的请求processQueue(null, newAccessToken); // 3. 重发本次失败的请求if (!originalRequest.headers) {originalRequest.headers = new axios.AxiosHeaders();}originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;return request(originalRequest);} catch (refreshError: unknown) {// 刷新 token 失败,清除所有 token 并重定向到登录页console.error('Failed to refresh token:', refreshError);clearTokens();processQueue(refreshError as Error, null); // window.location.href = '/login'; // 或使用 router.push('/login')return Promise.reject(refreshError);} finally {isRefreshing = false;}} // 对于其他错误,直接抛出// 处理错误信息,确保类型安全const errorMessage =error.response?.data &&typeof error.response.data === 'object' &&'message' in error.response.data? (error.response.data as { message: string }).message: error.message;console.error('Request Error:', errorMessage);return Promise.reject(error);},
);
2.5完整的axios二次封装(request):
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { getAccessToken, getRefreshToken, setTokens, clearTokens } from '../stores/token';// --- 状态变量 ---
// 标记是否正在刷新 token,防止重复刷新
let isRefreshing: boolean = false;
// 存储因 token 过期而失败的请求队列
// 定义队列中每个元素的类型
interface FailedRequest {resolve: (value?: string | PromiseLike<string>) => void;reject: (reason?: unknown) => void;
}
let failedQueue: FailedRequest[] = [];/*** @description 处理队列中的请求* @param {Error | null} error - 刷新 token 过程中的错误* @param {string | null} token - 新的 access_token*/const processQueue = (error: Error | null, token: string | null = null): void => {failedQueue.forEach((prom) => {if (error) {prom.reject(error);} else {prom.resolve(token as string); // 确保在没有错误时 token 不为 null}});failedQueue = [];
};// --- 创建 Axios 实例 ---
const request: AxiosInstance = axios.create({// 在 .env 文件中配置 中的请求配置baseURL: '基础api',timeout: 10000, // 请求超时时间
});// --- 请求拦截器 ---
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {const accessToken = getAccessToken();if (accessToken) {// 在请求头中添加 Authorization 字段if (!config.headers) {config.headers = new axios.AxiosHeaders();}config.headers['Authorization'] = `Bearer ${accessToken}`;}return config;},(error: AxiosError) => {return Promise.reject(error);},
);// --- 响应拦截器 ---
request.interceptors.response.use(// 响应成功 (HTTP 状态码为 2xx)(response: AxiosResponse<any>) => {// 通常后端会把数据包裹在 data 中,这里直接返回 data,简化业务代码return response.data;}, // 响应失败 (HTTP 状态码非 2xx)async (error: AxiosError) => {const originalRequest = error.config as| (InternalAxiosRequestConfig & { _retry?: boolean })| undefined;// 如果没有config,直接返回错误if (!originalRequest) {console.error('Request Error: No config available');return Promise.reject(error);} // 检查是否是 401 Unauthorized 错误,并且不是刷新 token 的请求本身if (error.response?.status === 401 && !originalRequest._retry) {// 如果正在刷新 token,则将当前失败的请求加入队列if (isRefreshing) {return new Promise<string>((resolve, reject) => {failedQueue.push({resolve: (value?: string | PromiseLike<string>) => resolve(value as string),reject,});}).then((token) => {if (!originalRequest.headers) {originalRequest.headers = new axios.AxiosHeaders();}originalRequest.headers['Authorization'] = `Bearer ${token}`;return request(originalRequest); // 使用新 token 重新发送请求}).catch((err) => {return Promise.reject(err);});}originalRequest._retry = true; // 标记此请求已尝试过重试isRefreshing = true;const refreshToken = getRefreshToken();if (!refreshToken) {// 如果没有 refresh_token,直接跳转到登录页console.error('No refresh token available.');clearTokens(); // window.location.href = '/login'; // 或使用 router.push('/login')return Promise.reject(new Error('No refresh token, redirect to login.'));}try {// --- 调用刷新 Token 的 API ---// 注意:这里需要使用一个不带拦截器的 axios 实例来发请求,避免循环调const response = await axios.post<{data: {access_token: string;refresh_token: string;};}>('login接口', {refresh_token: refreshToken,});const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data.data; // 1. 更新本地存储的 tokensetTokens(newAccessToken, newRefreshToken); // 2. 处理并重发等待队列中的请求processQueue(null, newAccessToken); // 3. 重发本次失败的请求if (!originalRequest.headers) {originalRequest.headers = new axios.AxiosHeaders();}originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;return request(originalRequest);} catch (refreshError: unknown) {// 刷新 token 失败,清除所有 token 并重定向到登录页console.error('Failed to refresh token:', refreshError);clearTokens();processQueue(refreshError as Error, null); // window.location.href = '/login'; // 或使用 router.push('/login')return Promise.reject(refreshError);} finally {isRefreshing = false;}} // 对于其他错误,直接抛出// 处理错误信息,确保类型安全const errorMessage =error.response?.data &&typeof error.response.data === 'object' &&'message' in error.response.data? (error.response.data as { message: string }).message: error.message;console.error('Request Error:', errorMessage);return Promise.reject(error);},
);export default request;