打 API 的那一行程式碼本身不難,fetch(url) 一行就結束了。真正花時間的是它周圍的狀態:資料還沒回來時畫面要顯示什麼?失敗了要顯示什麼?使用者切換條件後,舊資料要不要先清掉?
Day 21 講 composable 的時候提過 useFetch 是很常見的例子,這篇就把它從最陽春的版本一路改到可以放進專案用。範例使用公開的假資料 API JSONPlaceholder,情境是一個「選擇使用者,列出他的文章」的頁面。
先不寫程式,想一下這個頁面會出現哪些畫面:
| 狀態 | 畫面 |
|---|---|
| 還沒開始 | 通常不會停在這裡太久,一進頁面就打 API |
| 載入中 | 轉圈圈或骨架屏 |
| 失敗 | 錯誤訊息,最好附一個「重試」按鈕 |
| 成功 | 文章列表 |
成功裡面還藏了一種:成功了,但是空的。API 回傳 [] 並不是錯誤,可是直接渲染出一個空的 <ul>,使用者會以為畫面壞掉了。
所以「三種狀態」其實是個簡化的說法。我們用 loading、error、data 三個變數去表示這幾種畫面,接下來的問題都出在「三個變數要怎麼表示四、五種畫面」。
<script setup>
import { ref, onMounted } from 'vue'
const posts = ref(null)
const error = ref(null)
const isLoading = ref(false)
async function getPosts() {
isLoading.value = true
try {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?userId=1')
posts.value = await res.json()
} catch (err) {
error.value = err
} finally {
isLoading.value = false
}
}
onMounted(getPosts)
</script>
把 isLoading.value = false 放進 finally,就不用在 try 和 catch 各寫一次。看起來很完整,但它有三個問題,我們一個一個看。
這是 JS 本身的行為,跟 Vue 無關。fetch 回傳的 Promise 只有在網路層失敗時才會 reject,例如斷網、DNS 解析失敗、CORS 被擋。只要伺服器有回應,就算是 404 或 500,Promise 都會正常 resolve。
我在本機開了一個會回 404 的假 server 實際測試:
const res = await fetch('/missing')
console.log(res.ok, res.status) // false 404,而且完全沒有進 catch
如果你用過 axios,可能會覺得奇怪,因為 axios 預設遇到 4xx、5xx 會直接 reject。換成 fetch 時就要自己檢查 res.ok(狀態碼在 200–299 之間才會是 true):
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
posts.value = await res.json()
主動 throw 之後,錯誤就會流進同一個 catch,處理錯誤的地方只需要一個。
接下來的問題都跟「重打」有關,先照 Day 21 的做法抽成 composable,比較好討論:
// composables/useFetch.js
import { ref } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
async function execute() {
isLoading.value = true
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (err) {
error.value = err
} finally {
isLoading.value = false
}
}
execute()
return { data, error, isLoading, refetch: execute }
}
回傳的是裝著 ref 的普通物件,使用的人解構也不會失去響應性,這是 Day 21 談過的原則。
假設第一次請求失敗,error 有值了。使用者按下「重試」,這次成功,data 也有值了。
但是 error 從頭到尾沒被清掉。這時候 error 和 data 同時有值,模板要顯示哪一個?答案取決於你 v-if 寫的順序,也就是說畫面對不對要看運氣。
所以每次發請求前要先重設:
async function execute() {
isLoading.value = true
error.value = null
data.value = null
// ...
}
error 一定要清掉。data 要不要清,就是設計上的選擇了:
回頭看 isLoading、error、data 這三個變數,排列組合有很多種,但真正合理的只有幾種。像「isLoading 是 true,error 也有值」這種組合是不合理的,而程式一旦漏掉某一行重設,它就可能出現。
與其靠三個布林值互相配合,不如直接用一個變數說清楚「現在是哪一種狀態」:
const status = ref('idle') // 'idle' | 'pending' | 'success' | 'error'
status 同一時間只會有一個值,不可能既是 pending 又是 error。isLoading 也不用自己維護,從 status 算出來就好:
const isLoading = computed(() => status.value === 'pending')
data 和 error 還是保留,它們負責存放「內容」;status 負責說明「現在是哪一種情況」。下一個坑會看到,status 不只是寫起來比較整齊,它還真的能幫我們避開一個 bug。
實際的頁面通常會讓使用者切換條件,例如下拉選單選使用者,網址從 ?userId=1 變成 ?userId=2,useFetch 就要自動重打。
最直覺的寫法是把網址的值傳進去:
const url = ref('/posts?userId=1')
const { data } = useFetch(url.value) // ❌ 傳進去的只是一個字串
這就是 Day 21 說的「參數也要傳盒子」。url.value 傳進去的那一刻,就只是一個普通字串,跟 url 這個 ref 已經沒有關係。我實際測過:之後把 url.value 改成 userId=2,data 還是使用者 1 的資料。
正確的做法是讓 useFetch 接受 ref 或 getter,然後在內部用 toValue()(Vue 3.3 加入)把它們統一取出值:
.value
watch 監聽它,網址一變就重打:import { ref, watch, toValue } from 'vue'
export function useFetch(url) {
// ...
async function execute() {
// ...
const res = await fetch(toValue(url))
// ...
}
watch(() => toValue(url), execute, { immediate: true })
// ...
}
使用的地方可以這樣寫:
const userId = ref(1)
const { data } = useFetch(() => `https://jsonplaceholder.typicode.com/posts?userId=${userId.value}`)
官方文件的範例是用 watchEffect 包住 fetchData()。這裡改用 watch 搭配 getter,是因為 execute 是 async 函式,而 watchEffect 只會追蹤第一個 await 之前讀到的響應式資料。依賴寫在 watch 的第一個參數,一眼就能看出「什麼改變了會重打」,比較不容易踩到這個限制。
網址會變之後,新的問題就出現了。
使用者先選了使用者 1,馬上又改選使用者 2。兩個請求同時在路上,如果使用者 1 的回應比較慢,就會發生這種事:
data = 使用者 2 的文章 ✅data = 使用者 1 的文章 ❌這種情況叫做 race condition(競態條件)。網路回應的順序不一定等於送出的順序,你沒辦法控制哪一個先回來。
解法是:發新請求之前,把還在路上的舊請求取消掉。fetch 可以搭配 AbortController:
const controller = new AbortController()
fetch(url, { signal: controller.signal })
controller.abort() // 這個 fetch 會 reject,錯誤的 name 是 'AbortError'
Vue 3.5 的 onWatcherCleanup 正好用在這裡。watcher 準備重新執行前,會先呼叫你註冊的清理函式:
watch(() => toValue(url), async () => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
isLoading.value = true
try {
const res = await fetch(toValue(url), { signal: controller.signal })
data.value = await res.json()
} catch (err) {
if (err.name !== 'AbortError') error.value = err
} finally {
isLoading.value = false
}
}, { immediate: true })
兩個要注意的地方:
onWatcherCleanup 一定要在第一個 await 之前呼叫,跟 Day 21 講 onMounted 要同步呼叫是同樣的道理。如果要在 await 之後註冊,可以改用 watch callback 的第三個參數 onCleanup。catch,但這不是真正的錯誤,要用 err.name === 'AbortError' 把它排除。上面這段已經解決了資料錯位,但我測試時發現另一個問題:
切換到 userId=2,新請求還在跑
isLoading = false ← ???
新請求明明還沒回來,isLoading 卻變成 false 了。
原因是舊請求被 abort 之後,雖然在 catch 裡被我們排除了,但它還是會走到 finally,把 isLoading 改成 false。這時候新請求剛把它設成 true,就被舊請求蓋掉了。畫面的結果是載入動畫閃一下就消失,列表區塊空白一段時間,然後資料才突然出現。
這就是前面說 status 能幫忙避開的 bug。不用 finally,改成在成功和失敗時各自設定 status,被取消的請求直接 return,什麼狀態都不動:
try {
// ...
status.value = 'success'
} catch (err) {
if (err.name === 'AbortError') return // 被取消的請求,什麼都不碰
error.value = err
status.value = 'error'
}
這時候 status 還是新請求設定的 'pending',畫面也就正確地停留在載入中。
把上面的修正整合起來,再加上兩個實用的功能:
refetch,它不經過 watcher,所以 onWatcherCleanup 管不到它。這裡改成把 controller 放在閉包裡,每次執行 execute 都先取消上一個請求,不管是誰觸發的都能處理。onScopeDispose 把它取消。// composables/useFetch.js
import { ref, computed, watch, toValue, onScopeDispose } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const status = ref('idle') // 'idle' | 'pending' | 'success' | 'error'
const isLoading = computed(() => status.value === 'pending')
let controller = null
async function execute() {
controller?.abort() // 不管是 watcher 還是手動重試,先取消上一個請求
controller = new AbortController()
const { signal } = controller
status.value = 'pending'
error.value = null
data.value = null
try {
const res = await fetch(toValue(url), { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
status.value = 'success'
} catch (err) {
if (err.name === 'AbortError') return
error.value = err
status.value = 'error'
}
}
watch(() => toValue(url), execute, { immediate: true })
onScopeDispose(() => controller?.abort())
return { data, error, status, isLoading, refetch: execute }
}
onScopeDispose 和 onUnmounted 很像,差別是它綁定的是目前的 effect scope,而不只是組件。在組件的 setup 裡呼叫時,組件卸載就會觸發;如果有人在 effectScope() 裡使用這個 composable,它也能正常運作。寫 composable 時用它會比較通用。
上面每一段程式碼我都用本機的假 server 跑過,包括 404、慢回應被取消、切換時的 status、空陣列,以及卸載後不會再寫入狀態。
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const userId = ref(1)
const { data: posts, error, status, refetch } = useFetch(
() => `https://jsonplaceholder.typicode.com/posts?userId=${userId.value}`
)
</script>
<template>
<select v-model.number="userId">
<option v-for="n in 3" :key="n" :value="n">使用者 {{ n }}</option>
<option :value="999">不存在的使用者</option>
</select>
<p v-if="status === 'pending'">載入中…</p>
<div v-else-if="status === 'error'">
<p>載入失敗:{{ error.message }}</p>
<button @click="refetch">重試</button>
</div>
<p v-else-if="status === 'success' && posts.length === 0">這位使用者還沒有文章</p>
<ul v-else-if="status === 'success'">
<li v-for="post in posts" :key="post.id">{{ post.title }}</li>
</ul>
</template>
JSONPlaceholder 只有 userId 1–10 有資料,選「不存在的使用者」時 API 會回傳 [],剛好可以看到空資料的畫面。
模板的判斷都是看 status,而不是看 data 有沒有值,所以順序比較不容易寫錯。如果是用三個變數判斷,例如先寫 v-if="data",再寫 v-else-if="isLoading",那「保留舊資料」的版本在重新載入時就永遠不會顯示 loading。用 status 判斷時,每個分支都對應一個明確的狀態,就不會出現這種問題。
posts.length === 0 前面加上 status === 'success' 的條件,是為了確保 posts 一定已經有值,不會在 null 上讀 length。
自己寫一次 useFetch 的價值,在於搞清楚這些狀態是怎麼來的。實際專案中,通常會直接使用現成的工具:
useFetch:用法跟這篇的版本很接近,還多了自動中斷、攔截器、逾時等功能。data、error、isLoading 或 status。讀懂這篇之後,再去看它們的文件就會很快上手。