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类名
<transition-group>除了支持基本的过渡类名外,还提供了一个额外的类名用于处理列表项的移动动画:
| 类名 | 描述 | 应用时机 |
|---|---|---|
list-enter-from |
进入过渡的起始状态 | 元素被插入前添加,插入后移除 |
list-enter-active |
进入过渡的激活状态 | 整个进入过渡期间应用 |
list-enter-to |
进入过渡的结束状态 | 元素被插入后添加,过渡完成后移除 |
list-leave-from |
离开过渡的起始状态 | 离开过渡开始时添加,离开过渡触发后立即移除 |
list-leave-active |
离开过渡的激活状态 | 整个离开过渡期间应用 |
list-leave-to |
离开过渡的结束状态 | 离开过渡开始后添加,过渡完成后移除 |
list-move |
列表项移动时的过渡状态 | 列表项位置变化时应用 |
3. 列表过渡的高级特性
3.1 自定义标签
默认情况下,<transition-group>会渲染为<span>标签,可以通过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 列表排序动画
当列表项的顺序发生变化时,<transition-group>会自动添加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硬件加速
通过transform和opacity属性进行动画,利用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. 总结
<transition-group>组件为Vue提供了强大的列表过渡能力,允许我们为列表项的添加、删除和排序添加平滑的动画效果。通过合理使用<transition-group>,我们可以:
- 实现列表项的进入和离开动画
- 实现列表项的移动和排序动画
- 实现交错动画和复杂的自定义动画
- 与第三方动画库集成
- 优化列表过渡的性能
在使用<transition-group>时,需要注意以下几点:
- 必须为每个列表项提供唯一的
key属性 - 合理使用
tag属性指定渲染的HTML标签 - 为移动动画定义
move类名 - 为离开的元素添加
position: absolute,避免布局问题 - 优先使用
transform和opacity属性进行动画 - 考虑性能优化,尤其是对于大型列表
<transition-group>为我们提供了创建流畅、生动的列表动画的能力,可以显著提升用户体验。但也要注意不要过度使用复杂的列表动画,避免影响应用的性能。
8. 练习
- 使用
<transition-group>实现一个简单的列表添加/删除动画 - 实现一个带排序功能的列表,带有平滑的排序动画
- 使用JavaScript钩子函数实现交错动画效果
- 实现一个购物车添加商品的动画效果
- 优化一个大型列表的过渡动画性能
- 实现一个待办事项列表,带有完成状态切换的动画