iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Modern Web

Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站系列 第 25

Day 25 Vue 非同步資料 – fetch/axios 串接、Loading、Error、快取

  • 分享至 

  • xImage
  •  

今日目標

  • fetch(或 axios)載入 projects.json(可替代為 API)
  • 在列表與詳情頁加入 Loading / Error 狀態
  • 實作 簡易快取(多頁切換不重抓)
  • (可選)在搜尋輸入加入 debounce

基礎觀念(白話)

  • Vue 的狀態建議集中在 Composable(組合式函式)或 Pinia 中管理。
  • 載資料 =「三態」:loading / data / error。畫面要能清楚表達這三種情況。
  • 列表/詳情都會用到 Projects → 做成可重用的 useProjects()

1) 準備資料來源

A. 本地 JSON(先用這個)

/public/projects.json(或放 CDN / 你的 API):

[
  {
    "id": 1,
    "slug": "maomao-shop",
    "title": "毛毛購物(寵物電商)",
    "tech": "Vue + Node.js|購物車、結帳、RWD",
    "desc": "主導前端架構,完成商品列表、購物流程與訂單頁。",
    "tags": ["vue", "node", "rwd", "ecommerce"],
    "images": ["/assets/projects/maomao-1.png"],
    "demo": "#",
    "repo": "#",
    "featured": true},
  {
    "id": 2,
    "slug": "line-bot-reservation",
    "title": "LINE Bot 預約系統",
    "tech": "Cloud Functions + LINE API|時段預約",
    "desc": "整合 LINE 聊天介面與雲端排程,完成會員預約流程。",
    "tags": ["line", "gcp", "serverless"],
    "images": ["/assets/projects/line-1.png"],
    "demo": "#",
    "repo": "#",
    "featured": false}
]

放在 public/ 好處是以 相對路徑 fetch('/projects.json') 即可;部署時也能直接隨站上傳。


2) 建立 Composable:useProjects()

src/composables/useProjects.ts

import { ref, computed } from 'vue'

export type Project = {
  id: number
  slug: string
  title: string
  tech: string
  desc: string
  tags: string[]
  images: string[]
  demo: string
  repo: string
  featured: boolean
}

const cache = ref<Project[] | null>(null)       // 簡易快取
const loaded = ref(false)
const loading = ref(false)
const error = ref<Error | null>(null)
const lastFetchedAt = ref<number | null>(null)

export function useProjects() {
  async function fetchAll(force = false) {
    if (cache.value && !force) return cache.value
    loading.value = true
    error.value = null
    try {
      const res = await fetch('/projects.json', { cache: 'no-cache' })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      const data = (await res.json()) as Project[]
      cache.value = data
      loaded.value = true
      lastFetchedAt.value = Date.now()
      return data
    } catch (err: any) {
      error.value = err
      cache.value = null
      throw err
    } finally {
      loading.value = false
    }
  }

  function getBySlug(slug: string) {
    return computed(() => cache.value?.find(p => p.slug === slug))
  }

  const stats = computed(() => ({
    count: cache.value?.length ?? 0,
    lastFetchedAt: lastFetchedAt.value
  }))

  return {
    // state
    projects: cache,
    loaded, loading, error, stats,
    // actions
    fetchAll, getBySlug
  }
}

之後要改成打真 API,只要把 fetch('/projects.json') 改成你的後端 URL 即可;如需權限/錯誤處理,在這層集中處理。


3) 列表頁(Projects.vue)改用非同步

src/components/Projects.vue

<template>
  <section id="projects" class="container section" aria-labelledby="projects-title">
    <h2 id="projects-title">作品集 Projects</h2>

    <div class="field" style="margin:12px 0;">
      <label>
        <input type="checkbox" v-model="onlyFeatured" />
        只看精選
      </label>
      <input
        type="text"
        v-model.trim="keyword"
        @input="onKeywordInput"
        placeholder="搜尋關鍵字(e.g. Vue, Node)"
        style="margin-left:12px; max-width:260px;"
      />
      <small class="muted" style="margin-left:8px;"
        >結果:{{ view.length }} / {{ stats.count }}</small>
    </div>

    <p v-if="loading">載入中...</p>
    <p v-else-if="error" class="error">載入失敗,請稍後再試。</p>

    <div v-else class="project-grid">
      <article class="card" v-for="p in view" :key="p.id">
        <h3>{{ p.title }}</h3>
        <p class="muted">{{ p.tech }}</p>
        <p>{{ p.desc }}</p>
        <div style="display:flex; gap:8px; margin-top:8px;">
          <RouterLink class="btn small" :to="{ name:'project-detail', params:{ slug: p.slug } }">
            查看詳情
          </RouterLink>
          <a class="btn small btn-outline" :href="p.repo" target="_blank" rel="noopener">GitHub</a>
        </div>
      </article>
    </div>
  </section>
</template>

<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useProjects } from '@/composables/useProjects'

const { projects, fetchAll, loading, error, stats } = useProjects()

// UI 狀態
const onlyFeatured = ref(false)
const keyword = ref('')

// 簡易 debounce(300ms)
let t: number | undefined
function onKeywordInput() {
  window.clearTimeout(t)
  t = window.setTimeout(() => {
    kw.value = keyword.value.toLowerCase()
  }, 300)
}
const kw = ref('')

// 導出給 template 的 view
const view = computed(() => {
  const list = projects.value ?? []
  return list.filter(p => {
    const f1 = !onlyFeatured.value || p.featured
    const f2 = !kw.value || p.title.toLowerCase().includes(kw.value) || p.tech.toLowerCase().includes(kw.value) || p.tags.some(t => t.toLowerCase().includes(kw.value))
    return f1 && f2
  })
})

onMounted(() => { fetchAll().catch(() => {}) })
</script>


4) 詳情頁改成讀 Composable(若未載入會先抓)

src/views/ProjectDetail.vue

<template>
  <section class="container section">
    <nav style="margin-bottom:12px;">
      <RouterLink to="/" class="btn btn-outline">← 返回列表</RouterLink>
    </nav>

    <p v-if="loading">載入中...</p>
    <p v-else-if="error" class="error">讀取失敗,請返回列表。</p>

    <template v-else>
      <section v-if="project">
        <h2>{{ project.title }}</h2>
        <p class="muted">{{ project.tech }}</p>

        <div class="gallery" v-if="project.images?.length" style="display:flex; gap:12px; flex-wrap:wrap; margin:12px 0;">
          <img v-for="src in project.images" :key="src" :src="src" alt="專案截圖" width="360" />
        </div>

        <p>{{ project.desc }}</p>

        <div style="display:flex; gap:8px; margin-top:8px;">
          <a class="btn" :href="project.demo" target="_blank" rel="noopener">Live Demo</a>
          <a class="btn btn-outline" :href="project.repo" target="_blank" rel="noopener">GitHub</a>
        </div>
      </section>

      <section v-else>
        <h2>找不到這個專案</h2>
        <p class="muted">請回到列表,或確認網址是否正確。</p>
        <RouterLink to="/" class="btn">返回列表</RouterLink>
      </section>
    </template>
  </section>
</template>

<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useProjects } from '@/composables/useProjects'

const route = useRoute()
const slug = computed(() => String(route.params.slug || ''))

const { fetchAll, getBySlug, loading, error } = useProjects()
const projectRef = getBySlug(slug.value)
const project = computed(() => projectRef.value)

onMounted(async () => {
  // 若還沒載過,就先抓一次
  if (!project.value) {
    try { await fetchAll() } catch {}
  }
})
</script>


5) (可選)改用 axios 與攔截器

如果你偏好 axios(方便設 header / 攔截器 / 重試):

npm i axios

src/lib/http.ts

import axios from 'axios'

export const http = axios.create({
  baseURL: '/', // 你的 API base
  timeout: 8000
})

http.interceptors.response.use(
  res => res,
  err => {
    // 可在這裡統一處理 401/500、上報、顯示錯誤 toast
    return Promise.reject(err)
  }
)

useProjects() 中改:

// const res = await fetch('/projects.json')
const { data } = await http.get<Project[]>('/projects.json')
cache.value = data


成果檢查清單

  • / 首次進入會看到「載入中…」,之後顯示卡片清單
  • 勾「只看精選」、輸入關鍵字 → 清單即時(有 300ms debounce)過濾
  • 進入 /projects/:slug,若未載入會自動抓;找不到 slug 顯示提示
  • 回到列表再進入其他詳情頁,不會重抓(因為在 Composable 快取)

小心踩雷(常見誤用 → 正確)

  1. 在多個元件各自 fetch
    • ❌ 每頁都打一次 API,浪費頻寬
    • ✅ 把抓資料搬進 Composable/Store,集中快取
  2. 不處理錯誤
    • await fetch() 失敗 → 畫面空白
    • ✅ 在畫面顯示錯誤狀態,並記錄 log
  3. 沒有 Loading 提示
    • ❌ 使用者不知道發生什麼事
    • ✅ 顯示「載入中」,並在完成/失敗後消失
  4. debounce 放在 computed
    • computed(debounce(...)) 容易踩怪
    • ✅ 對「輸入事件」做 debounce,再更新關聯 state

下一步(Day 26 預告)

  • 導入 Pinia 做狀態集中管理(取代 Composable 或作為底層)
  • 把「主題切換、技能篩選、關鍵字、專案列表快取」統一放到 store
  • 加上 持久化(localStorage),跨頁與重整後仍保留狀態

上一篇
Day 24 表單與驗證(Vue 版)— v-model + 自訂驗證 + Toast 成功提示
系列文
Angular、React、Vue 三框架實戰養成:從零打造高品質前端履歷網站25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言