Appearance
封裝非同步資料
上一節的最小示範只做到「作廢的請求不准寫入」,請求本身還在網路上跑,載入中與失敗的畫面也還沒著落。這一節我們把這些一次封裝好,做出一個可以在整個專案重用的 useBookFetch。
先說清楚:這是本書的教學版,刻意不叫 useFetch,因為知名工具庫 VueUse 有一個同名的現成函式(下一節會談到怎麼查、怎麼評估),兩者無關。自己動手做一次的價值在於:接手任何一個資料請求 composable 時,各位讀者都能看懂它在防什麼。
請求狀態的三種樣子
發一個請求,畫面關心的其實是三件事:資料回來了沒(data)、失敗了沒(error)、正在等嗎(isLoading)。模板幾乎都長這樣:
vue
<template>
<main class="app">
<p v-if="isLoading">載入中⋯</p>
<p v-else-if="error">出錯了:{{ error.message }}</p>
<article v-else-if="data">
<h2>{{ data.title }}</h2>
<p>庫存:{{ data.stock }}</p>
</article>
</main>
</template>每個要抓資料的元件都自己維護這三個 ref、自己在 try/catch/finally 裡切換它們,就是標準的「重複的邏輯」。抽。
useBookFetch 第一版
先立骨架:三個狀態、一個負責請求的 load()、觀察來源自動重抓,以及手動重抓的 refresh()。這一版的來源固定收一個 ref(例如目前選中的書籍 id),觀察它的 watch 正是第二章就熟悉的寫法:
js
import { ref, watch } from 'vue'
export function useBookFetch(bookId) { // bookId:一個 ref
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
async function load(id) {
// 心臟,下一節完成它
}
// ref 一變就重抓;immediate 讓第一次呼叫也發出請求
watch(bookId, (id) => load(id), { immediate: true })
function refresh() {
load(bookId.value)
}
return { data, error, isLoading, refresh }
}誰有權更新狀態
load() 的難點不在發請求,在競態。上一節的 cancelled 旗標只擋住寫入;這一版更進一步,用 AbortController 把舊請求真正取消掉。但光取消還不夠,被取消的請求依然會走完它的 catch 與 finally,要是它在那裡把 isLoading 關掉,或把「取消」當成錯誤寫進 error,畫面照樣出錯。
所以規則要訂得更嚴:只有最新一次請求,有權更新狀態。做法是把每一輪的 AbortController 當成身分證,寫入狀態前先驗明正身:
js
let controller = null // 永遠指向最新一輪請求的身分證
async function load(id) {
controller?.abort() // 舊請求直接取消
const current = new AbortController()
controller = current
isLoading.value = true
error.value = null
try {
const res = await fetch(`/api/books/${id}`, { signal: current.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`) // fetch 不會自己 reject,下面說明
const json = await res.json()
if (controller !== current) return // 身分證對不上:無權寫入
data.value = json
} catch (e) {
if (current.signal.aborted) return // 被取消的請求安靜退場,AbortError 不算錯誤
if (controller !== current) return
data.value = null
error.value = e
} finally {
if (controller === current) {
isLoading.value = false // 只有最新請求能把載入狀態關掉
}
}
}把「快速切換書籍」的時間線畫出來,就能看清楚這些防線各自擋住什麼:
請求 A 被取消後,它的 catch 與 finally 還是會執行,但兩道檢查讓它什麼都改不了:signal.aborted 擋下 AbortError,controller === current 確保 isLoading 只由請求 B 關閉。
refresh() 走同一條路
手動重抓不需要另一套邏輯。refresh() 就是拿目前的來源再呼叫一次 load(),而 load() 的第一行就會把還在跑的請求取消掉,身分證換發給新的一輪。連按三次「重新整理」,前兩次的請求會被第三次取消,狀態永遠只聽最新一次的。
自動重抓與手動重抓共用同一套取消與驗證,這是刻意的設計:防線只需要蓋一次,兩個入口都受保護。
HTTP 錯誤不會自己 reject
最後回頭解釋 load() 裡那行 if (!res.ok)。fetch() 有一個容易誤會的行為:伺服器回 404 或 500,Promise 照樣 resolve,只有網路層失敗(斷線、請求被取消)才會 reject。不主動檢查 res.ok,「找不到這本書」會被當成成功,data 拿到的是錯誤訊息的 JSON,error 卻是 null。
所以教學版把非 2xx 的回應主動 throw,讓它走進 catch,跟網路錯誤共用同一個 error 狀態。畫面只需要看三個 ref,不必知道錯誤是哪一層來的。
到這裡,useBookFetch 已經能安全地服務「選一本書、看一本書」的場景。至於表單送出後的等待、防止重複送出這類非同步狀態,情境不太一樣,留到第八章表單與資料流再處理。
不過它還有一個限制:來源只收 ref。如果呼叫端手上是一個固定的 id、或者想直接用 props 的欄位當來源呢?下一節設計可調適的 composable,我們讓同一套邏輯接得住更多情境。
