基于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], 1010: [] }
|
而我们熟知的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
|
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}` })) }) }) }) return localListMap }
|
一级类型选中状态处理
除此之外我们还需要考虑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
|
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 }
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()]) }
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) => { const RELATETYPELISTMAP = localListToMap() const RELATETYPELIST = [...RELATETYPELISTMAP.values()] 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) => { if (!value.length) { Object.keys(groupStatus).forEach(key => { groupStatus[key] = false }) return } const selectedMap = new Map(); value.forEach((v) => { const [key, val] = v.split('.') if (!selectedMap.has(key)) { selectedMap.set(key, []) } if (val) { selectedMap.get(key).push(val) } }); 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
|
const handleClickGroup = (group) => { const childrens = !RELATETYPELISTMAP.get(group.code).sub_type?.length ? [group] : RELATETYPELISTMAP.get(group.code).sub_type const childrensCode = childrens.map((val) => val.code) const selected = new Set(selectValue.value) if (groupStatus[group.code]) { childrensCode.forEach(code => { selected.delete(code) }) selectValue.value = [...selected] groupStatus[group.code] = false return; }
childrensCode.forEach(code => { selected.add(code) }) 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
| import { computed, reactive } from 'vue';
export const propsType = { value: { type: Object, required: true }, disabled: { type: Boolean }, placeholder: { type: String } }
export const emitType = ['update:value', 'changeRelateType']
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 }
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()]) }
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}` })) }) }) }) return localListMap }
const useSelectData = (props, emit) => { const RELATETYPELISTMAP = localListToMap() const RELATETYPELIST = [...RELATETYPELISTMAP.values()] 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) => { if (!value.length) { Object.keys(groupStatus).forEach(key => { groupStatus[key] = false }) return } const selectedMap = new Map(); value.forEach((v) => { const [key, val] = v.split('.') if (!selectedMap.has(key)) { selectedMap.set(key, []) } if (val) { selectedMap.get(key).push(val) } }); 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 } }) }
const handleClickGroup = (group) => { if (group.isTitle) { return; } const childrens = !RELATETYPELISTMAP.get(group.code).sub_type?.length ? [group] : RELATETYPELISTMAP.get(group.code).sub_type const childrensCode = childrens.map((val) => val.code) const selected = new Set(selectValue.value) if (groupStatus[group.code]) { childrensCode.forEach(code => { selected.delete(code) }) selectValue.value = [...selected] groupStatus[group.code] = false return; }
childrensCode.forEach(code => { selected.add(code) }) 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>
|