前情提要
前一阵子测试提了两个bug:
- xxx(某个下拉选择框)存在3万条数据,添加xxx页面无法正常使用
- xxx(某个下拉选择框)存在10000+数据,添加/编辑xxx系统页面一直处于加载中(下拉选择框内容过多导致页面卡死)
因为,我和我的导师决定封装一个高级下拉选择框组件,来替换到之前公司所有普通下拉框。
历时一个月(没错,我比较菜,进度较慢,但80%工作都是我做的,而且同时还在改其他bug,做其他需求),我们终于把这个组件做出来了。
组件功能
我们这个高级下拉选择框组件具有的功能有:
-
- 支持远程搜索
-
- 支持分页滚动加载更多(无限滚动)
-
- 支持单选/多选模式
-
- 支持预选值回显(单选或多选)
-
- 数据去重
-
- 分页参数以及选择框options缓存
详细说下:
- 远程搜索:分页的同时支持搜索(请求参数:page页码,per_page每页的数据,keyword搜索关键字,include_ids某个搜索框用于回显的数据id)
- 分页滚动:分页+懒加载+虚拟列表
- 单选/多选:每个选择框绑定一个 ids 数组,支持单选和多选
- 预选值回显:由于分页,预选值可能不在当前页码,所以我们后端支持了分页的同时进行查询(比如:回显的数据不在第一页,但是此时只渲染了第一页,后端返回的数据就会把这些 ids 对应的数据拼接到查询返回的 items 中,所以items 的值就是= 当前页的数据+ids 对应的数据)
- 数据去重:正如上述第四点,预选值会提前到 options 绑定渲染,所以需要把后面重复的 item 去重。
- 缓存,主要用于缓存分页参数和options,避免多个选择框的数据进行重复请求。
代码说明
伪代码说明:
<AdvancedSelect
v-model:value="formState.business_person_base[index]['person_ids']"
placeholder="请选择人员信息"
:api="getPersonNelLedger"
cache-key="person"
resultField="data.items"
:modelSelect="undefined"
:afterFetch="formatPersonData"
:disabled="Boolean(formState.departments_ids?.length)"
:showSearch="true"
:allowClear="true"
:filterOption="filterOption"
@dropdown-visible-change="handleDropdownVisibleChange"
@search="handleSearch"
@change="handleOper(formState.business_person_base[index]['person_ids'], index)"
/>
<template>
<Select
:show-search="showSearch"
@dropdown-visible-change="handleFetch"
v-bind="$attrs"
@change="handleChange"
:options="getOptions"
:value="state"
@update:value="setState"
@popup-scroll="handlePopupScroll"
placeholder="请选择"
:mode="modeSelect"
:max-tag-count="maxTagCount"
:maxTagTextLength="maxTagTextLength"
@search="fetchUser"
:filter-option="filterOption"
:disabled="disabled"
:allow-clear="allowClear"
>
<template #[item]="data" v-for="item in Object.keys($slots)">
<slot :name="item" v-bind="data || {}"></slot>
</template>
<template #suffixIcon v-if="loading">
<LoadingOutlined spin />
</template>
<template #notFoundContent v-if="loading">
<span>
<LoadingOutlined spin class="mr-1" />
{{ t('component.form.apiSelectNotFound') }}
</span>
</template>
</Select>
</template>
<script lang="ts" setup>
/**
* 支持API调用的下拉选择组件
*
* 功能特点:
* 1. 支持远程搜索
* 2. 支持分页滚动加载更多(无限滚动)
* 3. 支持单选/多选模式
* 4. 支持预选值回显(单选或多选)
* 5. 数据去重
*/
import { computed, onBeforeUnmount, onMounted, PropType, ref, toRefs, unref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import type { SelectValue } from 'ant-design-vue/es/select';
import { isFunction } from '@/utils/is';
import { useRuleFormItem } from '@/hooks/component/useFormItem';
import { debounce, get, isEqual, omit, throttle } from 'lodash-es';
import { LoadingOutlined } from '@ant-design/icons-vue';
import { useI18n } from '@/hooks/web/useI18n';
import { propTypes } from '@/utils/propTypes';
import {
getCachedData,
setCachedData,
getPaginationCache,
setPaginationCache,
} from '@/hooks/dataCache';
// 定义选项项的类型接口
type OptionsItem = { label?: string; value?: string; disabled?: boolean; [name: string]: any };
defineOptions({ name: 'ApiSelect', inheritAttrs: false });
// 组件属性定义
const props = defineProps({
// 选择器的值,支持多种类型以适应单选或多选场景
value: { type: [Array, Object, String, Number] as PropType<SelectValue> },
// 用于缓存 某个 下拉选择框 的key
cacheKey: propTypes.string.def(''),
// 是否将数字值转换为字符串
numberToString: propTypes.bool,
// API函数,用于获取选项数据
api: {
type: Function as PropType<(arg?: any) => Promise<OptionsItem[] | Recordable<any>>>,
default: null,
},
// API参数
params: propTypes.object.def({}),
// 支持从嵌套结果中获取数据,如 "data.items"
resultField: propTypes.string.def(''),
// 标签字段名称
labelField: propTypes.string.def('label'),
// 值字段名称
valueField: propTypes.string.def('value'),
// 是否立即加载数据
immediate: propTypes.bool.def(false),
// 是否始终重新加载数据
alwaysLoad: propTypes.bool.def(false),
// 静态选项数据
options: {
type: Array<OptionsItem>,
default: [],
},
// 请求前的钩子函数
beforeFetch: {
type: Function as PropType<Fn>,
default: null,
},
// 请求后的钩子函数,用于处理返回数据
afterFetch: {
type: Function as PropType<Fn>,
default: null,
},
// 占位符文本
placeholder: propTypes.string.def(''),
// 选择模式:单选、多选、标签
modeSelect: {
type: [String, undefined] as PropType<
'multiple' | 'tags' | 'SECRET_COMBOBOX_MODE_DO_NOT_USE' | undefined
>,
default: undefined,
},
// 是否禁用
disabled: propTypes.bool.def(false),
// 是否显示搜索
showSearch: propTypes.bool.def(true),
// 是否允许清空
allowClear: propTypes.bool.def(true),
// 最大标签数量
maxTagCount: propTypes.number.def(3),
// 最大标签文本长度
maxTagTextLength: propTypes.number.def(5),
// 是否过滤选项
filterOption: propTypes.func || propTypes.bool.def(false),
});
// 解构属性
let {
api,
beforeFetch,
afterFetch,
params,
resultField,
disabled,
showSearch,
allowClear,
maxTagCount,
maxTagTextLength,
filterOption,
cacheKey,
} = toRefs(props);
// 定义事件
const emit = defineEmits(['dropdown-visible-change', 'search']);
// 存储下拉选项数据
const optionsRef = ref<OptionsItem[]>([]);
// 加载状态
const loading = ref(false);
// 是否已完成首次加载
const isFirstLoaded = ref(false);
// 用于发出change事件的数据
const emitData = ref<OptionsItem[]>([]);
// 国际化函数
const { t } = useI18n();
// 分页相关变量
const currentPage = ref(1);
const pageSize = ref(50);
const totalItems = ref(0);
// 存储当前搜索关键词
const searchKeyword = ref('');
// 使用表单项规则钩子,处理表单验证
const [state, setState] = useRuleFormItem(props, 'value', 'update:value', emitData);
// 读取缓存的分页信息
const loadPaginationFromCache = () => {
if (!cacheKey.value) return false;
const paginationData = getPaginationCache(cacheKey.value);
if (paginationData) {
console.log('从缓存加载分页信息', paginationData);
currentPage.value = paginationData.page || 1;
pageSize.value = paginationData.pageSize || 50;
totalItems.value = paginationData.total || 0;
if (paginationData.keyword) {
searchKeyword.value = paginationData.keyword;
}
return true;
}
return false;
};
// 保存分页信息到缓存
const savePaginationToCache = () => {
if (!cacheKey.value) return;
setPaginationCache(cacheKey.value, {
page: currentPage.value,
pageSize: pageSize.value,
total: totalItems.value,
keyword: searchKeyword.value,
});
};
/**
* 计算属性:获取格式化后的选项数据
* 将原始选项数据转换为统一的 {label, value} 格式
*/
const getOptions = computed(() => {
const { labelField, valueField, numberToString } = props;
let data =
unref(optionsRef)?.reduce((prev, next: any) => {
if (next) {
const value = get(next, valueField);
prev.push({
...omit(next, [labelField, valueField]),
label: get(next, labelField),
value: numberToString ? `${value}` : value,
});
}
return prev;
}, [] as OptionsItem[]) || [];
return data?.length > 0 ? data : props.options;
});
const searchTag = ref(false);
/**
* 搜索用户函数,带防抖功能
* 当用户输入搜索关键词时调用API获取匹配的选项
*/
const fetchUser = debounce((value) => {
// 保存搜索关键词
searchKeyword.value = value || '';
api
.value({ page: 1, per_page: unref(pageSize), keyword: value })
.then(async (res) => {
// 更新总数
totalItems.value = get(res, 'data.total') || 0;
// 使用handleResultFetch处理结果
const processedData = await handleResultFetch(undefined, res);
if (processedData && processedData.length > 0) {
optionsRef.value = processedData;
} else {
optionsRef.value = [];
}
searchTag.value = true;
emit('search', optionsRef.value);
})
.catch((error) => {
console.error('Failed to search users:', error);
optionsRef.value = [];
});
}, 300);
/**
* 监听选择值变化,更新父组件的值
*/
watch(state, (v) => {
setState(v);
// emit('change', v);
});
watch(
() => props.value,
() => {
console.log('props.value', props.value);
initialize();
},
{ deep: true },
);
/**
* 监听API参数变化,重新获取数据
*/
watch(
() => props.params,
(value, oldValue) => {
if (isEqual(value, oldValue)) return;
fetch();
},
{ deep: true, immediate: props.immediate },
);
/**
* 主数据获取函数
* 调用API获取数据,并处理加载状态和错误
*/
async function fetch() {
if (!api.value || !isFunction(api.value) || loading.value) return;
// 尝试从缓存读取分页信息
loadPaginationFromCache();
optionsRef.value = [];
try {
loading.value = true;
if (beforeFetch.value && isFunction(beforeFetch.value)) {
params.value = (await beforeFetch.value(params.value)) || params.value;
}
// 获取数据并设置总数
let res = await api.value({
...params.value,
page: currentPage.value,
per_page: unref(pageSize),
});
totalItems.value = get(res, 'data.total') || 0;
// 使用handleResultFetch处理结果
const processedData = await handleResultFetch(undefined, res);
isFirstLoaded.value = true;
if (processedData && processedData.length > 0) {
optionsRef.value = processedData;
// 更新缓存
setCachedData(cacheKey.value, processedData);
savePaginationToCache();
}
} catch (error) {
console.warn(error);
isFirstLoaded.value = false;
} finally {
loading.value = false;
}
}
/**
* 处理下拉框可见性变化
* 当下拉框显示时,根据配置决定是否加载数据
*/
async function handleFetch(visible: boolean) {
if (visible) {
// 获取缓存数据
const cachedData = getCachedData(cacheKey.value);
if (cachedData && Array.isArray(cachedData) && cachedData.length > 0) {
console.log('从缓存加载选项数据', cachedData.length);
optionsRef.value = cachedData;
// 从缓存加载分页信息
loadPaginationFromCache();
} else if (props.alwaysLoad || (!props.immediate && !unref(isFirstLoaded))) {
// 无缓存数据,获取新数据
console.log('缓存中无数据,获取新数据');
await fetch();
}
} else {
// 下拉框关闭时,保存数据和分页信息到缓存, 如果搜索过,则不保存
if (optionsRef.value.length > 0 && !searchTag.value) {
setCachedData(cacheKey.value, optionsRef.value);
savePaginationToCache();
}
}
emit('dropdown-visible-change', visible);
}
/**
* 处理选择变化
* 当用户选择选项时保存选择数据
*/
const handleChange = (value, ...args) => {
setState(value);
emitData.value = args;
};
/**
* 处理下拉框滚动事件
* 实现滚动到底部时自动加载更多数据
*/
const handlePopupScroll = throttle(async function (e) {
const { target } = e;
const { scrollTop, scrollHeight, clientHeight } = target;
// 计算距离底部的距离
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
console.log('handlePopupScroll', scrollHeight, scrollTop, clientHeight, distanceToBottom);
// 当距离底部30px时就开始预加载,增加容错并提高用户体验
if (distanceToBottom <= 30) {
// 接近底部,触发分页逻辑
handlePagination();
}
}, 1000); // 设置200毫秒的节流间隔
/**
* 处理分页加载
* 当滚动到底部时,加载下一页数据
*/
async function handlePagination() {
loadPaginationFromCache();
// 检查是否已加载全部数据或正在加载中
console.log('handlePagination', currentPage.value, totalItems.value, loading.value);
if (currentPage.value * unref(pageSize) >= totalItems.value || loading.value) return;
// 设置加载锁,防止重复触发
loading.value = true;
currentPage.value += 1;
if (!api.value || !isFunction(api.value)) {
loading.value = false;
return;
}
try {
console.log('开始加载更多数据', currentPage.value, totalItems.value);
// 分页时带上搜索关键词
let res = await api.value({
...params.value,
page: currentPage.value,
per_page: unref(pageSize),
keyword: searchKeyword.value || '',
});
console.log('PaginationResult', res);
// 更新总数(可能会变)
totalItems.value = get(res, 'data.total') || totalItems.value;
// 使用handleResultFetch处理结果
const newData = await handleResultFetch(undefined, res);
// 确保处理后的数据是数组
if (newData && newData.length > 0) {
// 将新加载的数据与当前数据合并并去重
const newOptions = mergeAndUniqueOptions(optionsRef.value, newData);
optionsRef.value = newOptions;
// 更新缓存
setCachedData(cacheKey.value, optionsRef.value);
}
} catch (error) {
console.error('Failed to load more data:', error);
} finally {
loading.value = false;
}
}
watch(
() => props.api,
() => {
initialize();
},
);
// 检查ids是否在optionsRef.value中
const checkIds = (ids: string | string[], array: OptionsItem[]): boolean => {
// 如果 ids 是字符串,将其转换为数组
const idsArray = Array.isArray(ids) ? ids : [ids];
// 检查每个 id 是否在 array.value 中
return idsArray.every((item) => {
return array.some((option) => option.value === item);
});
};
/**
* 初始化函数
* 在组件挂载时初始化数据,处理预选值和首次数据加载
*/
async function initialize() {
// 先尝试从缓存读取分页信息
loadPaginationFromCache();
let handleResult: OptionsItem[] = [];
// 1. 获取 selectedIds 单选或多选
const idsToFetch: any = state.value;
let cachedData = getCachedData(cacheKey.value) as OptionsItem[];
if (cachedData) {
console.log('checkIds', idsToFetch, getCachedData(cacheKey.value));
if (checkIds(idsToFetch, cachedData)) {
optionsRef.value = cachedData as OptionsItem[];
return;
} else {
let idsData: any = Array.isArray(idsToFetch) ? idsToFetch : [idsToFetch];
let idsHandleResult = await handleResultFetch(idsData);
// 合并选中项和第一页数据,确保选中项在前面,并去重
handleResult = mergeAndUniqueOptions(cachedData as OptionsItem[], idsHandleResult);
console.log('initialize223', idsToFetch, cachedData, idsHandleResult, handleResult);
}
} else {
handleResult = await handleResultFetch(idsToFetch);
}
// 3. 操作完成后,再次获取最新缓存状态进行比较
// 一定要从缓存重新获取一下,避免多个select加载顺序不明确,导致缓存被覆盖的问题
const latestCache = getCachedData(cacheKey.value);
if (latestCache && (!cachedData || isEqual(latestCache, cachedData))) {
// 缓存在操作过程中被其他组件更新了
// 进行适当的合并
handleResult = mergeAndUniqueOptions(latestCache as OptionsItem[], handleResult);
}
optionsRef.value = handleResult;
setCachedData(cacheKey.value, handleResult);
savePaginationToCache();
}
// 返回处理后的结果,idsData为编辑时传入的id数组,rawResult为传入的原始结果
const handleResultFetch = async (idsData?: string[], rawResult?: any) => {
console.log('handleResultFetch', idsData, rawResult);
// 如果提供了原始结果,直接使用它;否则发起新的API请求
const result =
rawResult ||
(await api.value({
...params.value,
page: currentPage.value,
per_page: unref(pageSize),
keyword: searchKeyword.value || '',
include_ids: idsData || undefined,
}));
// 更新总数
totalItems.value = get(result, 'data.total') || 0;
const res = resultField.value ? get(result, resultField.value) : result;
savePaginationToCache();
// 如果有afterFetch钩子,则使用它处理数据
let handleResult: OptionsItem[] = [];
if (afterFetch.value && isFunction(afterFetch.value)) {
const processed = await afterFetch.value(res);
if (processed) {
handleResult = Array.isArray(processed) ? processed : [];
}
} else if (Array.isArray(res)) {
handleResult = res;
}
return handleResult;
};
/**
* 合并并去重选项函数
* 用于合并选中项和分页加载的数据,确保不重复显示相同选项
* @param existingOptions 已有选项(如选中项或当前列表中的项)
* @param newOptions 新加载的选项
* @returns 合并后的唯一选项数组
*/
function mergeAndUniqueOptions(
existingOptions: OptionsItem[],
newOptions: OptionsItem[],
): OptionsItem[] {
const uniqueOptions = new Map<string | undefined, OptionsItem>();
// 首先添加已有选项(保持原有顺序)
existingOptions.forEach((option: OptionsItem) => {
if (option?.value !== undefined) {
uniqueOptions.set(option.value, option);
}
});
// 添加新选项(如果不存在)
newOptions.forEach((option: OptionsItem) => {
if (option?.value !== undefined && !uniqueOptions.has(option.value)) {
uniqueOptions.set(option.value, option);
}
});
return Array.from(uniqueOptions.values());
}
/**
* 组件挂载时初始化数据
*/
onMounted(() => {
initialize();
});
/**
* 向父组件暴露的属性和方法
* 允许父组件访问选项数据
*/
defineExpose({
optionsRef: unref(optionsRef),
});
</script>
缓存函数:
// apiDataCache.ts
// 添加options信息缓存 key 都是某个select 框的唯一值key
const apiDataCache = new Map<string, any>();
// 添加分页信息缓存
const apiPaginationCache = new Map<
string,
{ page: number; pageSize: number; total: number; keyword?: string }
>();
export function getCachedData<T>(key: string): T | null {
if (!key || key === '') return null;
if (apiDataCache.has(key)) {
return apiDataCache.get(key) as T;
}
return null;
}
/**
* 获取缓存的分页信息
* @param key 缓存键
* @returns 分页信息,包括页码、每页大小和总数
*/
export function getPaginationCache(key: string) {
if (!key || key === '') return null;
if (apiPaginationCache.has(key)) {
return apiPaginationCache.get(key);
}
return null;
}
export function setCachedData<T>(key: string, data: T): void {
if (!key || key === '') return;
apiDataCache.set(key, data);
}
/**
* 设置分页信息缓存
* @param key 缓存键
* @param pagination 分页信息对象
*/
export function setPaginationCache(
key: string,
pagination: { page: number; pageSize: number; total: number; keyword?: string },
): void {
if (!key || key === '') return;
apiPaginationCache.set(key, pagination);
}
export function clearCache(keyPrefix?: string): void {
// If a prefix is provided, only clear keys that start with that prefix
if (keyPrefix) {
for (const key of apiDataCache.keys()) {
if (key.startsWith(keyPrefix)) {
apiDataCache.delete(key);
}
}
} else {
// Otherwise clear the entire cache
apiDataCache.clear();
}
}
/**
* 清除分页信息缓存
* @param keyPrefix 可选的键前缀,用于清除特定前缀的缓存
*/
export function clearPaginationCache(keyPrefix?: string): void {
if (keyPrefix) {
for (const key of apiPaginationCache.keys()) {
if (key.startsWith(keyPrefix)) {
apiPaginationCache.delete(key);
}
}
} else {
apiPaginationCache.clear();
}
}