---
url: /blog-vue/solve-fetch-waterfall-with-data-loader.md
description: 元件層層嵌套，API 一個等一個？來看看 Vue Data Loader 怎麼解決 fetch 瀑布流問題。(ﾉ>ω<)ﾉ
---

![solve-fetch-waterfall-with-data-loader](/solve-fetch-waterfall-with-data-loader.webp){.cover}

# 用 Vue Router 的 Data Loader 解決 fetch 瀑布流問題

大家好，我是鱈魚。(｡･∀･)ﾉﾞ

會寫這篇其實是很久以前看到 Vue Router 新增 [Data Loader](https://router.vuejs.org/data-loaders/)。

由於專案幾乎都標配了 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 Loader](https://router.vuejs.org/data-loaders/) 是 `unplugin-vue-router` 提供的功能，核心精神是**讓「資料請求」與元件同檔共存，卻不受元件生命週期束縛，由路由在換頁前提早平行觸發**。

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

::: tip Data Loader 詳細說明
由於這篇文章並非介紹 Data Loader，關於 Data Loader 具體用法與詳細說明，還請大家參考[官方文件](https://router.vuejs.org/data-loaders/)。
:::

以下是宣告並呼叫 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 秒。%=͟͟͞͞( •̀д•́)%

::: tip 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 每次導航都會重新執行，沒有跨導航的快取機制。

::: tip 需要跨導航快取？試試 Colada Loader
[Colada Loader](https://router.vuejs.org/data-loaders/colada/) 基於 `@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 讓載入動作跟著元件走，由路由器提早平行執行，兼顧效能與程式碼乾淨度。

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

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