Vue 3 列表过渡transition-group

1. 列表过渡概述

1.1 什么是列表过渡

列表过渡是指对列表中的元素进行增删改查操作时,为这些元素添加平滑的过渡动画。Vue提供了<transition-group>组件,专门用于处理列表的过渡效果。

1.2 列表过渡的应用场景

  • 列表项的添加和删除
  • 列表的排序和过滤
  • 动态生成的列表
  • 数据驱动的列表变化

1.3 <transition-group><transition>的区别

特性 <transition> <transition-group>
适用场景 单个元素或组件 列表或多个元素
渲染结果 不渲染额外DOM元素 渲染为指定的HTML标签(默认span)
key要求 可选(推荐使用) 必须为每个子元素提供唯一key
动画类型 进入/离开动画 进入/离开/移动动画
CSS类名 基本过渡类名 包含移动相关类名

2. <transition-group>的基本使用

2.1 基本语法

<template>
  <div>
    <button @click="addItem">Add Item</button>
    <button @click="removeItem">Remove Item</button>
    <button @click="shuffleItems">Shuffle Items</button>
    
    <transition-group name="list" tag="ul">
      <li v-for="item in items" :key="item.id" @click="removeItem(item.id)">
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, text: 'Item 1' },
        { id: 2, text: 'Item 2' },
        { id: 3, text: 'Item 3' }
      ],
      nextId: 4
    }
  },
  methods: {
    addItem() {
      this.items.push({
        id: this.nextId++,
        text: `Item ${this.nextId - 1}`
      })
    },
    
    removeItem(id) {
      this.items = this.items.filter(item => item.id !== id)
    },
    
    shuffleItems() {
      // 打乱数组顺序
      this.items = [...this.items].sort(() => Math.random() - 0.5)
    }
  }
}
</script>

<style>
ul {
  list-style: none;
  padding: 0;
  max-width: 300px;
}

li {
  background-color: #42b983;
  color: white;
  padding: 10px;
  margin: 5px 0;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s ease;
}

li:hover {
  background-color: #369a6e;
}

/* 进入和离开动画 */
.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateY(30px);
}

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

/* 列表项移动动画 */
.list-move {
  transition: transform 0.3s ease;
}

/* 确保离开元素在动画期间被正确定位 */
.list-leave-active {
  position: absolute;
  width: calc(100% - 20px);
}
</style>

2.2 核心CSS类名

&lt;transition-group&gt;除了支持基本的过渡类名外,还提供了一个额外的类名用于处理列表项的移动动画:

类名 描述 应用时机
list-enter-from 进入过渡的起始状态 元素被插入前添加,插入后移除
list-enter-active 进入过渡的激活状态 整个进入过渡期间应用
list-enter-to 进入过渡的结束状态 元素被插入后添加,过渡完成后移除
list-leave-from 离开过渡的起始状态 离开过渡开始时添加,离开过渡触发后立即移除
list-leave-active 离开过渡的激活状态 整个离开过渡期间应用
list-leave-to 离开过渡的结束状态 离开过渡开始后添加,过渡完成后移除
list-move 列表项移动时的过渡状态 列表项位置变化时应用

3. 列表过渡的高级特性

3.1 自定义标签

默认情况下,&lt;transition-group&gt;会渲染为&lt;span&gt;标签,可以通过tag属性指定其他标签:

<transition-group name="list" tag="div" class="list-container">
  <!-- 列表项 -->
</transition-group>

<transition-group name="list" tag="ul" class="list">
  <!-- 列表项 -->
</transition-group>

3.2 交错动画

通过JavaScript钩子函数,可以实现列表项的交错动画效果:

<template>
  <div>
    <button @click="addItems">Add 5 Items</button>
    <button @click="clearItems">Clear All</button>
    
    <transition-group
      name="staggered"
      tag="ul"
      @enter="staggeredEnter"
      :css="false"
    >
      <li v-for="item in items" :key="item.id">
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script>
import { gsap } from 'gsap'

export default {
  data() {
    return {
      items: [],
      nextId: 1
    }
  },
  methods: {
    addItems() {
      for (let i = 0; i < 5; i++) {
        this.items.push({
          id: this.nextId++,
          text: `Item ${this.nextId - 1}`
        })
      }
    },
    
    clearItems() {
      this.items = []
    },
    
    staggeredEnter(el, done) {
      // 获取当前元素在列表中的索引
      const index = Array.from(el.parentNode.children).indexOf(el)
      
      gsap.fromTo(el, 
        {
          opacity: 0,
          x: -50,
          y: 20,
          rotate: -10
        },
        {
          opacity: 1,
          x: 0,
          y: 0,
          rotate: 0,
          duration: 0.5,
          delay: index * 0.1, // 交错延迟
          ease: 'back.out(1.7)',
          onComplete: done
        }
      )
    }
  }
}
</script>

<style>
ul {
  list-style: none;
  padding: 0;
  max-width: 300px;
}

li {
  background-color: #3498db;
  color: white;
  padding: 10px;
  margin: 5px 0;
  border-radius: 4px;
}
</style>

3.3 列表排序动画

当列表项的顺序发生变化时,&lt;transition-group&gt;会自动添加move类名,实现平滑的排序动画:

<template>
  <div>
    <button @click="sortAsc">Sort Ascending</button>
    <button @click="sortDesc">Sort Descending</button>
    <button @click="shuffle">Shuffle</button>
    
    <transition-group name="sort" tag="div" class="grid">
      <div v-for="item in items" :key="item.id" class="grid-item">
        {{ item.value }}
      </div>
    </transition-group>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, value: 5 },
        { id: 2, value: 3 },
        { id: 3, value: 8 },
        { id: 4, value: 1 },
        { id: 5, value: 7 }
      ]
    }
  },
  methods: {
    sortAsc() {
      this.items.sort((a, b) => a.value - b.value)
    },
    
    sortDesc() {
      this.items.sort((a, b) => b.value - a.value)
    },
    
    shuffle() {
      this.items = [...this.items].sort(() => Math.random() - 0.5)
    }
  }
}
</script>

<style>
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
  gap: 10px;
  max-width: 400px;
}

.grid-item {
  background-color: #e74c3c;
  color: white;
  padding: 20px;
  text-align: center;
  border-radius: 4px;
  font-size: 20px;
  font-weight: bold;
}

/* 进入和离开动画 */
.sort-enter-from,
.sort-leave-to {
  opacity: 0;
  transform: scale(0.5);
}

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

/* 移动动画 */
.sort-move {
  transition: transform 0.5s ease;
}

/* 确保离开元素在动画期间被正确定位 */
.sort-leave-active {
  position: absolute;
  width: calc(25% - 10px);
}
</style>

4. 列表过渡的性能优化

4.1 使用v-memo优化

对于大型列表,可以使用v-memo指令优化性能,避免不必要的重新渲染:

<transition-group name="list" tag="ul">
  <li 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.text]"
  >
    {{ item.text }}
  </li>
</transition-group>

4.2 限制列表大小

对于非常大的列表,可以考虑限制显示的数量,只对可见的列表项应用过渡效果:

<template>
  <div>
    <transition-group name="list" tag="ul">
      <li 
        v-for="item in visibleItems" 
        :key="item.id"
      >
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [...], // 大型列表数据
      visibleCount: 10
    }
  },
  computed: {
    visibleItems() {
      return this.items.slice(0, this.visibleCount)
    }
  }
}
</script>

4.3 使用CSS硬件加速

通过transformopacity属性进行动画,利用CSS硬件加速提升性能:

/* 推荐:使用transform和opacity */
.list-move {
  transition: transform 0.3s ease;
}

/* 不推荐:使用top/left等属性 */
.list-move {
  transition: top 0.3s ease; /* 会触发重排 */
}

4.4 使用will-change属性

使用will-change属性提示浏览器哪些属性可能会发生变化,以便浏览器提前优化:

.grid-item {
  will-change: transform, opacity;
}

5. 实际应用案例

5.1 购物车动画

实现购物车添加商品时的动画效果:

<template>
  <div class="shopping-cart">
    <h2>Shopping Cart</h2>
    
    <div class="products">
      <h3>Products</h3>
      <div class="product-list">
        <div 
          v-for="product in products" 
          :key="product.id" 
          class="product"
          @click="addToCart(product)"
        >
          <h4>{{ product.name }}</h4>
          <p>Price: ${{ product.price }}</p>
          <button>Add to Cart</button>
        </div>
      </div>
    </div>
    
    <div class="cart">
      <h3>Cart ({{ cartItems.length }})</h3>
      <transition-group name="cart" tag="ul">
        <li v-for="item in cartItems" :key="item.id" class="cart-item">
          <span>{{ item.name }}</span>
          <span>${{ item.price }}</span>
          <button @click="removeFromCart(item.id)">Remove</button>
        </li>
      </transition-group>
      
      <div class="total" v-if="cartItems.length > 0">
        <strong>Total: ${{ totalPrice }}</strong>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      products: [
        { id: 1, name: 'Product 1', price: 10 },
        { id: 2, name: 'Product 2', price: 20 },
        { id: 3, name: 'Product 3', price: 30 },
        { id: 4, name: 'Product 4', price: 40 }
      ],
      cartItems: [],
      nextCartId: 100
    }
  },
  computed: {
    totalPrice() {
      return this.cartItems.reduce((total, item) => total + item.price, 0)
    }
  },
  methods: {
    addToCart(product) {
      this.cartItems.push({
        id: this.nextCartId++,
        ...product
      })
    },
    
    removeFromCart(id) {
      this.cartItems = this.cartItems.filter(item => item.id !== id)
    }
  }
}
</script>

<style>
.shopping-cart {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.products {
  margin-bottom: 30px;
}

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

.product {
  border: 1px solid #e0e0e0;
  padding: 15px;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.product:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.product button {
  background-color: #42b983;
  color: white;
  border: none;
  padding: 8px 12px;
  border-radius: 4px;
  cursor: pointer;
}

.cart {
  border-top: 2px solid #e0e0e0;
  padding-top: 20px;
}

.cart ul {
  list-style: none;
  padding: 0;
}

.cart-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px;
  background-color: #f5f5f5;
  margin: 5px 0;
  border-radius: 4px;
}

.cart-item button {
  background-color: #e74c3c;
  color: white;
  border: none;
  padding: 5px 10px;
  border-radius: 4px;
  cursor: pointer;
}

.total {
  margin-top: 20px;
  text-align: right;
  font-size: 18px;
}

/* 购物车动画 */
.cart-enter-from,
.cart-leave-to {
  opacity: 0;
  transform: translateX(30px) rotate(5deg);
}

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

.cart-move {
  transition: transform 0.3s ease;
}

.cart-leave-active {
  position: absolute;
  width: calc(100% - 20px);
}
</style>

5.2 待办事项列表

实现待办事项的添加、删除和完成动画:

<template>
  <div class="todo-app">
    <h2>Todo List</h2>
    
    <div class="add-todo">
      <input 
        v-model="newTodo" 
        placeholder="Add a new todo..."
        @keyup.enter="addTodo"
      >
      <button @click="addTodo">Add</button>
    </div>
    
    <transition-group name="todo" tag="ul">
      <li 
        v-for="todo in todos" 
        :key="todo.id" 
        class="todo-item"
        :class="{ completed: todo.completed }"
      >
        <input 
          type="checkbox" 
          v-model="todo.completed"
          class="todo-checkbox"
        >
        <span class="todo-text">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)" class="todo-delete">×</button>
      </li>
    </transition-group>
    
    <div class="todo-stats">
      <p>{{ todos.filter(todo => !todo.completed).length }} items left</p>
      <button @click="clearCompleted">Clear Completed</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      newTodo: '',
      todos: [
        { id: 1, text: 'Learn Vue 3', completed: false },
        { id: 2, text: 'Build a project', completed: false },
        { id: 3, text: 'Deploy to production', completed: false }
      ],
      nextId: 4
    }
  },
  methods: {
    addTodo() {
      if (this.newTodo.trim()) {
        this.todos.push({
          id: this.nextId++,
          text: this.newTodo.trim(),
          completed: false
        })
        this.newTodo = ''
      }
    },
    
    removeTodo(id) {
      this.todos = this.todos.filter(todo => todo.id !== id)
    },
    
    clearCompleted() {
      this.todos = this.todos.filter(todo => !todo.completed)
    }
  }
}
</script>

<style>
.todo-app {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
  font-family: Arial, sans-serif;
}

.add-todo {
  display: flex;
  margin-bottom: 20px;
}

.add-todo input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px 0 0 4px;
  font-size: 16px;
}

.add-todo button {
  padding: 10px 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 0 4px 4px 0;
  cursor: pointer;
}

.todo-item {
  display: flex;
  align-items: center;
  padding: 12px;
  border-bottom: 1px solid #eee;
  transition: all 0.3s ease;
}

.todo-item:hover {
  background-color: #f9f9f9;
}

.todo-item.completed .todo-text {
  text-decoration: line-through;
  color: #999;
}

.todo-checkbox {
  margin-right: 12px;
  width: 18px;
  height: 18px;
}

.todo-text {
  flex: 1;
  font-size: 16px;
}

.todo-delete {
  background: none;
  border: none;
  color: #ff6b6b;
  font-size: 20px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.2s ease;
}

.todo-item:hover .todo-delete {
  opacity: 1;
}

.todo-stats {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 20px;
  color: #666;
  font-size: 14px;
}

.todo-stats button {
  background: none;
  border: none;
  color: #666;
  cursor: pointer;
}

/* 待办事项动画 */
.todo-enter-from,
.todo-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}

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

.todo-leave-active {
  position: absolute;
  width: calc(100% - 24px);
}

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

6. 常见问题与解决方案

6.1 列表项移动时没有动画

问题:列表项顺序变化时,没有平滑的移动动画。

解决方案

  • 确保为每个列表项提供了唯一的key属性
  • 检查是否定义了move类名的CSS过渡效果
  • 确保列表项的父容器有固定的高度,避免布局塌陷

6.2 列表项重叠或闪烁

问题:列表项在过渡过程中出现重叠或闪烁。

解决方案

  • 为离开的元素添加position: absolute,确保它们不会影响其他元素的布局
  • 确保列表项的宽度一致
  • 使用will-change属性优化动画性能

6.3 大量列表项动画卡顿

问题:当列表项数量较多时,动画出现卡顿。

解决方案

  • 限制显示的列表项数量
  • 使用CSS硬件加速(transform和opacity)
  • 避免在动画过程中修改会导致重排的属性
  • 考虑使用虚拟滚动技术

6.4 动画结束后样式残留

问题:动画结束后,某些样式仍然残留。

解决方案

  • 确保过渡类名的样式只在过渡期间生效
  • 检查是否有其他CSS规则影响过渡效果
  • 使用!important覆盖默认样式(谨慎使用)

7. 总结

&lt;transition-group&gt;组件为Vue提供了强大的列表过渡能力,允许我们为列表项的添加、删除和排序添加平滑的动画效果。通过合理使用&lt;transition-group&gt;,我们可以:

  1. 实现列表项的进入和离开动画
  2. 实现列表项的移动和排序动画
  3. 实现交错动画和复杂的自定义动画
  4. 与第三方动画库集成
  5. 优化列表过渡的性能

在使用&lt;transition-group&gt;时,需要注意以下几点:

  • 必须为每个列表项提供唯一的key属性
  • 合理使用tag属性指定渲染的HTML标签
  • 为移动动画定义move类名
  • 为离开的元素添加position: absolute,避免布局问题
  • 优先使用transformopacity属性进行动画
  • 考虑性能优化,尤其是对于大型列表

&lt;transition-group&gt;为我们提供了创建流畅、生动的列表动画的能力,可以显著提升用户体验。但也要注意不要过度使用复杂的列表动画,避免影响应用的性能。

8. 练习

  1. 使用&lt;transition-group&gt;实现一个简单的列表添加/删除动画
  2. 实现一个带排序功能的列表,带有平滑的排序动画
  3. 使用JavaScript钩子函数实现交错动画效果
  4. 实现一个购物车添加商品的动画效果
  5. 优化一个大型列表的过渡动画性能
  6. 实现一个待办事项列表,带有完成状态切换的动画

9. 进一步阅读

« 上一篇 JavaScript动画钩子 下一篇 » 状态驱动的动画