iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 22 篇

Vue 走過路過不要錯過 Day22 - 串接 API:loading、error、資料三種狀態怎麼管

  • 分享至 

  • xImage
  •  

打 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 各寫一次。看起來很完整,但它有三個問題,我們一個一個看。

坑一:fetch 遇到 404 不會進 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,處理錯誤的地方只需要一個。

先抽成 useFetch

接下來的問題都跟「重打」有關,先照 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 要不要清,就是設計上的選擇了:

  • 清掉:切換條件時畫面會先回到載入中。不會出現「標題寫使用者 2,列表卻還是使用者 1 的文章」這種錯位,最安全。
  • 保留舊資料:重新整理同一份資料時,畫面不會閃一下,可以在舊資料上蓋一層半透明的 loading。
    這篇用「清掉」的做法,因為下一段要讓 URL 可以變動,換條件時保留舊資料反而容易誤導使用者。

三個變數會組出不可能的狀態

回頭看 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 加入)把它們統一取出值:

  • 傳 ref → 取 .value
  • 傳 getter 函式 → 呼叫它,拿回傳值
  • 傳普通字串 → 原樣回傳
    再用 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 的回應比較慢,就會發生這種事:

  1. 發出請求 A(userId=1)
  2. 發出請求 B(userId=2)
  3. B 先回來,data = 使用者 2 的文章 ✅
  4. A 後回來,data = 使用者 1 的文章 ❌
    下拉選單顯示使用者 2,列表卻是使用者 1 的文章。我用假 server 把 userId=1 的回應延遲 300ms、userId=2 延遲 50ms,結果跟上面描述的一模一樣,最後留在畫面上的是使用者 1 的資料。

這種情況叫做 race condition(競態條件)。網路回應的順序不一定等於送出的順序,你沒辦法控制哪一個先回來。

用 AbortController 取消舊請求

解法是:發新請求之前,把還在路上的舊請求取消掉。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' 把它排除。

finally 在這裡反而會出事

上面這段已經解決了資料錯位,但我測試時發現另一個問題:

切換到 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',畫面也就正確地停留在載入中。

完整的 useFetch

把上面的修正整合起來,再加上兩個實用的功能:

  • 重試:「重試」按鈕會呼叫 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 的價值,在於搞清楚這些狀態是怎麼來的。實際專案中,通常會直接使用現成的工具:

  • VueUse 的 useFetch:用法跟這篇的版本很接近,還多了自動中斷、攔截器、逾時等功能。
  • TanStack Query(Vue Query):如果需要快取、多個組件共用同一份資料、背景自動重新整理,自己寫的版本就不夠用了,交給它比較實在。
    不過不管用哪一個,它們回傳的東西都長得差不多:data、error、isLoading 或 status。讀懂這篇之後,再去看它們的文件就會很快上手。

上一篇
Vue 走過路過不要錯過 Day21 - Composables:把重複邏輯抽成 useXxx
系列文
重新認識Vue 走過路過不要錯過 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言