Vue.js 内置组件

Vue.js 提供了一系列内置组件,这些组件具有特殊的功能和行为,可以帮助你构建更强大、更灵活的应用程序。它们与普通组件类似,但有一些特殊的属性和行为。

1. 什么是内置组件?

内置组件是 Vue.js 核心库提供的特殊组件,它们以特定的方式扩展了 Vue 的功能。这些组件不需要注册,可以在任何组件的模板中直接使用。

<component>

动态组件,用于在不同组件之间动态切换

<slot>

内容分发插槽,实现组件内容的自定义

<transition>

过渡效果,为元素添加进入/离开动画

<transition-group>

列表过渡,为多个元素添加动画效果

<keep-alive>

组件缓存,保留组件状态避免重新渲染

<teleport>

DOM传送,将组件渲染到DOM的其他位置

<suspense>

异步组件,处理异步组件的加载状态

内置组件特点:
1. 无需注册,全局可用
2. 名称小写,包含连字符
3. 有特殊的props和行为
4. 扩展Vue的核心功能
5. 与普通组件一样使用

为什么需要内置组件?

解决常见模式
  • 动态组件切换
  • 内容分发和组合
  • 动画和过渡效果
  • 组件状态保持
  • DOM结构控制
  • 异步加载处理
提高开发效率
  • 避免重复实现通用功能
  • 提供标准化解决方案
  • 优化性能和用户体验
  • 简化复杂场景的实现
  • 促进代码复用
  • 增强应用的可维护性

2. 动态组件 <component>

<component> 组件用于动态地渲染不同的组件,通过 :is 属性指定要渲染的组件。

动态组件示例
动态渲染

<template>
  <div>
    <h3>动态组件演示</h3>

    <!-- 切换按钮 -->
    <div class="btn-group mb-3" role="group">
      <button @click="currentComponent = 'HomePage'"
              class="btn btn-primary">
        主页
      </button>
      <button @click="currentComponent = 'AboutPage'"
              class="btn btn-success">
        关于
      </button>
      <button @click="currentComponent = 'ContactPage'"
              class="btn btn-info">
        联系
      </button>
    </div>

    <!-- 动态组件 -->
    <component :is="currentComponent"></component>

    <!-- 使用组件对象 -->
    <div class="mt-4">
      <h4>使用组件对象</h4>
      <button @click="toggleDynamicComponent" class="btn btn-warning">
        切换动态组件
      </button>
      <component :is="dynamicComponent"></component>
    </div>
  </div>
</template>

<script>
// 定义组件
const HomePage = {
  template: `<div class="p-3 bg-primary text-white">
              <h4>主页组件</h4>
              <p>欢迎来到主页!</p>
            </div>`
};

const AboutPage = {
  template: `<div class="p-3 bg-success text-white">
              <h4>关于组件</h4>
              <p>这是关于页面。</p>
            </div>`
};

const ContactPage = {
  template: `<div class="p-3 bg-info text-white">
              <h4>联系组件</h4>
              <p>联系方式: example@email.com</p>
            </div>`
};

// 动态组件对象
const DynamicComponentA = {
  template: `<div class="p-3 bg-warning">
              <h5>动态组件A</h5>
              <p>这是动态加载的组件A</p>
            </div>`
};

const DynamicComponentB = {
  template: `<div class="p-3 bg-danger text-white">
              <h5>动态组件B</h5>
              <p>这是动态加载的组件B</p>
            </div>`
};

export default {
  components: {
    HomePage,
    AboutPage,
    ContactPage,
    DynamicComponentA,
    DynamicComponentB
  },
  data() {
    return {
      currentComponent: 'HomePage',
      dynamicComponent: DynamicComponentA,
      isComponentA: true
    };
  },
  methods: {
    toggleDynamicComponent() {
      this.isComponentA = !this.isComponentA;
      this.dynamicComponent = this.isComponentA
        ? DynamicComponentA
        : DynamicComponentB;
    }
  }
};
</script>
                                        

<template>
  <div>
    <h3>动态组件传递Props</h3>

    <!-- 控制面板 -->
    <div class="mb-3">
      <button @click="currentTab = 'UserProfile'" class="btn btn-primary">
        用户资料
      </button>
      <button @click="currentTab = 'UserSettings'" class="btn btn-success">
        用户设置
      </button>

      <div class="mt-2">
        <label>用户名:</label>
        <input v-model="user.name" type="text" class="form-control">
      </div>
    </div>

    <!-- 动态组件传递props -->
    <component
      :is="currentTab"
      :user="user"
      @update-user="updateUser">
    </component>
  </div>
</template>

<script>
const UserProfile = {
  props: ['user'],
  template: `<div class="p-3 border">
              <h4>用户资料</h4>
              <p>用户名: {{ user.name }}</p>
              <p>邮箱: {{ user.email }}</p>
              <p>年龄: {{ user.age }}</p>
            </div>`
};

const UserSettings = {
  props: ['user'],
  emits: ['update-user'],
  template: `<div class="p-3 border">
              <h4>用户设置</h4>
              <div class="mb-2">
                <label>修改用户名:</label>
                <input v-model="localUser.name" type="text" class="form-control">
              </div>
              <button @click="saveChanges" class="btn btn-primary">
                保存更改
              </button>
            </div>`,
  data() {
    return {
      localUser: { ...this.user }
    };
  },
  methods: {
    saveChanges() {
      this.$emit('update-user', this.localUser);
    }
  }
};

export default {
  components: {
    UserProfile,
    UserSettings
  },
  data() {
    return {
      currentTab: 'UserProfile',
      user: {
        name: '张三',
        email: 'zhangsan@example.com',
        age: 25
      }
    };
  },
  methods: {
    updateUser(newUser) {
      this.user = { ...newUser };
      alert('用户信息已更新!');
    }
  }
};
</script>
                                        

<template>
  <div>
    <h3>动态组件事件监听</h3>

    <!-- 组件选择 -->
    <div class="btn-group mb-3">
      <button @click="activeComponent = 'Counter'" class="btn btn-primary">
        计数器
      </button>
      <button @click="activeComponent = 'TodoList'" class="btn btn-success">
        待办事项
      </button>
    </div>

    <!-- 消息显示 -->
    <div v-if="message" class="alert alert-info">
      {{ message }}
    </div>

    <!-- 动态组件监听事件 -->
    <component
      :is="activeComponent"
      @increment="handleIncrement"
      @add-todo="handleAddTodo"
      @clear-todos="handleClearTodos">
    </component>
  </div>
</template>

<script>
const Counter = {
  emits: ['increment'],
  template: `<div class="p-3 border">
              <h4>计数器组件</h4>
              <button @click="increment" class="btn btn-primary">
                点击增加计数
              </button>
            </div>`,
  methods: {
    increment() {
      this.$emit('increment', { time: new Date().toLocaleTimeString() });
    }
  }
};

const TodoList = {
  emits: ['add-todo', 'clear-todos'],
  data() {
    return {
      newTodo: ''
    };
  },
  template: `<div class="p-3 border">
              <h4>待办事项组件</h4>
              <div class="mb-2">
                <input v-model="newTodo" type="text"
                       placeholder="输入待办事项" class="form-control">
              </div>
              <button @click="addTodo" class="btn btn-success me-2">
                添加待办
              </button>
              <button @click="clearTodos" class="btn btn-danger">
                清空所有
              </button>
            </div>`,
  methods: {
    addTodo() {
      if (this.newTodo.trim()) {
        this.$emit('add-todo', this.newTodo);
        this.newTodo = '';
      }
    },
    clearTodos() {
      this.$emit('clear-todos');
    }
  }
};

export default {
  components: {
    Counter,
    TodoList
  },
  data() {
    return {
      activeComponent: 'Counter',
      message: ''
    };
  },
  methods: {
    handleIncrement(data) {
      this.message = `计数器在 ${data.time} 被点击了`;
      setTimeout(() => {
        this.message = '';
      }, 2000);
    },
    handleAddTodo(todo) {
      this.message = `添加了待办事项: "${todo}"`;
      setTimeout(() => {
        this.message = '';
      }, 2000);
    },
    handleClearTodos() {
      this.message = '所有待办事项已清空';
      setTimeout(() => {
        this.message = '';
      }, 2000);
    }
  }
};
</script>
                                        
动态组件演示

主页组件

欢迎来到主页!

动态组件对象切换
动态组件A

这是动态加载的组件A

<component> 组件的关键特性

特性 描述 示例
动态渲染 根据条件渲染不同的组件 <component :is="currentView"></component>
传递Props 可以向动态组件传递props <component :is="comp" :data="data">
监听事件 可以监听动态组件发出的事件 <component :is="comp" @event="handler">
组件对象 :is可以接受组件选项对象 :is="{ template: '<div>Hi</div>' }"
组件名称 :is可以接受已注册组件的名称 :is="'MyComponent'"
HTML元素 :is也可以接受HTML元素名称 :is="'div'" (Vue 3)
使用场景: <component> 组件非常适合实现标签页(Tabs)、动态表单、多步骤向导、条件渲染不同UI组件等场景。它可以显著减少模板中的条件渲染逻辑。

3. 插槽系统 <slot>

<slot> 是 Vue 的内容分发 API,允许父组件向子组件传递模板内容。

插槽系统示例
内容分发

<!-- 子组件: Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header">
        <!-- 默认内容 -->
        <h4 class="mb-0">默认标题</h4>
      </slot>
    </div>

    <div class="card-body">
      <!-- 默认插槽 -->
      <slot>
        <p>这是默认内容。</p>
      </slot>
    </div>

    <div class="card-footer">
      <slot name="footer">
        <!-- 默认内容 -->
        <small class="text-muted">默认页脚</small>
      </slot>
    </div>
  </div>
</template>

<!-- 父组件使用 -->
<template>
  <div>
    <h3>基本插槽示例</h3>

    <!-- 使用默认插槽 -->
    <Card>
      <p>这是通过插槽传递的内容。</p>
      <p>父组件可以完全控制这里的内容。</p>
    </Card>

    <!-- 使用具名插槽 -->
    <Card>
      <template v-slot:header>
        <h4 class="mb-0 text-primary">自定义标题</h4>
      </template>

      <!-- 默认插槽内容 -->
      <p>这是卡片的主体内容。</p>
      <ul>
        <li>列表项1</li>
        <li>列表项2</li>
        <li>列表项3</li>
      </ul>

      <template v-slot:footer>
        <small class="text-success">自定义页脚内容</small>
      </template>
    </Card>

    <!-- 使用默认值 -->
    <Card />
  </div>
</template>

<script>
import Card from './Card.vue';

export default {
  components: { Card }
};
</script>

<style>
.card {
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
  margin-bottom: 1rem;
}
.card-header {
  background-color: #f8f9fa;
  padding: 0.75rem 1.25rem;
  border-bottom: 1px solid #dee2e6;
}
.card-body {
  padding: 1.25rem;
}
.card-footer {
  background-color: #f8f9fa;
  padding: 0.75rem 1.25rem;
  border-top: 1px solid #dee2e6;
}
</style>
                                        

<!-- 子组件: Layout.vue -->
<template>
  <div class="layout">
    <header class="layout-header">
      <slot name="header"></slot>
    </header>

    <div class="layout-container">
      <aside class="layout-sidebar">
        <slot name="sidebar"></slot>
      </aside>

      <main class="layout-main">
        <slot name="main"></slot>
      </main>
    </div>

    <footer class="layout-footer">
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

<style scoped>
.layout {
  display: flex;
  flex-direction: column;
  min-height: 400px;
  border: 1px solid #dee2e6;
  border-radius: 0.375rem;
}
.layout-header {
  background-color: #3498db;
  color: white;
  padding: 1rem;
}
.layout-container {
  display: flex;
  flex: 1;
}
.layout-sidebar {
  width: 200px;
  background-color: #f8f9fa;
  padding: 1rem;
  border-right: 1px solid #dee2e6;
}
.layout-main {
  flex: 1;
  padding: 1rem;
}
.layout-footer {
  background-color: #2c3e50;
  color: white;
  padding: 1rem;
}
</style>

<!-- 父组件使用 -->
<template>
  <div>
    <h3>具名插槽示例</h3>

    <Layout>
      <template v-slot:header>
        <h2>网站标题</h2>
        <p>欢迎来到我的网站</p>
      </template>

      <template v-slot:sidebar>
        <h4>导航菜单</h4>
        <ul>
          <li><a href="#">首页</a></li>
          <li><a href="#">关于</a></li>
          <li><a href="#">服务</a></li>
          <li><a href="#">联系</a></li>
        </ul>
      </template>

      <template v-slot:main>
        <h3>主要内容区域</h3>
        <p>这里是网站的主要内容。</p>
        <p>具名插槽允许我们将内容分发到指定的位置。</p>
      </template>

      <template v-slot:footer>
        <p>© 2023 我的网站. 保留所有权利.</p>
      </template>
    </Layout>

    <!-- 简写语法 -->
    <Layout>
      <template #header>
        <h2>简写语法示例</h2>
      </template>
      <template #sidebar>
        <p>使用#代替v-slot:</p>
      </template>
      <template #main>
        <p>这是主要内容。</p>
      </template>
      <template #footer>
        <p>页脚内容</p>
      </template>
    </Layout>
  </div>
</template>

<script>
import Layout from './Layout.vue';

export default {
  components: { Layout }
};
</script>
                                        

<!-- 子组件: DataList.vue -->
<template>
  <div class="data-list">
    <h3>数据列表</h3>

    <!-- 作用域插槽 -->
    <ul>
      <li v-for="(item, index) in items" :key="item.id">
        <!-- 向父组件暴露数据 -->
        <slot :item="item" :index="index">
          <!-- 默认渲染 -->
          {{ index + 1 }}. {{ item.name }}
        </slot>
      </li>
    </ul>

    <!-- 多个作用域插槽示例 -->
    <div class="user-table">
      <slot name="table-header" :count="items.length"></slot>

      <table class="table">
        <thead>
          <slot name="thead">
            <tr>
              <th>ID</th>
              <th>姓名</th>
              <th>年龄</th>
              <th>操作</th>
            </tr>
          </slot>
        </thead>
        <tbody>
          <tr v-for="user in items" :key="user.id">
            <!-- 作用域插槽 -->
            <slot name="row" :user="user">
              <td>{{ user.id }}</td>
              <td>{{ user.name }}</td>
              <td>{{ user.age }}</td>
              <td>--</td>
            </slot>
          </tr>
        </tbody>
      </table>

      <slot name="table-footer" :total-age="totalAge"></slot>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      default: () => []
    }
  },
  computed: {
    totalAge() {
      return this.items.reduce((sum, user) => sum + user.age, 0);
    }
  }
};
</script>

<!-- 父组件使用 -->
<template>
  <div>
    <h3>作用域插槽示例</h3>

    <DataList :items="users">
      <!-- 使用作用域插槽 -->
      <template v-slot:default="slotProps">
        <span :class="{ 'text-success': slotProps.item.age >= 18 }">
          {{ slotProps.index + 1 }}. {{ slotProps.item.name }}
          ({{ slotProps.item.age }}岁)
        </span>
      </template>
    </DataList>

    <DataList :items="users">
      <!-- 解构语法 -->
      <template v-slot:default="{ item, index }">
        <span :class="{ 'text-danger': item.age < 18 }">
          [#{{ index + 1 }}] {{ item.name.toUpperCase() }}
        </span>
      </template>
    </DataList>

    <DataList :items="users">
      <!-- 具名作用域插槽 -->
      <template #table-header="{ count }">
        <h4>用户列表 (共{{ count }}人)</h4>
      </template>

      <template #thead>
        <tr>
          <th>ID</th>
          <th>姓名</th>
          <th>年龄</th>
          <th>状态</th>
          <th>操作</th>
        </tr>
      </template>

      <template #row="{ user }">
        <td>{{ user.id }}</td>
        <td><strong>{{ user.name }}</strong></td>
        <td>{{ user.age }}</td>
        <td>
          <span :class="user.age >= 18 ? 'text-success' : 'text-warning'">
            {{ user.age >= 18 ? '成年人' : '未成年人' }}
          </span>
        </td>
        <td>
          <button @click="editUser(user)" class="btn btn-sm btn-primary">
            编辑
          </button>
        </td>
      </template>

      <template #table-footer="{ totalAge }">
        <div class="alert alert-info mt-2">
          总年龄: {{ totalAge }}岁,平均年龄: {{ (totalAge / users.length).toFixed(1) }}岁
        </div>
      </template>
    </DataList>
  </div>
</template>

<script>
import DataList from './DataList.vue';

export default {
  components: { DataList },
  data() {
    return {
      users: [
        { id: 1, name: '张三', age: 25 },
        { id: 2, name: '李四', age: 17 },
        { id: 3, name: '王五', age: 30 },
        { id: 4, name: '赵六', age: 22 },
        { id: 5, name: '孙七', age: 16 }
      ]
    };
  },
  methods: {
    editUser(user) {
      alert(`编辑用户: ${user.name}`);
    }
  }
};
</script>
                                        
插槽系统演示

插槽是Vue中强大的内容分发机制。以下展示不同类型的插槽:

默认标题

这是通过插槽传递的内容。

父组件可以完全控制这里的内容。

自定义标题

这是卡片的主体内容。

  • 列表项1
  • 列表项2
  • 列表项3

插槽类型对比

插槽类型 描述 语法 使用场景
默认插槽 未命名的插槽,接收所有未指定插槽的内容 <slot></slot> 简单的内容分发
具名插槽 有名称的插槽,用于将内容分发到特定位置 <slot name="header"></slot> 布局组件、复杂UI结构
作用域插槽 允许子组件向父组件传递数据 <slot :item="item"></slot> 数据列表、可定制渲染
动态插槽名 动态指定插槽名称 <slot :name="slotName"></slot> 动态布局、条件渲染
后备内容 插槽的默认内容 <slot>默认内容</slot> 提供默认UI,增强组件可用性
插槽 vs Props: 插槽用于分发内容(模板),而props用于传递数据。当需要自定义组件的渲染结构时使用插槽,当只需要传递数据时使用props。两者经常结合使用以创建高度可定制的组件。

4. 过渡动画 <transition>

<transition> 组件为元素的进入/离开提供过渡动画效果。

过渡动画示例
动画
动画元素

<template>
  <div>
    <h3>基本过渡示例</h3>

    <button @click="show = !show" class="btn btn-primary">
      切换显示/隐藏
    </button>

    <!-- 基本过渡 -->
    <transition>
      <div v-if="show" class="fade-box">
        这是一个会淡入淡出的元素
      </div>
    </transition>

    <!-- 自定义类名 -->
    <transition name="slide">
      <div v-if="show" class="slide-box">
        这是一个会滑入滑出的元素
      </div>
    </transition>

    <!-- 初始渲染过渡 -->
    <transition appear>
      <div class="appear-box">
        页面加载时会执行过渡动画
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: true
    };
  }
};
</script>

<style>
.fade-box {
  padding: 20px;
  background-color: #3498db;
  color: white;
  margin: 10px 0;
  border-radius: 5px;
}

.slide-box {
  padding: 20px;
  background-color: #2ecc71;
  color: white;
  margin: 10px 0;
  border-radius: 5px;
}

.appear-box {
  padding: 20px;
  background-color: #9b59b6;
  color: white;
  margin: 10px 0;
  border-radius: 5px;
}

/* 默认过渡类名 */
.v-enter-active,
.v-leave-active {
  transition: opacity 0.5s ease;
}

.v-enter-from,
.v-leave-to {
  opacity: 0;
}

/* 自定义过渡类名 */
.slide-enter-active,
.slide-leave-active {
  transition: all 0.5s ease;
}

.slide-enter-from {
  opacity: 0;
  transform: translateX(-100%);
}

.slide-leave-to {
  opacity: 0;
  transform: translateX(100%);
}

/* 初始渲染动画 */
.v-enter-active.appear-box {
  animation: bounce-in 0.5s;
}

@keyframes bounce-in {
  0% {
    transform: scale(0);
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
  }
}
</style>
                                        

<template>
  <div>
    <h3>CSS过渡示例</h3>

    <div class="mb-3">
      <button @click="showFade = !showFade" class="btn btn-primary me-2">
        淡入淡出
      </button>
      <button @click="showSlide = !showSlide" class="btn btn-success me-2">
        滑动
      </button>
      <button @click="showBounce = !showBounce" class="btn btn-info">
        弹跳
      </button>
    </div>

    <!-- 淡入淡出过渡 -->
    <transition name="fade">
      <div v-if="showFade" class="demo-box fade-box">
        淡入淡出效果
      </div>
    </transition>

    <!-- 滑动过渡 -->
    <transition name="slide-fade">
      <div v-if="showSlide" class="demo-box slide-box">
        滑动淡入效果
      </div>
    </transition>

    <!-- 弹跳过渡 -->
    <transition name="bounce">
      <div v-if="showBounce" class="demo-box bounce-box">
        弹跳效果
      </div>
    </transition>

    <!-- 多个元素的过渡 -->
    <transition name="list">
      <ul v-if="showList" key="list" class="list-group mt-3">
        <li class="list-group-item">项目1</li>
        <li class="list-group-item">项目2</li>
        <li class="list-group-item">项目3</li>
      </ul>
      <div v-else key="message" class="alert alert-warning mt-3">
        列表已被隐藏
      </div>
    </transition>
    <button @click="showList = !showList" class="btn btn-warning mt-2">
      切换列表/消息
    </button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      showFade: true,
      showSlide: true,
      showBounce: true,
      showList: true
    };
  }
};
</script>

<style>
.demo-box {
  padding: 20px;
  color: white;
  margin: 10px 0;
  border-radius: 5px;
  text-align: center;
}

.fade-box { background-color: #3498db; }
.slide-box { background-color: #2ecc71; }
.bounce-box { background-color: #e74c3c; }

/* 淡入淡出过渡 */
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.5s;
}
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}

/* 滑动淡入过渡 */
.slide-fade-enter-active {
  transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
  transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from {
  transform: translateX(20px);
  opacity: 0;
}
.slide-fade-leave-to {
  transform: translateX(-20px);
  opacity: 0;
}

/* 弹跳过渡 */
.bounce-enter-active {
  animation: bounce-in 0.5s;
}
.bounce-leave-active {
  animation: bounce-in 0.5s reverse;
}
@keyframes bounce-in {
  0% {
    transform: scale(0);
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
  }
}

/* 列表过渡 */
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}
.list-enter-from {
  opacity: 0;
  transform: translateY(30px);
}
.list-leave-to {
  opacity: 0;
  transform: translateY(-30px);
}
</style>
                                        

<template>
  <div>
    <h3>JavaScript动画示例</h3>

    <button @click="show = !show" class="btn btn-primary mb-3">
      切换显示 (使用JavaScript动画)
    </button>

    <!-- 使用JavaScript钩子 -->
    <transition
      @before-enter="beforeEnter"
      @enter="enter"
      @after-enter="afterEnter"
      @enter-cancelled="enterCancelled"
      @before-leave="beforeLeave"
      @leave="leave"
      @after-leave="afterLeave"
      @leave-cancelled="leaveCancelled"
      :css="false">
      <div v-if="show" class="js-animation-box">
        JavaScript动画
      </div>
    </transition>

    <!-- 结合第三方动画库 -->
    <button @click="showGsap = !showGsap" class="btn btn-success mt-3">
      切换GSAP动画
    </button>

    <transition
      @enter="gsapEnter"
      @leave="gsapLeave"
      :css="false">
      <div v-if="showGsap" class="gsap-box">
        GSAP动画效果
      </div>
    </transition>
  </div>
</template>

<script>
// 假设已经引入了GSAP库
// import gsap from 'gsap';

export default {
  data() {
    return {
      show: true,
      showGsap: true
    };
  },
  methods: {
    // JavaScript动画钩子
    beforeEnter(el) {
      console.log('beforeEnter');
      el.style.opacity = 0;
      el.style.transform = 'scale(0)';
    },
    enter(el, done) {
      console.log('enter');

      // 使用requestAnimationFrame实现动画
      let start = null;
      const duration = 600; // 600ms

      function animate(timestamp) {
        if (!start) start = timestamp;
        const progress = timestamp - start;
        const percentage = Math.min(progress / duration, 1);

        // 缓动函数
        const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
        const eased = easeOutCubic(percentage);

        el.style.opacity = eased;
        el.style.transform = `scale(${eased})`;

        if (progress < duration) {
          requestAnimationFrame(animate);
        } else {
          done(); // 动画完成
        }
      }

      requestAnimationFrame(animate);
    },
    afterEnter(el) {
      console.log('afterEnter');
      el.style.opacity = '';
      el.style.transform = '';
    },
    enterCancelled(el) {
      console.log('enterCancelled');
    },
    beforeLeave(el) {
      console.log('beforeLeave');
    },
    leave(el, done) {
      console.log('leave');

      let start = null;
      const duration = 600;

      function animate(timestamp) {
        if (!start) start = timestamp;
        const progress = timestamp - start;
        const percentage = Math.min(progress / duration, 1);

        // 缓动函数
        const easeInCubic = t => t * t * t;
        const eased = easeInCubic(percentage);

        el.style.opacity = 1 - eased;
        el.style.transform = `scale(${1 - eased})`;

        if (progress < duration) {
          requestAnimationFrame(animate);
        } else {
          done(); // 动画完成
        }
      }

      requestAnimationFrame(animate);
    },
    afterLeave(el) {
      console.log('afterLeave');
    },
    leaveCancelled(el) {
      console.log('leaveCancelled');
    },

    // GSAP动画
    gsapEnter(el, done) {
      // GSAP动画
      // gsap.from(el, {
      //   duration: 0.6,
      //   opacity: 0,
      //   scale: 0,
      //   ease: "back.out(1.7)",
      //   onComplete: done
      // });

      // 使用CSS动画模拟GSAP效果
      el.style.animation = 'gsapEnter 0.6s ease-out';
      setTimeout(done, 600);
    },
    gsapLeave(el, done) {
      // gsap.to(el, {
      //   duration: 0.6,
      //   opacity: 0,
      //   scale: 0,
      //   ease: "back.in(1.7)",
      //   onComplete: done
      // });

      el.style.animation = 'gsapLeave 0.6s ease-in';
      setTimeout(done, 600);
    }
  }
};
</script>

<style>
.js-animation-box {
  padding: 20px;
  background-color: #3498db;
  color: white;
  border-radius: 5px;
  text-align: center;
}

.gsap-box {
  padding: 20px;
  background-color: #e74c3c;
  color: white;
  border-radius: 5px;
  text-align: center;
  margin-top: 10px;
}

@keyframes gsapEnter {
  0% {
    opacity: 0;
    transform: scale(0) rotate(-180deg);
  }
  70% {
    transform: scale(1.2) rotate(10deg);
  }
  100% {
    opacity: 1;
    transform: scale(1) rotate(0deg);
  }
}

@keyframes gsapLeave {
  0% {
    opacity: 1;
    transform: scale(1) rotate(0deg);
  }
  30% {
    transform: scale(1.2) rotate(-10deg);
  }
  100% {
    opacity: 0;
    transform: scale(0) rotate(180deg);
  }
}
</style>
                                        

<template>
  <div>
    <h3>过渡模式示例</h3>

    <div class="mb-3">
      <button @click="toggleView" class="btn btn-primary">
        切换视图 (当前: {{ currentView }})
      </button>
    </div>

    <!-- 默认模式 (同时进行) -->
    <h4>默认模式 (同时进行)</h4>
    <transition name="fade">
      <div v-if="currentView === 'A'" key="A" class="view-box view-a">
        视图 A
      </div>
      <div v-else key="B" class="view-box view-b">
        视图 B
      </div>
    </transition>

    <!-- out-in 模式 -->
    <h4 class="mt-4">out-in 模式</h4>
    <transition name="slide" mode="out-in">
      <div v-if="currentView === 'A'" key="A" class="view-box view-a">
        视图 A
      </div>
      <div v-else key="B" class="view-box view-b">
        视图 B
      </div>
    </transition>

    <!-- in-out 模式 -->
    <h4 class="mt-4">in-out 模式</h4>
    <transition name="slide" mode="in-out">
      <div v-if="currentView === 'A'" key="A" class="view-box view-a">
        视图 A
      </div>
      <div v-else key="B" class="view-box view-b">
        视图 B
      </div>
    </transition>

    <!-- 动态过渡 -->
    <h4 class="mt-4">动态过渡</h4>
    <transition :name="transitionName">
      <div v-if="currentView === 'A'" key="A" class="view-box view-a">
        视图 A
      </div>
      <div v-else key="B" class="view-box view-b">
        视图 B
      </div>
    </transition>
    <div class="mt-2">
      <button @click="transitionName = 'fade'" class="btn btn-sm btn-primary">
        使用淡入淡出
      </button>
      <button @click="transitionName = 'slide'" class="btn btn-sm btn-success">
        使用滑动
      </button>
      <button @click="transitionName = 'bounce'" class="btn btn-sm btn-info">
        使用弹跳
      </button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentView: 'A',
      transitionName: 'fade'
    };
  },
  methods: {
    toggleView() {
      this.currentView = this.currentView === 'A' ? 'B' : 'A';
    }
  }
};
</script>

<style>
.view-box {
  padding: 30px;
  color: white;
  border-radius: 5px;
  text-align: center;
  height: 100px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.5rem;
}

.view-a {
  background-color: #3498db;
}

.view-b {
  background-color: #2ecc71;
}

/* 淡入淡出过渡 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

/* 滑动过渡 */
.slide-enter-active,
.slide-leave-active {
  transition: all 0.5s ease;
}

.slide-enter-from {
  opacity: 0;
  transform: translateX(100%);
}

.slide-leave-to {
  opacity: 0;
  transform: translateX(-100%);
}

/* in-out模式时,进入元素立即显示 */
.slide-enter-to {
  position: absolute;
}

/* 弹跳过渡 */
.bounce-enter-active {
  animation: bounce-in 0.5s;
}
.bounce-leave-active {
  animation: bounce-in 0.5s reverse;
}
@keyframes bounce-in {
  0% {
    transform: scale(0);
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
  }
}
</style>
                                        

过渡生命周期

1. before-enter - 进入动画开始前
2. enter - 进入动画进行中
3. after-enter - 进入动画完成后
4. enter-cancelled - 进入动画被取消
5. before-leave - 离开动画开始前
6. leave - 离开动画进行中
7. after-leave - 离开动画完成后
8. leave-cancelled - 离开动画被取消
过渡模式: Vue 提供了三种过渡模式:默认模式(同时进行)、out-in(先出后进)和 in-out(先进后出)。对于视图切换等场景,建议使用 out-in 模式,这样可以在旧元素完全离开后再开始新元素的进入动画,避免视觉上的冲突。

5. 列表过渡 <transition-group>

<transition-group> 组件用于为 v-for 列表中的多个元素添加过渡效果。

列表过渡示例
列表动画

<template>
  <div>
    <h3>基本列表过渡</h3>

    <div class="mb-3">
      <button @click="addItem" class="btn btn-primary me-2">
        添加项目
      </button>
      <button @click="removeItem" class="btn btn-danger me-2">
        移除项目
      </button>
      <button @click="shuffleItems" class="btn btn-warning">
        随机排序
      </button>
    </div>

    <!-- 列表过渡 -->
    <transition-group name="list" tag="ul" class="list-group">
      <li v-for="item in items"
          :key="item.id"
          class="list-group-item d-flex justify-content-between align-items-center">
        {{ item.name }}
        <button @click="removeSpecificItem(item.id)" class="btn btn-sm btn-danger">
          ×
        </button>
      </li>
    </transition-group>

    <p class="mt-2">项目数量: {{ items.length }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, name: '项目 1' },
        { id: 2, name: '项目 2' },
        { id: 3, name: '项目 3' },
        { id: 4, name: '项目 4' },
        { id: 5, name: '项目 5' }
      ],
      nextId: 6
    };
  },
  methods: {
    addItem() {
      this.items.push({
        id: this.nextId++,
        name: `项目 ${this.nextId - 1}`
      });
    },
    removeItem() {
      if (this.items.length > 0) {
        this.items.pop();
      }
    },
    removeSpecificItem(id) {
      this.items = this.items.filter(item => item.id !== id);
    },
    shuffleItems() {
      // 随机排序
      this.items = this.items.sort(() => Math.random() - 0.5);
    }
  }
};
</script>

<style>
/* 进入和离开动画 */
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}
.list-enter-from {
  opacity: 0;
  transform: translateY(30px);
}
.list-leave-to {
  opacity: 0;
  transform: translateX(-100%);
}

/* 确保离开的元素不占用布局空间 */
.list-leave-active {
  position: absolute;
  width: 100%;
}

/* 移动过渡 */
.list-move {
  transition: transform 0.5s ease;
}
</style>
                                        

<template>
  <div>
    <h3>移动过渡示例</h3>

    <div class="mb-3">
      <button @click="shuffle" class="btn btn-primary me-2">
        随机排序
      </button>
      <button @click="add" class="btn btn-success me-2">
        添加数字
      </button>
      <button @click="reset" class="btn btn-warning">
        重置
      </button>
    </div>

    <transition-group name="flip-list" tag="div" class="number-list">
      <div v-for="item in items"
           :key="item"
           class="number-item">
        {{ item }}
      </div>
    </transition-group>

    <h4 class="mt-4">可拖拽列表</h4>
    <transition-group name="drag-list" tag="div" class="draggable-list">
      <div v-for="item in draggableItems"
           :key="item.id"
           class="draggable-item"
           draggable="true"
           @dragstart="dragStart(item.id)"
           @dragover.prevent
           @drop="drop(item.id)">
        {{ item.name }}
        <span class="drag-handle">☰</span>
      </div>
    </transition-group>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
      draggableItems: [
        { id: 1, name: '任务 1' },
        { id: 2, name: '任务 2' },
        { id: 3, name: '任务 3' },
        { id: 4, name: '任务 4' },
        { id: 5, name: '任务 5' }
      ],
      draggedItemId: null
    };
  },
  methods: {
    shuffle() {
      this.items = this.items.sort(() => Math.random() - 0.5);
    },
    add() {
      const max = Math.max(...this.items);
      this.items.push(max + 1);
    },
    reset() {
      this.items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    },
    dragStart(id) {
      this.draggedItemId = id;
    },
    drop(targetId) {
      if (this.draggedItemId === null) return;

      const draggedIndex = this.draggableItems.findIndex(
        item => item.id === this.draggedItemId
      );
      const targetIndex = this.draggableItems.findIndex(
        item => item.id === targetId
      );

      if (draggedIndex !== -1 && targetIndex !== -1) {
        // 移动元素
        const item = this.draggableItems[draggedIndex];
        this.draggableItems.splice(draggedIndex, 1);
        this.draggableItems.splice(targetIndex, 0, item);
      }

      this.draggedItemId = null;
    }
  }
};
</script>

<style>
.number-list {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.number-item {
  width: 50px;
  height: 50px;
  background-color: #3498db;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 5px;
  font-size: 1.2rem;
  font-weight: bold;
}

/* FLIP动画 */
.flip-list-move {
  transition: transform 0.5s ease;
}

.draggable-list {
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.draggable-item {
  padding: 10px 15px;
  background-color: #f8f9fa;
  border: 1px solid #dee2e6;
  border-radius: 5px;
  cursor: move;
  display: flex;
  justify-content: space-between;
  align-items: center;
  transition: all 0.3s ease;
}

.draggable-item:hover {
  background-color: #e9ecef;
  transform: translateX(5px);
}

.drag-handle {
  color: #6c757d;
  cursor: grab;
}

.drag-list-move {
  transition: transform 0.3s ease;
}
</style>
                                        

<template>
  <div>
    <h3>交错动画示例</h3>

    <div class="mb-3">
      <button @click="addMultiple" class="btn btn-primary me-2">
        添加5个项目
      </button>
      <button @click="removeMultiple" class="btn btn-danger me-2">
        移除5个项目
      </button>
      <button @click="toggleList" class="btn btn-warning">
        {{ showList ? '隐藏' : '显示' }}列表
      </button>
    </div>

    <!-- 使用JavaScript钩子实现交错动画 -->
    <transition-group
      tag="div"
      class="stagger-list"
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
      :css="false">
      <div v-for="(item, index) in items"
           v-show="showList"
           :key="item.id"
           :data-index="index"
           class="stagger-item">
        {{ item.name }}
      </div>
    </transition-group>

    <!-- 使用CSS animation-delay实现交错动画 -->
    <transition-group
      v-if="showList"
      name="stagger"
      tag="div"
      class="stagger-list-css">
      <div v-for="(item, index) in cssItems"
           :key="item.id"
           :data-index="index"
           class="stagger-css-item">
        {{ item.name }}
      </div>
    </transition-group>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, name: '项目 1' },
        { id: 2, name: '项目 2' },
        { id: 3, name: '项目 3' },
        { id: 4, name: '项目 4' },
        { id: 5, name: '项目 5' }
      ],
      cssItems: [
        { id: 1, name: 'CSS 项目 1' },
        { id: 2, name: 'CSS 项目 2' },
        { id: 3, name: 'CSS 项目 3' },
        { id: 4, name: 'CSS 项目 4' },
        { id: 5, name: 'CSS 项目 5' }
      ],
      nextId: 6,
      nextCssId: 6,
      showList: true
    };
  },
  methods: {
    addMultiple() {
      for (let i = 0; i < 5; i++) {
        this.items.push({
          id: this.nextId++,
          name: `项目 ${this.nextId - 1}`
        });
        this.cssItems.push({
          id: this.nextCssId++,
          name: `CSS 项目 ${this.nextCssId - 1}`
        });
      }
    },
    removeMultiple() {
      for (let i = 0; i < 5; i++) {
        if (this.items.length > 0) this.items.pop();
        if (this.cssItems.length > 0) this.cssItems.pop();
      }
    },
    toggleList() {
      this.showList = !this.showList;
    },

    // JavaScript交错动画
    beforeEnter(el) {
      el.style.opacity = 0;
      el.style.transform = 'translateY(20px)';
    },
    enter(el, done) {
      const delay = el.dataset.index * 100; // 根据索引设置延迟

      setTimeout(() => {
        // 使用Web Animations API
        el.animate([
          {
            opacity: 0,
            transform: 'translateY(20px)'
          },
          {
            opacity: 1,
            transform: 'translateY(0)'
          }
        ], {
          duration: 300,
          easing: 'ease-out',
          fill: 'forwards'
        }).onfinish = done;
      }, delay);
    },
    leave(el, done) {
      const delay = el.dataset.index * 100;

      setTimeout(() => {
        el.animate([
          {
            opacity: 1,
            transform: 'translateY(0)'
          },
          {
            opacity: 0,
            transform: 'translateY(20px)'
          }
        ], {
          duration: 300,
          easing: 'ease-in',
          fill: 'forwards'
        }).onfinish = done;
      }, delay);
    }
  }
};
</script>

<style>
.stagger-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-bottom: 20px;
}

.stagger-item {
  padding: 15px;
  background-color: #3498db;
  color: white;
  border-radius: 5px;
  text-align: center;
}

.stagger-list-css {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.stagger-css-item {
  padding: 15px;
  background-color: #2ecc71;
  color: white;
  border-radius: 5px;
  text-align: center;
}

/* CSS交错动画 */
.stagger-enter-active {
  animation: stagger-enter 0.5s ease-out;
  animation-fill-mode: both;
}

.stagger-leave-active {
  animation: stagger-leave 0.5s ease-in;
  animation-fill-mode: both;
}

.stagger-move {
  transition: transform 0.5s ease;
}

/* 为每个元素设置不同的动画延迟 */
.stagger-enter-active:nth-child(1) { animation-delay: 0.1s; }
.stagger-enter-active:nth-child(2) { animation-delay: 0.2s; }
.stagger-enter-active:nth-child(3) { animation-delay: 0.3s; }
.stagger-enter-active:nth-child(4) { animation-delay: 0.4s; }
.stagger-enter-active:nth-child(5) { animation-delay: 0.5s; }

.stagger-leave-active:nth-child(1) { animation-delay: 0.1s; }
.stagger-leave-active:nth-child(2) { animation-delay: 0.2s; }
.stagger-leave-active:nth-child(3) { animation-delay: 0.3s; }
.stagger-leave-active:nth-child(4) { animation-delay: 0.4s; }
.stagger-leave-active:nth-child(5) { animation-delay: 0.5s; }

@keyframes stagger-enter {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes stagger-leave {
  from {
    opacity: 1;
    transform: translateY(0);
  }
  to {
    opacity: 0;
    transform: translateY(20px);
  }
}
</style>
                                        

<transition> vs <transition-group>

特性 <transition> <transition-group>
用途 单个元素/组件的过渡 多个元素(列表)的过渡
渲染元素 不渲染为DOM元素 渲染为真实的DOM元素(默认是span)
tag属性 不支持 支持,用于指定渲染的标签
key属性 不需要 必须为每个子元素设置key
移动过渡 不支持 支持,使用v-move
FLIP动画 不支持 内置支持,优化性能
性能优化: 对于大型列表的动画,使用 <transition-group> 时,确保为每个元素设置唯一的 key,这样 Vue 可以更高效地跟踪元素的变化。对于非常长的列表,考虑使用虚拟滚动等技术来优化性能。

6. 组件缓存 <keep-alive>

<keep-alive> 组件用于缓存不活动的组件实例,避免重复渲染,保持组件状态。

组件缓存示例
状态保持

<template>
  <div>
    <h3>组件缓存示例</h3>

    <div class="mb-3">
      <button @click="currentTab = 'TabA'" class="btn btn-primary me-2">
        标签页 A
      </button>
      <button @click="currentTab = 'TabB'" class="btn btn-success me-2">
        标签页 B
      </button>
      <button @click="currentTab = 'TabC'" class="btn btn-info">
        标签页 C
      </button>
    </div>

    <div class="mb-3">
      <div class="form-check form-check-inline">
        <input v-model="useKeepAlive" type="checkbox" id="keepAliveCheck" class="form-check-input">
        <label for="keepAliveCheck" class="form-check-label">
          使用 keep-alive 缓存组件
        </label>
      </div>
    </div>

    <!-- 不使用 keep-alive -->
    <div v-if="!useKeepAlive" class="border p-3">
      <h4>不使用 keep-alive</h4>
      <component :is="currentTab"></component>
    </div>

    <!-- 使用 keep-alive -->
    <div v-else class="border p-3">
      <h4>使用 keep-alive</h4>
      <keep-alive>
        <component :is="currentTab"></component>
      </keep-alive>
    </div>

    <div class="mt-3">
      <p>当前标签页: {{ currentTab }}</p>
      <p>组件状态: {{ useKeepAlive ? '已缓存' : '未缓存' }}</p>
    </div>
  </div>
</template>

<script>
// 定义标签页组件
const TabA = {
  name: 'TabA',
  template: `<div class="tab-content">
              <h5>标签页 A</h5>
              <p>这是一个计数器和输入框示例。</p>
              <div class="mb-2">
                <p>计数器: {{ count }}</p>
                <button @click="count++" class="btn btn-sm btn-primary">增加</button>
              </div>
              <div>
                <label>输入内容:</label>
                <input v-model="text" type="text" class="form-control">
                <p>输入的内容: {{ text }}</p>
              </div>
            </div>`,
  data() {
    return {
      count: 0,
      text: ''
    };
  },
  created() {
    console.log('TabA created');
  },
  mounted() {
    console.log('TabA mounted');
  },
  activated() {
    console.log('TabA activated');
  },
  deactivated() {
    console.log('TabA deactivated');
  },
  destroyed() {
    console.log('TabA destroyed');
  }
};

const TabB = {
  name: 'TabB',
  template: `<div class="tab-content">
              <h5>标签页 B</h5>
              <p>这是一个待办事项列表示例。</p>
              <div class="mb-2">
                <input v-model="newTodo" type="text"
                       placeholder="输入待办事项" class="form-control">
                <button @click="addTodo" class="btn btn-sm btn-success mt-2">添加</button>
              </div>
              <ul>
                <li v-for="(todo, index) in todos" :key="index">
                  {{ todo }}
                  <button @click="removeTodo(index)" class="btn btn-sm btn-danger">×</button>
                </li>
              </ul>
            </div>`,
  data() {
    return {
      newTodo: '',
      todos: ['学习Vue', '练习组件', '完成项目']
    };
  },
  methods: {
    addTodo() {
      if (this.newTodo.trim()) {
        this.todos.push(this.newTodo);
        this.newTodo = '';
      }
    },
    removeTodo(index) {
      this.todos.splice(index, 1);
    }
  },
  created() {
    console.log('TabB created');
  },
  mounted() {
    console.log('TabB mounted');
  },
  activated() {
    console.log('TabB activated');
  },
  deactivated() {
    console.log('TabB deactivated');
  },
  destroyed() {
    console.log('TabB destroyed');
  }
};

const TabC = {
  name: 'TabC',
  template: `<div class="tab-content">
              <h5>标签页 C</h5>
              <p>这是一个表单示例。</p>
              <form @submit.prevent="submitForm">
                <div class="mb-2">
                  <label>用户名:</label>
                  <input v-model="form.username" type="text" class="form-control" required>
                </div>
                <div class="mb-2">
                  <label>邮箱:</label>
                  <input v-model="form.email" type="email" class="form-control" required>
                </div>
                <div class="mb-2">
                  <label>消息:</label>
                  <textarea v-model="form.message" class="form-control" rows="3"></textarea>
                </div>
                <button type="submit" class="btn btn-primary">提交</button>
              </form>
              <div v-if="submitted" class="alert alert-success mt-2">
                表单已提交!
              </div>
            </div>`,
  data() {
    return {
      form: {
        username: '',
        email: '',
        message: ''
      },
      submitted: false
    };
  },
  methods: {
    submitForm() {
      console.log('表单提交:', this.form);
      this.submitted = true;
    }
  },
  created() {
    console.log('TabC created');
  },
  mounted() {
    console.log('TabC mounted');
  },
  activated() {
    console.log('TabC activated');
  },
  deactivated() {
    console.log('TabC deactivated');
  },
  destroyed() {
    console.log('TabC destroyed');
  }
};

export default {
  components: {
    TabA,
    TabB,
    TabC
  },
  data() {
    return {
      currentTab: 'TabA',
      useKeepAlive: true
    };
  }
};
</script>

<style>
.tab-content {
  padding: 15px;
  background-color: #f8f9fa;
  border-radius: 5px;
  min-height: 200px;
}
</style>
                                        

<template>
  <div>
    <h3>keep-alive 生命周期</h3>

    <div class="mb-3">
      <button @click="toggleComponent" class="btn btn-primary">
        {{ showComponent ? '隐藏' : '显示' }}组件
      </button>
    </div>

    <div class="lifecycle-log">
      <h5>生命周期事件日志</h5>
      <ul>
        <li v-for="(event, index) in events" :key="index">
          {{ event }}
        </li>
      </ul>
    </div>

    <keep-alive>
      <LifecycleDemo v-if="showComponent" @log="addEvent" />
    </keep-alive>
  </div>
</template>

<script>
const LifecycleDemo = {
  name: 'LifecycleDemo',
  template: `<div class="demo-component">
              <h5>生命周期演示组件</h5>
              <p>计数器: {{ count }}</p>
              <button @click="count++" class="btn btn-sm btn-primary">增加</button>
            </div>`,
  data() {
    return {
      count: 0
    };
  },
  beforeCreate() {
    this.$emit('log', 'beforeCreate - 实例初始化之前');
  },
  created() {
    this.$emit('log', 'created - 实例创建完成');
  },
  beforeMount() {
    this.$emit('log', 'beforeMount - 挂载之前');
  },
  mounted() {
    this.$emit('log', 'mounted - 挂载完成');
  },
  beforeUpdate() {
    this.$emit('log', 'beforeUpdate - 更新之前');
  },
  updated() {
    this.$emit('log', 'updated - 更新完成');
  },
  activated() {
    this.$emit('log', 'activated - 被 keep-alive 缓存的组件激活时');
  },
  deactivated() {
    this.$emit('log', 'deactivated - 被 keep-alive 缓存的组件停用时');
  },
  beforeUnmount() {
    this.$emit('log', 'beforeUnmount - 卸载之前');
  },
  unmounted() {
    this.$emit('log', 'unmounted - 卸载完成');
  }
};

export default {
  components: {
    LifecycleDemo
  },
  data() {
    return {
      showComponent: true,
      events: []
    };
  },
  methods: {
    toggleComponent() {
      this.showComponent = !this.showComponent;
    },
    addEvent(event) {
      this.events.unshift(`[${new Date().toLocaleTimeString()}] ${event}`);
      // 保持日志数量不超过10条
      if (this.events.length > 10) {
        this.events.pop();
      }
    }
  }
};
</script>

<style>
.lifecycle-log {
  max-height: 300px;
  overflow-y: auto;
  background-color: #f8f9fa;
  border: 1px solid #dee2e6;
  border-radius: 5px;
  padding: 15px;
  margin-bottom: 15px;
}

.lifecycle-log ul {
  list-style-type: none;
  padding-left: 0;
  margin-bottom: 0;
}

.lifecycle-log li {
  padding: 5px 0;
  border-bottom: 1px dashed #dee2e6;
  font-family: 'Courier New', monospace;
  font-size: 0.9rem;
}

.lifecycle-log li:last-child {
  border-bottom: none;
}

.demo-component {
  padding: 15px;
  background-color: #e8f4ff;
  border: 1px solid #3498db;
  border-radius: 5px;
}
</style>
                                        

<template>
  <div>
    <h3>条件缓存与缓存控制</h3>

    <div class="mb-3">
      <button @click="currentView = 'Home'" class="btn btn-primary me-2">
        首页
      </button>
      <button @click="currentView = 'Profile'" class="btn btn-success me-2">
        个人资料
      </button>
      <button @click="currentView = 'Settings'" class="btn btn-info me-2">
        设置
      </button>
      <button @click="currentView = 'Help'" class="btn btn-warning">
        帮助
      </button>
    </div>

    <!-- 条件缓存 -->
    <keep-alive :include="cachedComponents" :exclude="excludedComponents" :max="maxCache">
      <component :is="currentView"></component>
    </keep-alive>

    <div class="mt-3">
      <h5>缓存控制</h5>
      <div class="row">
        <div class="col-md-6">
          <h6>包含的组件 (include):</h6>
          <div class="form-check" v-for="component in allComponents" :key="component">
            <input v-model="cachedComponents"
                   :value="component"
                   type="checkbox"
                   :id="'include-' + component"
                   class="form-check-input">
            <label :for="'include-' + component" class="form-check-label">
              {{ component }}
            </label>
          </div>
        </div>
        <div class="col-md-6">
          <h6>排除的组件 (exclude):</h6>
          <div class="form-check" v-for="component in allComponents" :key="component">
            <input v-model="excludedComponents"
                   :value="component"
                   type="checkbox"
                   :id="'exclude-' + component"
                   class="form-check-input">
            <label :for="'exclude-' + component" class="form-check-label">
              {{ component }}
            </label>
          </div>
        </div>
      </div>

      <div class="mt-3">
        <label>最大缓存数 (max):</label>
        <input v-model.number="maxCache" type="range" min="1" max="5" class="form-range">
        <span>{{ maxCache }}</span>
      </div>
    </div>

    <div class="mt-3">
      <h5>缓存状态</h5>
      <p>当前组件: {{ currentView }}</p>
      <p>已缓存组件: {{ cachedComponents.join(', ') || '无' }}</p>
      <p>排除组件: {{ excludedComponents.join(', ') || '无' }}</p>
      <p>缓存数量限制: {{ maxCache }}</p>
    </div>
  </div>
</template>

<script>
// 定义组件
const Home = {
  name: 'Home',
  template: `<div class="component-box">
              <h5>首页</h5>
              <p>访问次数: {{ count }}</p>
              <button @click="count++" class="btn btn-sm btn-primary">访问</button>
            </div>`,
  data() {
    return {
      count: 0
    };
  },
  activated() {
    console.log('Home activated');
  },
  deactivated() {
    console.log('Home deactivated');
  }
};

const Profile = {
  name: 'Profile',
  template: `<div class="component-box">
              <h5>个人资料</h5>
              <div class="mb-2">
                <label>用户名:</label>
                <input v-model="username" type="text" class="form-control">
              </div>
              <p>当前用户名: {{ username }}</p>
            </div>`,
  data() {
    return {
      username: '用户' + Math.floor(Math.random() * 1000)
    };
  },
  activated() {
    console.log('Profile activated');
  },
  deactivated() {
    console.log('Profile deactivated');
  }
};

const Settings = {
  name: 'Settings',
  template: `<div class="component-box">
              <h5>设置</h5>
              <p>设置项: {{ settings }}</p>
              <button @click="addSetting" class="btn btn-sm btn-success">添加设置项</button>
            </div>`,
  data() {
    return {
      settings: ['主题', '语言']
    };
  },
  methods: {
    addSetting() {
      this.settings.push('设置项' + (this.settings.length + 1));
    }
  },
  activated() {
    console.log('Settings activated');
  },
  deactivated() {
    console.log('Settings deactivated');
  }
};

const Help = {
  name: 'Help',
  template: `<div class="component-box">
              <h5>帮助</h5>
              <p>帮助内容加载时间: {{ loadTime }}</p>
            </div>`,
  data() {
    return {
      loadTime: new Date().toLocaleTimeString()
    };
  },
  activated() {
    console.log('Help activated');
  },
  deactivated() {
    console.log('Help deactivated');
  }
};

export default {
  components: {
    Home,
    Profile,
    Settings,
    Help
  },
  data() {
    return {
      currentView: 'Home',
      allComponents: ['Home', 'Profile', 'Settings', 'Help'],
      cachedComponents: ['Home', 'Profile'], // 默认缓存Home和Profile
      excludedComponents: [], // 默认不排除任何组件
      maxCache: 3 // 默认最大缓存3个组件
    };
  }
};
</script>

<style>
.component-box {
  padding: 20px;
  background-color: #f8f9fa;
  border: 1px solid #dee2e6;
  border-radius: 5px;
  min-height: 150px;
}
</style>
                                        
组件缓存演示

使用 <keep-alive> 可以保持组件状态:

使用 keep-alive 时:

  • 组件切换时不会销毁和重新创建
  • 组件的状态(数据、DOM状态)会被保留
  • 触发 activateddeactivated 生命周期钩子
  • 不会触发 createdmountedunmounted 等钩子

不使用 keep-alive 时:

  • 组件切换时会销毁和重新创建
  • 组件的状态会丢失
  • 触发完整的生命周期钩子

keep-alive 属性配置

属性 描述 类型 默认值 示例
include 只有名称匹配的组件会被缓存 String, RegExp, Array - include="Home,Profile"
exclude 名称匹配的组件不会被缓存 String, RegExp, Array - exclude="Settings"
max 最多可以缓存的组件实例数量 Number - :max="5"
注意事项:<keep-alive> 缓存的组件不会触发 unmounted 生命周期钩子,而是触发 deactivated 钩子。当组件再次被显示时,会触发 activated 钩子而不是 mounted 钩子。

7. DOM传送 <teleport>

<teleport> 组件允许我们将子组件渲染到DOM中的其他位置,而不受父组件DOM结构的限制。

DOM传送示例
DOM操作
传送目标
DOM传送演示区域

下面的按钮会将模态框传送到上面的红色区域。


<template>
  <div>
    <h3>基本 Teleport 示例</h3>

    <!-- 传送目标 -->
    <div id="modal-container" class="teleport-target">
      <h5>模态框容器</h5>
    </div>

    <!-- 应用主体 -->
    <div class="app-content">
      <h4>应用内容</h4>
      <p>点击按钮将模态框传送到上面的容器中。</p>

      <button @click="showModal = true" class="btn btn-primary">
        显示模态框
      </button>

      <!-- 使用 teleport 传送模态框 -->
      <teleport to="#modal-container">
        <Modal v-if="showModal" @close="showModal = false">
          <template #header>
            <h5>这是一个模态框</h5>
          </template>
          <template #body>
            <p>这个模态框被传送到 #modal-container 元素中。</p>
            <p>即使 Modal 组件在 App 组件中定义,它也会被渲染到指定的目标位置。</p>
          </template>
        </Modal>
      </teleport>
    </div>

    <!-- 传送到 body -->
    <button @click="showToast = true" class="btn btn-success mt-3">
      显示 Toast 通知
    </button>

    <teleport to="body">
      <Toast v-if="showToast" @close="showToast = false">
        这是一个 Toast 通知,被传送到 body 元素。
      </Toast>
    </teleport>
  </div>
</template>

<script>
// 模态框组件
const Modal = {
  props: ['show'],
  emits: ['close'],
  template: `<div class="modal-overlay" @click.self="$emit('close')">
              <div class="modal-content">
                <div class="modal-header">
                  <slot name="header"></slot>
                  <button @click="$emit('close')" class="close-btn">×</button>
                </div>
                <div class="modal-body">
                  <slot name="body"></slot>
                </div>
                <div class="modal-footer">
                  <button @click="$emit('close')" class="btn btn-primary">关闭</button>
                </div>
              </div>
            </div>`
};

// Toast 组件
const Toast = {
  emits: ['close'],
  template: `<div class="toast">
              <div class="toast-content">
                <slot></slot>
                <button @click="$emit('close')" class="toast-close">×</button>
              </div>
            </div>`,
  mounted() {
    // 3秒后自动关闭
    setTimeout(() => {
      this.$emit('close');
    }, 3000);
  }
};

export default {
  components: {
    Modal,
    Toast
  },
  data() {
    return {
      showModal: false,
      showToast: false
    };
  }
};
</script>

<style>
.teleport-target {
  position: relative;
  padding: 20px;
  background-color: #e74c3c;
  color: white;
  border-radius: 5px;
  margin-bottom: 20px;
  min-height: 100px;
}

.app-content {
  padding: 20px;
  background-color: #f8f9fa;
  border: 1px solid #dee2e6;
  border-radius: 5px;
}

/* 模态框样式 */
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-content {
  background-color: white;
  border-radius: 5px;
  width: 500px;
  max-width: 90%;
  max-height: 80%;
  overflow: auto;
}

.modal-header {
  padding: 15px 20px;
  border-bottom: 1px solid #dee2e6;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.modal-body {
  padding: 20px;
}

.modal-footer {
  padding: 15px 20px;
  border-top: 1px solid #dee2e6;
  text-align: right;
}

.close-btn {
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  color: #6c757d;
}

.close-btn:hover {
  color: #343a40;
}

/* Toast 样式 */
.toast {
  position: fixed;
  bottom: 20px;
  right: 20px;
  z-index: 1050;
}

.toast-content {
  background-color: #333;
  color: white;
  padding: 15px 20px;
  border-radius: 5px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-width: 300px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.toast-close {
  background: none;
  border: none;
  color: white;
  font-size: 1.2rem;
  cursor: pointer;
  margin-left: 15px;
}
</style>
                                        

<template>
  <div>
    <h3>条件传送示例</h3>

    <div class="mb-3">
      <div class="form-check form-check-inline">
        <input v-model="useTeleport" type="checkbox" id="useTeleport" class="form-check-input">
        <label for="useTeleport" class="form-check-label">使用 teleport</label>
      </div>
      <div class="form-check form-check-inline">
        <input v-model="disableTeleport" type="checkbox" id="disableTeleport" class="form-check-input">
        <label for="disableTeleport" class="form-check-label">禁用 teleport</label>
      </div>
    </div>

    <div class="mb-3">
      <label>选择传送目标:</label>
      <select v-model="teleportTo" class="form-select">
        <option value="#target-a">目标 A</option>
        <option value="#target-b">目标 B</option>
        <option value="body">body 元素</option>
      </select>
    </div>

    <!-- 传送目标 -->
    <div class="row">
      <div class="col-md-6">
        <div id="target-a" class="teleport-target target-a">
          <h5>目标 A</h5>
        </div>
      </div>
      <div class="col-md-6">
        <div id="target-b" class="teleport-target target-b">
          <h5>目标 B</h5>
        </div>
      </div>
    </div>

    <!-- 应用内容 -->
    <div class="app-content mt-3">
      <h4>应用内容区域</h4>
      <button @click="showContent = !showContent" class="btn btn-primary">
        {{ showContent ? '隐藏' : '显示' }}内容
      </button>

      <!-- 条件传送 -->
      <template v-if="showContent">
        <template v-if="useTeleport">
          <teleport :to="teleportTo" :disabled="disableTeleport">
            <TeleportedContent />
          </teleport>
        </template>
        <template v-else>
          <TeleportedContent />
        </template>
      </template>
    </div>

    <div class="mt-3">
      <p>当前配置:</p>
      <ul>
        <li>使用 teleport: {{ useTeleport ? '是' : '否' }}</li>
        <li>传送目标: {{ teleportTo }}</li>
        <li>禁用 teleport: {{ disableTeleport ? '是' : '否' }}</li>
        <li>内容显示: {{ showContent ? '是' : '否' }}</li>
      </ul>
    </div>
  </div>
</template>

<script>
const TeleportedContent = {
  name: 'TeleportedContent',
  template: `<div class="content-box">
              <h5>可传送的内容</h5>
              <p>计数器: {{ count }}</p>
              <button @click="count++" class="btn btn-sm btn-primary">增加</button>
              <p class="mt-2">这个组件可以被传送到不同的位置。</p>
            </div>`,
  data() {
    return {
      count: 0
    };
  },
  created() {
    console.log('TeleportedContent created');
  },
  mounted() {
    console.log('TeleportedContent mounted');
  },
  unmounted() {
    console.log('TeleportedContent unmounted');
  }
};

export default {
  components: {
    TeleportedContent
  },
  data() {
    return {
      useTeleport: true,
      disableTeleport: false,
      teleportTo: '#target-a',
      showContent: true
    };
  }
};
</script>

<style>
.target-a {
  background-color: #e74c3c;
}

.target-b {
  background-color: #3498db;
}

.content-box {
  padding: 20px;
  background-color: #2ecc71;
  color: white;
  border-radius: 5px;
  margin-top: 10px;
}
</style>
                                        

<template>
  <div>
    <h3>多个 Teleport 示例</h3>

    <!-- 多个传送目标 -->
    <div class="teleport-targets">
      <div id="header-target" class="target-header">
        <h5>头部区域</h5>
      </div>
      <div id="sidebar-target" class="target-sidebar">
        <h5>侧边栏区域</h5>
      </div>
      <div id="footer-target" class="target-footer">
        <h5>页脚区域</h5>
      </div>
    </div>

    <!-- 应用内容 -->
    <div class="app-main">
      <h4>应用主内容</h4>
      <p>这个组件管理多个可传送到不同位置的内容。</p>

      <div class="mb-3">
        <button @click="toggleHeader" class="btn btn-primary me-2">
          {{ showHeader ? '隐藏' : '显示' }}头部内容
        </button>
        <button @click="toggleSidebar" class="btn btn-success me-2">
          {{ showSidebar ? '隐藏' : '显示' }}侧边栏内容
        </button>
        <button @click="toggleFooter" class="btn btn-info">
          {{ showFooter ? '隐藏' : '显示' }}页脚内容
        </button>
      </div>

      <!-- 传送到头部 -->
      <teleport to="#header-target">
        <HeaderContent v-if="showHeader" />
      </teleport>

      <!-- 传送到侧边栏 -->
      <teleport to="#sidebar-target">
        <SidebarContent v-if="showSidebar" />
      </teleport>

      <!-- 传送到页脚 -->
      <teleport to="#footer-target">
        <FooterContent v-if="showFooter" />
      </teleport>

      <!-- 多个 teleport 到同一个目标 -->
      <div class="mt-4">
        <h5>多个内容传送到同一个目标</h5>
        <button @click="addNotification" class="btn btn-warning">
          添加通知
        </button>

        <!-- 通知容器 -->
        <div id="notification-container"></div>

        <!-- 多个 teleport 到同一个目标 -->
        <teleport v-for="(notification, index) in notifications"
                 :key="notification.id"
                 to="#notification-container">
          <Notification :message="notification.message"
                       @close="removeNotification(notification.id)" />
        </teleport>
      </div>
    </div>
  </div>
</template>

<script>
// 头部内容组件
const HeaderContent = {
  template: `<div class="header-content">
              <span>网站标题 | 用户: 张三 | </span>
              <button @click="$emit('logout')" class="btn btn-sm btn-outline-light">
                退出登录
              </button>
            </div>`
};

// 侧边栏内容组件
const SidebarContent = {
  template: `<div class="sidebar-content">
              <ul>
                <li><a href="#">首页</a></li>
                <li><a href="#">个人资料</a></li>
                <li><a href="#">设置</a></li>
                <li><a href="#">帮助</a></li>
              </ul>
            </div>`
};

// 页脚内容组件
const FooterContent = {
  template: `<div class="footer-content">
              <p>© 2023 我的网站. 保留所有权利.</p>
              <p><a href="#">隐私政策</a> | <a href="#">使用条款</a></p>
            </div>`
};

// 通知组件
const Notification = {
  props: ['message'],
  emits: ['close'],
  template: `<div class="notification">
              <span>{{ message }}</span>
              <button @click="$emit('close')" class="close-btn">×</button>
            </div>`,
  mounted() {
    // 5秒后自动关闭
    setTimeout(() => {
      this.$emit('close');
    }, 5000);
  }
};

export default {
  components: {
    HeaderContent,
    SidebarContent,
    FooterContent,
    Notification
  },
  data() {
    return {
      showHeader: true,
      showSidebar: true,
      showFooter: true,
      notifications: [],
      nextNotificationId: 1
    };
  },
  methods: {
    toggleHeader() {
      this.showHeader = !this.showHeader;
    },
    toggleSidebar() {
      this.showSidebar = !this.showSidebar;
    },
    toggleFooter() {
      this.showFooter = !this.showFooter;
    },
    addNotification() {
      this.notifications.push({
        id: this.nextNotificationId++,
        message: `通知 #${this.nextNotificationId - 1}: ${new Date().toLocaleTimeString()}`
      });
    },
    removeNotification(id) {
      this.notifications = this.notifications.filter(n => n.id !== id);
    }
  }
};
</script>

<style>
.teleport-targets {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 10px;
  margin-bottom: 20px;
}

.target-header {
  background-color: #2c3e50;
  color: white;
  padding: 15px;
  border-radius: 5px;
}

.target-sidebar {
  background-color: #34495e;
  color: white;
  padding: 15px;
  border-radius: 5px;
}

.target-footer {
  background-color: #7f8c8d;
  color: white;
  padding: 15px;
  border-radius: 5px;
}

.app-main {
  padding: 20px;
  background-color: #f8f9fa;
  border: 1px solid #dee2e6;
  border-radius: 5px;
}

.header-content {
  display: flex;
  align-items: center;
  gap: 10px;
}

.sidebar-content ul {
  list-style-type: none;
  padding-left: 0;
  margin-bottom: 0;
}

.sidebar-content li {
  padding: 5px 0;
}

.sidebar-content a {
  color: white;
  text-decoration: none;
}

.sidebar-content a:hover {
  text-decoration: underline;
}

.footer-content {
  text-align: center;
}

.footer-content a {
  color: white;
}

#notification-container {
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 1000;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.notification {
  background-color: #333;
  color: white;
  padding: 10px 15px;
  border-radius: 5px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-width: 250px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}

.close-btn {
  background: none;
  border: none;
  color: white;
  font-size: 1.2rem;
  cursor: pointer;
  margin-left: 10px;
}
</style>
                                        

Teleport 使用场景

场景 描述 示例
模态框/对话框 避免模态框受到父组件CSS的影响 <teleport to="body"><Modal></teleport>
通知/Toast 全局通知显示在页面固定位置 <teleport to="#notifications">
工具提示 确保提示框显示在正确层级 <teleport to="#tooltip-root">
加载遮罩 全屏加载动画不受布局限制 <teleport to="body"><Loading></teleport>
上下文菜单 确保菜单显示在正确位置 <teleport to="#context-menu">
Teleport vs 普通渲染: 使用 <teleport> 时,组件在逻辑上仍然是父组件的子组件(可以访问父组件的props、provide/inject等),但在DOM结构上被移动到了目标位置。这使得你可以保持组件逻辑的完整性,同时解决一些CSS层级和布局问题。

8. 异步组件 <suspense>

<suspense> 组件用于处理异步组件的加载状态,提供更好的用户体验。

异步组件示例
异步加载

<template>
  <div>
    <h3>基本 Suspense 示例</h3>

    <div class="mb-3">
      <button @click="loadComponent" class="btn btn-primary" :disabled="isLoading">
        {{ isLoading ? '加载中...' : '加载异步组件' }}
      </button>
      <button @click="reset" class="btn btn-secondary">重置</button>
    </div>

    <!-- 使用 Suspense -->
    <Suspense v-if="showComponent">
      <!-- 默认插槽:要渲染的异步组件 -->
      <template #default>
        <AsyncComponent />
      </template>

      <!-- fallback 插槽:加载状态 -->
      <template #fallback>
        <div class="loading-state">
          <div class="spinner"></div>
          <p>正在加载组件,请稍候...</p>
        </div>
      </template>
    </Suspense>

    <!-- 模拟的异步组件 -->
    <Suspense>
      <template #default>
        <MockAsyncComponent />
      </template>
      <template #fallback>
        <div class="loading-state">
          <p>模拟组件加载中...</p>
        </div>
      </template>
    </Suspense>
  </div>
</template>

<script>
// 异步组件定义
const AsyncComponent = {
  name: 'AsyncComponent',
  // 异步 setup 函数
  async setup() {
    console.log('AsyncComponent setup开始');

    // 模拟异步数据加载
    const data = await new Promise(resolve => {
      setTimeout(() => {
        resolve({
          title: '异步加载的组件',
          content: '这个组件是异步加载的。',
          items: ['项目1', '项目2', '项目3', '项目4', '项目5']
        });
      }, 2000);
    });

    console.log('AsyncComponent setup完成');

    // 返回响应式数据
    return {
      ...data,
      count: 0
    };
  },
  template: `<div class="async-component">
              <h4>{{ title }}</h4>
              <p>{{ content }}</p>
              <p>计数器: {{ count }}</p>
              <button @click="count++" class="btn btn-sm btn-primary">增加</button>

              <ul class="mt-3">
                <li v-for="(item, index) in items" :key="index">
                  {{ item }}
                </li>
              </ul>
            </div>`
};

// 模拟异步组件
const MockAsyncComponent = {
  name: 'MockAsyncComponent',
  async setup() {
    // 等待1秒
    await new Promise(resolve => setTimeout(resolve, 1000));

    return {
      message: '模拟异步组件已加载完成!',
      loadedAt: new Date().toLocaleTimeString()
    };
  },
  template: `<div class="mock-async">
              <h5>模拟异步组件</h5>
              <p>{{ message }}</p>
              <p>加载时间: {{ loadedAt }}</p>
            </div>`
};

export default {
  components: {
    AsyncComponent,
    MockAsyncComponent
  },
  data() {
    return {
      showComponent: false,
      isLoading: false
    };
  },
  methods: {
    async loadComponent() {
      this.isLoading = true;
      this.showComponent = false;

      // 模拟网络延迟
      await new Promise(resolve => setTimeout(resolve, 500));

      this.showComponent = true;
      this.isLoading = false;
    },
    reset() {
      this.showComponent = false;
      this.isLoading = false;
    }
  }
};
</script>

<style>
.loading-state {
  padding: 40px;
  text-align: center;
  background-color: #f8f9fa;
  border: 1px dashed #dee2e6;
  border-radius: 5px;
  margin: 20px 0;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 15px;
}

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

.async-component {
  padding: 20px;
  background-color: #e8f4ff;
  border: 1px solid #3498db;
  border-radius: 5px;
  margin: 20px 0;
}

.mock-async {
  padding: 20px;
  background-color: #e8f6ef;
  border: 1px solid #2ecc71;
  border-radius: 5px;
  margin: 20px 0;
}
</style>
                                        

<template>
  <div>
    <h3>多个异步组件示例</h3>

    <div class="mb-3">
      <button @click="loadAll" class="btn btn-primary me-2" :disabled="isLoading">
        加载所有组件
      </button>
      <button @click="loadOneByOne" class="btn btn-success me-2" :disabled="isLoading">
        逐个加载组件
      </button>
      <button @click="reset" class="btn btn-secondary">重置</button>
    </div>

    <!-- 嵌套的 Suspense -->
    <Suspense v-if="showComponents">
      <template #default>
        <div class="multiple-components">
          <Suspense>
            <template #default>
              <UserProfile />
            </template>
            <template #fallback>
              <div class="component-loading">
                <p>加载用户资料...</p>
              </div>
            </template>
          </Suspense>

          <Suspense>
            <template #default>
              <UserPosts />
            </template>
            <template #fallback>
              <div class="component-loading">
                <p>加载用户帖子...</p>
              </div>
            </template>
          </Suspense>

          <Suspense>
            <template #default>
              <UserStats />
            </template>
            <template #fallback>
              <div class="component-loading">
                <p>加载用户统计...</p>
              </div>
            </template>
          </Suspense>
        </div>
      </template>

      <template #fallback>
        <div class="main-loading">
          <div class="spinner"></div>
          <p>正在加载页面内容...</p>
        </div>
      </template>
    </Suspense>

    <!-- 显示加载状态 -->
    <div class="mt-3">
      <h5>加载状态</h5>
      <ul>
        <li v-for="(status, component) in loadingStatus" :key="component">
          {{ component }}: {{ status ? '加载中' : '已加载' }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
// 模拟异步数据获取
function fetchData(endpoint, delay = 1000) {
  return new Promise(resolve => {
    setTimeout(() => {
      const mockData = {
        '/api/user': {
          name: '张三',
          email: 'zhangsan@example.com',
          joinDate: '2023-01-15'
        },
        '/api/posts': [
          { id: 1, title: '第一篇帖子', date: '2023-10-01' },
          { id: 2, title: '第二篇帖子', date: '2023-10-05' },
          { id: 3, title: '第三篇帖子', date: '2023-10-10' }
        ],
        '/api/stats': {
          posts: 15,
          followers: 120,
          following: 85,
          likes: 450
        }
      };

      resolve(mockData[endpoint] || {});
    }, delay);
  });
}

// 用户资料组件
const UserProfile = {
  name: 'UserProfile',
  async setup() {
    console.log('UserProfile 开始加载');
    const data = await fetchData('/api/user', 1500);
    console.log('UserProfile 加载完成');

    return { user: data };
  },
  template: `<div class="component-box profile-box">
              <h5>用户资料</h5>
              <p>姓名: {{ user.name }}</p>
              <p>邮箱: {{ user.email }}</p>
              <p>加入日期: {{ user.joinDate }}</p>
            </div>`
};

// 用户帖子组件
const UserPosts = {
  name: 'UserPosts',
  async setup() {
    console.log('UserPosts 开始加载');
    const data = await fetchData('/api/posts', 2000);
    console.log('UserPosts 加载完成');

    return { posts: data };
  },
  template: `<div class="component-box posts-box">
              <h5>用户帖子</h5>
              <ul>
                <li v-for="post in posts" :key="post.id">
                  {{ post.title }} ({{ post.date }})
                </li>
              </ul>
            </div>`
};

// 用户统计组件
const UserStats = {
  name: 'UserStats',
  async setup() {
    console.log('UserStats 开始加载');
    const data = await fetchData('/api/stats', 1000);
    console.log('UserStats 加载完成');

    return { stats: data };
  },
  template: `<div class="component-box stats-box">
              <h5>用户统计</h5>
              <div class="stats-grid">
                <div class="stat-item">
                  <div class="stat-value">{{ stats.posts }}</div>
                  <div class="stat-label">帖子</div>
                </div>
                <div class="stat-item">
                  <div class="stat-value">{{ stats.followers }}</div>
                  <div class="stat-label">粉丝</div>
                </div>
                <div class="stat-item">
                  <div class="stat-value">{{ stats.following }}</div>
                  <div class="stat-label">关注</div>
                </div>
                <div class="stat-item">
                  <div class="stat-value">{{ stats.likes }}</div>
                  <div class="stat-label">点赞</div>
                </div>
              </div>
            </div>`
};

export default {
  components: {
    UserProfile,
    UserPosts,
    UserStats
  },
  data() {
    return {
      showComponents: false,
      isLoading: false,
      loadingStatus: {
        UserProfile: false,
        UserPosts: false,
        UserStats: false
      }
    };
  },
  methods: {
    async loadAll() {
      this.isLoading = true;
      this.showComponents = false;

      // 重置加载状态
      Object.keys(this.loadingStatus).forEach(key => {
        this.loadingStatus[key] = true;
      });

      await new Promise(resolve => setTimeout(resolve, 500));

      this.showComponents = true;

      // 模拟加载完成
      setTimeout(() => {
        Object.keys(this.loadingStatus).forEach(key => {
          this.loadingStatus[key] = false;
        });
        this.isLoading = false;
      }, 2500);
    },

    async loadOneByOne() {
      this.isLoading = true;
      this.showComponents = false;

      // 重置加载状态
      Object.keys(this.loadingStatus).forEach(key => {
        this.loadingStatus[key] = false;
      });

      await new Promise(resolve => setTimeout(resolve, 500));

      this.showComponents = true;

      // 逐个标记为加载中
      const components = Object.keys(this.loadingStatus);
      for (let i = 0; i < components.length; i++) {
        const component = components[i];
        this.loadingStatus[component] = true;

        // 模拟每个组件加载时间不同
        await new Promise(resolve => setTimeout(resolve, 800));

        this.loadingStatus[component] = false;
      }

      this.isLoading = false;
    },

    reset() {
      this.showComponents = false;
      this.isLoading = false;
      Object.keys(this.loadingStatus).forEach(key => {
        this.loadingStatus[key] = false;
      });
    }
  }
};
</script>

<style>
.multiple-components {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 20px;
}

.component-box {
  padding: 20px;
  border-radius: 5px;
  min-height: 150px;
}

.profile-box {
  background-color: #e8f4ff;
  border: 1px solid #3498db;
}

.posts-box {
  background-color: #e8f6ef;
  border: 1px solid #2ecc71;
}

.stats-box {
  background-color: #fef9e7;
  border: 1px solid #f1c40f;
}

.component-loading {
  padding: 20px;
  text-align: center;
  background-color: #f8f9fa;
  border: 1px dashed #dee2e6;
  border-radius: 5px;
  min-height: 150px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.main-loading {
  padding: 60px;
  text-align: center;
  background-color: #f8f9fa;
  border: 2px dashed #dee2e6;
  border-radius: 5px;
  margin: 20px 0;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 15px;
  margin-top: 15px;
}

.stat-item {
  text-align: center;
  padding: 10px;
  background-color: white;
  border-radius: 5px;
}

.stat-value {
  font-size: 1.5rem;
  font-weight: bold;
  color: #2c3e50;
}

.stat-label {
  font-size: 0.9rem;
  color: #7f8c8d;
}
</style>
                                        

<template>
  <div>
    <h3>Suspense 错误处理</h3>

    <div class="mb-3">
      <button @click="loadWithError" class="btn btn-danger me-2">
        加载会出错的组件
      </button>
      <button @click="loadNormal" class="btn btn-success me-2">
        加载正常组件
      </button>
      <button @click="reset" class="btn btn-secondary">
        重置
      </button>
    </div>

    <!-- 错误边界组件 -->
    <ErrorBoundary>
      <Suspense v-if="showComponent">
        <template #default>
          <DynamicComponent :type="componentType" />
        </template>
        <template #fallback>
          <div class="loading-state">
            <div class="spinner"></div>
            <p>正在加载组件...</p>
          </div>
        </template>
      </Suspense>
    </ErrorBoundary>

    <!-- 错误信息 -->
    <div v-if="error" class="alert alert-danger mt-3">
      <h5>组件加载出错</h5>
      <p>错误信息: {{ error }}</p>
      <button @click="clearError" class="btn btn-sm btn-warning">清除错误</button>
    </div>
  </div>
</template>

<script>
// 错误边界组件
const ErrorBoundary = {
  name: 'ErrorBoundary',
  data() {
    return {
      error: null
    };
  },
  errorCaptured(err, instance, info) {
    console.error('错误被捕获:', err, instance, info);
    this.error = err.message;

    // 返回 false 阻止错误继续向上传播
    return false;
  },
  render() {
    // 如果有错误,显示错误UI
    if (this.error) {
      return this.$slots.error
        ? this.$slots.error({ error: this.error })
        : this.$slots.default
          ? this.$slots.default()[0]
          : null;
    }

    // 没有错误,渲染默认插槽
    return this.$slots.default ? this.$slots.default()[0] : null;
  }
};

// 动态组件
const DynamicComponent = {
  props: ['type'],
  async setup(props) {
    console.log(`加载组件类型: ${props.type}`);

    if (props.type === 'error') {
      // 模拟加载失败
      await new Promise(resolve => setTimeout(resolve, 1000));
      throw new Error('组件加载失败:模拟网络错误或服务器错误');
    }

    if (props.type === 'slow') {
      // 模拟慢速加载
      await new Promise(resolve => setTimeout(resolve, 3000));
    } else {
      // 正常加载
      await new Promise(resolve => setTimeout(resolve, 1000));
    }

    return {
      message: props.type === 'error'
        ? '这行代码不会执行'
        : `组件 "${props.type}" 加载成功!`,
      timestamp: new Date().toLocaleTimeString()
    };
  },
  template: `<div class="dynamic-component">
              <h5>动态组件</h5>
              <p>{{ message }}</p>
              <p>加载时间: {{ timestamp }}</p>
            </div>`
};

export default {
  components: {
    ErrorBoundary,
    DynamicComponent
  },
  data() {
    return {
      showComponent: false,
      componentType: 'normal',
      error: null
    };
  },
  methods: {
    loadWithError() {
      this.reset();
      this.componentType = 'error';
      this.showComponent = true;
    },
    loadNormal() {
      this.reset();
      this.componentType = 'normal';
      this.showComponent = true;
    },
    reset() {
      this.showComponent = false;
      this.error = null;
    },
    clearError() {
      this.error = null;
    }
  }
};
</script>

<style>
.dynamic-component {
  padding: 20px;
  background-color: #e8f4ff;
  border: 1px solid #3498db;
  border-radius: 5px;
  margin: 20px 0;
}

.loading-state {
  padding: 40px;
  text-align: center;
  background-color: #f8f9fa;
  border: 1px dashed #dee2e6;
  border-radius: 5px;
  margin: 20px 0;
}
</style>
                                        

Suspense 工作原理

异步组件加载流程:
1. 组件开始加载 → 显示 #fallback 内容
2. 异步操作(setup/asyncComponent)进行中
3. 异步操作完成 → 渲染 #default 内容
4. 如果发生错误 → 触发错误处理机制
使用建议: <suspense> 主要与 Vue 3 的组合式 API 一起使用,特别是与异步 setup() 函数配合。它可以显著改善异步组件加载的用户体验,但要注意错误处理和适当的加载状态设计。

9. 使用场景总结

何时使用内置组件
  • <component>: 标签页切换、动态表单、条件渲染
  • <slot>: 布局组件、可定制UI、内容分发
  • <transition>: 页面切换、元素显示/隐藏、加载状态
  • <transition-group>: 列表操作、拖拽排序、数据更新
  • <keep-alive>: 标签页缓存、表单状态保持、多步骤向导
  • <teleport>: 模态框、通知、工具提示、全局加载
  • <suspense>: 异步组件加载、数据获取、代码分割
组合使用示例

<!-- 组合使用多个内置组件 -->
<keep-alive>
  <transition name="fade" mode="out-in">
    <Suspense>
      <template #default>
        <teleport to="#modal-container">
          <component :is="currentModal">
            <slot name="modal-content"></slot>
          </component>
        </teleport>
      </template>
      <template #fallback>
        <div>加载中...</div>
      </template>
    </Suspense>
  </transition>
</keep-alive>
                                        

这种组合可以实现:

  • 异步加载的模态框组件
  • 平滑的过渡动画
  • 组件状态缓存
  • DOM结构分离

10. 最佳实践

内置组件 最佳实践 常见错误
<component> 使用 :key 强制重新渲染,避免状态混乱 忘记处理动态组件的生命周期
<slot> 提供有意义的默认内容,使用作用域插槽传递数据 插槽嵌套过深导致难以维护
<transition> 使用适当的过渡模式,为移动设备优化性能 过渡时间过长影响用户体验
<transition-group> 为列表项设置唯一 :key,使用 v-move 大型列表中使用过渡导致性能问题
<keep-alive> 合理使用 include/exclude,避免内存泄漏 缓存过多组件导致内存占用过高
<teleport> 确保目标元素在DOM中存在,避免SSR问题 忘记处理组件卸载时的清理
<suspense> 提供有意义的加载状态,处理加载失败情况 错误处理不完善导致应用崩溃
性能优化建议: 对于动画,使用CSS动画而不是JavaScript动画以获得更好的性能;对于大型列表,考虑虚拟滚动;对于频繁切换的组件,合理使用<keep-alive>;对于异步组件,使用代码分割减少初始加载时间。
注意事项: 内置组件虽然强大,但不应过度使用。例如,不应该为每个元素都添加过渡动画,也不应该缓存所有组件。应根据实际需求合理选择和使用内置组件。

总结

Vue.js 内置组件提供了强大的功能来增强应用的能力和用户体验:

  • <component>: 动态组件渲染,实现灵活的UI切换
  • <slot>: 内容分发系统,创建可复用的布局组件
  • <transition>: 过渡动画系统,提升用户体验
  • <transition-group>: 列表动画,为动态列表添加视觉效果
  • <keep-alive>: 组件缓存,保持组件状态避免重复渲染
  • <teleport>: DOM传送,解决布局和层叠上下文问题
  • <suspense>: 异步组件处理,改善加载体验

这些内置组件是 Vue.js 生态系统的重要组成部分,掌握它们可以让你构建更强大、更灵活、用户体验更好的应用程序。