基于element select多分组多级下拉筛选封装

实现效果

示例

具体需求

  • 这是个多选下拉组件
  • 组件内包含有分组分组不可点击)
  • 分组下有二级分类
  • 点击一级分类,选中该分类下所有二级分类

乍一看是挺容易的需求,正当我也以为如此时,转折发生了

难点出现

下拉框的源数据结构事例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
[
{
'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类型,如:

    1
    2
    3
    4
    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就行了

具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<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>

处理源数据

首先我们确定了后端给我们的数据结构是如下结构:

1
2
3
4
5
6
7
8
9
10
interface BaseData {
code: number
desc: string
}
interface LvData extends BaseData {
sub_type: BaseData[]
}
interface GroupData extends BaseData {
children: LvData[]
}

根据我们想到的template结构,目前这个结构三级嵌套我们没法直接用

为了达到我们这个尽可能简单的实现的目的来分析这个结构:

  • 首先分组这一层本来就不能选,而且我们把它扁平化了,起码要与一级分类同级
  • 二级分类总要被遍历的,所以拆开它弊大于利

所以最终的结构如下:

1
2
3
4
const RELATETYPELIST = [
{ code: 10, desc: '分组1', isTitle: true },
{ code: 1010, desc: '类型一级'sub_type: [] }
]
RELATETYPELIST处理

为了实现点击一级分类快速找到对应的二级分类,并且选中我们还需要一份数据,此处我选择Map类型的数据,好处在于Object类型的数据如果用code做key会被重新排序,为了偷懒(少处理一次数据),所以在处理渲染数组RELATETYPELIST之前直接造出Map,然后取Map的value就行了,毕竟Map的顺序不会被重排

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/**
* 下拉框渲染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值就可以了,所以结构如下:

1
2
3
4
const groupStatus = {
1010: false,
// ...
}
双向绑定的值处理

下拉框的渲染和一级分类选中的问题解决了,我么该考虑下select组件v-model的数据怎么处理了,这里我才用computed来作为绑定值,具体为啥用computed可以实现看官网吧,下面分析如何写getter和setter:

  • 输入和输出需要保持数据结构一致
  • 为了满足后端的要求,我们需要将emit的输出处理成对象
  • 为了满足element组件的要求,我们还需要对getter的输出处理为数组

如下为具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* 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的处理: https://cn.vuejs.org/guide/components/v-model.html

事件处理

至此我们解决了页面渲染的问题

我们已有的数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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一致的一级分类下的二级分类数量就行了,一致的就是全选,此时一级分类变色,不一致就不变

如下是代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 选项改变时
*/
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的数据里,同时一级分类状态切换为已选

具体代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 点击一级分类时
* @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
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
// useSelectData
import { computed, reactive } from 'vue';
/**
* props类型
*/
export const propsType = {
value: {
type: Object,
required: true
},
disabled: {
type: Boolean
},
placeholder: {
type: String
}
}
/**
* emit的类型
*/
export const emitType = ['update:value', 'changeRelateType']

/**
* relateType 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]) => {
if (subVal.length) {
subVal.forEach((val) => {
v.push(`${key}.${val}`)
})
} else {
v.push(`${key}`)
}
})
return v
}
/**
* 响应式返回给上一层的数据
* @param {Array<String>} value
* @returns {{[key as String]: Array}}
*/
const setValue = (value) => {
const vMap = new Map()
value.forEach((val) => {
const [key, subVal] = val.split('.')
if (!vMap.has(key)) {
vMap.set(key, [])
}
if (subVal) {
vMap.get(key).push(subVal)
}
})
if (!vMap.size) {
return undefined
}
return Object.fromEntries([...vMap.entries()])
}

/**
* 下拉框渲染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
}

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)
}
})

/**
* 选项改变时
*/
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)
}
});
// 获取已选项的一级的code
const selectKeys = [...selectedMap.keys()]
selectKeys.forEach((key) => {
// 比较已选的二级和源数据二级的长度
// 长度相等则改变group的选中态
if (selectedMap.get(key).length === (RELATETYPELISTMAP.get(key)?.sub_type ?? []).length) {
groupStatus[key] = true
} else {
groupStatus[key] = false
}
})
}

/**
* 点击select group时
* @param {*} group
*/
const handleClickGroup = (group) => {
// title只做展示用的点了也不给他处理
if (group.isTitle) {
return;
}
// 获取子项
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
}

return {
RELATETYPELIST,
groupStatus,
selectValue,
handleChange,
handleClickGroup
}
}

export default useSelectData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<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>

<script setup>
import useSelectData, { propsType, emitType } from './useSelectData'
import { onMounted } from 'vue'

const props = defineProps(propsType)
const emit = defineEmits(emitType)

const { selectValue, groupStatus, RELATETYPELIST, handleChange, handleClickGroup } = useSelectData(props, emit)

onMounted(() => {
handleChange(selectValue.value)
})
</script>

<style scoped lang="scss">
.group {
&.can-select {
cursor: pointer;
}
}
.all-selected {
color: #409EFF;
}
.pl-30 {
padding-left: 30px;
}
</style>