這篇記什麼

我手上六個前端專案(Vue、React、Astro+Vue、Astro+React、Angular、Svelte 各一)都把 HTTP 請求收在 core/http/ 一層。這件事的價值我一直說不清楚——直到要升 axios 的那天。

axios 從 1.13.2 升到 1.19.0,跨六個 minor 版本,src 底下沒有改動任何一行。 用 axios 的五個專案同時升;升完把六個專案的單元測試全跑一遍,共 3188 個,一個都沒紅。

minor 版本不代表行為完全沒變。翻 axios 的 release notes,這段區間裡就有幾個 Notable Changes:1.16.0 的 fetch adapter 開始強制 maxBodyLength/maxContentLength、1.18.0 開始拒絕少了 // 的畸形 http(s) URL、1.19.0 改了 NO_PROXY 比對方式,同步攔截器失敗後也不會再送出請求(axios releases)。所以「升完零紅」不是理所當然,是要有東西守著才敢說。

封裝層實際包了什麼

五個 axios 專案的 core/http/ 檔案組成幾乎一模一樣:

檔案管什麼
httpClient.tsaxios.create() 出來的實例,對外唯一的出口
requestHeaders.ts統一標頭、冪等鍵(同一個請求重送幾次結果都一樣的那把鑰匙)
cacheInterceptor.ts回應快取
retryInterceptor.ts重試
tokenRefresh.tsaccess token 過期時換新的
apiAuthToken.tstoken 的存取
index.ts統一出口

axios.create() 會用自訂設定建一個新的 axios 實例(axios README: Creating an instance),攔截器、baseURL、標頭都掛在這個實例上,所以「對外唯一的出口」是字面意義的唯一。

Angular 用 kebab-case 檔名、測試檔放同目錄(框架慣例);Astro+Vue 多一支 serverApi.ts 給 SSR 用。差異都在命名與框架慣例,職責切法是同一套。

邊界劃在哪:型別可以外洩,執行時 API 不行

盤點「誰 import 了 axios」時,一開始看到頁面層也在 import,以為封裝破了。實際看下去不是:

// 這種沒問題——只借型別,打包後不會有 axios 的執行時依賴
import type { AxiosError } from 'axios';
 
// 這種才是洩漏——執行時真的呼叫到 axios 的 API
import axios from 'axios';
axios.isAxiosError(error);

判準:import type 進來的東西編譯完就消失,換底層時只要改型別宣告;import 進來並實際呼叫的,換底層時每一處都要改。 TypeScript 官方對 import type 的說法是「一律完全抹除,執行時不留任何痕跡」(TypeScript 3.8 release notes)。

六個專案掃下來,產品程式碼沒有一處自己發請求。唯一在封裝層外用到 axios 執行時 API 的是 Vue 專案裡的 ApiTesterPage.vue——而它不是洩漏,是刻意的例外。

那個頁面是 API 測試工具:使用者自己填 URL、method、headers、body 去打任意端點。走 httpClient 的話會被套上 baseURL、認證標頭、重試、快取與 token refresh,全部都會汙染測試結果。它需要的正是「沒有加工的原始請求」。

這件事我一開始判斷錯了。 盤點時把它列為待修的洩漏,本來打算在 core/http 加一個 isHttpError 型別守衛讓頁面改用。實作到一半才發現那個頁面裡有一行 await axios({...})——本來就在直接發請求,不只是借 isAxiosError 判型別。而且加那個守衛沒有第二個呼叫端,等於製造一個死程式碼。

最後的處理是把「為什麼這裡是例外」寫在 import axios 上面。規則要能講出例外的理由,不然下一個人只會看到違規。

core/error/errorClassifier.ts 自己 import axios 是合理的——錯誤分類本來就要認得 AxiosError 才能把它轉成 AppError。那支檔案屬於封裝層的一部分,不是洩漏。

為什麼「換底層很容易」不是重點

講封裝的時候常見的說法是「以後要換掉 axios 很容易」。那件事幾乎不會發生,拿它當理由說服不了人。

真正每年都會發生的是升級。 這次的實證:跨六個 minor 版本、五個專案同時升、六個專案共 3188 個測試零紅、src 零改動。

因為真正碰到 axios API 的只有 core/http/ 那六七支,而它們每一支都有測試。要驗的東西被收斂成一層,而那一層有測試守著。

反過來想:如果 20 個頁面各自 axios.get,這次升級就是 20 個地方要看,而且不會有人真的一個一個看——只會跑一次測試,紅了才知道,沒紅就當作沒事。

換一個底層函式庫:Svelte 用 ky,形狀一樣

Svelte 那個專案底層不是 axios 而是 ky,但對外一樣是 httpClient,錯誤一樣走 classifyError,檔案一樣切成 httpClient/cacheInterceptor/requestHeaders/tokenRefresh。

這正好證明封裝的形狀跟底層無關。我對 HTTP client 封裝的要求是:請求收在一層、對外介面一致、錯誤統一分類,至於底下用 axios 還是 ky 不做規定——規範的是要達成的保證,不是實作的形式。

但要補一句:對外介面一樣,不代表底層函式庫的控制流一樣。同一份「401 換發後重送」的設計,ky 那份就因為攔截器重送不走同一條鏈而出過 bug,行為的等價要另外驗(見 攔截器裡重送請求,重送那次不一定走同一條鏈)。

也順帶說明為什麼 Svelte 這次不用升 axios:它根本沒裝。

處理結果

  • axios 版本已在五個專案統一到 ^1.19.0
  • ApiTesterPage 的例外理由已寫進程式碼

沒做的:沒有加 isHttpError 型別守衛。只有一個呼叫端,而且那個呼叫端本來就該用原始 axios,加了就是死程式碼——那正是 寫了但沒接上 在講的毛病。

相關