Skip to content

列表过渡2024:Vue.js开发者掌握TransitionGroup动画完整指南

📊 SEO元描述:2024年最新Vue列表过渡教程,详解TransitionGroup组件、列表项动画、移动过渡。包含完整实战案例,适合Vue.js开发者快速掌握列表动画技术。

核心关键词:Vue列表过渡2024、TransitionGroup、Vue列表动画、列表项过渡、Vue动态列表

长尾关键词:Vue列表动画怎么做、TransitionGroup如何使用、Vue列表项过渡效果、动态列表动画最佳实践、前端列表动效


📚 列表过渡学习目标与核心收获

通过本节Vue列表过渡深度教程,你将系统性掌握:

  • TransitionGroup组件:深入理解Vue内置列表过渡组件的工作原理
  • 列表项进入离开:掌握列表项添加删除时的动画效果
  • 移动过渡技术:实现列表项位置变化时的平滑移动动画
  • 复杂列表场景:处理排序、筛选、分页等复杂列表操作
  • 性能优化策略:大数据量列表的动画性能优化方案
  • 交互体验设计:创造直观自然的列表操作体验

🎯 适合人群

  • Vue.js中级开发者需要为列表操作添加动画效果
  • UI/UX开发者希望提升列表交互的用户体验
  • 数据展示应用开发者需要处理动态列表的视觉反馈
  • 组件库开发者想要创建专业的列表动画组件

🌟 列表过渡是什么?为什么对数据展示如此重要?

列表过渡是什么?这是处理动态数据展示时最重要的问题。列表过渡是指当列表中的项目发生增加、删除、移动等变化时的动画效果,通过视觉连续性帮助用户理解数据变化,也是现代数据应用的重要组成部分。

列表过渡的核心价值

  • 🎯 数据变化可视化:清晰展示列表数据的增删改操作
  • 🔧 用户认知辅助:帮助用户跟踪和理解数据变化
  • 💡 操作反馈增强:为用户操作提供即时的视觉反馈
  • 📚 界面连续性:保持界面元素的空间关系连续性
  • 🚀 专业体验提升:提升应用的专业度和用户满意度

💡 设计建议:列表动画应该快速而清晰,让用户能够轻松跟踪数据变化,避免过长的动画时间影响操作效率

TransitionGroup基础

Vue的<TransitionGroup>组件专门用于处理列表中多个元素的过渡效果:

vue
<template>
  <div class="list-transition-demo">
    <div class="controls">
      <input 
        v-model="newItem" 
        @keyup.enter="addItem"
        placeholder="输入新项目,按回车添加"
        class="input-field"
      >
      <button @click="addItem" class="add-btn">添加项目</button>
      <button @click="shuffle" class="shuffle-btn">随机排序</button>
      <button @click="reset" class="reset-btn">重置列表</button>
    </div>
    
    <!-- 基础列表过渡 -->
    <TransitionGroup 
      name="list" 
      tag="ul" 
      class="item-list"
    >
      <li 
        v-for="item in items" 
        :key="item.id"
        class="list-item"
        @click="removeItem(item.id)"
      >
        <span class="item-content">{{ item.text }}</span>
        <span class="item-time">{{ item.time }}</span>
        <span class="remove-hint">点击删除</span>
      </li>
    </TransitionGroup>
    
    <!-- 空状态 -->
    <Transition name="empty-state">
      <div v-if="items.length === 0" class="empty-state">
        <div class="empty-icon">📝</div>
        <p>列表为空,添加一些项目试试吧!</p>
      </div>
    </Transition>
  </div>
</template>

<script>
export default {
  name: 'ListTransitionDemo',
  data() {
    return {
      newItem: '',
      nextId: 4,
      items: [
        { id: 1, text: '学习Vue.js基础', time: '09:00' },
        { id: 2, text: '掌握组件通信', time: '10:30' },
        { id: 3, text: '实践项目开发', time: '14:00' }
      ]
    }
  },
  methods: {
    addItem() {
      if (this.newItem.trim()) {
        const now = new Date()
        this.items.push({
          id: this.nextId++,
          text: this.newItem.trim(),
          time: now.toLocaleTimeString('zh-CN', { 
            hour: '2-digit', 
            minute: '2-digit' 
          })
        })
        this.newItem = ''
      }
    },
    
    removeItem(id) {
      const index = this.items.findIndex(item => item.id === id)
      if (index > -1) {
        this.items.splice(index, 1)
      }
    },
    
    shuffle() {
      // Fisher-Yates 洗牌算法
      for (let i = this.items.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [this.items[i], this.items[j]] = [this.items[j], this.items[i]]
      }
    },
    
    reset() {
      this.items = [
        { id: 1, text: '学习Vue.js基础', time: '09:00' },
        { id: 2, text: '掌握组件通信', time: '10:30' },
        { id: 3, text: '实践项目开发', time: '14:00' }
      ]
      this.nextId = 4
    }
  }
}
</script>

<style scoped>
.controls {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
  flex-wrap: wrap;
  align-items: center;
}

.input-field {
  flex: 1;
  min-width: 200px;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.add-btn, .shuffle-btn, .reset-btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.add-btn {
  background: #007bff;
  color: white;
}

.shuffle-btn {
  background: #28a745;
  color: white;
}

.reset-btn {
  background: #6c757d;
  color: white;
}

.item-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.list-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px;
  margin-bottom: 8px;
  background: white;
  border: 1px solid #e9ecef;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s;
}

.list-item:hover {
  background: #f8f9fa;
  border-color: #007bff;
}

.item-content {
  flex: 1;
  font-weight: 500;
}

.item-time {
  color: #6c757d;
  font-size: 12px;
  margin-right: 12px;
}

.remove-hint {
  color: #dc3545;
  font-size: 12px;
  opacity: 0;
  transition: opacity 0.2s;
}

.list-item:hover .remove-hint {
  opacity: 1;
}

/* 列表过渡动画 */
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

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

/* 空状态过渡 */
.empty-state {
  text-align: center;
  padding: 40px;
  color: #6c757d;
}

.empty-icon {
  font-size: 48px;
  margin-bottom: 16px;
}

.empty-state-enter-active,
.empty-state-leave-active {
  transition: all 0.3s ease;
}

.empty-state-enter-from,
.empty-state-leave-to {
  opacity: 0;
  transform: scale(0.9);
}
</style>

TransitionGroup核心特性

  • tag属性:指定渲染的HTML标签,默认为<span>
  • name属性:定义过渡类名前缀
  • move-class:自定义移动过渡的类名
  • appear属性:启用初始渲染过渡

移动过渡(Move Transitions)

什么是移动过渡?如何实现平滑的位置变化?

移动过渡是TransitionGroup的特色功能,当列表项的位置发生变化时自动应用平滑的移动动画:

vue
<template>
  <div class="move-transition-demo">
    <div class="demo-controls">
      <button @click="sortByName">按名称排序</button>
      <button @click="sortByScore">按分数排序</button>
      <button @click="sortById">按ID排序</button>
      <button @click="randomize">随机排序</button>
    </div>
    
    <!-- 带有移动过渡的列表 -->
    <TransitionGroup 
      name="flip-list" 
      tag="div" 
      class="student-list"
    >
      <div 
        v-for="student in students" 
        :key="student.id"
        class="student-card"
      >
        <div class="student-avatar">
          {{ student.avatar }}
        </div>
        <div class="student-info">
          <h3 class="student-name">{{ student.name }}</h3>
          <p class="student-score">分数: {{ student.score }}</p>
          <p class="student-id">ID: {{ student.id }}</p>
        </div>
        <div class="student-rank">
          #{{ getCurrentRank(student.id) }}
        </div>
      </div>
    </TransitionGroup>
  </div>
</template>

<script>
export default {
  name: 'MoveTransitionDemo',
  data() {
    return {
      students: [
        { id: 1, name: '张三', score: 95, avatar: '👨‍🎓' },
        { id: 2, name: '李四', score: 87, avatar: '👩‍🎓' },
        { id: 3, name: '王五', score: 92, avatar: '👨‍💻' },
        { id: 4, name: '赵六', score: 88, avatar: '👩‍💻' },
        { id: 5, name: '钱七', score: 96, avatar: '👨‍🔬' },
        { id: 6, name: '孙八', score: 84, avatar: '👩‍🔬' }
      ]
    }
  },
  methods: {
    sortByName() {
      this.students.sort((a, b) => a.name.localeCompare(b.name))
    },
    
    sortByScore() {
      this.students.sort((a, b) => b.score - a.score)
    },
    
    sortById() {
      this.students.sort((a, b) => a.id - b.id)
    },
    
    randomize() {
      for (let i = this.students.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [this.students[i], this.students[j]] = [this.students[j], this.students[i]]
      }
    },
    
    getCurrentRank(id) {
      return this.students.findIndex(student => student.id === id) + 1
    }
  }
}
</script>

<style scoped>
.demo-controls {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.demo-controls button {
  padding: 8px 16px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.2s;
}

.demo-controls button:hover {
  background: #0056b3;
}

.student-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 16px;
}

.student-card {
  display: flex;
  align-items: center;
  padding: 16px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  transition: all 0.2s;
}

.student-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.student-avatar {
  font-size: 32px;
  margin-right: 16px;
}

.student-info {
  flex: 1;
}

.student-name {
  margin: 0 0 8px 0;
  font-size: 18px;
  color: #333;
}

.student-score {
  margin: 4px 0;
  color: #007bff;
  font-weight: 500;
}

.student-id {
  margin: 4px 0 0 0;
  color: #6c757d;
  font-size: 12px;
}

.student-rank {
  font-size: 24px;
  font-weight: bold;
  color: #28a745;
  margin-left: 16px;
}

/* FLIP 列表过渡动画 */
.flip-list-enter-active,
.flip-list-leave-active {
  transition: all 0.5s ease;
}

.flip-list-enter-from,
.flip-list-leave-to {
  opacity: 0;
  transform: scale(0.8) rotateY(90deg);
}

/* 移动过渡 - 关键! */
.flip-list-move {
  transition: transform 0.6s ease;
}

/* 确保离开的元素不影响移动动画 */
.flip-list-leave-active {
  position: absolute;
}
</style>

移动过渡的工作原理

  • 🎯 FLIP技术:First, Last, Invert, Play - 计算元素位置变化
  • 🎯 自动检测:Vue自动检测元素位置变化并应用过渡
  • 🎯 transform优化:使用transform属性实现高性能动画
  • 🎯 布局保持:在动画过程中保持布局稳定性

💼 性能提示:移动过渡使用transform属性,可以触发GPU加速,即使在大量元素的列表中也能保持流畅


🔧 复杂列表过渡场景

交错动画效果

为列表项添加交错进入动画,创造更有层次的视觉效果:

vue
<template>
  <div class="staggered-demo">
    <div class="demo-header">
      <h3>交错动画演示</h3>
      <button @click="reloadList" class="reload-btn">
        重新加载列表
      </button>
    </div>

    <TransitionGroup
      name="staggered"
      tag="div"
      class="staggered-list"
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
    >
      <div
        v-for="(item, index) in visibleItems"
        :key="item.id"
        :data-index="index"
        class="staggered-item"
      >
        <div class="item-icon">{{ item.icon }}</div>
        <div class="item-content">
          <h4>{{ item.title }}</h4>
          <p>{{ item.description }}</p>
        </div>
        <button @click="removeItem(item.id)" class="remove-btn">

        </button>
      </div>
    </TransitionGroup>
  </div>
</template>

<script>
export default {
  name: 'StaggeredDemo',
  data() {
    return {
      visibleItems: [],
      allItems: [
        { id: 1, icon: '🚀', title: '快速启动', description: '一键启动项目开发环境' },
        { id: 2, icon: '🎨', title: '美观设计', description: '现代化的用户界面设计' },
        { id: 3, icon: '⚡', title: '高性能', description: '优化的代码执行效率' },
        { id: 4, icon: '🔧', title: '易配置', description: '简单直观的配置选项' },
        { id: 5, icon: '📱', title: '响应式', description: '完美适配各种设备' },
        { id: 6, icon: '🛡️', title: '安全可靠', description: '企业级安全保障' }
      ]
    }
  },
  mounted() {
    this.loadItems()
  },
  methods: {
    loadItems() {
      this.visibleItems = [...this.allItems]
    },

    reloadList() {
      this.visibleItems = []
      setTimeout(() => {
        this.loadItems()
      }, 300)
    },

    removeItem(id) {
      const index = this.visibleItems.findIndex(item => item.id === id)
      if (index > -1) {
        this.visibleItems.splice(index, 1)
      }
    },

    beforeEnter(el) {
      el.style.opacity = 0
      el.style.transform = 'translateY(30px) scale(0.9)'
    },

    enter(el, done) {
      const delay = el.dataset.index * 100
      setTimeout(() => {
        el.style.transition = 'all 0.5s ease'
        el.style.opacity = 1
        el.style.transform = 'translateY(0) scale(1)'
        setTimeout(done, 500)
      }, delay)
    },

    leave(el, done) {
      const delay = el.dataset.index * 50
      setTimeout(() => {
        el.style.transition = 'all 0.3s ease'
        el.style.opacity = 0
        el.style.transform = 'translateY(-20px) scale(0.8)'
        setTimeout(done, 300)
      }, delay)
    }
  }
}
</script>

<style scoped>
.demo-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.reload-btn {
  padding: 8px 16px;
  background: #28a745;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.staggered-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 16px;
}

.staggered-item {
  display: flex;
  align-items: center;
  padding: 20px;
  background: white;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  position: relative;
}

.item-icon {
  font-size: 32px;
  margin-right: 16px;
}

.item-content h4 {
  margin: 0 0 8px 0;
  color: #333;
}

.item-content p {
  margin: 0;
  color: #666;
  font-size: 14px;
}

.remove-btn {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 24px;
  height: 24px;
  border: none;
  background: #dc3545;
  color: white;
  border-radius: 50%;
  cursor: pointer;
  font-size: 12px;
  opacity: 0;
  transition: opacity 0.2s;
}

.staggered-item:hover .remove-btn {
  opacity: 1;
}
</style>

筛选和搜索过渡

实现带有平滑过渡的列表筛选和搜索功能:

vue
<template>
  <div class="filter-demo">
    <div class="filter-controls">
      <input
        v-model="searchQuery"
        placeholder="搜索产品..."
        class="search-input"
      >

      <select v-model="selectedCategory" class="category-select">
        <option value="">所有分类</option>
        <option value="electronics">电子产品</option>
        <option value="clothing">服装</option>
        <option value="books">图书</option>
        <option value="home">家居</option>
      </select>

      <select v-model="sortBy" class="sort-select">
        <option value="name">按名称排序</option>
        <option value="price">按价格排序</option>
        <option value="rating">按评分排序</option>
      </select>
    </div>

    <div class="results-info">
      显示 {{ filteredProducts.length }} 个结果
    </div>

    <TransitionGroup
      name="filter-list"
      tag="div"
      class="product-grid"
    >
      <div
        v-for="product in filteredProducts"
        :key="product.id"
        class="product-card"
      >
        <div class="product-image">{{ product.emoji }}</div>
        <div class="product-info">
          <h3 class="product-name">{{ product.name }}</h3>
          <p class="product-category">{{ getCategoryName(product.category) }}</p>
          <div class="product-details">
            <span class="product-price">¥{{ product.price }}</span>
            <span class="product-rating">⭐ {{ product.rating }}</span>
          </div>
        </div>
      </div>
    </TransitionGroup>

    <Transition name="no-results">
      <div v-if="filteredProducts.length === 0" class="no-results">
        <div class="no-results-icon">🔍</div>
        <p>没有找到匹配的产品</p>
        <button @click="clearFilters" class="clear-btn">清除筛选</button>
      </div>
    </Transition>
  </div>
</template>

<script>
export default {
  name: 'FilterDemo',
  data() {
    return {
      searchQuery: '',
      selectedCategory: '',
      sortBy: 'name',
      products: [
        { id: 1, name: 'iPhone 14', category: 'electronics', price: 5999, rating: 4.8, emoji: '📱' },
        { id: 2, name: '连衣裙', category: 'clothing', price: 299, rating: 4.5, emoji: '👗' },
        { id: 3, name: 'Vue.js实战', category: 'books', price: 89, rating: 4.9, emoji: '📚' },
        { id: 4, name: '咖啡杯', category: 'home', price: 45, rating: 4.3, emoji: '☕' },
        { id: 5, name: 'MacBook Pro', category: 'electronics', price: 12999, rating: 4.9, emoji: '💻' },
        { id: 6, name: '牛仔裤', category: 'clothing', price: 199, rating: 4.2, emoji: '👖' },
        { id: 7, name: 'JavaScript高级程序设计', category: 'books', price: 129, rating: 4.7, emoji: '📖' },
        { id: 8, name: '台灯', category: 'home', price: 159, rating: 4.4, emoji: '💡' }
      ]
    }
  },
  computed: {
    filteredProducts() {
      let result = this.products

      // 搜索筛选
      if (this.searchQuery) {
        result = result.filter(product =>
          product.name.toLowerCase().includes(this.searchQuery.toLowerCase())
        )
      }

      // 分类筛选
      if (this.selectedCategory) {
        result = result.filter(product => product.category === this.selectedCategory)
      }

      // 排序
      result.sort((a, b) => {
        switch (this.sortBy) {
          case 'price':
            return a.price - b.price
          case 'rating':
            return b.rating - a.rating
          default:
            return a.name.localeCompare(b.name)
        }
      })

      return result
    }
  },
  methods: {
    getCategoryName(category) {
      const names = {
        electronics: '电子产品',
        clothing: '服装',
        books: '图书',
        home: '家居'
      }
      return names[category] || category
    },

    clearFilters() {
      this.searchQuery = ''
      this.selectedCategory = ''
      this.sortBy = 'name'
    }
  }
}
</script>

<style scoped>
.filter-controls {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
  flex-wrap: wrap;
}

.search-input,
.category-select,
.sort-select {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.search-input {
  flex: 1;
  min-width: 200px;
}

.results-info {
  margin-bottom: 20px;
  color: #666;
  font-size: 14px;
}

.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 16px;
}

.product-card {
  background: white;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  transition: all 0.2s;
}

.product-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.product-image {
  font-size: 48px;
  text-align: center;
  margin-bottom: 16px;
}

.product-name {
  margin: 0 0 8px 0;
  font-size: 18px;
  color: #333;
}

.product-category {
  margin: 0 0 12px 0;
  color: #666;
  font-size: 12px;
  text-transform: uppercase;
}

.product-details {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.product-price {
  font-size: 18px;
  font-weight: bold;
  color: #007bff;
}

.product-rating {
  font-size: 14px;
  color: #ffc107;
}

/* 筛选列表过渡 */
.filter-list-enter-active,
.filter-list-leave-active {
  transition: all 0.4s ease;
}

.filter-list-enter-from {
  opacity: 0;
  transform: scale(0.8) translateY(20px);
}

.filter-list-leave-to {
  opacity: 0;
  transform: scale(0.8) translateY(-20px);
}

.filter-list-move {
  transition: transform 0.4s ease;
}

/* 无结果状态 */
.no-results {
  text-align: center;
  padding: 60px 20px;
  color: #666;
}

.no-results-icon {
  font-size: 64px;
  margin-bottom: 16px;
}

.clear-btn {
  margin-top: 16px;
  padding: 8px 16px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.no-results-enter-active,
.no-results-leave-active {
  transition: all 0.3s ease;
}

.no-results-enter-from,
.no-results-leave-to {
  opacity: 0;
  transform: scale(0.9);
}
</style>

📚 列表过渡学习总结与下一步规划

✅ 本节核心收获回顾

通过本节Vue列表过渡深度教程的学习,你已经掌握:

  1. TransitionGroup组件:理解Vue内置列表过渡组件的配置和使用方法
  2. 移动过渡技术:掌握FLIP技术实现平滑的列表项位置变化动画
  3. 交错动画效果:学会创建有层次感的列表项进入离开动画
  4. 复杂列表场景:处理筛选、搜索、排序等动态列表操作的过渡效果
  5. 性能优化策略:了解大数据量列表动画的优化方案和最佳实践

🎯 列表过渡下一步

  1. 学习状态过渡:掌握数值、颜色等状态的平滑过渡技术
  2. 虚拟滚动集成:在大数据量场景中结合虚拟滚动和动画
  3. 手势动画支持:添加拖拽、滑动等手势操作的动画反馈
  4. 第三方库集成:结合专业动画库实现更复杂的列表动效

🔗 相关学习资源

💪 实践建议

  1. 创建动画组件库:封装常用的列表过渡效果为可复用组件
  2. 性能基准测试:测试不同动画方案在大数据量下的性能表现
  3. 用户体验优化:根据用户反馈调整动画时长和效果强度
  4. 无障碍访问支持:确保列表动画不影响键盘导航和屏幕阅读器

🔍 常见问题FAQ

Q1: TransitionGroup和Transition有什么区别?

A: Transition用于单个元素的过渡,TransitionGroup用于列表中多个元素的过渡。TransitionGroup支持移动过渡,可以处理元素位置变化的动画。

Q2: 为什么我的移动过渡没有效果?

A: 确保设置了move-class或使用默认的{name}-move类名,并且CSS中定义了transition属性。同时确保列表项有唯一的key值。

Q3: 大数据量列表动画卡顿怎么办?

A: 考虑使用虚拟滚动,限制同时动画的元素数量,使用transform和opacity属性,避免在动画期间进行复杂计算。

Q4: 如何实现列表项的拖拽排序动画?

A: 可以结合Vue.Draggable库和TransitionGroup,或者使用SortableJS等拖拽库,配合自定义的移动过渡效果。

Q5: 列表过渡如何与路由切换结合?

A: 可以在路由组件中使用TransitionGroup,或者监听路由变化来触发列表的重新渲染和过渡效果。


🛠️ 列表过渡性能优化指南

大数据量优化策略

vue
<template>
  <!-- 虚拟滚动 + 过渡动画 -->
  <div class="optimized-list">
    <TransitionGroup
      name="optimized"
      tag="div"
      class="virtual-list"
      :css="false"
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
    >
      <div
        v-for="item in visibleItems"
        :key="item.id"
        class="list-item"
        :style="{ transform: `translateY(${item.offset}px)` }"
      >
        {{ item.content }}
      </div>
    </TransitionGroup>
  </div>
</template>

<script>
export default {
  methods: {
    beforeEnter(el) {
      // 使用requestAnimationFrame优化动画
      el.style.opacity = 0
      el.style.transform += ' scale(0.8)'
    },

    enter(el, done) {
      // 批量处理动画,避免布局抖动
      requestAnimationFrame(() => {
        el.style.transition = 'all 0.3s ease'
        el.style.opacity = 1
        el.style.transform = el.style.transform.replace('scale(0.8)', 'scale(1)')
        setTimeout(done, 300)
      })
    },

    leave(el, done) {
      el.style.transition = 'all 0.2s ease'
      el.style.opacity = 0
      el.style.transform += ' scale(0.8)'
      setTimeout(done, 200)
    }
  }
}
</script>

"列表过渡是Vue.js动画系统中最实用的功能之一,它让数据的变化变得可视化和自然。掌握TransitionGroup不仅能提升应用的专业度,更能为用户创造直观的数据操作体验。继续探索状态过渡,让你的Vue应用动效更加完整和专业!"