手把手实现一个高性能的Collapse折叠组件
项目初期的技术选型
上个月接了个后台管理系统的活,客户要做一个可配置的表单生成器。用户可以拖拽字段,每个字段组还能折叠收起。这需求听起来挺简单,但实际做起来才发现坑不少。
一开始我打算自己手写一个 collapse 组件,用 CSS 的 height 过渡加 JS 控制显隐。写了半天发现动画不流畅,尤其是内容高度动态变化的时候,根本没法准确获取 scrollHeight。后来一想,这种基础组件没必要重复造轮子,干脆上了 Vue 的 Transition + 自定义封装。
最终方案是基于 Vue 3 的组合式 API 写了一个可复用的 Collapse 组件,支持任意内容、动态高度、多实例联动,还加了动画缓动效果。核心思路是用 max-height 做过渡,配合 visibility 控制渲染时机。虽然不是最完美的解法,但开发成本低,维护也方便。
最大的坑:性能问题
项目上线前压测时发现,当页面上有 20 多个折叠面板同时展开/收起时,页面直接卡成幻灯片。开始没想到是 collapse 导致的,排查了一圈才定位到问题出在 transition 上。
问题是这样的:我最初用的是 height: auto 到 height: 0 的过渡,但浏览器对 height: auto 不支持过渡动画,所以实际上是瞬间收起,然后靠 JS 强行读取 offsetHeight 再设置 transitionDuration,结果就是每一帧都要重排(reflow),大量 DOM 操作直接干爆主线程。
折腾了半天发现,业内通用做法是用 max-height 来代替 height。比如设成 max-height: 0 到 max-height: 500px,这样就能触发 CSS 过渡。虽然不够精确,但胜在性能好。改完之后帧率从 12fps 拉到了 58fps,舒服多了。
这里注意我踩过好几次坑:max-height 不能设太大,比如 9999px,那样会导致 GPU 渲染层提升,反而更卡。最后测试下来,300px~500px 足够大多数场景,超出的部分靠 overflow: hidden 截掉也没影响。
核心代码就这几行
下面是最终的 Vue 组件实现,结构很简单,重点在样式控制:
<template>
<div class="collapse-wrapper">
<div class="collapse-header" @click="toggle">
<slot name="header">{{ title }}</slot>
</div>
<div ref="content" class="collapse-content" :style="contentStyle">
<slot></slot>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: Boolean,
title: String,
maxHeight: {
type: Number,
default: 300
}
})
const emit = defineEmits(['update:modelValue'])
const content = ref(null)
const isExpanded = ref(props.modelValue)
const toggle = () => {
isExpanded.value = !isExpanded.value
emit('update:modelValue', isExpanded.value)
}
const contentStyle = computed(() => {
if (isExpanded.value) {
// 动态计算真实高度
const height = content.value?.scrollHeight || 0
return {
maxHeight: ${Math.min(height, props.maxHeight)}px,
opacity: 1,
visibility: 'visible'
}
} else {
return {
maxHeight: '0px',
opacity: 0,
visibility: 'hidden'
}
}
})
</script>
<style scoped>
.collapse-wrapper {
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.collapse-header {
padding: 12px 16px;
background-color: #f7f8fa;
cursor: pointer;
font-size: 14px;
user-select: none;
}
.collapse-header:hover {
background-color: #edeff2;
}
.collapse-content {
max-height: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0 16px;
background-color: #fff;
}
</style>
谁更灵活?谁更省事?
其实还有另一种方案:用 Vue 的 <Transition> 包一层,通过 enter-from / leave-to 控制。我也试过,代码看起来更“标准”,但有个致命问题——transition 结束后 DOM 才移除,导致某些场景下内容还没加载完就被隐藏了,特别是异步数据。
比如有个折叠项里面是表格,数据要从 https://jztheme.com/api/table-data 拿,loading 状态刚出来,动画一结束直接被 visibility: hidden 干掉了,用户体验极差。最后还是回到了手动控制 style 的方式,虽然不够“优雅”,但能精准控制渲染时机。
另外提醒一点:不要在 collapse 里放 heavy component,比如富文本编辑器或图表。即使用 v-show 控制,组件也会一直活着,内存占用下不来。我们的解决方案是加了个 lazy-render 属性,只有第一次展开才挂载内部内容,后续用缓存。这个功能后来成了标配。
踩坑提醒:这三点一定注意
- 别用 height: auto 做过渡,亲测无效且卡顿
- max-height 别设太大,避免不必要的 GPU 层合成
- collapse 内部如果有 position: absolute 元素,记得父级加 relative,不然会溢出错位
还有一个小问题到现在没完美解决:快速连续点击 header 时,动画状态容易错乱,max-height 来回跳。理论上可以用一个 isAnimating 标志位锁住操作,但加了之后交互显得迟钝。最后选择接受这个小瑕疵,毕竟用户连续狂点的情况不多,影响不大。
回顾与反思
现在回头看,这个 collapse 组件整体还算稳定。线上跑了快一个月,没收到相关 bug 反馈。最大的收获是明白了“实用优先”这个道理。有时候看起来很 dirty 的写法,反而是最可靠的。
如果再做一次,我会考虑把动画逻辑抽成 directive,比如 v-collapse,这样在列表里用起来更简洁。但现在这样也能用,暂时没动力重构。
另外其实可以接入 ResizeObserver 监听内容变化,自动更新高度。不过我们项目里内容基本静态,加上 RO 兼容性在低端安卓机上不太稳,就没上。这是个优化方向,有兴趣的同学可以试试。
以上是我踩坑后的总结,希望对你有帮助
这个组件现在已经在我们多个项目里复用了,稍微改改就能塞进 React 或者原生环境。虽然不算什么高深技术,但确实是那种“不做不知道,一做一身汗”的典型小功能。
如果你有更好的实现方式,比如怎么优雅处理快速点击的问题,欢迎评论区交流。这类看似简单的交互,其实最考验细节把控。

暂无评论