基于element select多分组多级下拉筛选封装
基于element select多分组多级下拉筛选封装
实现效果
具体需求
- 这是个多选下拉组件
- 组件内包含有
分组
(分组
不可点击) 分组
下有二级分类
- 点击
一级分类
,选中该分类下所有二级分类
乍一看是挺容易的需求,正当我也以为如此时,转折发生了
难点出现
下拉框的源数据结构事例如下:
[
{
'code': 10,
'desc': '分组1',
'children': [
{
'code': 1010,
'desc': '类型一级',
'sub_type': [
{
'code': 1020,
'desc': '类型二级1'
},
]
},
{
'code': 1011,
'desc': '类型一级2',
'sub_type': []
},
]
},
{
'code': 20,
'desc': '分组2',
'children': [
{
'code': 2010,
'desc': '类型一级',
'sub_type': [
{
'code': 1020,
'desc': '类型二级1'
},
]
},
{
'code': 2011,
'desc': '类型一级2',
'sub_type': []
},
]
}
]
看了数据好像也没有感觉出来难在哪,仔细观摩,数据从children开始为分组
下的二级分类
数据,分为一级和二级,问题点:
-
一级类型的code不会重复,但是仔细观察发现他居然有可能没有
二级分类
,这还选个毛啊,实际需求隐式包含了几条规则- 单独一级是可以选的
- 有二级的
一级分类
是不可以单独选中的
-
不同
一级分类
下的二级分类
code是有重复 -
由于上面说的code会重复,所以最后确定后端要的数据结构是个Object类型,如:
const data = { 1010: [1020], // 有二级并且选了的就是key是一级code,value是二级数组 1010: [] // 没有二级只选了一级,value是个空数组 }
而我们熟知的select多选的数据结构都是Array类
代码实现
了解了需求分析了问题,终于到了该解决问题的时候了,俗话说有困难就解决困难,解决不掉就等着被解决就行了,人和代码有一个能跑就行了
模版的设计
如果我们按element的select分组的结构写的话就不可避免的涉及到几个问题:
分组
是不可选的- 如果
一级分类
作为分组,那么如果所有一级分类
都没有二级分类
,分组会直接不展示,也就是下拉框会变为空的 一级分类
是可以单独选择的
就这几个问题而言,使用默认的select组件带的分组是行不通了
为了满足以下条件:
分组
不可点- 没有
二级分类
的一级分类
可单独选 - 有
二级分类
的一级分类
点了就选择当前分类下所有二级分类
思考结构的时候太复杂的结构更不利于维护,尽量往简单了想,索性全部用el-option来处理吧,把多级的结构扁平化直接就一层还好理解,具体实现:
分组
可以直接用disabled+class
控制不可选就完事了一级分类
分为两种情况:- 一种是包含
二级分类
的这个时候一级分类
不能单独选中就用div+class模拟
稍小一点的option做个区分 - 另一种呢是不存在
二级分类
的一级分类
,这个时候它可以单独选中,就用option来实现就行了
- 一种是包含
- 二级分类就简单了直接遍历sub_type渲染option就行了
具体代码:
<template>
<el-select v-model="selectValue" :placeholder="props.placeholder || '请选择'" multiple clearable :disabled="props.disabled" @change="handleChange">
<template v-for="group in RELATETYPELIST">
<!-- 分组 -->
<el-option class="el-select-group__title group" :key="group.code" v-if="group.isTitle" :label="`${group.desc}`" :value="group.code" disabled>{{ group.desc }}</el-option>
<template v-else>
<!-- 两级,这个充当一级 -->
<div
v-if="group.sub_type && !!group.sub_type.length"
:key="group.code"
class="el-select-group__title group can-select"
:class="{ 'all-selected': groupStatus[group.code] }"
:label="`${group.desc}`"
:value="group.code"
@click="handleClickGroup(group)"
>{{ group.desc }}</div>
<!-- 只有一级的时候 -->
<el-option v-else class="group can-select" :key="group.code" :label="`${group.desc}`" :value="group.code">{{ group.desc }}</el-option>
<!-- 二级分类 -->
<el-option class="pl-30" v-for="item in group.sub_type" :key="item.code" :label="`${group.desc}-${item.desc}`" :value="item.code">
{{item.desc}}
</el-option>
</template>
</template>
</el-select>
</template>
处理源数据
首先我们确定了后端给我们的数据结构是如下结构:
interface BaseData {
code: number
desc: string
}
interface LvData extends BaseData {
sub_type: BaseData[]
}
interface GroupData extends BaseData {
children: LvData[]
}
根据我们想到的template
结构,目前这个结构三级嵌套我们没法直接用
为了达到我们这个尽可能简单的实现的目的来分析这个结构:
- 首先
分组
这一层本来就不能选,而且我们把它扁平化了,起码要与一级分类
同级 二级分类
总要被遍历的,所以拆开它弊大于利
所以最终的结构如下:
const RELATETYPELIST = [
{ code: 10, desc: '分组1', isTitle: true },
{ code: 1010, desc: '类型一级', sub_type: [] }
]
RELATETYPELIST处理
为了实现点击一级分类
快速找到对应的二级分类
,并且选中我们还需要一份数据,此处我选择Map类型
的数据,好处在于Object类型的数据如果用code做key会被重新排序,为了偷懒(少处理一次数据),所以在处理渲染数组RELATETYPELIST
之前直接造出Map
,然后取Map
的value就行了,毕竟Map
的顺序不会被重排
/**
* 下拉框渲染options的源数据
* @returns
*/
const localListToMap = () => {
const localList = [
{
'code': 10,
'desc': '分组1',
'children': [
{
'code': 1010,
'desc': '类型一级',
'sub_type': [
{
'code': 1020,
'desc': '类型二级1'
},
]
},
{
'code': 1011,
'desc': '类型一级2',
'sub_type': []
},
]
},
{
'code': 20,
'desc': '分组2',
'children': [
{
'code': 2010,
'desc': '类型一级',
'sub_type': [
{
'code': 1020,
'desc': '类型二级1'
},
]
},
{
'code': 2011,
'desc': '类型一级2',
'sub_type': []
},
]
}
]
const localListMap = new Map()
// 把最外层的分组跟第一级类型摊平
localList.forEach((parent) => {
localListMap.set(`${parent.code}`, { code: `${parent.code}`, desc: parent.desc, isTitle: true })
parent.children.forEach(d => {
localListMap.set(`${d.code}`, { ...d, code: `${d.code}`, sub_type: d.sub_type?.map(child => ({ ...child, code: `${d.code}.${child.code}` })) })
})
})
// 根据一级code和分组code 组成Map
return localListMap
}
// Map(6) {'10' => {…}, '1010' => {…}, '1011' => {…}, '20' => {…}, '2010' => {…}, …}
// {
// "key": "10",
// "value": {
// "code": "10",
// "desc": "分组1",
// "isTitle": true
// }
// }
// {
// "key": "1010",
// "value": {
// "code": "1010",
// "desc": "类型一级",
// "sub_type": [
// {
// "code": "1010.1020",
// "desc": "类型二级1"
// }
// ]
// }
// }
一级类型选中状态处理
除此之外我们还需要考虑div做一级分类
的时候如何准确的加上class,当然在处理Map
的时候加上属性控制也可以,但是这样又会让结构变得不再纯粹,所以我使用一个新的对象存状态,这个时候就不需要考虑顺序问题了,只需要存一级类型
的code和一个Boolean
值就可以了,所以结构如下:
const groupStatus = {
1010: false,
// ...
}
双向绑定的值处理
下拉框的渲染和一级分类选中的问题解决了,我么该考虑下select组件v-model
的数据怎么处理了,这里我才用computed
来作为绑定值,具体为啥用computed
可以实现看官网吧,下面分析如何写getter和setter:
- 输入和输出需要保持数据结构一致
- 为了满足后端的要求,我们需要将
emit
的输出处理成对象
- 为了满足element组件的要求,我们还需要对getter的输出处理为
数组
如下为具体实现:
/**
* select 组件内部绑定的value的getter函数
* @param {{[key as Stirng]: Array}} value 外部传入的双向绑定对象
* @returns
* @example
* // value demo
* const value = {
* 1010: [1101, 1102, 1103]
* }
* const val = getValue(value) // ['1010.1101', '1010.1102', '1010.1103']
*/
const getValue = (value = {}) => {
const v = []
// 将对象转为数组进行处理
Object.entries(value).forEach(([key, subVal]) => {
// 如果存在二级分类则便利二级分类处理为以 . 链接的字符串满足option的对应关系
if (subVal.length) {
subVal.forEach((val) => {
v.push(`${key}.${val}`)
})
} else {
// 否则直接加进去,为不包含二级分类只有一级分类的option
v.push(`${key}`)
}
})
return v
}
/**
* 响应式返回给上一层的数据
* @param {Array<String>} value
* @returns {{[key as String]: Array}}
*/
const setValue = (value) => {
const vMap = new Map() // 这里也可以直接用一个空对象进行添加
value.forEach((val) => {
// 将已选的数据每项用 . 拆开表示一级code和二级code
const [key, subVal] = val.split('.')
// 判断是否存在这个一级code作为key的值,如果vMap用对象的话可以使用hasOwnProperty判断
if (!vMap.has(key)) {
vMap.set(key, [])
}
// 如果存在二级code则加入数组里
if (subVal) {
vMap.get(key).push(subVal)
}
})
// 如果没选中任何值则然后undefined
if (!vMap.size) {
return undefined
}
// 返回一个对象,如{ 1010: [1020] }
return Object.fromEntries([...vMap.entries()])
}
/**
* 双向绑定的处理
*/
const selectValue = computed({
get: () => getValue(props.value),
set: (value) => {
const v = setValue(value)
emit('update:value', v)
emit('changeSelect', v)
}
})
v-model的处理: cn.vuejs.org/guide/compo…
事件处理
至此我们解决了页面渲染的问题
我们已有的数据如下:
const useSelectData = (props, emit) => {
// map结构,一级code做key
const RELATETYPELISTMAP = localListToMap()
// 所有一级对应的数据,包含二级数据
const RELATETYPELIST = [...RELATETYPELISTMAP.values()]
// 所有一级分组的选中态, filter过滤掉最外层只展示的分组
const groupStatus = reactive(
Object.fromEntries([...RELATETYPELISTMAP.entries()]
.filter(([key, val]) => !val.isTitle)
.map(([key, val]) => [key, false]))
)
/**
* 双向绑定的处理
*/
const selectValue = computed({
get: () => getValue(props.value),
set: (value) => {
const v = setValue(value)
emit('update:value', v)
emit('changeRelateType', v)
}
})
return {
RELATETYPELIST,
groupStatus,
selectValue
}
}
我们还需要解决的问题是:
- 点击
二级分类
时判断一级分类
是否被选中 - 点击
一级分类
时选中所有当前一级分类下
的所有二级分类
处理正常点击option
处理二级分类点击时一级分类是否选中,我们可以直接监听select组件的change事件,具体思路如下:
- 当清空select时传入的value会是一个空数组,这个时候把所有的
一级分类
选中状态的对象都赋值为false - 当选择了某个option时,把已选的数据处理成一个
Map
或者Object
,这样对比源数据和已选数据比较容易,直接比对code一致的一级分类
下的二级分类
数量就行了,一致的就是全选,此时一级分类
变色,不一致就不变
如下是代码实现:
/**
* 选项改变时
*/
const handleChange = (value) => {
// 如果清空选项把group的选中态清空
if (!value.length) {
Object.keys(groupStatus).forEach(key => {
groupStatus[key] = false
})
return
}
const selectedMap = new Map();
// 将已选项处理成一个Map
value.forEach((v) => {
const [key, val] = v.split('.')
if (!selectedMap.has(key)) {
selectedMap.set(key, [])
}
if (val) {
selectedMap.get(key).push(val)
}
});
// 这个selectedMap的结构是
// Map<{
// key: 一级code,
// value: [二级code]
// }>
// 获取已选项的一级的code
const selectKeys = [...selectedMap.keys()]
selectKeys.forEach((key) => {
// 比较已选的二级和源数据二级的长度
// 长度相等则改变一级分类的选中态
if (selectedMap.get(key).length === (RELATETYPELISTMAP.get(key)?.sub_type ?? []).length) {
groupStatus[key] = true
} else {
groupStatus[key] = false
}
})
}
处理点击一级分类
当点击一级分类
的时候需要选中它下面的所有二级分类
点击的时候判断点击的这个一级分类
是否已经全选,分两种情况处理具体实现思路如下:
- 如果已经全选则取消全选,同时删除他下面
二级分类
的选中态 - 如果不是全选,则把当前
一级分类
下面的没有被选择的二级分类
加到v-model的数据里,同时一级分类
状态切换为已选
具体代码实现如下:
/**
* 点击一级分类时
* @param {*} group
*/
const handleClickGroup = (group) => {
// 获取子项, 如果不存在二级分类就把当前的一级分类拿出来
const childrens = !RELATETYPELISTMAP.get(group.code).sub_type?.length ? [group] : RELATETYPELISTMAP.get(group.code).sub_type
// 获取子项code
const childrensCode = childrens.map((val) => val.code)
// 获取已选的code
const selected = new Set(selectValue.value)
// 如果当前组已经是全选了,则删除已选的
if (groupStatus[group.code]) {
// 删除已选的里面包含的子项
childrensCode.forEach(code => {
selected.delete(code)
})
// 重新给select赋值
selectValue.value = [...selected]
// 改变当前组的选中态
groupStatus[group.code] = false
return;
}
// 往里塞当前组的code
childrensCode.forEach(code => {
selected.add(code)
})
// 重新给select赋值
selectValue.value = [...selected]
// 改变当前组的选中态
groupStatus[group.code] = true
}
完整代码请查看我的个人网站:wangblogs.top/2023/07/18/…