手把手实现一个高性能的Collapse折叠组件

慕容窅恒 组件 阅读 2,109
赞 6 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个后台管理系统的活,客户要做一个可配置的表单生成器。用户可以拖拽字段,每个字段组还能折叠收起。这需求听起来挺简单,但实际做起来才发现坑不少。

手把手实现一个高性能的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 或者原生环境。虽然不算什么高深技术,但确实是那种“不做不知道,一做一身汗”的典型小功能。

如果你有更好的实现方式,比如怎么优雅处理快速点击的问题,欢迎评论区交流。这类看似简单的交互,其实最考验细节把控。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论