跳至主要內容
Skip to content

Vue 請求、快取與體驗優化

Vue 專案的性能不只取決於 bundle 和渲染。很多頁面慢,是因為 API 請求瀑布流、重複請求、快取策略不足、Loading 狀態粗糙,或是使用者操作必須等後端回應才有反饋。

一句話回答:Vue 請求、快取與體驗優化的核心,是減少不必要的等待、避免重複請求、讓必要資料更早準備,並在不可避免的等待中提供穩定且可信的 UI;常見手段包含 API 並行、取消請求、請求去重、前端快取、資料預取、骨架屏、分階段渲染、樂觀更新、HTTP cache 與 Service Worker。


一、 必考觀念

1. 請求慢不一定只能叫後端優化

後端 API 慢當然要處理,但前端也會讓等待變得更糟。

常見問題:

  • 可以並行的 API 被寫成串行。
  • 使用者切換條件時舊請求沒有取消。
  • 相同資料在多個組件裡重複請求。
  • 每次返回列表頁都重新拉資料。
  • 首屏不重要的資料阻塞了主要內容。
  • Loading 只剩一個轉圈圈,畫面長時間空白。
  • 更新資料一定等 server 回來才改畫面。

所以前端要處理的是:

text
哪些請求可以少發?
哪些請求可以早發?
哪些請求可以並行?
哪些資料可以快取?
等待時畫面怎麼穩定?

2. 把資料分優先級

不是所有資料都要阻塞首屏。

資料類型策略
首屏核心資料優先載入,必要時阻塞主要內容
首屏次要資料先顯示骨架或占位,資料回來再補
非首屏資料滾動、切 tab、進入視口附近再載
高機率下一步資料idle、hover、路由即將進入時預取
低頻重型資料使用者操作後再載入

例如商品頁:

  • 商品名稱、價格、主圖是核心資料。
  • 推薦商品、評論、歷史紀錄可以延後。
  • 同類商品列表可以 idle 後預取。

3. 體驗優化不等於假裝很快

骨架屏、Loading、樂觀更新都不是用來掩蓋問題,而是讓使用者在等待時仍然知道系統狀態。

好的體驗優化要做到:

  • 不白屏。
  • 不跳動。
  • 不重複閃爍 Loading。
  • 失敗時能恢復。
  • 舊資料與新資料邊界清楚。
  • 使用者操作有即時回饋。

如果資料其實很慢,UI 不能假裝成功;應該提供進度、重試、取消或暫存狀態。


二、 請求並行與瀑布流

1. 避免不必要的串行

不好的寫法:

typescript
const user = await fetchUser()
const permissions = await fetchPermissions()
const dashboard = await fetchDashboard()

如果三個請求彼此沒有依賴,這會讓總時間變成三者相加。

可以改成並行:

typescript
const [user, permissions, dashboard] = await Promise.all([
  fetchUser(),
  fetchPermissions(),
  fetchDashboard(),
])

總時間接近最慢的那個請求。

2. 有依賴的請求才串行

如果後一個請求需要前一個結果,串行是合理的。

typescript
const user = await fetchUser()
const orders = await fetchOrders(user.id)

但即使有部分依賴,也可以拆開:

typescript
const user = await fetchUser()

const [orders, recommendations] = await Promise.all([
  fetchOrders(user.id),
  fetchRecommendations(user.id),
])

面試時可以說:不是所有請求都應該 Promise.all,而是要先看資料依賴關係。

3. 不要讓次要請求阻塞主要內容

例如首頁有主內容與推薦區:

typescript
const article = await fetchArticle(id)
renderArticle(article)

fetchRecommendations(id).then(data => {
  recommendations.value = data
})

核心內容先出來,次要內容稍後補上。這比等所有資料都回來才渲染更好。

延伸讀:Vue SPA 如何優化首屏載入速度


三、 取消請求與避免競態

1. 為什麼要取消請求?

常見場景:

  • 搜尋框連續輸入。
  • 路由快速切換。
  • 篩選條件連續變更。
  • 元件卸載後請求才回來。

如果不處理,可能出現:

  • 舊請求晚回來,覆蓋新資料。
  • 已卸載組件仍嘗試更新狀態。
  • 無效請求浪費頻寬。
  • Loading 狀態錯亂。

2. 使用 AbortController

typescript
const keyword = ref('')
const results = ref<SearchResult[]>([])

watch(keyword, async (value, _, onCleanup) => {
  const controller = new AbortController()

  onCleanup(() => {
    controller.abort()
  })

  const response = await fetch(`/api/search?q=${value}`, {
    signal: controller.signal,
  })

  results.value = await response.json()
})

當 keyword 再次變化時,上一個 watcher 的 cleanup 會執行,舊請求會被取消。

延伸讀:watch 與 computed 的區別

3. 使用 request id 避免舊資料覆蓋

有些 API 或封裝層不方便取消,也可以用 request id。

typescript
let requestId = 0

async function loadUser(id: string) {
  const currentId = ++requestId

  loading.value = true

  try {
    const data = await fetchUser(id)

    if (currentId !== requestId) {
      return
    }

    user.value = data
  } finally {
    if (currentId === requestId) {
      loading.value = false
    }
  }
}

這能避免舊請求晚回來覆蓋新狀態。

4. 元件卸載時清理請求

typescript
const controller = new AbortController()

onMounted(async () => {
  data.value = await fetchData({
    signal: controller.signal,
  })
})

onBeforeUnmount(() => {
  controller.abort()
})

如果請求生命週期和元件綁在一起,元件卸載時就該清理。

延伸讀:接口請求一般放在哪個生命週期?為什麼?


四、 請求去重與快取

1. 相同請求不要重複發

如果多個組件同時需要同一份資料,可以做 in-flight 去重。

typescript
const pending = new Map<string, Promise<unknown>>()

function fetchOnce<T>(key: string, request: () => Promise<T>) {
  if (pending.has(key)) {
    return pending.get(key) as Promise<T>
  }

  const promise = request().finally(() => {
    pending.delete(key)
  })

  pending.set(key, promise)

  return promise
}

使用:

typescript
const user = await fetchOnce(`user:${id}`, () => fetchUser(id))

這樣同一時間內相同 key 的請求只會發一次。

2. 快取要有失效策略

快取不只是把資料存起來,還要知道什麼時候不能用了。

常見策略:

策略適合場景
memory cache單次頁面會話、列表返回
Pinia store cache跨組件共享資料
localStorage小量、低敏感、可序列化資料
IndexedDB大量結構化資料、離線資料
HTTP cache靜態或可快取 API
Service Worker cache離線與重訪問體驗

每個快取都要考慮:

  • key 怎麼設計?
  • TTL 多久?
  • 什麼操作要 invalidate?
  • 失敗時是否回退舊資料?
  • 使用者是否需要看到資料過期狀態?

3. stale-while-revalidate 思路

常見好體驗:

text
先顯示快取資料
-> 背景重新請求最新資料
-> 成功後更新畫面
-> 失敗時保留舊資料並提示

簡化範例:

typescript
const cache = new Map<string, User[]>()

async function loadUsers() {
  const key = 'users:list'

  if (cache.has(key)) {
    users.value = cache.get(key)!
  }

  const fresh = await fetchUsers()
  cache.set(key, fresh)
  users.value = fresh
}

這種方式能讓返回頁面時很快看到舊資料,同時保持資料會更新。

4. 列表頁返回詳情頁要避免重新白屏

常見場景:

text
列表頁
-> 點進詳情
-> 返回列表

如果每次返回都清空列表再重新載入,體驗會很差。

可用策略:

  • KeepAlive 保留列表頁狀態。
  • Pinia 快取列表資料與滾動位置。
  • memory cache 保存最近查詢結果。
  • 返回時先顯示舊資料,再背景刷新。

延伸讀:深入 Vue KeepAlive:組件緩存與狀態保留


五、 預取與預載

1. 預取要基於使用者路徑

適合預取:

  • hover 菜單時預取下一頁 chunk 或資料。
  • 使用者進入列表頁後預取第一批詳情資料。
  • 表單最後一步前預取提交所需設定。
  • 首屏空閒時預取高機率下一頁。

不適合:

  • 一進站就預取所有路由。
  • 預取大量低機率資料。
  • 在慢網路下搶首屏頻寬。

2. 用 requestIdleCallback 延後低優先級工作

typescript
function runWhenIdle(callback: () => void) {
  if ('requestIdleCallback' in window) {
    window.requestIdleCallback(callback)
    return
  }

  window.setTimeout(callback, 1)
}

runWhenIdle(() => {
  prefetchNextPage()
})

低優先級預取可以等瀏覽器空閒再做,避免干擾首屏。

3. 預取也要能取消或降級

預取不是越多越好。

需要考慮:

  • 使用者是否在省流量模式。
  • 網路是否慢。
  • 裝置是否低階。
  • 是否會影響核心請求。
  • 預取資料是否容易過期。

資深回答要能說出:預取是性能策略,也是資源取捨。


六、 Loading、骨架屏與分階段渲染

1. Loading 要分層

不要整個頁面只有一個全屏 Loading。

可以拆成:

  • 頁面初次載入。
  • 區塊載入。
  • 按鈕提交中。
  • 表格局部刷新。
  • 背景刷新。

例如:

vue
<template>
  <UserSkeleton v-if="initialLoading" />

  <UserPanel
    v-else
    :user="user"
    :refreshing="refreshing"
  />
</template>

初次載入和背景刷新應該是不同 UI,不然每次刷新都整頁閃爍。

2. 骨架屏要接近真實布局

骨架屏不是隨便畫幾條灰線。

好的骨架屏:

  • 尺寸接近真實內容。
  • 保留圖片、標題、列表的大致位置。
  • 資料回來後不造成大幅 CLS。
  • 不要用在非常短的等待上造成閃爍。

如果 API 很快,骨架屏一閃而過反而干擾。可以設定最短顯示時間或延遲顯示。

3. 分階段渲染比等全部資料更好

text
主要內容先顯示
-> 次要內容補上
-> 推薦/評論/圖表延後

例如文章詳情:

  • 文章標題與正文先顯示。
  • 作者資訊稍後補。
  • 相關文章再延後。
  • 評論進入視口再載入。

這能降低白屏時間,也讓使用者更快開始閱讀。


七、 樂觀更新與失敗回滾

1. 樂觀更新是什麼?

使用者操作後,先更新 UI,再等待 server 確認。

例如點讚:

typescript
async function likePost(post: Post) {
  post.liked = true
  post.likeCount++

  try {
    await likePostApi(post.id)
  } catch (error) {
    post.liked = false
    post.likeCount--
    showToast('操作失敗,已還原')
  }
}

好處是反饋快。

風險是失敗時要能回滾。

2. 適合樂觀更新的場景

適合:

  • 點讚。
  • 收藏。
  • 勾選。
  • 排序。
  • 小型偏好設定。
  • 成功率高、衝突少的操作。

不適合:

  • 金流。
  • 庫存。
  • 權限。
  • 高衝突協作資料。
  • 成功與否非常關鍵的操作。

樂觀更新不是所有地方都能用。

3. 失敗回滾要保留原狀態

更嚴謹的寫法:

typescript
async function updateName(user: User, name: string) {
  const previousName = user.name

  user.name = name

  try {
    await updateUserName(user.id, name)
  } catch (error) {
    user.name = previousName
    showToast('更新失敗,已還原')
  }
}

如果操作很複雜,要保存 patch 或快照,才能可靠回滾。


八、 PWA 與 Service Worker

1. Service Worker 能做什麼?

Service Worker 可以攔截網路請求,實現:

  • 靜態資源快取。
  • 離線頁面。
  • API 快取。
  • stale-while-revalidate。
  • 背景同步。
  • Push notification。

在 Vue 專案中,常見是搭配 PWA 插件或框架能力管理。

2. 哪些資料適合 Service Worker 快取?

適合:

  • 靜態資源。
  • 文件頁。
  • 不常變的公開資料。
  • 圖片與字體。
  • 可離線閱讀內容。

不適合或需謹慎:

  • 個人敏感資料。
  • 權限資料。
  • 即時庫存。
  • 金流資訊。
  • 高一致性 API。

3. PWA 不是所有專案都需要

PWA 適合:

  • 離線閱讀。
  • 弱網路場景。
  • 高重訪問率工具。
  • 行動裝置體驗重要的產品。

如果是一般後台管理系統,可能只需要 HTTP cache、前端 memory cache 與良好 Loading,不一定要上完整 PWA。


九、 追問題庫

Q1:API 請求慢,你會怎麼優化?

先分辨是後端慢、網路慢,還是前端請求策略不合理。

前端可以做:

  • 並行無依賴請求。
  • 取消舊請求。
  • 請求去重。
  • 快取穩定資料。
  • 預取高機率資料。
  • 首屏核心資料優先。
  • 次要資料延後。
  • Loading、骨架屏、分階段渲染改善體驗。

Q2:快取會不會造成資料不一致?

會,所以快取一定要有失效策略。

常見做法:

  • TTL。
  • mutation 後 invalidate。
  • stale-while-revalidate。
  • 手動刷新。
  • 根據 query key 管理快取。
  • 對高一致性資料少用或不用前端快取。

Q3:骨架屏算性能優化嗎?

算感知性能優化。

它不一定讓 API 更快,但能避免白屏,讓使用者知道頁面正在載入。真正的性能仍然要靠請求、快取、分包與渲染策略一起處理。

Q4:樂觀更新一定好嗎?

不一定。

樂觀更新適合成功率高、失敗可回滾、衝突少的操作。不適合金流、庫存、權限這類需要強一致性的場景。

Q5:Service Worker 快取 API 要注意什麼?

要注意資料敏感性、一致性、快取失效、版本更新與登出清理。

不要把個人敏感資料或高一致性資料隨便放進長期快取。


十、 實作題

題目:一個 Vue 列表頁每次切回來都白屏重新載入,你怎麼優化?

可以這樣回答:

  1. 先確認是路由切換重新 mount,還是資料層每次清空。
  2. 如果列表狀態需要保留,可以使用 KeepAlive。
  3. 將列表資料、查詢條件與滾動位置放到 Pinia 或局部快取。
  4. 返回時先顯示快取資料,不要清空畫面。
  5. 背景重新請求最新資料,成功後更新。
  6. 對相同 query key 做請求去重,避免多組件重複請求。
  7. 若使用者快速切換篩選條件,取消舊請求或用 request id 防止覆蓋。
  8. 初次載入用骨架屏,背景刷新用較輕量的 refreshing 狀態。
  9. 對詳情頁可在 hover 或 idle 時預取。
  10. 優化後用 Network 和 Performance 確認請求數、白屏時間與互動延遲。

這題的重點是:不要只說「加快 API」,而是要同時處理路由狀態、資料快取、請求生命週期與 UI 反饋。


十一、 資深視角

1. 快取策略要配合資料一致性

不同資料需要不同策略:

資料策略
使用者權限謹慎快取,登出或角色變更要清理
商品庫存低 TTL 或不做前端長快取
文章內容可長快取或 stale-while-revalidate
列表查詢query key 快取,mutation 後 invalidate
靜態設定可 memory cache 或 localStorage

資深回答要能說:不是所有資料都適合一種快取策略。

2. 體驗優化要誠實

不要用 Loading 或假資料掩蓋真實狀態。

好的體驗是:

  • 讓使用者知道正在做什麼。
  • 操作成功或失敗都有回饋。
  • 失敗能重試或回滾。
  • 舊資料與新資料狀態清楚。
  • 不讓畫面頻繁閃爍。

3. 團隊可以抽出資料請求層

成熟專案通常會把請求能力收斂:

  • API client。
  • 錯誤處理。
  • loading/error 狀態。
  • cancel token / AbortController。
  • query key。
  • 快取與失效。
  • retry。
  • toast / modal 回饋。

這不一定要自己手寫完整框架,也可以選擇現成資料請求庫。重點是不要讓每個頁面各自發明一套 loading、cache、retry。


總結

問題回答重點
請求怎麼優化並行、取消、去重、核心資料優先、次要資料延後
快取怎麼設計memory、store、HTTP、IndexedDB、Service Worker,並搭配失效策略
預取怎麼用基於使用者路徑,避免搶首屏頻寬
Loading 怎麼做初次載入、局部載入、背景刷新要分層
樂觀更新適合什麼成功率高、可回滾、低衝突操作
PWA 適合什麼離線、弱網路、高重訪問率場景

一句完整的面試回答可以是:

Vue 請求、快取與體驗優化我會先看請求瀑布圖,判斷是否有不必要串行、重複請求或次要資料阻塞首屏。無依賴 API 可以並行,快速切換條件時用 AbortController 或 request id 避免舊請求覆蓋新資料。對穩定資料可以做 memory cache、Pinia cache 或 HTTP cache,返回列表頁時先顯示舊資料,再背景刷新。高機率下一步資料可以在 hover、idle 或路由即將進入時預取,但不要搶首屏頻寬。UI 上初次載入用骨架屏,局部刷新用較輕量狀態,成功率高且可回滾的操作可以用樂觀更新。快取策略要根據資料一致性設計,不是所有 API 都適合長快取。

延伸閱讀