作业 折叠过渡效果:封装CollapseTransition组件
概述
Vue 的 <Transition> 组件是构建动画效果的核心基础设施。本节将其封装为可复用的 CollapseTransition 组件,实现内容高度的折叠/展开过渡效果。看似简单的折叠动画,实则包含多个技术细节。最终形态将包含多种过渡动画效果,如 expand-x(水平展开)和 collapse(折叠收缩),本节先聚焦折叠过渡的实现。
最终效果
展开状态:
┌─────────────────────────────────┐
│ 描述面板内容 [−] │
│ 这是一段详细描述文字... │
│ 折叠过渡效果的演示内容 │
└─────────────────────────────────┘
折叠状态:
┌─────────────────────────────────┐
│ 描述面板内容 [+] │
└─────────────────────────────────┘
过渡过程:高度从 auto → 0(折叠)或 0 → auto(展开)
text
技术原理
折叠过渡的核心难点
| 难点 | 说明 |
|---|---|
height: auto 不可动画 | CSS transition 不支持 auto 值的过渡 |
| 内容高度动态计算 | 需要用 JS 获取元素的 scrollHeight |
| 过渡结束的精确判断 | transitionend 事件可能触发多次 |
| 嵌套过渡冲突 | 父子组件都有过渡时可能互相干扰 |
| 浏览器重排时序 | beforeLeave 设置的高度需要强制重排后才生效 |
Vue Transition JavaScript 钩子
Vue 的 <Transition> 组件提供了完整的 JavaScript 钩子体系,用于在过渡的各个阶段执行自定义逻辑:
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@enter-cancelled="onEnterCancelled"
@before-leave="onBeforeLeave"
@leave="onLeave"
@after-leave="onAfterLeave"
@leave-cancelled="onLeaveCancelled"
>
<!-- Transition content -->
</Transition>
vue-html
各钩子函数的调用时机:
| 钩子 | 触发时机 | 典型用途 |
|---|---|---|
beforeEnter(el) | 元素插入 DOM 前 | 设置初始状态(如 height: 0) |
enter(el, done) | 元素插入 DOM 后一帧 | 启动进入动画 |
afterEnter(el) | 进入过渡完成 | 清除临时样式 |
enterCancelled(el) | 进入过渡被取消 | 清理动画资源 |
beforeLeave(el) | 离开过渡开始前 | 记录当前状态 |
leave(el, done) | 离开过渡开始 | 启动离开动画 |
afterLeave(el) | 离开过渡完成,DOM 已移除 | 清除临时样式 |
leaveCancelled(el) | 仅 v-show 可用,离开过渡取消 | 清理动画资源 |
注意:纯 JS 过渡中,
enter和leave钩子的done回调必须被调用,否则过渡不会正常结束。当同时使用 CSS 过渡时,done是可选的。
解决方案:JS 钩子 + scrollHeight
// 过渡流程
展开:display: block → height: 0 → height: scrollHeight → height: auto
折叠:height: auto → height: scrollHeight → height: 0 → display: none
ts
组件实现
CollapseTransition.vue
<!-- components/base/CollapseTransition.vue -->
<template>
<Transition
:name="name"
@before-enter="beforeEnter"
@enter="enter"
@after-enter="afterEnter"
@before-leave="beforeLeave"
@leave="leave"
@after-leave="afterLeave"
>
<slot />
</Transition>
</template>
<script setup lang="ts">
interface Props {
name?: string
}
withDefaults(defineProps<Props>(), {
name: 'collapse',
})
const beforeEnter = (el: Element) => {
const htmlEl = el as HTMLElement
// 设置初始状态:高度为 0,溢出隐藏,添加过渡效果
htmlEl.style.height = '0'
htmlEl.style.overflow = 'hidden'
htmlEl.style.transition = `height ${0.3}s ease`
}
const enter = (el: Element) => {
const htmlEl = el as HTMLElement
// 使用 scrollHeight 获取元素的实际内容高度
if (htmlEl.scrollHeight !== 0) {
htmlEl.style.height = `${htmlEl.scrollHeight}px`
} else {
htmlEl.style.height = ''
}
}
const afterEnter = (el: Element) => {
const htmlEl = el as HTMLElement
// 过渡完成后清除所有临时样式,恢复自然布局
htmlEl.style.height = ''
htmlEl.style.overflow = ''
htmlEl.style.transition = ''
}
const beforeLeave = (el: Element) => {
const htmlEl = el as HTMLElement
// 记录当前高度,作为动画起始值
htmlEl.style.height = `${htmlEl.scrollHeight}px`
htmlEl.style.overflow = 'hidden'
}
const leave = (el: Element) => {
const htmlEl = el as HTMLElement
// 强制浏览器重排(reflow),确保 beforeLeave 设置的高度已渲染
// 读取 scrollHeight 会触发浏览器同步计算布局
// eslint-disable-next-line no-unused-expressions
htmlEl.scrollHeight
// 设置目标高度为 0,触发过渡动画
htmlEl.style.height = '0'
}
const afterLeave = (el: Element) => {
const htmlEl = el as HTMLElement
// 清除临时样式
htmlEl.style.height = ''
htmlEl.style.overflow = ''
htmlEl.style.transition = ''
}
</script>
vue
使用 ="false" 的纯 JS 版本
当不需要 CSS 过渡类名辅助时,建议添加 :css="false" 禁用 Vue 的 CSS 过渡检测:
<template>
<Transition :css="false" @enter="enter" @leave="leave">
<slot />
</Transition>
</template>
<script setup lang="ts">
const enter = (el: Element, done: () => void) => {
const htmlEl = el as HTMLElement
htmlEl.style.overflow = 'hidden'
htmlEl.style.height = '0'
// 使用 requestAnimationFrame 确保初始状态已渲染
requestAnimationFrame(() => {
htmlEl.style.height = `${htmlEl.scrollHeight}px`
htmlEl.addEventListener('transitionend', () => {
htmlEl.style.height = ''
htmlEl.style.overflow = ''
done()
}, { once: true })
})
}
const leave = (el: Element, done: () => void) => {
const htmlEl = el as HTMLElement
htmlEl.style.height = `${htmlEl.scrollHeight}px`
htmlEl.style.overflow = 'hidden'
// eslint-disable-next-line no-unused-expressions
htmlEl.scrollHeight // 强制重排
requestAnimationFrame(() => {
htmlEl.style.height = '0'
htmlEl.addEventListener('transitionend', () => {
htmlEl.style.height = ''
htmlEl.style.overflow = ''
done()
}, { once: true })
})
}
</script>
vue
使用示例
基础折叠面板
<template>
<div class="collapse-panel">
<div class="panel-header" @click="visible = !visible">
<span>描述面板内容</span>
<el-icon>
<ArrowDown :class="{ 'is-active': visible }" />
</el-icon>
</div>
<CollapseTransition>
<div v-show="visible" class="panel-content">
<p>这是一段详细描述文字...</p>
<p>折叠过渡效果的演示内容</p>
</div>
</CollapseTransition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import CollapseTransition from '@/components/base/CollapseTransition.vue'
const visible = ref(true)
</script>
<style scoped>
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--el-fill-color-light);
cursor: pointer;
}
.panel-content {
padding: 16px;
}
.is-active {
transform: rotate(180deg);
transition: transform 0.3s;
}
</style>
vue
嵌套折叠场景
嵌套折叠时,内层折叠不会影响外层,因为每层组件独立管理自己的高度过渡:
<template>
<CollapseTransition>
<div v-show="outerVisible">
外层内容
<CollapseTransition>
<div v-show="innerVisible">
内层折叠内容
</div>
</CollapseTransition>
</div>
</CollapseTransition>
</template>
vue
带过渡时长的折叠组件
支持自定义过渡时长和缓动函数:
<!-- CollapseTransition.vue 增强版 -->
<template>
<Transition
@before-enter="beforeEnter"
@enter="enter"
@after-enter="afterEnter"
@before-leave="beforeLeave"
@leave="leave"
@after-leave="afterLeave"
>
<slot />
</Transition>
</template>
<script setup lang="ts">
interface Props {
duration?: number
easing?: string
}
const props = withDefaults(defineProps<Props>(), {
duration: 300,
easing: 'ease',
})
const transitionStyle = `height ${props.duration}ms ${props.easing}`
const beforeEnter = (el: Element) => {
const htmlEl = el as HTMLElement
htmlEl.style.height = '0'
htmlEl.style.overflow = 'hidden'
htmlEl.style.transition = transitionStyle
}
const enter = (el: Element) => {
const htmlEl = el as HTMLElement
if (htmlEl.scrollHeight !== 0) {
htmlEl.style.height = `${htmlEl.scrollHeight}px`
}
}
const afterEnter = (el: Element) => {
const htmlEl = el as HTMLElement
htmlEl.style.height = ''
htmlEl.style.overflow = ''
htmlEl.style.transition = ''
}
const beforeLeave = (el: Element) => {
const htmlEl = el as HTMLElement
htmlEl.style.height = `${htmlEl.scrollHeight}px`
htmlEl.style.overflow = 'hidden'
htmlEl.style.transition = transitionStyle
}
const leave = (el: Element) => {
const htmlEl = el as HTMLElement
// eslint-disable-next-line no-unused-expressions
htmlEl.scrollHeight
htmlEl.style.height = '0'
}
const afterLeave = (el: Element) => {
const htmlEl = el as HTMLElement
htmlEl.style.height = ''
htmlEl.style.overflow = ''
htmlEl.style.transition = ''
}
</script>
vue
过渡钩子时序图
展开(Enter):
beforeEnter → height: 0, overflow: hidden, transition: height 0.3s ease
↓
enter → height: scrollHeight
↓
[CSS transition 动画 0→scrollHeight]
↓
afterEnter → 清除 height/overflow/transition style
折叠(Leave):
beforeLeave → height: scrollHeight, overflow: hidden
↓
leave → 强制重排(scrollHeight读取) → height: 0
↓
[CSS transition 动画 scrollHeight→0]
↓
afterLeave → 清除 height/overflow/transition style
text
方案对比
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| JS 钩子 + scrollHeight | Transition 事件钩子 | 精确控制、兼容性好、动画时长准确 | 代码量稍多 |
| CSS max-height | max-height: 0 → max-height: 999px | 纯 CSS,无需 JS | 动画时长不准确,内容少时动画过快 |
| VueUse useTransition | 组合式函数 | 声明式,API 简洁 | 额外依赖,灵活性受限 |
| animate() API | Web Animations API | 原生支持,可取消/暂停 | 浏览器兼容性(IE 不支持) |
| Grid 行高过渡 | grid-template-rows: 0fr → 1fr | 纯 CSS,无需 scrollHeight | 需要特定 HTML 结构 |
实践要点
height: auto无法直接做 CSS 过渡,必须用 JS 获取scrollHeight后设置具体像素值leave钩子中需要强制重排(读取scrollHeight),确保浏览器先应用beforeLeave设置的高度- 过渡结束后必须清除行内 style(
height、overflow、transition),避免影响后续布局 - 使用
v-show而非v-if,因为v-if会移除 DOM 导致过渡无法触发 - 纯 JS 过渡建议添加
:css="false"属性,禁用 Vue 的 CSS 过渡类名检测,避免不必要的性能开销 transitionend事件可能因为子元素的 transition 而多次触发,可使用{ once: true }或检查event.propertyName === 'height'来过滤
↑