返回文章列表
vue

Axios 完整封裝

axios完整封裝,並整合 axios-retry 實現自動重試機制,同時安全地處理 Access Token 過期後的「單航班」刷新(single-flight refresh)邏輯

2026年4月7日 1 次瀏覽 haodai
Axios 完整封裝

封裝 Axios、整合 axios-retry 與自動刷新 Token

本文件示範如何在前端專案中,將 axios 完整封裝,並整合 axios-retry 實現自動重試機制,同時安全地處理 Access Token 過期後的「單航班」刷新(single-flight refresh)邏輯,避免並發重刷問題。以 Vue 3 + Vite + TypeScript 為例,但概念適用於任意前端框架。


為什麼要封裝 Axios?

  • 統一設定baseURLtimeout、預設 headers、錯誤處理在一處維護。

  • 自動帶入 Token:在 Request Interceptor 注入 Authorization

  • 自動刷新 Token:在 Response Interceptor 遇到 401 時,單點刷新並重新送出原請求。

  • 自動重試:以 axios-retry 對暫時性錯誤(網路、5xx)做回退重試(exponential backoff)。


安裝套件

bash
1
2
3
4
5
npm i -D axios axios-retry
# 或
pnpm add -D axios axios-retry


建議目錄結構

  • src/lib/http.ts:建立與導出封裝後的 axios 實例與型別。

  • src/lib/auth.ts:負責 Token 存取(可接 Pinia/Redux/LocalStorage)。

  • src/api/*.ts:各領域 API 模組,引用 http 實例。


Token 存取模組(範例:src/lib/auth.ts

此處以 LocalStorage 為簡化示範,實務上建議接上狀態管理與更嚴謹的安全策略(例如使用 httpOnly Cookie 搭配 CSR/SSR 需求權衡)。

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/lib/auth.ts
export interface Tokens {
  accessToken: string;
  refreshToken: string;
}

const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';

export function getAccessToken(): string | null {
  return localStorage.getItem(ACCESS_TOKEN_KEY);
}

export function getRefreshToken(): string | null {
  return localStorage.getItem(REFRESH_TOKEN_KEY);
}

export function setTokens(tokens: Partial<Tokens>): void {
  if (typeof tokens.accessToken === 'string') {
    localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
  }
  if (typeof tokens.refreshToken === 'string') {
    localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
  }
}

export function clearTokens(): void {
  localStorage.removeItem(ACCESS_TOKEN_KEY);
  localStorage.removeItem(REFRESH_TOKEN_KEY);
}


封裝 Axios 與整合 axios-retry(src/lib/http.ts

  • 設定 axios-retry:預設只對「網路錯誤」與「5xx」重試,避免對 4xx 盲目重試。

  • Request Interceptor:自動附加 Authorization: Bearer <accessToken>

  • Response Interceptor:攔 401,執行單航班刷新;刷新成功後重送原請求,否則登出/清 Token。

  • 避免重複刷新:以 isRefreshing 與 pendingRequests 佇列實作。

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
// src/lib/http.ts
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import axiosRetry, { isNetworkOrIdempotentRequestError } from 'axios-retry';
import { getAccessToken, getRefreshToken, setTokens, clearTokens } from './auth';

declare module 'axios' {
  // 為原請求加上自定義旗標,避免重複行為或識別來源
  export interface AxiosRequestConfig {
    _retry?: boolean;
    _isRefreshCall?: boolean;
    _skipAuth?: boolean;
  }
}

export interface RefreshResponse {
  accessToken: string;
  refreshToken?: string;
}

const BASE_URL = import.meta?.env?.VITE_API_BASE_URL ?? '/api';

const http: AxiosInstance = axios.create({
  baseURL: BASE_URL,
  timeout: 15_000,
  withCredentials: false, // 若使用 httpOnly Cookie,依需求設為 true
});

// 配置 axios-retry(指數回退),針對暫時性錯誤重試
axiosRetry(http, {
  retries: 3,
  retryDelay: axiosRetry.exponentialDelay,
  // 僅重試網路錯誤與 5xx;避免對 401/403 這類授權錯誤盲目重試
  retryCondition: (error) => {
    // 不重試刷新 Token 的請求,避免迴圈
    const isRefreshCall = (error?.config as AxiosRequestConfig | undefined)?._isRefreshCall;
    if (isRefreshCall) return false;
    // 預設條件:網路錯誤或 idempotent 請求失敗(5xx)
    if (isNetworkOrIdempotentRequestError(error)) return true;
    // 可選:對 429 Too Many Requests 也做回退重試
    if (axios.isAxiosError(error) && error.response?.status === 429) return true;
    return false;
  },
});

// ------- 單航班刷新邏輯 -------
let isRefreshing = false;
let pendingRequests: Array<(token: string | null) => void> = [];

function subscribeTokenRefresh(cb: (token: string | null) => void) {
  pendingRequests.push(cb);
}

function onRefreshed(newAccessToken: string | null) {
  pendingRequests.forEach((cb) => cb(newAccessToken));
  pendingRequests = [];
}

async function refreshToken(): Promise<string | null> {
  const refreshToken = getRefreshToken();
  if (!refreshToken) return null;

  try {
    const resp = await http.post<RefreshResponse>(
      '/auth/refresh',
      { refreshToken },
      { _isRefreshCall: true, _skipAuth: true }
    );
    const { accessToken, refreshToken: nextRefresh } = resp.data;
    setTokens({ accessToken, refreshToken: nextRefresh });
    return accessToken;
  } catch (err) {
    return null;
  }
}

// ------- 請求攔截:自動帶 Token -------
http.interceptors.request.use((config) => {
  if (!config._skipAuth) {
    const token = getAccessToken();
    if (token) {
      config.headers = config.headers ?? {};
      (config.headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
    }
  }
  return config;
});

// ------- 回應攔截:統一處理成功/錯誤、401 刷新 -------
http.interceptors.response.use(
  (response: AxiosResponse) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as AxiosRequestConfig | undefined;
    const status = error.response?.status;

    // 非 401 或無原請求,交給 axios-retry 或呼叫端處理
    if (status !== 401 || !originalRequest) {
      return Promise.reject(error);
    }

    // 避免同一請求重複刷新
    if (originalRequest._retry) {
      return Promise.reject(error);
    }
    originalRequest._retry = true;

    // 若已在刷新中,將當前請求掛起,待刷新完成後重送
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        subscribeTokenRefresh((newToken) => {
          if (!newToken) {
            reject(error);
            return;
          }
          // 更新 Authorization 後重送原請求
          originalRequest.headers = originalRequest.headers ?? {};
          (originalRequest.headers as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
          resolve(http(originalRequest));
        });
      });
    }

    // 開始刷新
    isRefreshing = true;
    try {
      const newToken = await refreshToken();
      onRefreshed(newToken);

      if (!newToken) {
        // 刷新失敗:清理 Token 並把錯誤往外拋,讓呼叫端做登出或跳轉
        clearTokens();
        throw error;
      }

      // 搭載新 Token 重送原請求
      originalRequest.headers = originalRequest.headers ?? {};
      (originalRequest.headers as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
      return http(originalRequest);
    } catch (e) {
      throw e;
    } finally {
      isRefreshing = false;
    }
  }
);

export default http;


在 API 模組中使用(src/api/user.ts

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/api/user.ts
import http from '@/lib/http';

export interface UserProfile {
  id: string;
  email: string;
  name: string;
}

export async function fetchMyProfile(): Promise<UserProfile> {
  const { data } = await http.get<UserProfile>('/users/me');
  return data;
}


在 Vue 組件或 Store 中呼叫

tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 例:某個 component 或 Pinia action
import { fetchMyProfile } from '@/api/user';

async function load() {
  try {
    const me = await fetchMyProfile();
    console.log(me);
  } catch (err) {
    // 若刷新失敗導致 401,這裡可以導向登入頁或顯示提示
    // router.push({ name: 'login' });
  }
}


重要細節與最佳實務

  • 避免刷新請求再被重試:對刷新 API 設 config._isRefreshCall = true,讓 axios-retry 跳過它,避免迴圈。

  • 只重試暫時性錯誤axios-retry 的 retryCondition 預設只處理網路/5xx,避免 4xx(授權類)被無謂重試。

  • 單航班刷新:使用 isRefreshing 與 pendingRequests,避免多個 401 併發時重複呼叫刷新端點。

  • 安全性:若可行,建議改以 httpOnly Cookie 存放敏感 Token,並在後端實作 CSRF 防護;本文為簡化示範。

  • SSR 注意:若為 SSR,請避免在伺服器端使用全域 localStorage;可在請求範圍注入 Token(per-request context)。

  • 取消請求:對長時間請求建議加 AbortController/CancelToken,以免無謂重試佔用資源。

  • Idempotent 請求:重試機制理應只用於安全可重送的請求(GET 或具冪等語義的操作)。


常見錯誤排查

  • 刷新 API 路徑或回傳型別不一致:請調整 refreshToken() 內的請求路徑與 RefreshResponse 型別。

  • 重試無效:檢查 axios-retry 是否正確套用於同一個 http 實例,或 retryCondition 是否過於嚴格。

  • 無限 401 迴圈:確認 originalRequest._retry 是否生效、刷新請求是否被排除、以及刷新成功後是否確實更新了 Authorization

  • 顯示已刷新仍然 401:後端可能需要一定的 Token 傳播時間,或客戶端忽略了覆寫 Authorization 後立即重送。


單元測試與端對端測試建議

  • 以 msw 模擬 API,針對下列情境撰寫測試:

    • 正常 200 回應

    • 5xx 下 axios-retry 回退重試

    • 401 後成功刷新並重送原請求

    • 401 後刷新失敗,最終拋錯(應清 Token 與導向登入)

    • 併發多個 401 僅觸發一次刷新(單航班)


延伸:更進階的需求

  • 針對不同服務建立多個 http 實例,用不同 baseURL 與憑證策略。,在 retryCondition 添加對自定義錯誤碼(如 X-Retryable: true)的判斷。

  • 將 pendingRequests 以 Promise 封裝為「刷新完成通知器」,提升可讀性。

  • 將 Token 存取改為抽象介面,方便在 Web/Native/Node 共用。