Vue3实现element plus 中的Collapse 折叠面板

1,179 阅读9分钟

在之前写过一篇element plus 中的表单控件是怎么封装出来的 。 介绍了input, checkboxcheckbox-group, radio, radio-group 的实现。今天将会继续实现element plus 中组件,今天要实现的是Collapse

为什么会选择实现Collapse呢?

  • 因为Collapse 是一个比较常用的功能
  • 实现Collapse可以加深我们对vue 知识点的认知,比如v-model的运用, slot的运用,Transition 动画等。使得我们在项目开发中可以更灵活快速的开发和解决问题

分析 Collapse 中用哪些主要功能

  1. Collapse 的每一项由标题和内容组成,标题右侧有箭头
  2. 标题可以是String 类型,也可以复杂类型(包括一些html标签)
  3. 点击标题可以展开内容,再次点击可以把内容收起来,展开收起过程有动画
  4. 内容模板自定义
  5. 可以禁用某一项,禁用之后无法在进行点击
  6. 支持手风琴效果,即只有一个是打开的,当点击另外一个的时候需要把之前的收起来
  7. 支持传入默认打开项的设置

技术分析

  1. Collapse 是一个大组件, 里面的每一项是小组件。使用的时候小组件是直接写在大组件之间的,由此可以的得出大组件中必然要使用slot
  2. 由功能分析的第二条可以得知单个项的组件标题,可以通过props 传入(简单标题),也可以自定义标题模板,即需要使用slot
  3. 点击可以收缩展开,说明标题上需要添加点击事件。这个事件需要改变内容的显示状态
  4. 展开收缩需要有动画,说明我们会用到Transition 动画组件
  5. 内容模板自定义说明内容直接使用slot
  6. 支持禁用,即我们需要一个参数disabled的配置来判断是否是禁用状态,如果是,添加禁用样式并且点击事件不做任何逻辑处理
  7. 支持手风琴效果,说明我们需要传入一个配置参数用来判断用户是否开启了手风琴模式,如果传入了,点击了标题则需要将之前的收起来,我们可以用一个数组来记录打开的,手风琴模式下,直接是进行第一项的替换,如果是非手风琴模式则应该是push
  8. 支持默认打开即我们需要一个参数来确定默认打开的项。

开发阶段:

创建目录结构:

image.png

布局结构实现

编写UiCollapse.vue

<template>
  <div class="ui-collapse">
    <slot></slot>
  </div>
</template>

<script setup>
defineOptions({
  name: 'UiCollapse'
})
</script>

<style scoped>

</style>

编写UiCollapseItem.vue

<template>
  <div class="collapse-item">
    <p class="title" ><slot name="title">{{ title }}</slot></p>
      <div class="content" >
        <slot></slot>
      </div>
  </div>
</template>

<script setup>
defineOptions({
  name: 'UiCollapseItem'
})
const props = defineProps({
  title: {
    type: String,
    default: ''
  }
})
</script>

<style lang="scss" scoped>
.collapse-item {
  border-bottom: 1px solid #ebeef5;
  
  .title {
    position: relative;
    line-height: 48px;
    display: flex;
    align-items: center;
    margin: 0;
    cursor: pointer;

    &::after {
      content: '';
      width: 14px;
      height: 14px;
      background: url("@/assets/images/arrow-right.svg") no-repeat center;
      background-size: cover;
      display: block;
      position: absolute;
      right: 10px;
      top: 20px;
      transition: transform 0.5s;
    }
  }
  .content { 
    padding-bottom: 25px;
    padding-right: 30px;
  }
}

</style>

编写index.js

import UiCollapse from "./UiCollapse.vue";
import UiCollapseItem from "./UiCollapseItem.vue";

export default {
  install(app) {
    app.component(UiCollapse.name, UiCollapse);
    app.component(UiCollapseItem.name, UiCollapseItem);
  },
};

注意这里使用了插件化的写法,这样可以在main.js 里面进行use 使用。

修改main.js

import { createApp } from "vue";
import App from "./App.vue";
import UiCollapse from "@/components/UiPlus/UiCollapse";
const app = createApp(App);
app.use(UiCollapse);
app.mount("#app");

App.vue 里面进行使用

<template>
  <div>
    <UiCollapse >
      <UiCollapseItem title="Consistency">
        Consistent with real life: in line with the process and logic of real life, and comply with languages and habits that the users are used to;
        Consistent within interface: all elements should be consistent, such as: design style, icons and texts, position of elements, etc.
      </UiCollapseItem>
      <UiCollapseItem>
        <template #title>
          今天心情太好了 <img src="@/assets/images/kaixin.svg" width="28" />
        </template>
        晒太阳去,去游泳也不错, 走啊, 哈哈哈
      </UiCollapseItem>
      <UiCollapseItem title="减肥计划">
        今年从150 减到120
      </UiCollapseItem>
    </UiCollapse>
  </div>
</template>

<script setup>
</script>

<style scoped>
</style>

这样我们的布局结构就实现了

看下效果:

image.png

实现默认展开和点击收缩功能

修改UiCollapse.vue

<template>
  <div class="ui-collapse">
    <slot></slot>
  </div>
</template>

<script setup>
import { ref, provide } from 'vue'

defineOptions({
  name: 'UiCollapse'
})

const props = defineProps({
  modelValue: {
    type: Array,
    default () {
      return []
    }
  }
})

const emit = defineEmits(['update:modelValue'])

const activeValue = ref([...props.modelValue])
provide('collapseObj', {
  activeValue,
  toggle (name) {
    const index = activeValue.value.indexOf(name)
    if (index !== -1) {
      activeValue.value.splice(index, 1)
    } else {
      activeValue.value.push(name)
    }
    emit('update:modelValue', activeValue.value)
  }
})

</script>

<style scoped>

</style>

在这里:

  • 我们增加了modelValue 属性的定义,因为我们使用的时候会通过v-model 绑定的值来定义展开的项
  • 通过provide 传值给子组件,因为这里我们使用的slot, props 是不能使用的
  • provide 传了选中的值和一个改变收缩展开状态的方法到子组件
  • 在修改收缩状态的方法中,修改完收缩状态的之后,发送update:modelValue ,更改使用时v-model 绑定的值实现双向绑定。

修改UiCollapseItem.vue

<template>
 <div class="collapse-item" :class="{ active: isActive}">
  <p class="title" @click="toggle(name)"><slot name="title">{{ title }}</slot></p>
      <div class="content" v-if="collapseObj.activeValue.value.includes(name)">
        <slot></slot>
      </div>
  </div>
</template>

<script setup>
import { inject, computed } from 'vue'
defineOptions({
  name: 'UiCollapseItem'
})
const props = defineProps({
  title: {
    type: String,
    default: ''
  },
  name: {
    type: [String, Number],
    default: '-'
  },
})

const collapseObj = inject('collapseObj')

const toggle = (name) => {
  collapseObj.toggle(name)
}

const isActive = computed(() => collapseObj.activeValue.value.includes(props.name))
</script>

<style lang="scss" scoped>
.collapse-item {
  border-bottom: 1px solid #ebeef5;
  
  .title {
    position: relative;
    line-height: 48px;
    display: flex;
    align-items: center;
    margin: 0;
    cursor: pointer;

    &::after {
      content: '';
      width: 14px;
      height: 14px;
      background: url("@/assets/images/arrow-right.svg") no-repeat center;
      background-size: cover;
      display: block;
      position: absolute;
      right: 10px;
      top: 20px;
      transition: transform 0.5s;
    }
  }
  &.active .title::after{
    transform: rotate(90deg);
  }
  .content { 
    padding-bottom: 25px;
    padding-right: 30px;
  }
}

</style>

在这个组件中:

  • 新增了name 属性,来定义UiCollapseItem 唯一标识。
  • 使用inject 接收了父组件传过来的,展开状态的值和更改展开状态的方法
  • 点击时调用父组件传过来的方法更改展开状态
  • 根元素上增加了动态样式:class="{ active: isActive},由选中的决定
  • 在内容的标签上新增了v-if="collapseObj.activeValue.value.includes(name)" 判断,判断当前项是否是选中状态,是就显示,不是不显示

修改App.vue

<template>
  <div>
    <UiCollapse v-model="activeModel">
      <UiCollapseItem title="Consistency" name="1">
        Consistent with real life: in line with the process and logic of real life, and comply with languages and habits that the users are used to;
        Consistent within interface: all elements should be consistent, such as: design style, icons and texts, position of elements, etc.
      </UiCollapseItem>
      <UiCollapseItem name="2">
        <template #title>
          今天心情太好了 <img src="@/assets/images/kaixin.svg" width="28" />
        </template>
        晒太阳去,去游泳也不错, 走啊, 哈哈哈
      </UiCollapseItem>
      <UiCollapseItem title="减肥计划" name="3">
        今年从150 减到120
      </UiCollapseItem>
    </UiCollapse>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
const activeModel = ref([])

watch(() => activeModel.value, (newValue) => {
  console.log(activeModel.value, '变化')
}, {
  deep: true
})
</script>

<style scoped>
</style>


在App.vue中:

  • 我们给UiCollapseItem 增加了 name属性,name唯一标识,选中时的记录
  • UiCollapse 增加了v-model="activeModel"
  • watch 监听activeModel变化,变化后打印最新值

来查看下效果:

zhedie1.gif

可以看到可以正常点击展开和收缩了。并且同步到v-model绑定的值。

初始化v-model绑定的值是空数组,现在我们将他改成

const activeModel = ref(['2'])

image.png

可以看到初始化时,第二个就选中了。 我们将第三项加进去

const activeModel = ref(['2', '3'])

image.png

可以看到默认选中了两个展开。到这里我们就实现了点击收缩和展开的功能,还实现了传入默认展开的项。接下来我们实现禁用功能。

禁用功能的实现

禁用功能主要是针对某一项的。所以现在我们来修改下UiCollapseItem.vue。

props 增加disabled,如下:

const props = defineProps({
  title: {
    type: String,
    default: ''
  },
  name: {
    type: [String, Number],
    default: '-'
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

点击标题的时候判断disabled是否为真,如果是真,则直接return 不做任何处理。修改如下

const toggle = (name) => {
  if (props.disabled){
    return
  } else {
    collapseObj.toggle(name)
  }
}

添加禁用类名和样式:

<div class="collapse-item" :class="{ disabled: disabled, active: isActive}">
.collapse-item {
  border-bottom: 1px solid #ebeef5;
  
  &.disabled .title{
    cursor: no-drop;
    color: #c0c4cc;
  }
 }

App.vue 添加disabled属性的传入,这里我们将第三个改为禁用

<UiCollapseItem name="3" title="减肥计划" :disabled="true">
    今年从150 减到120
</UiCollapseItem>

现在来查看效果

zhedie2.gif

可以看到第三个不能点击展开了, 前面两个可以正在点击展开。这样我们就实现了禁用功能。接下来我们来实现手风琴功能。

手风琴功能实现

修改UiCollapse.vue,添加accordion属性,如下:

const props = defineProps({
  modelValue: {
    type: Array,
    default () {
      return []
    }
  },
  accordion: {
    type: Boolean,
    default: false
  }
})

修改收缩展开的方法,判断是手风琴模式,直接进行选中值的替换。

toggle (name) {
    const index = activeValue.value.indexOf(name)
    if (index !== -1) {
      activeValue.value.splice(index, 1)
    } else {
      if (props.accordion) {
        activeValue.value[0] = name
      } else {
        activeValue.value.push(name)
      }
    }
    emit('update:modelValue', activeValue.value)
  }

修改App.vue, UiCollapse 组件增加accordion 传入。

<UiCollapse v-model="activeModel" accordion>

查看运行效果:

zhedie3.gif

可以看到,同一时间就只会有一个展开了,这样我们就实现了手风琴效果。接下来我们来实现动画效果。

动画效果实现:

修改UiCollapseItem.vue

<template>
  <div class="collapse-item" :class="{ disabled: disabled, active: isActive}">
    <p class="title" @click="toggle(name)"><slot name="title">{{ title }}</slot></p>
    <Transition name="slide"
      @before-enter="onBeforeEnter"
      @enter="onEnter"
      @after-enter="onAfterEnter"
      @before-leave="onBeforeLeave"
      @leave="onLeave"
      @after-leave="onAfterLeave"
    >
      <div v-if="collapseObj.activeValue.value.includes(name)" class="content-wraper">
        <div class="content" >
          <slot></slot>
        </div>
      </div>
    </Transition>
  </div>
</template>

<script setup>
import { inject, computed } from 'vue';
const props = defineProps({
  title: {
    type: String,
    default: ''
  },
  name: {
    type: [String, Number],
    default: '-'
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const collapseObj = inject('collapseObj')

const toggle = (name) => {
  if (props.disabled){
    return
  } else {
    collapseObj.toggle(name)
  }
}

const isActive = computed(() => collapseObj.activeValue.value.includes(props.name))

const onBeforeEnter = (el) => {
  el.style.height = 0
}

const onEnter = (el) => {
  el.style.height = el.scrollHeight + 'px'
}

const onAfterEnter = (el) => {
  el.style.height = ''
}

const onBeforeLeave = (el) => {
  el.style.height = el.scrollHeight + 'px'
}

const onLeave = (el) => {
  el.style.height = 0
}

const onAfterLeave = (el) => {
  el.style.height = ''
}


</script>

<style lang="scss" scoped>
.collapse-item {
  border-bottom: 1px solid #ebeef5;
  
  &.disabled .title{
    cursor: no-drop;
    color: #c0c4cc;
  }
  .title {
    position: relative;
    line-height: 48px;
    display: flex;
    align-items: center;
    margin: 0;
    cursor: pointer;

    &::after {
      content: '';
      width: 14px;
      height: 14px;
      background: url("@/assets/images/arrow-right.svg") no-repeat center;
      background-size: cover;
      display: block;
      position: absolute;
      right: 10px;
      top: 20px;
      transition: transform 0.5s;
    }
  }

  &.active .title::after{
    transform: rotate(90deg);
  }
  .content-wraper {
    overflow: hidden;
  }
  .content {
    padding-bottom: 25px;
    padding-right: 30px;
  }
}

.slide-enter-active,
.slide-leave-active {
  transition: height 0.5s;
}

</style>
  • 增加Transition 组件包裹内容部分
  • 内容部分添加了一个父级div元素,因为我们class="content"元素上有一个padding-bottom: 25px的样式,添加动画之后会出现卡顿现象
  • 在Transition 添加了6个钩子函数:
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@before-leave="onBeforeLeave"
@leave="onLeave"
@after-leave="onAfterLeave"

这里使用钩子函数原因是,内容的高度是不确定的,需要动态获取。

钩子函数处理逻辑如下:

const onBeforeEnter = (el) => {
  el.style.height = 0
}

const onEnter = (el) => {
  console.log(el.scrollHeight)
  el.style.height = el.scrollHeight + 'px'
}

const onAfterEnter = (el) => {
  el.style.height = ''
}

const onBeforeLeave = (el) => {
  el.style.height = el.scrollHeight + 'px'
}

const onLeave = (el) => {
  el.style.height = 0
}

const onAfterLeave = (el) => {
  el.style.height = ''
}

添加动画过渡样式:

.slide-enter-active,
.slide-leave-active {
  transition: height 0.5s;
}

查看运行效果:

zhedie6.gif

可以看到我们就实现了动画效果,到这里Collapse 的实现就完成了。我们来总结下

总结

通过Collapse 的实现:

  • 我们掌握了slot的使用技巧,掌握slot 方式的父子组件方式传值
  • 更熟悉了v-model 的用法
  • 对高度不确定的元素,使用Transition 如何处理
  • 掌握了插件式开发组件的方法

今天的分享就到这里了,感谢您的收看。若您对element-plus的实现 感兴趣还可以查看: # element plus 中的表单控件是怎么封装出来的 若您对ui组件库的开发感兴趣,还可以查看# 在Vue中如何开发一个UI组件库