--- title: '💸 網站流量費好貴!學校老師沒告訴你的 AbortController 省錢術 ✨!' disqus: hackmd description: Vue + TanStack Query 實戰教學,教你用 AbortController 節省頻寬、減少無效請求,避免爆流量費! keywords: [TanStack Query, AbortController, Vue3, Nuxt4, Axios signal, cancelQueries, 前端效能優化, 高併發, 節流, 流量費, 架構師教學] --- # 💸 網站流量費好貴!學校老師沒告訴你的 AbortController 省錢術 ✨! ![截圖 2025-11-12 下午6.32.54](https://hackmd.io/_uploads/ByfDzJGeWx.png) ## 都用了 TanStack Query,為何還需要 AbortController?🤔 > 這篇源自我上週在 Vue 群開的投票,壓倒性票數選了這個主題。 > 然後跟架構師朋友竹子(Willy)聊了聊,發現這個誤會比我想像的還深... > 所以來寫篇文章好好講清楚 😎 本文適合使用 Vue + TanStack Query 開發大型後台或高頻操作頁面的工程師。 --- ## 先說結論 🎯 | | TanStack Query | AbortController | |---|---|---| | **解決什麼問題** | 相同 key 的請求去重、Cache 管理、背景重新驗證 | 真正終止網路請求、節省頻寬、避免競態條件 | | **無法解決** | 不同 key 的 in-flight requests | Cache 管理 | | **適合場景** | 靜態資料、低頻更新 | 即時性業務、頻繁切換路由/Tab | **核心概念**: * TanStack Query 只是「停止觀察」,不是「終止請求」 * AbortController 是「取消請求」,但只有純前端部分,實質上還是要依賴後端監聽http status,實務上後端若用 Node.js、Go 或 NestJS,可透過 req.aborted 事件偵測前端中斷請求,立即停止查詢,真正節省伺服器成本。 --- ## 起因:竹子的靈魂拷問 💬 前幾個禮拜竹子跟我聊到: > **竹子**: 「tanstack query 因為是 **cache** 管理,觸發請求的時機不是在 onMounted,而是 **setup 就發出請求了**。 我的觀點是 **request abort 是因為組件被卸載時,但請求還沒結束**,這時 abort 可以節省資源。但**如果用 tanstack query 因為是完成後直接存到 cache,反覆請求資源也都會節流,應用 cache 的資料,所以沒有需要 abort**。資料生命週期跟組件生命週期脫鉤,不但能節省流量,也可以背景處理。 > 蠻想知道當使用 tanstack query 時,你認為 abort 使用的時機點在什麼時候?我覺得可能有我沒想到的情境,想問問你的想法。」 我當下的反應:「咦?這個誤會有點大...」 然後我們就開始了一場技術討論(聊了快一小時 XD) --- ## 問題在哪?Cache ≠ 請求終止 🚫 換句話說,TanStack Query 管的是「資料狀態」,AbortController 管的是「網路生命週期」 先來個簡單的例子: ```typescript // 情境:你有 5 個頁面 const pages = [ { path: '/wallet/deposit', key: 'deposit' }, { path: '/wallet/withdraw', key: 'withdraw' }, { path: '/trade/futures/orders', key: 'futures' }, { path: '/trade/perpetual/positions', key: 'perpetual' }, { path: '/users/list', key: 'users' } ]; ``` ### TanStack Query 的行為 ```typescript // 每個頁面都是獨立的 useQuery useQuery({ queryKey: ['deposit'], queryFn: fetchDeposit }); useQuery({ queryKey: ['withdraw'], queryFn: fetchWithdraw }); useQuery({ queryKey: ['futures'], queryFn: fetchFutures }); ``` **重點來了**: - ✅ 如果你在同一個頁面重複打 `['deposit']`,會被去重 - ❌ 但如果你快速切換 5 個頁面,5 個請求**全部會發出**(因為 key 不同,會被視為應該執行) ### 真實痛點 OP 人員可能同時開 10+ 個分頁在操作。點擊不同路由的連結有時候會很快 使用者行為:瘋狂點側邊欄切換頁面或者瀏覽器的上一頁(奶油手,肯定是常態) **後果**: ``` 👉 5 個頁面 × 3 個 API = 15 個請求同時發出 👉 其中數個 Response 可能總體積變得有點大 👉 使用者已經離開頁面了,請求還在背景跑 (除非是下載文件檔案允許背景進行) 👉 頻寬爆炸 💥 流量費帳單哭哭 😭 ``` --- ## 真實場景:我遇到的三個大坑 🕳️ ### 場景 1:Tab 狂點(年/月/日報表) ```vue <el-tabs v-model="activeTab"> <el-tab-pane label="日報表" name="daily" /> <el-tab-pane label="月報表" name="monthly" /> <el-tab-pane label="年報表" name="yearly" /> </el-tabs> ``` **業務需求**: - OP 人員切換 Tab 要**馬上看到資料**(不想按搜尋) - 報表資料隨時在變,**不能用舊 cache** **TanStack Query 的問題**: ```typescript // 使用者手抖,1 秒內狂點 3 次 useQuery({ queryKey: ['report', activeTab.value], queryFn: () => fetchReport(activeTab.value), staleTime: 0 // 關掉 cache,確保即時性 }); // ❌ 結果:daily、monthly、yearly 三個請求同時發出 ``` **實際影響**: - 單個報表 API Response:**1.5MB** - 3 個請求,但真正用到的只有最後一個 = 流量浪費 --- ### 場景 2:即時性業務(訂單、流水) > **狀況**:「這些流水和帳變之類的報表數字隨時在變,都不能因為 cache 問題少更新。」 ```typescript // ❌ 不適合用 TanStack Query 的場景 useQuery({ queryKey: ['orders'], staleTime: 30000, // 30 秒內用 cache // 問題:訂單可能 1 秒內就新增/更新了 }); ``` **為什麼不用**: 1. **業務要求即時性** → 每次進頁面都要最新資料,Cache 反而阻礙了正常行爲 2. **失去 TanStack Query 優勢** → 既然不用 cache,幹嘛引入這層複雜度? --- ### 場景 3:多頁面快速切換 ```typescript // 使用者操作:首頁 → 錢包 → 出金 → 使用者列表 // 每個頁面停留時間:< 1 秒 // ❌ 沒有 Abort 的結果 // 4 個頁面的 API 請求全部發出 // 其中 2 個 Response 超過 2MB // 總流量變高 💸 // ✅ 有 Abort 的結果 // 只有最後停留頁面的個請求 // 總流量不會變得太大 💰 ``` **改善幅度**: - 請求數減少:**至少30%** ⬇️ - 流量減少:**約50%,但還是要依賴後端監聽http狀態是否取消** ⬇️ - 效能:**少掉不必要的後續動作,當然會變得更好** ⚡ --- ## 解法:AbortController 出場 🦸 ### 核心工具:`abortManager.ts` 這是我專案裡最關鍵的 util,用 **Map + Set** 管理所有請求: ```typescript /** 追蹤所有請求的 controller */ const keyMap = new Map<string, Set<AbortController>>(); const controllers = new Set<AbortController>(); /** 追蹤 controller(可選帶 key) */ export function track(c: AbortController, key?: string) { controllers.add(c); if (!key) return; if (!keyMap.has(key)) keyMap.set(key, new Set()); keyMap.get(key)!.add(c); } /** 移除追蹤 */ export function untrack(c: AbortController) { controllers.delete(c); keyMap.forEach(set => set.delete(c)); } /** 終止所有請求 */ export function abortAll() { controllers.forEach(c => c.abort()); controllers.clear(); } /** 只取消指定 key 的請求 */ export function abortKey(key: string) { const set = keyMap.get(key); if (!set) return; set.forEach(c => c.abort()); set.clear(); } ``` **設計亮點**: 1. **Set 不重複** → 同一個 controller 不會被追蹤多次 2. **Key-based 管理** → 可以針對 --- 特定 API 終止(不是無腦全殺) 3. **全域清理** → 路由切換時一鍵清空 ### Axios 整合:自動注入 Signal ```typescript import { track } from '@/utils/abortManager'; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 120000 }); // 請求攔截器:自動塞 signal request.interceptors.request.use((config) => { const controller = new AbortController(); config.signal = controller.signal; // 追蹤 controller(用 URL path 當 key) const key = config.url?.split('?')[0]; // 去掉 query string track(controller, key); return config; }); // 響應攔截器:處理 Abort 錯誤 request.interceptors.response.use( (response) => response, (error) => { if (error.name === 'CanceledError') { console.log('Request cancelled:', error.message); // 不要顯示錯誤訊息給使用者(這是正常行為) return Promise.reject(error); } return Promise.reject(error); } ); ``` --- ### Composable 層:路由 Hooks 整合 ```typescript // src/views/finance/composables/useDepositRecords.ts import { onBeforeRouteLeave } from 'vue-router'; import { abortAll, abortKey } from '@/utils/abortManager'; import to from 'await-to-js'; export function useReportDaily() { const RequestKey = { REPORT_DAILY: 'report/daily', REPORT_MONTHLY: 'report/monthly' } as const; const getReportDaily = async () => { // 1. 先終止相同 key 的舊請求(防止重複提交) abortKey(RequestKey.REPORT_DAILY); // 2. 發出新請求 const [err, res] = await to(fetchReportDaily(form.value)); // ... }; // ✅ 離開頁面時終止所有請求 onBeforeRouteLeave(() => { abortAll(); }); return { getReportDaily }; } ``` **關鍵點**: 1. **離開頁面前清理** → `onBeforeRouteLeave(() => abortAll())` 2. **防止重複提交** → 提交前先 `abortKey()` 終止舊請求 3. **精準控制** → 針對不同 API 使用不同 key --- ### 白名單機制:報表下載不能砍 ```typescript // 有些請求不該被 abort(例如:檔案下載) const ABORT_WHITELIST = [ '/api/export/csv', '/api/export/excel', '/api/report/download' ]; export function track(c: AbortController, key?: string) { if (key && ABORT_WHITELIST.includes(key)) { // 白名單內的請求不追蹤,讓它背景慢慢跑 return; } controllers.add(c); // ... } ``` **適用場景**: - 📊 報表下載(使用者期待背景完成) - 📁 檔案上傳(中斷會導致資料遺失) - 💳 重要交易(必須完成的寫入操作) --- ## 跟竹子的技術討論(精華節錄)💡 ### 🤔 問:TanStack Query 的 debounce 不夠用嗎? > **竹子**:「TanStack Query 本身就有 debounce,應該能避免重複請求吧?」 **我的回答**: Debounce 只能**延遲發出**,不能**終止已發出的請求**。 ```typescript // TanStack Query 的 debounce useQuery({ queryKey: ['search', keyword.value], queryFn: () => searchAPI(keyword.value), // ⚠️ debounce 只能「延後發出」,不能「砍掉已發出的」 }); // 使用者快速輸入:'a' → 'ab' → 'abc' // 結果:3 個請求都發出了(只是有延遲) ``` **AbortController 的優勢**: ```typescript // 使用者輸入時,立刻砍掉舊搜尋 watch(keyword, () => { abortKey('search'); // 先砍 search(keyword.value); // 再打 }); ``` --- ### 🤔 問:為什麼不統一用一種方案? > **竹子**:「有些人討厭不統一的做法,但事實上就是依照需求特性調整做法」 **我的回答**: 技術選型要基於**業務場景**,不統一是怕太發散,就是我常講的沒有正常的制度會導致很大的災難。 | 場景 | 適合方案 | 原因 | |------|---------|------| | 下拉選單資料 | TanStack Query | 靜態資料,適合 cache | | 即時訂單列表 | AbortController | 需要最新資料,cache 沒意義 | | 報表 Tab 切換 | AbortController | 頻繁切換,避免流量浪費 | | 使用者資料查詢 | TanStack Query + Abort | 結合兩者優勢 | **實務建議**: ```typescript // 1. 靜態資料:純 TanStack Query const { data } = useQuery({ queryKey: ['currencies'], queryFn: fetchCurrencies, staleTime: Infinity // 永久 cache,爽 }); // 2. 即時資料:純 AbortController const getOrders = async () => { abortKey('orders'); const [err, res] = await to(fetchOrders()); // ... }; // 3. 混合場景:兩者都要 const { data, refetch } = useQuery({ queryKey: ['userDetail', userId.value], queryFn: ({ signal }) => fetchUser(userId.value, { signal }), staleTime: 30000 }); onBeforeRouteLeave(() => { queryClient.cancelQueries(['userDetail']); }); ``` --- ## 常見誤區(我踩過的坑)🕳️ ### ❌ 誤區 1:以為 TanStack Query 會自動砍請求 ```typescript // ❌ 錯誤認知 const { data } = useQuery({ queryKey: ['orders'], queryFn: fetchOrders }); // 組件卸載時,請求「不會」被終止 // 只是結果不會更新到組件而已 ``` **正確做法**: ```typescript // ✅ 手動傳 signal(或在 Axios 全域處理) const { data } = useQuery({ queryKey: ['orders'], queryFn: ({ signal }) => fetchOrders({ signal }) }); ``` --- ### ❌ 誤區 2:濫用 abortAll() ```typescript // ❌ 不好:提交表單時全部砍掉 const handleSubmit = () => { abortAll(); // 可能誤殺其他重要請求 submitForm(); }; ``` **正確做法**: ```typescript // ✅ 用 abortKey() 精準打擊 const handleSubmit = () => { abortKey('form/submit'); // 只砍同類請求 submitForm(); }; ``` --- ### ❌ 誤區 3:忘記清理 controller ```typescript // ❌ 忘記 untrack,controller 會一直留在 Set 裡 const controller = new AbortController(); track(controller, 'myAPI'); // 請求完成後,controller 還在... ``` **正確做法**: ```typescript // ✅ 在 Axios interceptor 統一處理 request.interceptors.response.use( (response) => { const controller = getControllerFromSignal(response.config.signal); if (controller) untrack(controller); return response; }, (error) => { const controller = getControllerFromSignal(error.config?.signal); if (controller) untrack(controller); return Promise.reject(error); } ); ``` --- ## Nuxt 開發者看這邊!👀 ### 問:Nuxt 也需要手動處理 AbortController 嗎? 早些時間點當我把這篇文章分享到 Vue 群時就有人問: > **「Nuxt 是不是也要自己實作 AbortController?」** **好消息:Nuxt 4.2+ 已經內建支援了!** 🎉 --- ### Nuxt 4.2 的自動化方案 從 2025 年 10 月發佈的 Nuxt 4.2 開始,框架已經內建 AbortController 支援: [Nuxt blog: 4.2 Released ](https://nuxt.com/blog/v4-2#abort-control-for-data-fetching) #### 1️⃣ **useAsyncData 自動接收 signal** ```typescript // ✅ Nuxt 4.2+ 的做法 const { data, error, clear, refresh } = await useAsyncData( 'users', (_nuxtApp, { signal }) => $fetch('/api/users', { signal }) ) // 離開頁面時,Nuxt 會自動 abort ``` **關鍵差異**: - Vue 3:需要手動在 `onBeforeRouteLeave` 呼叫 `abortAll()` - Nuxt 4.2:框架自動處理,handler 接收內建的 `signal` --- #### 2️⃣ **dedupe 選項控制請求策略** ```typescript const { data, refresh } = await useAsyncData( 'report', async (_nuxtApp, { signal }) => { return $fetch('/api/report', { signal }) }, { dedupe: 'cancel' // ⬅️ 關鍵設定 } ) // 當你快速切換 Tab 時: onMounted(() => { refresh() // 會自動 abort 前一個請求 }) ``` **dedupe 兩種模式**: | 模式 | 行為 | 適用場景 | |------|------|----------| | `cancel` | 新請求會 abort 舊請求 | Tab 切換、搜尋防抖 | | `defer` | 等舊請求完成才發新的 | 避免 API rate limit | --- #### 3️⃣ **手動控制:傳入自己的 AbortController** ```typescript const abortController = new AbortController() const { refresh } = await useAsyncData( 'longTask', (_nuxtApp, { signal }) => $fetch('/api/heavy', { signal }) ) // 使用者點擊「取消」按鈕 const handleCancel = () => { abortController.abort() refresh({ signal: abortController.signal }) } ``` --- ### 實際效果比較 **情境**:使用者快速切換「日報表 → 月報表 → 年報表」 注意,體積是舉例,實際上還是要看你的總Request有多少 | 方案 | 請求數 | 頻寬消耗 | |------|--------|----------| | 原始 Vue 3(無 Abort) | 3 個 | 4.5MB | | Vue 3 + 手動 AbortController | 1 個 | 1.5MB ✅ | | Nuxt 4.2(dedupe: cancel) | 1 個 | 1.5MB ✅ | **結論**:Nuxt 4.2 開箱即用,不需要自己寫 `abortManager.ts`! --- ### 什麼時候還是需要手動處理? 即使用了 Nuxt 4.2,以下場景還是建議自己管理: #### ❌ 場景 1:非 Composable 的請求 ```typescript // ❌ 直接用 axios,Nuxt 管不到 import axios from 'axios' const handleSubmit = async () => { // 這個請求不會被 Nuxt 自動 abort const res = await axios.post('/api/submit', form.value) } ``` **解法**:改用 `$fetch` 或手動加 AbortController。 --- #### ❌ 場景 2:多個獨立請求同時發出 ```typescript // ❌ 5 個請求有不同 key,Nuxt 無法去重 const fetchAllData = async () => { await Promise.all([ useAsyncData('deposit', () => $fetch('/api/deposit')), useAsyncData('withdraw', () => $fetch('/api/withdraw')), useAsyncData('futures', () => $fetch('/api/futures')), useAsyncData('perpetual', () => $fetch('/api/perpetual')), useAsyncData('users', () => $fetch('/api/users')) ]) } ``` **解法**:在 `onBeforeRouteLeave` 統一清理,或用本文的 `abortManager.ts`。 --- #### ❌ 場景 3:需要白名單機制 ```typescript // Nuxt 的 dedupe 是全域行為,無法針對特定 API 關閉 // 例如:報表下載不想被 abort const { data } = await useAsyncData( 'download', () => $fetch('/api/export/excel'), { dedupe: 'cancel' } // ⚠️ 還是會被 abort ) ``` **解法**:使用本文的白名單機制,手動控制哪些請求不該被砍。 --- ### Nuxt vs Vue 3 | 面向 | Vue 3 | Nuxt 4.2+ | |------|-------|-----------| | **AbortController 支援** | 需自己實作 | 內建於 `useAsyncData` | | **離開頁面自動清理** | 需手動 `onBeforeRouteLeave` | 框架自動處理 | | **dedupe 去重** | 需自己寫邏輯 | 內建 `cancel` / `defer` 模式 | | **白名單機制** | 可自訂 | 無內建,需自己處理 | | **適用範圍** | 所有請求方式 | 僅限 `useFetch` / `useAsyncData` | **建議**: - **純 Nuxt 專案**:優先用框架內建功能(`dedupe: 'cancel'`) - **混合場景**(有 axios / 自訂請求):還是需要本文的 `abortManager.ts` - **需要白名單**:手動實作(Nuxt 沒提供) --- ## 如果我單純只想用TanStack Query呢? 當然可以!! 我們這篇文章講的就是Abort的重要性和Request KEY的區別 稍早前竹子的LINE群也有熱心的朋朋幫忙補充: ![image](https://hackmd.io/_uploads/S1j715Mx-e.png) >Danny: **cancelQueries** 會觸發 **retryer.cancel()**,而 **retryer** 會呼叫 **abortController.abort()**。 但只有當你的 queryFn 把這個 signal 傳給 fetch/axios 時,請求才會被真正中止。 我們先從**TanStack Query v5(query-core)** 原始碼來看: ### queryClient.cancelQueries() cancelQueries() 會找到所有符合條件的 Query, 逐一呼叫每個 query.cancel()。 ```typescript= cancelQueries<TTaggedQueryKey extends QueryKey = QueryKey>( filters?: QueryFilters<TTaggedQueryKey>, cancelOptions: CancelOptions = {}, ): Promise<void> { const defaultedCancelOptions = { revert: true, ...cancelOptions } const promises = notifyManager.batch(() => this.#queryCache .findAll(filters) .map((query) => query.cancel(defaultedCancelOptions)), ) return Promise.all(promises).then(noop).catch(noop) } ``` ### Query.cancel() 它呼叫了內部的 this.#retryer.cancel(),這才是實際中止請求的地方。 ```typescript= cancel(options?: CancelOptions): Promise<void> { const promise = this.#retryer?.promise this.#retryer?.cancel(options) return promise ? promise.then(noop).catch(noop) : Promise.resolve() } ``` ### createRetryer() 裡的 onCancel 當 queryClient.cancelQueries() 觸發 → 進入這個 onCancel。 它會呼叫 abortController.abort()。 若你的 fetchFn(也就是 queryFn)內有吃進這個 signal, 則會真正中止網路請求。 ```typescript= this.#retryer = createRetryer({ fn: context.fetchFn as () => Promise<TData>, onCancel: (error) => { if (error instanceof CancelledError && error.revert) { this.setState({ fetchStatus: 'idle' as const }) } abortController.abort() }, ... }) ``` ### abortController 的建立 這一段會自動注入 signal 到傳給 fetchFn 的 context 物件中。 只要你的 queryFn 宣告 ({ signal }) => ... 就能取得這個 signal。 ```typescript= const abortController = new AbortController() const addSignalProperty = (object: unknown) => { Object.defineProperty(object, 'signal', { enumerable: true, get: () => abortController.signal, }) } ``` ### 來實踐看看 ```typescript= const { data } = useQuery({ queryKey: ['orders'], queryFn: ({ signal }) => axios.get('/api/orders', { signal }), }) ``` :::info 💡 當呼叫 cancelQueries() 時: * 會透過 retryer.cancel() 觸發 onCancel()。 * 內部呼叫 abortController.abort()。 若 queryFn 有使用 ({ signal }) => fetch(..., { signal }), 就會真的中止網路請求。 若沒接 signal:Query 仍會停止觀察結果,但請求會照樣跑完。 ::: --- ## 總結:隨「業務需求」去適應 🎨 這篇講的是流量省錢,但本質其實是對「前端資源生命周期的掌控力」。 當你能主動終止無意義的浪費,就代表你對架構設計才真正開始啟蒙。 ### TanStack Query 的優勢 ✨ - ✅ 相同 key 的請求去重 - ✅ 自動背景重新驗證 - ✅ Optimistic updates - ✅ 分頁 / 無限滾動支援 - ✅ DevTools 可視化 **適合場景**: - 靜態資料(幣種、國家列表) - 使用者資料(個人檔案) - 低頻更新的資料 --- ### AbortController 的優勢 ⚡ - ✅ 真正終止網路請求 - ✅ 節省頻寬與伺服器資源 - ✅ 避免競態條件 - ✅ 精準控制請求生命週期 - ✅ 適應各種業務場景 **適合場景**: - 即時性業務(訂單、流水) - 頻繁切換(Tab、路由) - 大型 Response(報表) - 搜尋防抖 + 立即終止舊請求 --- ### 最終建議 💡 **不要教條式地選技術,而是看業務需求靈活組合。** 當然你不需要跟我一樣手刻AbortController,以 TanStack 的現有Solution絕對可以獲得更方便的支援哦!! ```typescript // 🎯 理想架構:分層處理 // 1. Axios 層:全域 AbortController 管理 // 2. Service 層:提供統一的 API 介面 // 3. Composable 層:根據業務選 TanStack Query 或 Abort // 4. Component 層:專注於 UI 邏輯 ``` --- ## 延伸閱讀 📚 - [MDN - AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) - [TanStack Query - Cancellation](https://tanstack.com/query/latest/docs/react/guides/query-cancellation) - [Axios - Cancellation](https://axios-http.com/docs/cancellation) - [Vue Router - Navigation Guards](https://router.vuejs.org/guide/advanced/navigation-guards.html) --- **特別感謝** 🙏 感謝竹子(Willy)跟我聊了一個小時,讓這篇文章的論述更完整。 - 竹子的脆:[@f2e_willy](https://www.threads.com/@f2e_willy) - 我的脆:[@astolfo_proto](https://www.threads.com/@astolfo_proto) --- 如果這篇文章對你有幫助,歡迎按讚分享!👇 ###### tags: `VueJS`, `AbortController`, `TanStack Query`, `前端開發` `Web Development`, `前端效能優化`, `前端流量優化`, `網站優化`