Vue 動態元件

動態元件(Dynamic Components)是 Vue 提供的一個強大功能,讓你可以在多個元件之間動態切換。這在建立 Tab 面板、步驟嚮導、可插拔的 UI 等場景非常有用。

基本用法

使用 Vue 內建的 <component> 元素配合 :is 屬性來實現動態元件:

<script setup>
import { ref, shallowRef } from 'vue'
import TabHome from './TabHome.vue'
import TabPosts from './TabPosts.vue'
import TabArchive from './TabArchive.vue'

// 使用 shallowRef 來儲存元件(效能較好)
const currentTab = shallowRef(TabHome)

const tabs = [
  { name: '首頁', component: TabHome },
  { name: '文章', component: TabPosts },
  { name: '封存', component: TabArchive }
]
</script>

<template>
  <div class="tabs">
    <button
      v-for="tab in tabs"
      :key="tab.name"
      :class="{ active: currentTab === tab.component }"
      @click="currentTab = tab.component"
    >
      {{ tab.name }}
    </button>
  </div>

  <!-- 動態元件 -->
  <component :is="currentTab" />
</template>

<style scoped>
.tabs {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

button.active {
  font-weight: bold;
  border-bottom: 2px solid #42b883;
}
</style>

:is 屬性的值

:is 可以接受:

  1. 已註冊的元件名稱(字串)
  2. 實際的元件物件
  3. HTML 標籤名稱
<script setup>
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'

const componentName = ref('MyComponent')  // 字串
const componentObj = MyComponent           // 元件物件
const htmlTag = ref('div')                 // HTML 標籤
</script>

<template>
  <!-- 使用元件物件(推薦) -->
  <component :is="componentObj" />

  <!-- 使用 HTML 標籤 -->
  <component :is="htmlTag">
    這會渲染成 div
  </component>
</template>

KeepAlive - 保持元件狀態

預設情況下,切換動態元件時,被切換掉的元件會被銷毀。如果想保留元件的狀態(例如表單輸入、滾動位置),可以使用 <KeepAlive>

<script setup>
import { ref, shallowRef } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'

const currentTab = shallowRef(TabA)
</script>

<template>
  <button @click="currentTab = TabA">Tab A</button>
  <button @click="currentTab = TabB">Tab B</button>

  <!-- 使用 KeepAlive 保持狀態 -->
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>
</template>

範例:切換時保留表單輸入

<!-- TabForm.vue -->
<script setup>
import { ref } from 'vue'

const name = ref('')
const email = ref('')
</script>

<template>
  <form>
    <input v-model="name" placeholder="姓名">
    <input v-model="email" placeholder="Email">
  </form>
</template>

使用 <KeepAlive> 後,切換回來時,表單的輸入內容會保留。

include 和 exclude

可以指定哪些元件要被快取:

<script setup>
import TabA from './TabA.vue'
import TabB from './TabB.vue'
import TabC from './TabC.vue'
</script>

<template>
  <!-- 只快取 TabA 和 TabB -->
  <KeepAlive :include="['TabA', 'TabB']">
    <component :is="currentTab" />
  </KeepAlive>

  <!-- 不快取 TabC -->
  <KeepAlive :exclude="['TabC']">
    <component :is="currentTab" />
  </KeepAlive>

  <!-- 使用正規表達式 -->
  <KeepAlive :include="/^Tab/">
    <component :is="currentTab" />
  </KeepAlive>
</template>
includeexclude 會根據元件的 name 選項來匹配。在 <script setup> 中,需要額外定義 name。
<script setup>
// script setup 中定義 name
defineOptions({
  name: 'TabA'
})
</script>

max - 最大快取數量

限制快取的元件數量,超過時會銷毀最久未使用的:

<KeepAlive :max="3">
  <component :is="currentTab" />
</KeepAlive>

快取元件的生命週期

<KeepAlive> 快取的元件有兩個額外的生命週期 hooks:

<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  console.log('元件被啟用(從快取恢復)')
  // 重新獲取資料
  fetchData()
})

onDeactivated(() => {
  console.log('元件被停用(進入快取)')
  // 暫停計時器
  pauseTimer()
})
</script>

傳遞 Props 和監聽事件

動態元件可以像普通元件一樣接收 props 和發送事件:

<script setup>
import { ref, shallowRef } from 'vue'
import UserProfile from './UserProfile.vue'
import UserSettings from './UserSettings.vue'

const currentComponent = shallowRef(UserProfile)
const user = ref({ name: 'John', email: 'john@example.com' })

function handleUpdate(updatedUser) {
  user.value = updatedUser
}
</script>

<template>
  <component
    :is="currentComponent"
    :user="user"
    @update="handleUpdate"
  />
</template>

實際範例

Tab 面板

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

// 使用 markRaw 避免不必要的響應式轉換
const tabs = [
  {
    id: 'profile',
    label: '個人資料',
    component: markRaw(defineAsyncComponent(() => import('./ProfileTab.vue')))
  },
  {
    id: 'security',
    label: '安全設定',
    component: markRaw(defineAsyncComponent(() => import('./SecurityTab.vue')))
  },
  {
    id: 'notifications',
    label: '通知設定',
    component: markRaw(defineAsyncComponent(() => import('./NotificationsTab.vue')))
  }
]

const activeTabId = ref('profile')

const activeTab = computed(() => {
  return tabs.find(tab => tab.id === activeTabId.value)
})
</script>

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

    <div class="tab-content">
      <KeepAlive>
        <component :is="activeTab.component" />
      </KeepAlive>
    </div>
  </div>
</template>

步驟嚮導

<script setup>
import { ref, computed, shallowRef } from 'vue'
import Step1 from './Step1.vue'
import Step2 from './Step2.vue'
import Step3 from './Step3.vue'

const steps = [
  { id: 1, title: '基本資料', component: Step1 },
  { id: 2, title: '選擇方案', component: Step2 },
  { id: 3, title: '確認送出', component: Step3 }
]

const currentStepIndex = ref(0)

const currentStep = computed(() => steps[currentStepIndex.value])
const isFirstStep = computed(() => currentStepIndex.value === 0)
const isLastStep = computed(() => currentStepIndex.value === steps.length - 1)

// 表單資料(跨步驟共用)
const formData = ref({
  name: '',
  email: '',
  plan: ''
})

function nextStep() {
  if (!isLastStep.value) {
    currentStepIndex.value++
  }
}

function prevStep() {
  if (!isFirstStep.value) {
    currentStepIndex.value--
  }
}

function submit() {
  console.log('提交表單:', formData.value)
}
</script>

<template>
  <div class="wizard">
    <!-- 步驟指示器 -->
    <div class="steps-indicator">
      <div
        v-for="(step, index) in steps"
        :key="step.id"
        :class="{
          step: true,
          active: index === currentStepIndex,
          completed: index < currentStepIndex
        }"
      >
        <span class="step-number">{{ index + 1 }}</span>
        <span class="step-title">{{ step.title }}</span>
      </div>
    </div>

    <!-- 步驟內容 -->
    <div class="step-content">
      <KeepAlive>
        <component
          :is="currentStep.component"
          v-model="formData"
        />
      </KeepAlive>
    </div>

    <!-- 導航按鈕 -->
    <div class="step-actions">
      <button @click="prevStep" :disabled="isFirstStep">
        上一步
      </button>
      <button v-if="!isLastStep" @click="nextStep">
        下一步
      </button>
      <button v-else @click="submit" class="submit">
        送出
      </button>
    </div>
  </div>
</template>

動態表單欄位

<script setup>
import { ref, markRaw } from 'vue'
import TextInput from './fields/TextInput.vue'
import NumberInput from './fields/NumberInput.vue'
import SelectInput from './fields/SelectInput.vue'
import CheckboxInput from './fields/CheckboxInput.vue'

const fieldComponents = {
  text: markRaw(TextInput),
  number: markRaw(NumberInput),
  select: markRaw(SelectInput),
  checkbox: markRaw(CheckboxInput)
}

const formFields = ref([
  { id: 'name', type: 'text', label: '姓名', value: '' },
  { id: 'age', type: 'number', label: '年齡', value: 0 },
  { id: 'country', type: 'select', label: '國家', value: '', options: ['台灣', '日本', '美國'] },
  { id: 'agree', type: 'checkbox', label: '同意條款', value: false }
])
</script>

<template>
  <form @submit.prevent>
    <div v-for="field in formFields" :key="field.id" class="form-field">
      <label :for="field.id">{{ field.label }}</label>
      <component
        :is="fieldComponents[field.type]"
        :id="field.id"
        v-model="field.value"
        :options="field.options"
      />
    </div>
  </form>
</template>

可擴展的 Dashboard

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

// 可用的 widget 元件
const availableWidgets = {
  stats: markRaw(defineAsyncComponent(() => import('./widgets/StatsWidget.vue'))),
  chart: markRaw(defineAsyncComponent(() => import('./widgets/ChartWidget.vue'))),
  activity: markRaw(defineAsyncComponent(() => import('./widgets/ActivityWidget.vue'))),
  calendar: markRaw(defineAsyncComponent(() => import('./widgets/CalendarWidget.vue')))
}

// 使用者的 dashboard 配置
const userWidgets = ref([
  { id: 1, type: 'stats', title: '統計數據' },
  { id: 2, type: 'chart', title: '圖表' },
  { id: 3, type: 'activity', title: '最近活動' }
])

function removeWidget(id) {
  userWidgets.value = userWidgets.value.filter(w => w.id !== id)
}

function addWidget(type) {
  userWidgets.value.push({
    id: Date.now(),
    type,
    title: type.charAt(0).toUpperCase() + type.slice(1)
  })
}
</script>

<template>
  <div class="dashboard">
    <div class="widget-grid">
      <div v-for="widget in userWidgets" :key="widget.id" class="widget">
        <div class="widget-header">
          <h3>{{ widget.title }}</h3>
          <button @click="removeWidget(widget.id)">×</button>
        </div>
        <div class="widget-content">
          <component :is="availableWidgets[widget.type]" />
        </div>
      </div>
    </div>

    <div class="add-widget">
      <button
        v-for="(_, type) in availableWidgets"
        :key="type"
        @click="addWidget(type)"
      >
        + {{ type }}
      </button>
    </div>
  </div>
</template>

效能優化

使用 shallowRef

儲存元件時,使用 shallowRef 而不是 ref,避免不必要的深層響應式轉換:

<script setup>
import { shallowRef } from 'vue'
import ComponentA from './ComponentA.vue'

// ✅ 使用 shallowRef
const currentComponent = shallowRef(ComponentA)

// ❌ 避免:ref 會對元件物件進行深層響應式轉換
// const currentComponent = ref(ComponentA)
</script>

使用 markRaw

如果元件儲存在響應式物件中,使用 markRaw 標記:

<script setup>
import { reactive, markRaw } from 'vue'
import ComponentA from './ComponentA.vue'

const state = reactive({
  // ✅ 使用 markRaw 避免響應式轉換
  component: markRaw(ComponentA)
})
</script>

非同步載入元件

搭配 defineAsyncComponent 實現按需載入:

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

const tabs = {
  home: defineAsyncComponent(() => import('./HomeTab.vue')),
  profile: defineAsyncComponent(() => import('./ProfileTab.vue')),
  settings: defineAsyncComponent(() => import('./SettingsTab.vue'))
}

const currentTab = shallowRef('home')
</script>

<template>
  <component :is="tabs[currentTab]" />
</template>