Vue 非同步元件

非同步元件(Async Components)是 Vue 提供的一種延遲載入元件的方式。當應用程式很大時,使用非同步元件可以將程式碼分割成較小的區塊,只在需要時才載入,從而提升初始載入速度。

defineAsyncComponent

Vue 3 提供 defineAsyncComponent 函式來定義非同步元件:

<script setup>
import { defineAsyncComponent } from 'vue'

// 基本用法:傳入一個返回 Promise 的函式
const AsyncComponent = defineAsyncComponent(() =>
  import('./HeavyComponent.vue')
)
</script>

<template>
  <AsyncComponent />
</template>

當元件第一次被渲染時,Vue 才會呼叫 import 函式載入元件。

進階選項

defineAsyncComponent 可以接受一個選項物件,提供更多控制:

<script setup>
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorDisplay from './ErrorDisplay.vue'

const AsyncComponent = defineAsyncComponent({
  // 載入元件的函式
  loader: () => import('./HeavyComponent.vue'),

  // 載入中時顯示的元件
  loadingComponent: LoadingSpinner,

  // 顯示 loadingComponent 前的延遲時間(毫秒)
  delay: 200,

  // 載入失敗時顯示的元件
  errorComponent: ErrorDisplay,

  // 超時時間(毫秒),超過後顯示錯誤元件
  timeout: 3000
})
</script>

<template>
  <AsyncComponent />
</template>

選項說明

選項說明預設值
loader返回 Promise 的載入函式必填
loadingComponent載入中時顯示的元件
errorComponent載入失敗時顯示的元件
delay顯示 loadingComponent 前的延遲200ms
timeout超時時間,超過後觸發錯誤Infinity
onError錯誤處理函式

錯誤處理

<script setup>
import { defineAsyncComponent } from 'vue'

const AsyncComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  errorComponent: ErrorDisplay,
  onError(error, retry, fail, attempts) {
    // error: 錯誤物件
    // retry: 重試函式
    // fail: 失敗函式(停止重試)
    // attempts: 已嘗試次數

    if (error.message.includes('fetch') && attempts <= 3) {
      // 網路錯誤,重試最多 3 次
      retry()
    } else {
      // 其他錯誤,或超過重試次數,放棄
      fail()
    }
  }
})
</script>

搭配 Suspense

Vue 3 的 <Suspense> 元件可以更優雅地處理非同步元件的載入狀態:

<script setup>
import { defineAsyncComponent } from 'vue'

const AsyncDashboard = defineAsyncComponent(() =>
  import('./Dashboard.vue')
)
</script>

<template>
  <Suspense>
    <!-- 預設插槽:非同步內容 -->
    <template #default>
      <AsyncDashboard />
    </template>

    <!-- fallback 插槽:載入中顯示 -->
    <template #fallback>
      <div class="loading">載入中...</div>
    </template>
  </Suspense>
</template>

Suspense 的優勢

  1. 統一管理:可以在一個地方處理多個非同步元件的載入狀態
  2. 搭配 async setup:可以在 setup 中使用 await
  3. 嵌套支援:支援嵌套的非同步依賴
<!-- 多個非同步元件 -->
<template>
  <Suspense>
    <template #default>
      <div>
        <AsyncHeader />
        <AsyncSidebar />
        <AsyncContent />
      </div>
    </template>
    <template #fallback>
      <div>載入頁面中...</div>
    </template>
  </Suspense>
</template>

async setup

搭配 <Suspense>,元件可以有 async 的 setup:

<!-- AsyncComponent.vue -->
<script setup>
// 可以直接使用 await
const response = await fetch('/api/data')
const data = await response.json()
</script>

<template>
  <div>{{ data }}</div>
</template>
<!-- 父元件 -->
<template>
  <Suspense>
    <AsyncComponent />
    <template #fallback>載入中...</template>
  </Suspense>
</template>

實際應用

路由級別的程式碼分割

搭配 Vue Router 實現路由級別的程式碼分割:

// router.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      component: () => import('./views/Home.vue')
    },
    {
      path: '/about',
      component: () => import('./views/About.vue')
    },
    {
      path: '/dashboard',
      component: () => import('./views/Dashboard.vue')
    }
  ]
})

條件載入

只在需要時載入特定元件:

<script setup>
import { ref, defineAsyncComponent } from 'vue'

const showChart = ref(false)

// 只有在 showChart 為 true 時才會載入
const ChartComponent = defineAsyncComponent(() =>
  import('./Chart.vue')
)
</script>

<template>
  <button @click="showChart = true">顯示圖表</button>

  <ChartComponent v-if="showChart" />
</template>

Modal 通常不需要在頁面載入時就載入:

<script setup>
import { ref, defineAsyncComponent } from 'vue'

const showModal = ref(false)

const HeavyModal = defineAsyncComponent({
  loader: () => import('./HeavyModal.vue'),
  loadingComponent: {
    template: '<div class="modal-loading">載入中...</div>'
  }
})
</script>

<template>
  <button @click="showModal = true">開啟</button>

  <HeavyModal v-if="showModal" @close="showModal = false" />
</template>

Tab 面板延遲載入

<script setup>
import { ref, defineAsyncComponent, shallowRef, markRaw } from 'vue'

const tabs = [
  {
    id: 'overview',
    label: '總覽',
    component: markRaw(defineAsyncComponent(() =>
      import('./tabs/Overview.vue')
    ))
  },
  {
    id: 'analytics',
    label: '分析',
    component: markRaw(defineAsyncComponent(() =>
      import('./tabs/Analytics.vue')
    ))
  },
  {
    id: 'reports',
    label: '報表',
    component: markRaw(defineAsyncComponent(() =>
      import('./tabs/Reports.vue')
    ))
  }
]

const activeTab = ref('overview')

const currentComponent = computed(() => {
  return tabs.find(t => t.id === activeTab.value)?.component
})
</script>

<template>
  <div class="tabs">
    <button
      v-for="tab in tabs"
      :key="tab.id"
      :class="{ active: activeTab === tab.id }"
      @click="activeTab = tab.id"
    >
      {{ tab.label }}
    </button>
  </div>

  <Suspense>
    <template #default>
      <KeepAlive>
        <component :is="currentComponent" />
      </KeepAlive>
    </template>
    <template #fallback>
      <div class="loading-tab">載入分頁中...</div>
    </template>
  </Suspense>
</template>

預載入(Prefetch)

在使用者可能需要之前預先載入元件:

<script setup>
import { defineAsyncComponent, onMounted } from 'vue'

// 定義非同步元件
const HeavyComponent = defineAsyncComponent(() =>
  import('./HeavyComponent.vue')
)

// 預載入函式
const preloadHeavyComponent = () => import('./HeavyComponent.vue')

// 滑鼠移入時預載入
function handleMouseEnter() {
  preloadHeavyComponent()
}

// 或在 mounted 後預載入
onMounted(() => {
  // 使用 requestIdleCallback 在瀏覽器閒置時預載入
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      preloadHeavyComponent()
    })
  }
})
</script>

<template>
  <button @mouseenter="handleMouseEnter" @click="showComponent = true">
    顯示元件
  </button>
</template>

載入和錯誤元件

建立通用的載入元件

<!-- LoadingSpinner.vue -->
<template>
  <div class="loading-spinner">
    <div class="spinner"></div>
    <p>{{ message }}</p>
  </div>
</template>

<script setup>
defineProps({
  message: {
    type: String,
    default: '載入中...'
  }
})
</script>

<style scoped>
.loading-spinner {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 40px;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #f3f3f3;
  border-top: 3px solid #42b883;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

建立通用的錯誤元件

<!-- ErrorDisplay.vue -->
<template>
  <div class="error-display">
    <div class="error-icon">⚠️</div>
    <h3>載入失敗</h3>
    <p>{{ message }}</p>
    <button v-if="retry" @click="retry">重試</button>
  </div>
</template>

<script setup>
defineProps({
  message: {
    type: String,
    default: '無法載入元件,請稍後再試'
  },
  retry: {
    type: Function,
    default: null
  }
})
</script>

<style scoped>
.error-display {
  text-align: center;
  padding: 40px;
  color: #721c24;
  background: #f8d7da;
  border-radius: 8px;
}

.error-icon {
  font-size: 48px;
}

button {
  margin-top: 16px;
  padding: 8px 16px;
  background: #dc3545;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

建立非同步元件工廠函式

// asyncComponentFactory.js
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorDisplay from './ErrorDisplay.vue'

export function createAsyncComponent(loader, options = {}) {
  return defineAsyncComponent({
    loader,
    loadingComponent: options.loadingComponent || LoadingSpinner,
    errorComponent: options.errorComponent || ErrorDisplay,
    delay: options.delay ?? 200,
    timeout: options.timeout ?? 10000,
    onError(error, retry, fail, attempts) {
      if (attempts <= 3) {
        retry()
      } else {
        fail()
      }
    }
  })
}

使用:

<script setup>
import { createAsyncComponent } from './asyncComponentFactory'

const Dashboard = createAsyncComponent(
  () => import('./Dashboard.vue')
)

const Analytics = createAsyncComponent(
  () => import('./Analytics.vue'),
  { timeout: 5000 }
)
</script>

效能考量

  1. 適度使用:不是所有元件都需要非同步載入,只有較大或不常用的元件才需要

  2. 預載入重要路由:使用者可能會訪問的路由可以預先載入

  3. 設定合理的 delay:避免快速載入的元件顯示閃爍的載入畫面

  4. 使用 Webpack/Vite 的 magic comments

// 設定 chunk 名稱
const Component = defineAsyncComponent(() =>
  import(/* webpackChunkName: "my-chunk" */ './Component.vue')
)

// Vite 中預載入
const Component = defineAsyncComponent(() =>
  import(/* @vite-ignore */ './Component.vue')
)