Skip to content

封裝非同步資料

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

上一節的最小示範只做到「作廢的請求不准寫入」,請求本身還在網路上跑,載入中與失敗的畫面也還沒著落。這一節我們把這些一次封裝好,做出一個可以在整個專案重用的 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、自己在 trycatchfinally 裡切換它們,就是標準的「重複的邏輯」。抽。

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 把舊請求真正取消掉。但光取消還不夠,被取消的請求依然會走完它的 catchfinally,要是它在那裡把 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 被取消後,它的 catchfinally 還是會執行,但兩道檢查讓它什麼都改不了:signal.aborted 擋下 AbortErrorcontroller === 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,我們讓同一套邏輯接得住更多情境。