Vue 請求、快取與體驗優化
Vue 專案的性能不只取決於 bundle 和渲染。很多頁面慢,是因為 API 請求瀑布流、重複請求、快取策略不足、Loading 狀態粗糙,或是使用者操作必須等後端回應才有反饋。
一句話回答:Vue 請求、快取與體驗優化的核心,是減少不必要的等待、避免重複請求、讓必要資料更早準備,並在不可避免的等待中提供穩定且可信的 UI;常見手段包含 API 並行、取消請求、請求去重、前端快取、資料預取、骨架屏、分階段渲染、樂觀更新、HTTP cache 與 Service Worker。
一、 必考觀念
1. 請求慢不一定只能叫後端優化
後端 API 慢當然要處理,但前端也會讓等待變得更糟。
常見問題:
- 可以並行的 API 被寫成串行。
- 使用者切換條件時舊請求沒有取消。
- 相同資料在多個組件裡重複請求。
- 每次返回列表頁都重新拉資料。
- 首屏不重要的資料阻塞了主要內容。
- Loading 只剩一個轉圈圈,畫面長時間空白。
- 更新資料一定等 server 回來才改畫面。
所以前端要處理的是:
哪些請求可以少發?
哪些請求可以早發?
哪些請求可以並行?
哪些資料可以快取?
等待時畫面怎麼穩定?2. 把資料分優先級
不是所有資料都要阻塞首屏。
| 資料類型 | 策略 |
|---|---|
| 首屏核心資料 | 優先載入,必要時阻塞主要內容 |
| 首屏次要資料 | 先顯示骨架或占位,資料回來再補 |
| 非首屏資料 | 滾動、切 tab、進入視口附近再載 |
| 高機率下一步資料 | idle、hover、路由即將進入時預取 |
| 低頻重型資料 | 使用者操作後再載入 |
例如商品頁:
- 商品名稱、價格、主圖是核心資料。
- 推薦商品、評論、歷史紀錄可以延後。
- 同類商品列表可以 idle 後預取。
3. 體驗優化不等於假裝很快
骨架屏、Loading、樂觀更新都不是用來掩蓋問題,而是讓使用者在等待時仍然知道系統狀態。
好的體驗優化要做到:
- 不白屏。
- 不跳動。
- 不重複閃爍 Loading。
- 失敗時能恢復。
- 舊資料與新資料邊界清楚。
- 使用者操作有即時回饋。
如果資料其實很慢,UI 不能假裝成功;應該提供進度、重試、取消或暫存狀態。
二、 請求並行與瀑布流
1. 避免不必要的串行
不好的寫法:
const user = await fetchUser()
const permissions = await fetchPermissions()
const dashboard = await fetchDashboard()如果三個請求彼此沒有依賴,這會讓總時間變成三者相加。
可以改成並行:
const [user, permissions, dashboard] = await Promise.all([
fetchUser(),
fetchPermissions(),
fetchDashboard(),
])總時間接近最慢的那個請求。
2. 有依賴的請求才串行
如果後一個請求需要前一個結果,串行是合理的。
const user = await fetchUser()
const orders = await fetchOrders(user.id)但即使有部分依賴,也可以拆開:
const user = await fetchUser()
const [orders, recommendations] = await Promise.all([
fetchOrders(user.id),
fetchRecommendations(user.id),
])面試時可以說:不是所有請求都應該 Promise.all,而是要先看資料依賴關係。
3. 不要讓次要請求阻塞主要內容
例如首頁有主內容與推薦區:
const article = await fetchArticle(id)
renderArticle(article)
fetchRecommendations(id).then(data => {
recommendations.value = data
})核心內容先出來,次要內容稍後補上。這比等所有資料都回來才渲染更好。
延伸讀:Vue SPA 如何優化首屏載入速度。
三、 取消請求與避免競態
1. 為什麼要取消請求?
常見場景:
- 搜尋框連續輸入。
- 路由快速切換。
- 篩選條件連續變更。
- 元件卸載後請求才回來。
如果不處理,可能出現:
- 舊請求晚回來,覆蓋新資料。
- 已卸載組件仍嘗試更新狀態。
- 無效請求浪費頻寬。
- Loading 狀態錯亂。
2. 使用 AbortController
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。
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. 元件卸載時清理請求
const controller = new AbortController()
onMounted(async () => {
data.value = await fetchData({
signal: controller.signal,
})
})
onBeforeUnmount(() => {
controller.abort()
})如果請求生命週期和元件綁在一起,元件卸載時就該清理。
延伸讀:接口請求一般放在哪個生命週期?為什麼?。
四、 請求去重與快取
1. 相同請求不要重複發
如果多個組件同時需要同一份資料,可以做 in-flight 去重。
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
}使用:
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 思路
常見好體驗:
先顯示快取資料
-> 背景重新請求最新資料
-> 成功後更新畫面
-> 失敗時保留舊資料並提示簡化範例:
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. 列表頁返回詳情頁要避免重新白屏
常見場景:
列表頁
-> 點進詳情
-> 返回列表如果每次返回都清空列表再重新載入,體驗會很差。
可用策略:
- KeepAlive 保留列表頁狀態。
- Pinia 快取列表資料與滾動位置。
- memory cache 保存最近查詢結果。
- 返回時先顯示舊資料,再背景刷新。
延伸讀:深入 Vue KeepAlive:組件緩存與狀態保留。
五、 預取與預載
1. 預取要基於使用者路徑
適合預取:
- hover 菜單時預取下一頁 chunk 或資料。
- 使用者進入列表頁後預取第一批詳情資料。
- 表單最後一步前預取提交所需設定。
- 首屏空閒時預取高機率下一頁。
不適合:
- 一進站就預取所有路由。
- 預取大量低機率資料。
- 在慢網路下搶首屏頻寬。
2. 用 requestIdleCallback 延後低優先級工作
function runWhenIdle(callback: () => void) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(callback)
return
}
window.setTimeout(callback, 1)
}
runWhenIdle(() => {
prefetchNextPage()
})低優先級預取可以等瀏覽器空閒再做,避免干擾首屏。
3. 預取也要能取消或降級
預取不是越多越好。
需要考慮:
- 使用者是否在省流量模式。
- 網路是否慢。
- 裝置是否低階。
- 是否會影響核心請求。
- 預取資料是否容易過期。
資深回答要能說出:預取是性能策略,也是資源取捨。
六、 Loading、骨架屏與分階段渲染
1. Loading 要分層
不要整個頁面只有一個全屏 Loading。
可以拆成:
- 頁面初次載入。
- 區塊載入。
- 按鈕提交中。
- 表格局部刷新。
- 背景刷新。
例如:
<template>
<UserSkeleton v-if="initialLoading" />
<UserPanel
v-else
:user="user"
:refreshing="refreshing"
/>
</template>初次載入和背景刷新應該是不同 UI,不然每次刷新都整頁閃爍。
2. 骨架屏要接近真實布局
骨架屏不是隨便畫幾條灰線。
好的骨架屏:
- 尺寸接近真實內容。
- 保留圖片、標題、列表的大致位置。
- 資料回來後不造成大幅 CLS。
- 不要用在非常短的等待上造成閃爍。
如果 API 很快,骨架屏一閃而過反而干擾。可以設定最短顯示時間或延遲顯示。
3. 分階段渲染比等全部資料更好
主要內容先顯示
-> 次要內容補上
-> 推薦/評論/圖表延後例如文章詳情:
- 文章標題與正文先顯示。
- 作者資訊稍後補。
- 相關文章再延後。
- 評論進入視口再載入。
這能降低白屏時間,也讓使用者更快開始閱讀。
七、 樂觀更新與失敗回滾
1. 樂觀更新是什麼?
使用者操作後,先更新 UI,再等待 server 確認。
例如點讚:
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. 失敗回滾要保留原狀態
更嚴謹的寫法:
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 列表頁每次切回來都白屏重新載入,你怎麼優化?
可以這樣回答:
- 先確認是路由切換重新 mount,還是資料層每次清空。
- 如果列表狀態需要保留,可以使用 KeepAlive。
- 將列表資料、查詢條件與滾動位置放到 Pinia 或局部快取。
- 返回時先顯示快取資料,不要清空畫面。
- 背景重新請求最新資料,成功後更新。
- 對相同 query key 做請求去重,避免多組件重複請求。
- 若使用者快速切換篩選條件,取消舊請求或用 request id 防止覆蓋。
- 初次載入用骨架屏,背景刷新用較輕量的 refreshing 狀態。
- 對詳情頁可在 hover 或 idle 時預取。
- 優化後用 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 都適合長快取。