远程搜索下拉框封装

首先要有需求,需求驱使才能让我们打工人创造业务组件

需求分析

我们平时做一些管理系统之类的应用时都需要一些关联搜索的下拉选择,所以就需要我们写很多下拉相关的业务组件

方案选择

思路呢,我暂时就想得到以下几套方案:

  • 所有的远程搜索下拉框都写一个组件
  • 封装一个组件作为通用组件,通过props传参实现不同的搜索

首先分析第一个方案,每个远程搜索都写一个独立的组件。

  • 优点:很明显不用考虑别的就实现输入和输出就完事了;
  • 缺点:就是会制造大量的重复代码,以及很多组件出来;

第二个方案就是封装一个通用组件,不同的搜索传不同参数。

  • 优点:
    • 一个组件支撑某些情况下更改起来会相对便捷
    • 通过配置项实现不同的搜索下拉减少功能重复的组件
  • 缺点:
    • 编写起来需要考虑很多情况,很多奇怪的搜索可能照顾不到
    • 需要的props可能会相对较多

实现思路

首先考虑远程搜索组件需要用到的必要的属性(element-plus的二次封装,所以它需要的属性以下不在过多赘述,会进行透传的)

这种组件实际需要的核心属性只包含以下几种:

props说明类型默认值是否必传
modelValuev-model绑定的值Array/String/Number-
url后端提供的suggest接口String-
queryKeysuggest接口需要的联想传参字段名String-
selectOptionsKey下拉option需要的label和value对应的接口返回的字段[String, String][‘code’, ‘desc’]
multiple是否多选Booleanfalse
selectDefaultOptions默认的下拉框选项,不需要远程或者需要额外选项的时候可以传这个Array[]

但是呢,只包含核心属性明显是不足以应付各种需求的,而且大部分场景都是和form表单一起用所以封一起更省事(写业务经常纠结各种场景判断导致时间不够用,所以还是得会偷懒啊)所以还提供下面一些额外的配置:

props说明类型默认值
desc可选,可通过v-model绑定,下拉选项的展示的值String/Array-
labelform-item的labelString-
rules可选,form-item的rulesFormItemRule-
prop可选,form-item的propString-
otherOptions一些select相关的配置,都是可选的Object-

otherOptions 配置项

props说明类型默认值
placeholder占位文字String / () => String`请选择${props.label}`
remoteMethod远程搜索方法,不满足通用的时候,自己定一个搜索方法(query: string) => any[]-
labelHandler下拉选项的展示值处理(option: Record<string, T>) => String-

代码实现

首先处理我们的props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface SuggestProps {
desc?: string | number | Array<string | number> | undefined
modelValue: string | number | Array<string | number>
label: string
multiple?: boolean
prop?: string
rules?: FormItemRule | FormItemRule[]
selectDefaultOptions?: SelectOptions[]
url?: string
queryKey?: string
otherOptions?: any
selectOptionsKey?: [string, string]
}
const props = withDefaults(defineProps<SuggestProps>(), {
desc: '',
selectDefaultOptions: () => [],
multiple: false,
url: '',
queryKey: 'name',
selectOptionsKey: () => ['code', 'desc'],
});

由于我目前只用得到双向绑定取值所以就定义了两个触发更新的emit

1
2
3
4
5
interface SuggestEmits {
(event: 'update:modelValue', e: string | number | any[]): void
(event: 'update:desc', e: string | number | any[] | undefined): void
}
const emit = defineEmits<SuggestEmits>();

根据我们传入的props需要提取出一部分属性给el-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
const { modelValue, label, rules, selectDefaultOptions, multiple, selectOptionsKey } = toRefs(props);

const loading = ref(false);
const options = ref<any[]>([]);

const placeholder = computed(() => typeof props?.otherOptions?.placeholder === 'function' ? props?.otherOptions?.placeholder() : t(props?.otherOptions?.placeholder ?? '') || `${t('message.placeholder.placeSelect')}${t(props.label)}`);

const suggestOptions = reactive({
filterable: true,
remote: true,
multiple,
loading,
...(isReactive(props.otherOptions) ? toRefs(props.otherOptions) : props.otherOptions),
placeholder,
clearable: true,
remoteMethod: async (query: string) => {
if (query) {
loading.value = true;
if (props.otherOptions?.remoteMethod) {
options.value = await props.otherOptions.remoteMethod(query);
loading.value = false;
} else {
const data = await $post(props.url, {
[props.queryKey]: query,
});
if (data?.code === 0) {
options.value = data.info.list;
loading.value = false;
}
}
} else {
options.value = [];
}
},
});

以及一部分el-option需要的属性

1
2
3
4
5
6
7
8
const options = ref<any[]>([]);

const labelView = (item: EmptyObjectType) => {
if (props?.otherOptions?.labelHandler) {
return props.otherOptions.labelHandler(item);
}
return item[selectOptionsKey.value[1]] ?? '';
};

模版代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<el-form-item :label="`${$t(label)}:`" :rules="rules" :prop="props.prop">
<el-select
:model-value="modelValue"
@update:model-value="selectChange"
v-bind="suggestOptions"
>
<el-option
v-for="item in options"
:key="item[selectOptionsKey[0]]"
:label="item[selectOptionsKey[1]]"
:value="item[selectOptionsKey[0]]"
>
<div v-html="labelView(item)"></div>
</el-option>
</el-select>
</el-form-item>
</template>

需要的属性都处理完后,我们该考虑如何处理选中的值如何向上层传递,如何处理上层传下来的数据回显问题了

首先我们需要知道我们设计将选中项的code(/id)和desc(选项描述)都存为了数组(/字符串),那么我们就需要处理两种情况

  • 数组,也就是多选的情况,通过filter、配合includes过滤出选中的项
  • 字符串,单选情况,通过find直接找到选中的项
1
2
3
4
5
6
7
8
9
10
const selectChange = (e: any) => {
emit('update:modelValue', e);
if (!multiple.value) {
const desc = options.value.find(i => i[selectOptionsKey.value[0]] === e)?.[selectOptionsKey.value[1]];
emit('update:desc', desc);
} else {
const desc = options.value.filter(i => e.includes(i[selectOptionsKey.value[0]])).map(i => i[selectOptionsKey.value[1]]);
emit('update:desc', desc);
}
};

目前来讲这个组件已经可以使用了,但是用起来会发现如果初始化的时候传入了modelValue和desc还是会出现选择框内只有code展示的问题,这个问题就是因为初始化的时候el-option是没有值的,这里用一个一次性的监听去处理下初始选项的问题就好了,如下为解决方案:

1
2
3
4
5
6
7
8
9
10
11
const cancelWatch = watch(() => props.desc, () => {
if (multiple.value && props.desc && typeof props.modelValue === 'object') {
props.modelValue.forEach((value, index) => {
options.value.push({ [selectOptionsKey.value[0]]: value, [selectOptionsKey.value[1]]: (props.desc as any)?.[index] ?? value });
});
nextTick(() => cancelWatch());
} else if (props.desc) {
options.value.push({ [selectOptionsKey.value[0]]: props.modelValue, [selectOptionsKey.value[1]]: props.desc });
nextTick(() => cancelWatch());
}
}, { immediate: true });

解决完初始选项和向上传递选中值的问题后,还要完善我们的默认选项的功能,这里通过watch监听这个props属性,如下:

1
2
3
watch(selectDefaultOptions, () => {
options.value = [...options.value, ...selectDefaultOptions.value];
}, { immediate: 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
<template>
<el-form-item :label="`${$t(label)}:`" :rules="rules" :prop="props.prop">
<el-select
:model-value="modelValue"
@update:model-value="selectChange"
v-bind="suggestOptions"
>
<el-option
v-for="item in options"
:key="item[selectOptionsKey[0]]"
:label="item[selectOptionsKey[1]]"
:value="item[selectOptionsKey[0]]"
>
<div v-html="labelView(item)"></div>
</el-option>
</el-select>
</el-form-item>
</template>
<script setup lang="ts" name="FrSuggest">
import { $post } from '@/plugins/axios';
import type { FormItemRule } from 'element-plus';
import { isReactive, nextTick, watch, ref, toRefs, reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';

interface SuggestProps {
desc?: string | number | Array<string | number> | undefined
modelValue: string | number | Array<string | number>
label: string
multiple?: boolean
prop?: string
rules?: FormItemRule | FormItemRule[]
selectDefaultOptions?: SelectOptions[]
url?: string
queryKey?: string
otherOptions?: any
selectOptionsKey?: [string, string]
}

interface SuggestEmits {
(event: 'update:modelValue', e: string | number | any[]): void
(event: 'update:desc', e: string | number | any[] | undefined): void
}
const { t } = useI18n();
const props = withDefaults(defineProps<SuggestProps>(), {
desc: '',
selectDefaultOptions: () => [],
multiple: false,
url: '',
queryKey: 'name',
selectOptionsKey: () => ['code', 'desc'],
});

const { modelValue, label, rules, selectDefaultOptions, multiple, selectOptionsKey } = toRefs(props);
const emit = defineEmits<SuggestEmits>();

const loading = ref(false);
const options = ref<any[]>([]);

const placeholder = computed(() => typeof props?.otherOptions?.placeholder === 'function' ? props?.otherOptions?.placeholder() : t(props?.otherOptions?.placeholder ?? '') || `${t('message.placeholder.placeSelect')}${t(props.label)}`);

const suggestOptions = reactive({
filterable: true,
remote: true,
multiple,
loading,
...(isReactive(props.otherOptions) ? toRefs(props.otherOptions) : props.otherOptions),
placeholder,
clearable: true,
remoteMethod: async (query: string) => {
if (query) {
loading.value = true;
if (props.otherOptions?.remoteMethod) {
options.value = await props.otherOptions.remoteMethod(query);
loading.value = false;
} else {
const data = await $post(props.url, {
[props.queryKey]: query,
});
if (data?.code === 0) {
options.value = data.info.list;
loading.value = false;
}
}
} else {
options.value = [];
}
},
});

const labelView = (item: EmptyObjectType) => {
if (props?.otherOptions?.labelHandler) {
return props.otherOptions.labelHandler(item);
}
return item[selectOptionsKey.value[1]] ?? '';
};

const selectChange = (e: any) => {
emit('update:modelValue', e);
if (!multiple.value) {
const desc = options.value.find(i => i[selectOptionsKey.value[0]] === e)?.[selectOptionsKey.value[1]];
emit('update:desc', desc);
} else {
const desc = options.value.filter(i => e.includes(i[selectOptionsKey.value[0]])).map(i => i[selectOptionsKey.value[1]]);
emit('update:desc', desc);
}
};

watch(selectDefaultOptions, () => {
options.value = [...options.value, ...selectDefaultOptions.value];
}, { immediate: true });

const cancelWatch = watch(() => props.desc, () => {
if (multiple.value && props.desc && typeof props.modelValue === 'object') {
props.modelValue.forEach((value, index) => {
options.value.push({ [selectOptionsKey.value[0]]: value, [selectOptionsKey.value[1]]: (props.desc as any)?.[index] ?? value });
});
nextTick(() => cancelWatch());
} else if (props.desc) {
options.value.push({ [selectOptionsKey.value[0]]: props.modelValue, [selectOptionsKey.value[1]]: props.desc });
nextTick(() => cancelWatch());
}
}, { immediate: true });

</script>

i18n为国际化翻译用的,不需要的请自行删除