高级下拉选择框组件封装

前情提要

前一阵子测试提了两个bug:

  1. xxx(某个下拉选择框)存在3万条数据,添加xxx页面无法正常使用
  2. xxx(某个下拉选择框)存在10000+数据,添加/编辑xxx系统页面一直处于加载中(下拉选择框内容过多导致页面卡死)

因为,我和我的导师决定封装一个高级下拉选择框组件,来替换到之前公司所有普通下拉框。

历时一个月(没错,我比较菜,进度较慢,但80%工作都是我做的,而且同时还在改其他bug,做其他需求),我们终于把这个组件做出来了。

在这里插入图片描述

组件功能

我们这个高级下拉选择框组件具有的功能有:

    1. 支持远程搜索
    1. 支持分页滚动加载更多(无限滚动)
    1. 支持单选/多选模式
    1. 支持预选值回显(单选或多选)
    1. 数据去重
    1. 分页参数以及选择框options缓存

详细说下:

  1. 远程搜索:分页的同时支持搜索(请求参数:page页码,per_page每页的数据,keyword搜索关键字,include_ids某个搜索框用于回显的数据id)
  2. 分页滚动:分页+懒加载+虚拟列表
  3. 单选/多选:每个选择框绑定一个 ids 数组,支持单选和多选
  4. 预选值回显:由于分页,预选值可能不在当前页码,所以我们后端支持了分页的同时进行查询(比如:回显的数据不在第一页,但是此时只渲染了第一页,后端返回的数据就会把这些 ids 对应的数据拼接到查询返回的 items 中,所以items 的值就是= 当前页的数据+ids 对应的数据)
  5. 数据去重:正如上述第四点,预选值会提前到 options 绑定渲染,所以需要把后面重复的 item 去重。
  6. 缓存,主要用于缓存分页参数和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();
  }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

秀秀_heo

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值