在现代前端开发中,折叠面板组件是后台管理系统、商品详情页以及复杂表单中不可或缺的交互元素。它能够有效节省屏幕空间,将大量信息按层级组织,让用户按需展开查看。Vue 3 的组合式 API 为我们提供了更灵活的逻辑组织方式,使得组件的复用性和可维护性大幅提升。封装一个既支持手风琴模式又支持多开模式的折叠面板,核心在于理清父子组件间的状态共享机制以及不同模式下的数据结构差异。

组件架构设计与状态管理机制
要实现一个高内聚低耦合的折叠面板组件,首先需要明确组件的层级结构。通常我们会将其拆分为两个基础组件:外层的容器组件负责管理所有子面板的状态,内层的面板组件负责具体的标题展示与内容过渡。这种父子分离的设计模式在 Element Plus、Ant Design Vue 等主流 UI 库中非常常见。
在 Vue 3 中,如果让每个子面板独立维护自身的展开状态,那么实现手风琴模式将会极其困难,因为子面板之间无法感知彼此的状态。因此,状态提升是关键。我们需要将当前展开的面板标识符统一交由父组件管理。父组件通过 props 接收初始状态,并提供一个更新状态的方法。
为了避免层层传递 props 和 emit 事件带来的代码臃肿,Vue 3 提供了依赖注入机制。父组件可以使用 provide 将当前的活动状态以及切换状态的函数暴露给后代组件,子组件则通过 inject 获取这些数据。这样,无论嵌套多深,子面板都能直接与父组件通信,实现状态的同步更新。
// 父组件 Collapse.vue 核心逻辑
import { provide, ref, computed } from 'vue';
import { collapseContextKey } from './context';
const props = defineProps({
modelValue: [String, Number, Array],
accordion: Boolean
});
const emits = defineEmits(['update:modelValue', 'change']);
const activeNames = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue;
}
return props.accordion ? [props.modelValue] : [props.modelValue].filter(Boolean);
});
// 切换面板状态的核心方法
const toggleItem = (name) => {
// 具体逻辑将在后续小节展开
};
provide(collapseContextKey, {
activeNames,
toggleItem
});
手风琴模式的实现原理与代码落地
手风琴模式的核心约束在于同一时间只能有一个面板处于展开状态。当用户点击一个未展开的面板时,该面板展开,同时之前展开的面板必须立即折叠。这种模式适用于内容互斥的场景,比如筛选条件、导航菜单等,能够强制用户聚焦于单一维度的信息。
在数据结构上,手风琴模式对应的是单值状态。父组件维护一个单一的字符串或数字变量来记录当前展开的面板名称。当触发切换事件时,如果点击的面板名称与当前值相同,说明用户想要折叠该面板,此时将状态值置空;如果不同,则直接将状态值替换为点击的面板名称。
子组件在接收到父组件注入的活动状态数组后,只需判断自身名称是否存在于该数组中即可决定是否展开。由于手风琴模式下数组最多只有一个元素,逻辑判断非常简单。下面是手风琴模式切换逻辑的具体实现。
// 父组件中 toggleItem 方法的实现(手风琴模式)
const toggleItem = (name) => {
if (props.accordion) {
// 手风琴模式:判断点击的是否是当前已展开的面板
const currentValue = activeNames.value[0];
const newValue = currentValue === name ? '' : name;
emits('update:modelValue', newValue);
emits('change', newValue);
} else {
// 多开模式逻辑稍后实现
}
};
// 子组件 CollapseItem.vue 核心逻辑
import { inject, computed } from 'vue';
import { collapseContextKey } from './context';
const props = defineProps({
name: { type: [String, Number], required: true },
title: { type: String, default: '' }
});
const { activeNames, toggleItem } = inject(collapseContextKey);
// 判断当前面板是否处于展开状态
const isActive = computed(() => {
return activeNames.value.includes(props.name);
});
const handleClick = () => {
toggleItem(props.name);
};
多开模式的设计与无缝切换方案
多开模式允许用户同时展开多个折叠面板,各个面板之间的展开与折叠互不影响。这种模式适用于需要对比查看不同模块内容的场景,比如商品参数、评价与详情同时展示。在数据结构上,多开模式对应的是数组状态,父组件需要维护一个包含所有已展开面板名称的数组。
当用户点击某个面板时,如果该面板已经存在于数组中,说明需要将其折叠,此时应从数组中移除该元素;如果不存在,说明需要将其展开,此时应将该元素添加到数组中。在 Vue 3 中,为了保证响应式更新,我们需要注意不要直接修改原数组,而是返回一个新数组。
为了让组件更加灵活,我们需要通过一个 accordion 布尔属性在两种模式间无缝切换。父组件在处理切换逻辑时,会根据 accordion 属性走不同的分支。同时,为了兼容开发者可能传入非数组类型的情况,我们需要在计算属性中进行类型转换和容错处理。
// 父组件中 toggleItem 方法的完整实现(支持多开模式)
const toggleItem = (name) => {
if (props.accordion) {
// 手风琴模式逻辑
const currentValue = activeNames.value[0];
const newValue = currentValue === name ? '' : name;
emits('update:modelValue', newValue);
emits('change', newValue);
} else {
// 多开模式逻辑
const currentArray = activeNames.value.slice();
const index = currentArray.indexOf(name);
if (index === -1) {
// 不存在,添加到数组中
currentArray.push(name);
} else {
// 已存在,从数组中移除
currentArray.splice(index, 1);
}
emits('update:modelValue', currentArray);
emits('change', currentArray);
}
};
动画过渡与无障碍访问优化
一个优秀的组件不仅逻辑要严谨,交互体验也必须流畅。折叠面板在展开和折叠时,如果直接通过 v-if 或 v-show 控制元素的显示隐藏,会显得非常生硬。我们需要借助 Vue 3 的 Transition 组件来实现高度过渡动画。
由于 CSS 的 height 属性无法直接从 auto 过渡到 0,我们需要在 JavaScript 钩子中动态计算元素的实际高度。在 before-enter 阶段将高度设为 0,在 enter 阶段将其设为 scrollHeight,离开时则反向操作。这样就能实现平滑的展开折叠效果。
此外,无障碍访问是容易被忽视的环节。折叠面板的标题区域应该具备按钮的语义。我们需要在标题外层包裹 <button> 标签,并为其添加 aria-expanded 属性来指示当前面板的展开状态,同时添加 aria-controls 属性指向内容区域的 ID,让屏幕阅读器能够正确识别并播报状态。
<template>
<div class="collapse-item">
<div class="collapse-item__header" @click="handleClick">
<button class="collapse-item__button" :aria-expanded="isActive" :aria-controls="`content-${name}`">
{{ title }}
</button>
</div>
<transition name="collapse-transition" @before-enter="beforeEnter" @enter="enter" @before-leave="beforeLeave" @leave="leave">
<div class="collapse-item__wrapper" v-show="isActive" :id="`content-${name}`">
<div class="collapse-item__content">
<slot></slot>
</div>
</div>
</transition>
</div>
</template>
<script setup>
// 动画钩子函数实现
const beforeEnter = (el) => {
el.style.height = '0';
el.style.overflow = 'hidden';
};
const enter = (el) => {
el.style.height = el.scrollHeight ? `${el.scrollHeight}px` : '';
};
const beforeLeave = (el) => {
el.style.height = el.scrollHeight ? `${el.scrollHeight}px` : '';
el.style.overflow = 'hidden';
};
const leave = (el) => {
setTimeout(() => {
el.style.height = '0';
});
};
</script>