---
title: '💸 網站流量費好貴!學校老師沒告訴你的 AbortController 省錢術 ✨!'
disqus: hackmd
description: Vue + TanStack Query 實戰教學,教你用 AbortController 節省頻寬、減少無效請求,避免爆流量費!
keywords: [TanStack Query, AbortController, Vue3, Nuxt4, Axios signal, cancelQueries, 前端效能優化, 高併發, 節流, 流量費, 架構師教學]
---
# 💸 網站流量費好貴!學校老師沒告訴你的 AbortController 省錢術 ✨!

## 都用了 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群也有熱心的朋朋幫忙補充:

>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`, `前端效能優化`, `前端流量優化`, `網站優化`