Skip to content

solve-fetch-waterfall-with-data-loader

用 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 卻只能一個個載入。

你說這不就是設計不良?方便展示的範例啦,難保不會遇到這種情況嘛。(́⊙◞౪◟⊙‵)

vue
<!-- 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>
vue
<!-- 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>
vue
<!-- 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 一次打完:

ts
const [user, postList, commentList] = await Promise.all([
  fetchUser(1),
  fetchPostList(1),
  fetchCommentList(1),
])

這雖然解決了瀑布流,卻會引發新問題:

  • 資料與元件脫鉤:原本各元件自行抓資料,現在全堆在父元件。
  • props 滿天飛:子元件不能自己要資料,只能仰賴父元件層層餵進來。

有沒有辦法既讓元件獨立抓自己的資料,又同時做到平行載入呢?

Vue Router 的 Data Loader

Data Loaderunplugin-vue-router 提供的功能,核心精神是讓「資料請求」與元件同檔共存,卻不受元件生命週期束縛,由路由在換頁前提早平行觸發

如此一來,Loader 一樣寫在子元件檔案中,不需要拉到父元件,資料歸屬一目了然,元件獨立性完整保留。

Data Loader 詳細說明

由於這篇文章並非介紹 Data Loader,關於 Data Loader 具體用法與詳細說明,還請大家參考官方文件

以下是宣告並呼叫 Loader 的簡易範例。

vue
<!-- 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 裡串聯等待:

ts
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,只會發出一次請求
ts
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 讓載入動作跟著元件走,由路由器提早平行執行,兼顧效能與程式碼乾淨度。

感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。

有錯誤或任何想法還請多多指教。( ´∀`)~♥