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 可以接受:
- 已註冊的元件名稱(字串)
- 實際的元件物件
- 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>
include 和 exclude 會根據元件的 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>