我們來想像一個場景:你正在一個電商網站上,精心挑選了半小時的商品,填好了複雜的收貨地址,滿心歡喜地點擊提交訂單 Button。
突然,頁面Duang🎈地一下,跳轉到了登錄頁,並提示你:“登錄狀態已過期,請重新登錄”。
那一刻,你的內心是什麼感受?我想大概率是崩潰的,並且想把這個網站拉進黑名單。
這就是一個典型的、因為Token
過期處理不當,而導致的災難級用戶體驗。作為一個負責任的開發者,這是我們絕對不能接受的。
今天就聊聊,我們團隊是如何通過請求攔截和隊列控制,來實現無感刷新Token的。讓用戶即使在Token
過期的情況下,也能無縫地繼續操作,就好像什麼都沒有發生過一樣。
為什麼需要兩個Token?
要實現無感刷新,我們首先需要後端同學的配合,採用雙Token的認證機制。
accessToken
: 這是我們每次請求業務接口時,都需要在請求頭裡帶上的令牌。它的特點是生命周期短(比如1小時),因為暴露的風險更高。refreshToken
: 它的唯一作用,就是用來獲取一個新的accessToken
。它的特點是生命周期長(比如7天),並且需要被安全地存儲(比如HttpOnly的Cookie裡)。流程是這樣的:用戶登錄成功後,後端會同時返回accessToken
和refreshToken
。前端將accessToken
存在內存(或LocalStorage)裡,然後在後續的請求中,通過refreshToken
來刷新。
axios
的請求攔截器我們整個方案的核心,是利用axios
(或其他HTTP請求庫)提供的請求攔截器(Interceptor)。它就像一個哨兵,可以在請求發送前和響應返回後,對請求進行攔截和改造。
我們的目標是:
accessToken
已過期的錯誤(通常是401
狀態碼)。refreshToken
,悄悄地在後台發起一個獲取新accessToken
的請求。accessToken
後,更新我們本地存儲的Token
。Token
重新發送出去。這個過程對用戶來說,是完全透明的。他們最多只會感覺到某一次API請求,比平時慢了一點點。
下面是我們團隊在項目中,實際使用的axios
攔截器偽代碼。
import axios from 'axios';
// 創建一個新的axios實例
const api = axios.create({
baseURL: '/api',
timeout: 5000,
});
// ------------------- 請求攔截器 -------------------
api.interceptors.request.use(config => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
}, error => {
return Promise.reject(error);
});
// ------------------- 響應攔截器 -------------------
// 用於標記是否正在刷新token
let isRefreshing = false;
// 用於存儲因為token過期而被掛起的請求
let requestsQueue = [];
api.interceptors.response.use(
response => {
return response;
},
async error => {
const { config, response } = error;
// 如果返回的HTTP狀態碼是401,說明access_token過期了
if (response && response.status === 401) {
// 如果當前沒有在刷新token,那麼我們就去刷新token
if (!isRefreshing) {
isRefreshing = true;
try {
// 調用刷新token的接口
const { data } = await axios.post('/refresh-token', {
refreshToken: localStorage.getItem('refreshToken')
});
const newAccessToken = data.accessToken;
localStorage.setItem('accessToken', newAccessToken);
// token刷新成功後,重新執行所有被掛起的請求
requestsQueue.forEach(cb => cb(newAccessToken));
// 清空隊列
requestsQueue = [];
// 把本次失敗的請求也重新執行一次
config.headers.Authorization = `Bearer ${newAccessToken}`;
return api(config);
} catch (refreshError) {
// 如果刷新token也失敗了,說明refreshToken也過期了
// 此時只能清空本地存儲,跳轉到登錄頁
console.error('Refresh token failed:', refreshError);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
// window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
} else {
// 如果當前正在刷新token,就把這次失敗的請求,存儲到隊列裡
// 返回一個pending的Promise,等token刷新後再去執行
return new Promise((resolve) => {
requestsQueue.push((newAccessToken) => {
config.headers.Authorization = `Bearer ${newAccessToken}`;
resolve(api(config));
});
});
}
}
return Promise.reject(error);
}
);
export default api;
這段代碼的關鍵點,也是面試時最能體現你思考深度的地方:
isRefreshing 狀態鎖:
這是為了解決並發問題。想像一下,如果一個頁面同時發起了3個API請求,而accessToken剛好過期,這3個請求會同時收到401。如果沒有isRefreshing這個鎖,它們會同時去調用/refresh-token接口,發起3次刷新請求,這是完全沒有必要的浪費,甚至可能因為並發問題導致後端邏輯出錯。
有了這個鎖,只有第一個收到401的請求,會真正去執行刷新邏輯。
requestsQueue 請求隊列:
當第一個請求正在刷新Token時(isRefreshing = true),後面那2個收到401的請求怎麼辦?我們不能直接拋棄它們。正確的做法,是把它們的resolve函數推進一個隊列(requestsQueue)裡,暫時掛起。
等第一個請求成功拿到新的accessToken後,再遍歷這個隊列,把所有被掛起的請求,用新的Token重新執行一遍。
無感刷新Token這個功能,用戶成功的時候,是感知不到它的存在的。
但恰恰是這種無感的細節,區分出了一個能用的應用和一個好用的應用。
因為一個資深的開發者,他不僅關心功能的實現,更應該關心用戶體驗和整個系統的健壯性。
希望這一套解決思路,能對你有所幫助🤞😁。