
用 Vue Router 的 Data Loader 解決 fetch 瀑布流問題
大家好,我是鱈魚。(。・∀・)ノ゙
會寫這篇其實是很久以前看到 Vue Router 新增 Data Loader。
由於專案幾乎都標配了 TanStack Query,慧根不足的我,實在是想不到有甚麼具體情境需要 Data Loader。(´・ω・`)
直到最近某次與 AI 討論時,AI 提到 fetch 瀑布流(Waterfall)問題,才讓我重新想到 Data Loader 並來實際實驗看看。
所謂的 fetch 瀑布流(Waterfall),就是指嵌套元件中的 API 請求需要一個個排隊,導致畫面跟著一塊塊載入的現象。
這樣說太抽象了,來看看具體範例。◝( •ω• )◟
什麼是 fetch 瀑布流?
以下是一個簡單的三層巢狀元件,需要的 ID 資料已存在網址上,UI 卻只能一個個載入。
你說這不就是設計不良?方便展示的範例啦,難保不會遇到這種情況嘛。(́⊙◞౪◟⊙‵)
<!-- PostPage.vue -->
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { useRoute } from 'vue-router'
const route = useRoute()
const postId = Number(route.params.id) // 從網址取得 postId
const { state: post } = useAsyncState(() => fetchPost(postId), undefined) // 需耗時 1 秒
</script>
<template>
<!-- 實務上往往為了顯示 Loading 畫面而使用外層 v-if -->
<div v-if="post">
<h1>{{ post.title }}</h1>
<!-- 等上層 1 秒才開始掛載 -->
<post-comment-list :post-id="postId" />
</div>
<div v-else>
載入中⋯
</div>
</template><!-- PostCommentList.vue -->
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
const props = defineProps<{ postId: number }>()
const { state: commentList } = useAsyncState(
() => fetchCommentList(props.postId), // 再等 1 秒
undefined,
)
</script>
<template>
<!-- 留言載入時,一樣用 v-if 處理狀態 -->
<ul v-if="commentList">
<li v-for="comment in commentList" :key="comment.id">
{{ comment.content }}
</li>
<!-- 一樣等上層 1 秒才開始掛載 -->
<post-related-list :post-id="postId" />
</ul>
</template><!-- PostRelatedList.vue -->
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
const props = defineProps<{ postId: number }>()
const { state: relatedList } = useAsyncState(
() => fetchRelatedPosts(props.postId), // 又等 1 秒
[],
)
</script>API 沒有順序耦合,可以同時發起,但只要子元件被 v-if(或 Suspense)擋住,父元件還沒拿到資料,子元件根本無從建立,瀑布流就這樣產生了:
User ████████░░░░░░░░░░░░░░░░ 1s
Post ░░░░░░░░████████░░░░░░░░ 1s
Comment ░░░░░░░░░░░░░░░░████████ 1s
總耗時:3s三個各 1 秒的 API,因為排隊累加,總共花了 3 秒。( ˘•ω•˘ )
常見的解法
最直覺的解法,就是全在最外層父元件用 Promise.all 一次打完:
const [user, postList, commentList] = await Promise.all([
fetchUser(1),
fetchPostList(1),
fetchCommentList(1),
])這雖然解決了瀑布流,卻會引發新問題:
- 資料與元件脫鉤:原本各元件自行抓資料,現在全堆在父元件。
- props 滿天飛:子元件不能自己要資料,只能仰賴父元件層層餵進來。
有沒有辦法既讓元件獨立抓自己的資料,又同時做到平行載入呢?
Vue Router 的 Data Loader
Data Loader 是 unplugin-vue-router 提供的功能,核心精神是讓「資料請求」與元件同檔共存,卻不受元件生命週期束縛,由路由在換頁前提早平行觸發。
如此一來,Loader 一樣寫在子元件檔案中,不需要拉到父元件,資料歸屬一目了然,元件獨立性完整保留。
Data Loader 詳細說明
由於這篇文章並非介紹 Data Loader,關於 Data Loader 具體用法與詳細說明,還請大家參考官方文件。
以下是宣告並呼叫 Loader 的簡易範例。
<!-- PostCommentList.vue -->
<script lang="ts">
import { defineBasicLoader } from 'unplugin-vue-router/data-loaders/basic'
// 1. 在子元件宣告 Loader。參數直接從路由 (to.params.id) 來,不用等父元件傳 props!
export const useCommentListLoader = defineBasicLoader('/posts/:id', async (to) => fetchCommentList(Number(to.params.id)))
</script>
<script setup lang="ts">
// 2. 拿資料囉!由於路由切換前早就提早打過 API,這裡的資料是「瞬間」備妥的!
const { data: commentList } = useCommentListLoader()
</script>
<template>
<ul>
<li v-for="comment in commentList" :key="comment.id">
{{ comment.content }}
</li>
<post-related-list />
</ul>
</template>這種做法不但維持「元件自管資料」的乾淨架構,多個 Loader 還會在跳轉瞬間一起幫你平行抓好資料:
Post ████████░░░░░░░░░░░░░░░░ 1s
Comment ████████░░░░░░░░░░░░░░░░ 1s
Related ████████░░░░░░░░░░░░░░░░ 1s
總耗時:1s同樣三支 API,從 3 秒縮短到 1 秒。=͟͟͞͞( •̀д•́)
defineBasicLoader 為什麼要放在非 setup 的 <script> 中?
因為 loader 必須寫在模組層級 (module level),路由器才有辦法在「切換頁面(元件建立前)」階段,掃描到它並提前執行。
<script setup> 裡頭的程式碼屬於元件層級,一定要進到頁面、建立元件時才會跑,這就白白錯失了「換頁前預先平行抓取資料」的最佳時機。
Data Loader 如何處理資料依賴?
你可能會想:「如果 B API 必須使用 A API 抓回來的資料,也就是 API 之間有順序耦合呢?改用 Data Loader 就能平行嗎?」
有時依賴確實避不掉,就像身上的油怎麼樣都甩不掉。(╥ω╥`)
但 Loader 有趣的部分是它發生於切換頁面之前(導航階段),因此你能直接在 Loader 裡串聯等待:
export const useDashboardLoader = defineBasicLoader('/dashboard', async () => {
const user = await fetchMe()
// 等待 fetchMe 回傳後再去抓文章
const posts = await fetchPostList(user.id)
return { user, posts }
})雖然從網路看依舊是瀑布流,但優勢在於所有等待都集中在換頁前的「最頂層全局進度條」,等畫面真正切換時資料已備齊,大幅減少版面跳動(Layout Shift)問題。
不過 Basic Loader 每次導航都會重新執行,沒有跨導航的快取機制。
需要跨導航快取?試試 Colada Loader
Colada Loader 基於 @pinia/colada,除了保有平行載入的能力,還額外提供:
- 快取:透過
staleTime控制資料新鮮度,期限內不重發請求 - 去重複:同一時間多處呼叫相同 Loader,只會發出一次請求
import { defineColadaLoader } from 'unplugin-vue-router/data-loaders/pinia-colada'
export const useMeLoader = defineColadaLoader('/dashboard', {
key: () => ['me'],
async query() {
return fetchMe()
},
staleTime: 1000 * 60 * 5, // 5 分鐘內不重新請求
})即使多個 Loader 都需要 fetchMe,也只會發出一次請求,其餘直接拿快取,用過 TanStack Query 的朋友應該會覺得很熟悉。
Colada Loader 也支援 SSR,在 Nuxt 上可以保證 Hydration 不會觸發額外的 fetch 操作。
甚麼?你說叫後端濃縮成一支 API?成功的話,還請分享一下你抓到他什麼把柄。(́⊙◞౪◟⊙‵)
總結 🐟
- 瀑布流是嵌套元件中的 API 請求需要一個個排隊,導致畫面跟著一塊塊載入的現象。
- 在最外層使用
Promise.all確實能解決,但資料邏輯卻死綁在父元件,犧牲元件獨立性。 - Data Loader 讓載入動作跟著元件走,由路由器提早平行執行,兼顧效能與程式碼乾淨度。
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。
有錯誤或任何想法還請多多指教。( ´∀`)~♥