在上一回的修煉中,我們打造了 axios
客戶端。它配備的「回應攔截器」可以在偵測到 401
錯誤時,果斷地將使用者登出並踢回登入頁。這確保了系統的安全性,但從使用者體驗的角度來看,卻顯得有些不近人情。
想像一下,你正在填寫一份複雜的表單,就在點擊「送出」的那一刻,因為 Access Token
剛好過期,你就被強制登出了!
今天,我們將改造攔截器,讓它在遇到 401
錯誤時,不先急著放棄,而是嘗試「自動刷新 Token,並重試失敗的請求」,打造無縫的使用者體驗。
要實現「自動刷新」,我們首先需要理解一個在現代驗證機制中非常重要的模式:Access Token
+ Refresh Token
的組合。
Access Token (存取權杖):
Refresh Token (刷新權杖):
HttpOnly
Cookie 中。它們的流程如下:
Access Token
和一個長壽的 Refresh Token
。Access Token
。Access Token
過期,後端回傳 401
錯誤。401
,立刻發起一個特殊的 API 請求(例如 /api/auth/refresh
),並帶上 Refresh Token
。Refresh Token
,如果有效,就簽發一個新的 Access Token
並回傳給前端。Access Token
後,用它來重新發起剛才失敗的那個請求。我們可以嘗試在 axios
的回應攔截器中手動實現這個邏輯。這會讓我們更深入地理解其複雜性。
// src/api/client.js
// ...
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 檢查是否是 401 錯誤,且不是因為刷新 token 本身失敗的
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 標記為已重試
try {
// 呼叫刷新 token 的 API
const { data } = await apiClient.post('/auth/refresh', {
refreshToken: getRefreshTokenFromSomewhere()
});
const newAccessToken = data.accessToken;
const authStore = useAuthStore();
// 更新 Pinia 和 localStorage 中的 token
authStore.setAccessToken(newAccessToken);
// 更新原始請求的標頭
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
// 重新發送原始請求
return apiClient(originalRequest);
} catch (refreshError) {
// 如果刷新 token 也失敗了,表示 refresh token 也過期了
// 此時才真正需要將使用者登出
const authStore = useAuthStore();
authStore.logout();
router.push({ name: 'login' });
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
這個實作看起來可行,但它有一個潛在的巨大問題:併發請求 (Concurrent Requests)。
想像一下,在 Access Token
過期的瞬間,頁面上有 3 個 API 請求同時發出。它們會同時收到 401
,然後觸發 3 次刷新 Token 的請求,這會造成資源浪費和潛在的競態條件 (Race Condition)。
要解決這個問題,需要更複雜的邏輯(例如:用一個變數鎖定刷新狀態、用一個佇列儲存待重試的請求),手動實現起來非常繁瑣且容易出錯。
幸運的是,社群已經為我們提供了完美的解決方案。
axios-auth-refresh
(推薦)axios-auth-refresh
是一個專門用來處理 axios
中認證刷新邏輯的函式庫。它優雅地解決了我們上面提到的所有問題,包括並發請求。
1. 安裝函式庫
npm install axios-auth-refresh
2. 改造 client.js
使用它非常簡單,我們只需要提供一個「刷新邏輯函式」給它即可。
// src/api/client.js
import axios from 'axios';
import createAuthRefreshInterceptor from 'axios-auth-refresh';
import { useAuthStore } from '../stores/auth';
import router from '../router';
// 建立 axios 實例 (與之前相同)
const apiClient = axios.create({ ... });
// 定義「刷新邏輯函式」
const refreshAuthLogic = async (failedRequest) => {
const authStore = useAuthStore();
try {
// 這裡假設我們有一個 API 可以用 refresh token 換取新的 access token
// 並且 refresh token 是安全地儲存在 HttpOnly Cookie 中,所以請求會自動帶上
const response = await axios.post(`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`);
const newAccessToken = response.data.accessToken;
// 更新 Pinia Store
authStore.setAccessToken(newAccessToken);
// 更新失敗請求的標頭並重新發送
failedRequest.response.config.headers.Authorization = `Bearer ${newAccessToken}`;
return Promise.resolve();
} catch (error) {
// 如果刷新也失敗,就執行登出
authStore.logout();
router.push({ name: 'login' });
return Promise.reject(error);
}
};
// 將攔截器應用到我們的 axios 實例上
createAuthRefreshInterceptor(apiClient, refreshAuthLogic);
// 我們不再需要自己寫複雜的回應攔截器來處理 401
// 但可以保留它來處理 401 以外的其他錯誤
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status !== 401) {
// 在這裡處理 401 以外的錯誤
console.error(`發生錯誤: ${error.response.status}`);
}
return Promise.reject(error);
}
);
// 請求攔截器保持不變
apiClient.interceptors.request.use(...);
export default apiClient;
這樣一來,程式碼變得簡潔,我們只需要專注於「如何刷新 Token」的邏輯,所有關於攔截、重試、處理並發請求的複雜工作,axios-auth-refresh
都幫我們處理好了。
今天,我們的 API 處理能力又提升了一個檔次。我們學到了:
Access Token
+ Refresh Token
模式是兼顧安全與體驗的現代驗證標準。axios
攔截器中實現「自動重試」是可行的,但需要處理棘手的並發請求問題。axios-auth-refresh
函式庫,可以極大地簡化認證刷新與請求重試的邏輯,是專業專案中的首選方案。透過這個無縫的重試機制,再也不會因為短暫的 Token 過期而粗暴地打斷使用者了。
明日,Day 24:[Systemの呼吸・壹之型] Product列表 - 商品展示與搜尋。心を燃やせ 🔥!