Skip to content

Vue.js 动态组件

什么是动态组件?

动态组件允许我们在同一个挂载点动态切换不同的组件,常用于标签页、多步骤表单、条件渲染等场景。

基本用法

使用 <component> 元素和 is 属性来实现动态组件:

vue
<script setup>
import { ref } from 'vue'
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'
import ComponentC from './ComponentC.vue'

const currentComponent = ref('ComponentA')

const components = {
  ComponentA,
  ComponentB,
  ComponentC
}
</script>

<template>
  <div>
    <button @click="currentComponent = 'ComponentA'">组件 A</button>
    <button @click="currentComponent = 'ComponentB'">组件 B</button>
    <button @click="currentComponent = 'ComponentC'">组件 C</button>
    
    <component :is="components[currentComponent]" />
  </div>
</template>

使用 KeepAlive 缓存组件

默认情况下,切换组件时会销毁旧组件。使用 <KeepAlive> 可以缓存组件状态:

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

const currentTab = ref('TabA')
</script>

<template>
  <div>
    <button @click="currentTab = 'TabA'">标签 A</button>
    <button @click="currentTab = 'TabB'">标签 B</button>
    
    <!-- 使用 KeepAlive 缓存组件 -->
    <KeepAlive>
      <component :is="currentTab === 'TabA' ? TabA : TabB" />
    </KeepAlive>
  </div>
</template>

KeepAlive 的生命周期钩子

<KeepAlive> 缓存的组件有两个特殊的生命周期钩子:

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

// 组件被激活时调用
onActivated(() => {
  console.log('组件被激活')
})

// 组件被停用时调用
onDeactivated(() => {
  console.log('组件被停用')
})
</script>

<template>
  <div>缓存的组件内容</div>
</template>

KeepAlive 的 include 和 exclude

可以通过 includeexclude 属性控制哪些组件需要缓存:

vue
<script setup>
import { ref } from 'vue'
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'
import ComponentC from './ComponentC.vue'

const current = ref('ComponentA')
</script>

<template>
  <div>
    <!-- 只缓存 ComponentA 和 ComponentB -->
    <KeepAlive :include="['ComponentA', 'ComponentB']">
      <component :is="current" />
    </KeepAlive>
    
    <!-- 缓存除了 ComponentC 之外的所有组件 -->
    <KeepAlive :exclude="['ComponentC']">
      <component :is="current" />
    </KeepAlive>
    
    <!-- 最多缓存 2 个组件实例 -->
    <KeepAlive :max="2">
      <component :is="current" />
    </KeepAlive>
  </div>
</template>

实战示例:标签页切换

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

const currentTab = ref('home')

const tabs = [
  { id: 'home', label: '首页', icon: '🏠' },
  { id: 'profile', label: '个人资料', icon: '👤' },
  { id: 'settings', label: '设置', icon: '⚙️' },
  { id: 'messages', label: '消息', icon: '💬' }
]

// 标签页组件
const tabComponents = {
  home: {
    template: `
      <div class="tab-content">
        <h2>🏠 欢迎回来!</h2>
        <p>这是首页内容</p>
        <div class="cards">
          <div class="card">卡片 1</div>
          <div class="card">卡片 2</div>
          <div class="card">卡片 3</div>
        </div>
      </div>
    `
  },
  profile: {
    setup() {
      const user = ref({
        name: '张三',
        email: 'zhangsan@example.com',
        bio: '热爱编程的开发者'
      })
      return { user }
    },
    template: `
      <div class="tab-content">
        <h2>👤 个人资料</h2>
        <div class="profile-info">
          <p><strong>姓名:</strong>{{ user.name }}</p>
          <p><strong>邮箱:</strong>{{ user.email }}</p>
          <p><strong>简介:</strong>{{ user.bio }}</p>
        </div>
      </div>
    `
  },
  settings: {
    setup() {
      const settings = ref({
        notifications: true,
        darkMode: false,
        language: 'zh-CN'
      })
      return { settings }
    },
    template: `
      <div class="tab-content">
        <h2>⚙️ 设置</h2>
        <div class="settings-list">
          <label>
            <input type="checkbox" v-model="settings.notifications" />
            启用通知
          </label>
          <label>
            <input type="checkbox" v-model="settings.darkMode" />
            深色模式
          </label>
          <label>
            语言:
            <select v-model="settings.language">
              <option value="zh-CN">简体中文</option>
              <option value="en-US">English</option>
            </select>
          </label>
        </div>
      </div>
    `
  },
  messages: {
    setup() {
      const messages = ref([
        { id: 1, from: '李四', text: '你好!', time: '10:30' },
        { id: 2, from: '王五', text: '周末一起吃饭?', time: '11:45' },
        { id: 3, from: '赵六', text: '项目进展如何?', time: '14:20' }
      ])
      return { messages }
    },
    template: `
      <div class="tab-content">
        <h2>💬 消息</h2>
        <div class="messages-list">
          <div v-for="msg in messages" :key="msg.id" class="message">
            <strong>{{ msg.from }}</strong>
            <p>{{ msg.text }}</p>
            <span class="time">{{ msg.time }}</span>
          </div>
        </div>
      </div>
    `
  }
}
</script>

<template>
  <div class="tabs-container">
    <div class="tabs-header">
      <button
        v-for="tab in tabs"
        :key="tab.id"
        :class="{ active: currentTab === tab.id }"
        @click="currentTab = tab.id"
        class="tab-button"
      >
        <span class="icon">{{ tab.icon }}</span>
        <span>{{ tab.label }}</span>
      </button>
    </div>
    
    <div class="tabs-body">
      <KeepAlive>
        <component :is="tabComponents[currentTab]" />
      </KeepAlive>
    </div>
  </div>
</template>

<style scoped>
.tabs-container {
  max-width: 800px;
  margin: 20px auto;
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
}

.tabs-header {
  display: flex;
  background-color: #f5f5f5;
  border-bottom: 2px solid #ddd;
}

.tab-button {
  flex: 1;
  padding: 15px 20px;
  background: none;
  border: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: all 0.3s;
  color: #666;
}

.tab-button:hover {
  background-color: #e8e8e8;
}

.tab-button.active {
  background-color: white;
  color: #42b983;
  font-weight: bold;
  border-bottom: 3px solid #42b983;
}

.icon {
  font-size: 20px;
}

.tabs-body {
  padding: 20px;
  min-height: 300px;
  background-color: white;
}

.tab-content {
  animation: fadeIn 0.3s;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 15px;
  margin-top: 20px;
}

.card {
  padding: 30px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border-radius: 8px;
  text-align: center;
  font-weight: bold;
}

.profile-info {
  background-color: #f9f9f9;
  padding: 20px;
  border-radius: 8px;
  margin-top: 15px;
}

.profile-info p {
  margin: 10px 0;
}

.settings-list {
  display: flex;
  flex-direction: column;
  gap: 15px;
  margin-top: 15px;
}

.settings-list label {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px;
  background-color: #f9f9f9;
  border-radius: 4px;
}

.messages-list {
  display: flex;
  flex-direction: column;
  gap: 15px;
  margin-top: 15px;
}

.message {
  padding: 15px;
  background-color: #f9f9f9;
  border-radius: 8px;
  border-left: 4px solid #42b983;
}

.message strong {
  color: #42b983;
}

.message p {
  margin: 8px 0;
}

.time {
  font-size: 12px;
  color: #999;
}
</style>

实战示例:多步骤表单

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

const currentStep = ref(1)
const formData = ref({
  // 步骤 1
  username: '',
  email: '',
  // 步骤 2
  address: '',
  city: '',
  // 步骤 3
  cardNumber: '',
  cvv: ''
})

const steps = [
  { id: 1, title: '账户信息', icon: '👤' },
  { id: 2, title: '地址信息', icon: '📍' },
  { id: 3, title: '支付信息', icon: '💳' }
]

const canGoNext = computed(() => {
  if (currentStep.value === 1) {
    return formData.value.username && formData.value.email
  } else if (currentStep.value === 2) {
    return formData.value.address && formData.value.city
  } else if (currentStep.value === 3) {
    return formData.value.cardNumber && formData.value.cvv
  }
  return false
})

function nextStep() {
  if (currentStep.value < 3 && canGoNext.value) {
    currentStep.value++
  }
}

function prevStep() {
  if (currentStep.value > 1) {
    currentStep.value--
  }
}

function submitForm() {
  console.log('提交表单:', formData.value)
  alert('表单提交成功!')
}

// 步骤组件
const stepComponents = {
  1: {
    setup() {
      return { formData }
    },
    template: `
      <div class="step-content">
        <h3>👤 账户信息</h3>
        <div class="form-group">
          <label>用户名</label>
          <input v-model="formData.username" placeholder="请输入用户名" />
        </div>
        <div class="form-group">
          <label>邮箱</label>
          <input v-model="formData.email" type="email" placeholder="请输入邮箱" />
        </div>
      </div>
    `
  },
  2: {
    setup() {
      return { formData }
    },
    template: `
      <div class="step-content">
        <h3>📍 地址信息</h3>
        <div class="form-group">
          <label>详细地址</label>
          <input v-model="formData.address" placeholder="请输入详细地址" />
        </div>
        <div class="form-group">
          <label>城市</label>
          <input v-model="formData.city" placeholder="请输入城市" />
        </div>
      </div>
    `
  },
  3: {
    setup() {
      return { formData }
    },
    template: `
      <div class="step-content">
        <h3>💳 支付信息</h3>
        <div class="form-group">
          <label>卡号</label>
          <input v-model="formData.cardNumber" placeholder="请输入卡号" />
        </div>
        <div class="form-group">
          <label>CVV</label>
          <input v-model="formData.cvv" placeholder="请输入CVV" />
        </div>
      </div>
    `
  }
}
</script>

<template>
  <div class="wizard-container">
    <h2>注册向导</h2>
    
    <!-- 步骤指示器 -->
    <div class="steps-indicator">
      <div
        v-for="step in steps"
        :key="step.id"
        :class="['step', { active: currentStep === step.id, completed: currentStep > step.id }]"
      >
        <div class="step-number">
          <span v-if="currentStep > step.id">✓</span>
          <span v-else>{{ step.icon }}</span>
        </div>
        <div class="step-title">{{ step.title }}</div>
      </div>
    </div>
    
    <!-- 动态步骤内容 -->
    <div class="step-container">
      <KeepAlive>
        <component :is="stepComponents[currentStep]" />
      </KeepAlive>
    </div>
    
    <!-- 导航按钮 -->
    <div class="navigation">
      <button
        @click="prevStep"
        :disabled="currentStep === 1"
        class="btn btn-secondary"
      >
        上一步
      </button>
      
      <button
        v-if="currentStep < 3"
        @click="nextStep"
        :disabled="!canGoNext"
        class="btn btn-primary"
      >
        下一步
      </button>
      
      <button
        v-else
        @click="submitForm"
        :disabled="!canGoNext"
        class="btn btn-success"
      >
        提交
      </button>
    </div>
  </div>
</template>

<style scoped>
.wizard-container {
  max-width: 600px;
  margin: 20px auto;
  padding: 30px;
  border: 1px solid #ddd;
  border-radius: 12px;
  background-color: white;
}

.steps-indicator {
  display: flex;
  justify-content: space-between;
  margin-bottom: 40px;
  position: relative;
}

.steps-indicator::before {
  content: '';
  position: absolute;
  top: 20px;
  left: 10%;
  right: 10%;
  height: 2px;
  background-color: #ddd;
  z-index: 0;
}

.step {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  position: relative;
  z-index: 1;
}

.step-number {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background-color: #f0f0f0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  margin-bottom: 8px;
  transition: all 0.3s;
}

.step.active .step-number {
  background-color: #42b983;
  color: white;
  transform: scale(1.1);
}

.step.completed .step-number {
  background-color: #67c23a;
  color: white;
}

.step-title {
  font-size: 14px;
  color: #666;
}

.step.active .step-title {
  color: #42b983;
  font-weight: bold;
}

.step-container {
  min-height: 250px;
  margin-bottom: 30px;
}

.step-content {
  animation: slideIn 0.3s;
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateX(20px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: bold;
  color: #333;
}

.form-group input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.navigation {
  display: flex;
  justify-content: space-between;
  gap: 10px;
}

.btn {
  flex: 1;
  padding: 12px 24px;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  cursor: pointer;
  transition: all 0.3s;
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-secondary {
  background-color: #f0f0f0;
  color: #333;
}

.btn-secondary:hover:not(:disabled) {
  background-color: #e0e0e0;
}

.btn-primary {
  background-color: #42b983;
  color: white;
}

.btn-primary:hover:not(:disabled) {
  background-color: #35a372;
}

.btn-success {
  background-color: #67c23a;
  color: white;
}

.btn-success:hover:not(:disabled) {
  background-color: #5daf34;
}
</style>

动态组件的注意事项

1. 组件名称

使用 includeexclude 时,需要确保组件有 name 选项:

vue
<script>
export default {
  name: 'MyComponent'
}
</script>

<script setup>
// 组件逻辑
</script>

2. 性能考虑

  • 使用 KeepAlive 会占用更多内存
  • 使用 max 属性限制缓存数量
  • 对于不需要保持状态的组件,不要使用 KeepAlive

3. 生命周期

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

// 普通生命周期
onMounted(() => console.log('mounted'))
onUnmounted(() => console.log('unmounted'))

// KeepAlive 特有的生命周期
onActivated(() => console.log('activated'))
onDeactivated(() => console.log('deactivated'))
</script>

总结

  • 使用 <component :is=""> 实现动态组件
  • <KeepAlive> 可以缓存组件状态
  • includeexcludemax 控制缓存行为
  • onActivatedonDeactivated 是 KeepAlive 特有的生命周期钩子
  • 动态组件常用于标签页、向导、条件渲染等场景

下一步

接下来学习 异步组件,了解如何按需加载组件。