项目记录|Antd Select分页请求

背景

产品要求选择框需要选择所有数据,而后端设计的接口返回的数据是分页数据,不能一次性获取全部数据。所有需要基于Antd Select组件进行封装。

设计思路

  • 监听下拉滚动的滚动距离,当离接触指定距离时请求下一页的数据,正好Antd Select有属性onPopupScroll能够获取滚动距离。
  • 组件内需要有变量记录当前页current、数据条数size、数据总条数total,当滚动到快结束时,current+1请求数据,当获取的数据条数等于总条数时,不再请求数据。
  • 允许搜索数据,使用变量keyword记录搜索关键字,并把current置为1后请求数据。同时提供组件属性searchParam传递请求时携带的参数。
  • 需要选择框的数据与请求结果数据的映射optionMap,由于Antd Select的数据结构是labelvalue,所以需要指定请求数据的结果中的哪些参数做为labelvalue。同时也可以使用回调函数丰富label的表现。
  • 声明使用哪个请求接口,由于项目使用RTK Query,这里以RTK Query为例。

具体实现

推导useMutation钩子参数及返回值类型

项目中使用的Rtk Query进行数据请求。在React Query 中,有两种主要类型的查询:QueryMutation

  • Query:通常用于获取(get)数据,并在数据发生变化时自动重新获取数据。
  • Mutation:用于执行数据变更操作(如创建、更新或删除数据)。

项目中分页查询使用的Mutation 类型,每次使用组件传递的查询接口可能不同,可能查询员工,也可能查询商品,或其他。需要知道查询时携带的参数及返回的结果。为了在指定搜索参数searchParam及映射optionMap有ts的类型提示,不变成anyscript,所以需要推导useMutation的相关类型。

typescript 复制代码
// 参数类型 Request:
{
  current: number; // 当前页数
  size: number; // 每页数量
  input: Partial<{...}>; // 搜索参数
}

// 响应类型 Response:
{
  current: number; // 当前页数
  size: number; // 每页数量
  total: number; // 总条数
  records: Array<{...}>; // 返回结果,数据列表
}


import type { MutationDefinition } from "@reduxjs/toolkit/query";
import type { UseMutation } from "@reduxjs/toolkit/dist/query/react/buildHooks";

type ParamsAndResult<T> = T extends UseMutation<
  MutationDefinition<infer Request, any, string, infer Response, string>
>
  ? {
      searchParam: Request extends { input: infer SearchParam }
        ? SearchParam
        : never;
      result: Response extends { records: (infer Result)[] } ? Result : never;
    }
  : never;

ParamsAndResult类型用于提取UseMutation类型的请求参数Request和响应结果Response。首先检查泛型T是否是UseMutation 类型,尝试提取 Request 中的 input 属性作为 searchParam 的类型,再提取 Response 中的 records 属性作为 result 的类型。当有不满足推导格式,返回never

页面记录及请求

ini 复制代码
const keyword = useRef<string>(); // 搜索关键字
const current = useRef<number>(1); // 当前页数
const isSearching = useRef<boolean>(false); // 滚动加载标志位
const total = useRef<number>(0); // 列表总数
const [dataList, setDataList] = useState<ParamsAndResult<T>["result"][]>([]); // 数据列表

// useApi 是传递给组件使用的请求接口 useMutation
const [getDataList, { isLoading }] = useApi();

// 请求数据列表,当搜索、下滑请求、清空搜索时触发
// 更新列表数据
const refetchData = async () => {
  // 防止重复请求
  if (isSearching.current) return;
  isSearching.current = true;
  try {
    // 合并搜索参数和关键字,作为统一搜索参数
    const mergeSearchParam = {
      ...(searchParam ?? {}),
      keyword: keyword.current,
    };
    
    const payload = await getDataList({
      current: current.current,
      size: defaultSize,
      input: mergeSearchParam,
    }).unwrap();
    // 记录总条数,用于判断请求的数据是否结束
    total.current = payload.total;
    
    // 如果是第一页,则直接设置数据列表为返回的记录;
    // 否则,将新获取的记录追加到现有的数据列表中
    if (current.current === 1) {
      setDataList(payload.records);
    } else {
      setDataList((pre) => [...pre, ...payload.records]);
    }
  } catch (error: any) {
    message.error(error.message);
  } finally {
    isSearching.current = false;
  }
};

// 对应antd select的onClear
// 当清空时请求第一页数据,并防抖处理,避免连续触发
const handleClear = debounce(() => {
  keyword.current = undefined;
  current.current = 1;
  refetchData();
  onClear?.();
}, 500);

// 对应antd select的onSearch
// 更加关键字搜索请求数据,并防抖处理,避免连续触发
const handleSearch = debounce((value: string) => {
  keyword.current = value;
  current.current = 1;
  refetchData();
}, 500);

// 对应antd select的onPopupScroll
// 当滚动到距离底部指定距离时请求下一页数据
// 如果列表条数已经是总条数时退出
const handleScroll = async (e: any) => {
  if (dataList.length >= total.current) return;
  if (isSearching.current) return;
  const { target } = e;
  if (
    target.scrollTop + target.offsetHeight >=
    target.scrollHeight - defaultTriggerHeight
  ) {
    current.current++;
    await refetchData();
  }
};

useEffect(() => {
  // 搜索参数变更时,重新请求数据
  current.current = 1;
  refetchData();
}, [searchParam]);

ref透传及修正泛型提示

typescript 复制代码
export default React.forwardRef<BaseSelectRef, BaseSelectProps<any>>(
  BaseSelect,
) as <T extends UseMutation<MutationDefinition<any, any, string, any, string>>>(
  props: BaseSelectProps<T> & {
    ref?: React.Ref<BaseSelectRef | undefined>;
  },
) => React.ReactElement;

React.forwardRef将 ref 传递给子组件,这里把ref绑定到Antd Select上,外部使用时可以传递ref访问到组件内的Antd Select。

BaseSelect组件完整代码

ini 复制代码
import { debounce } from "lodash";
import { message, Select, type SelectProps, Spin } from "antd";
import { type BaseSelectRef as SelectRef } from "rc-select/es/BaseSelect";
import React, { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import type { MutationDefinition } from "@reduxjs/toolkit/query";
import type { UseMutation } from "@reduxjs/toolkit/dist/query/react/buildHooks";

type ParamsAndResult<T> = T extends UseMutation<
  MutationDefinition<infer Request, any, string, infer Response, string>
>
  ? {
      searchParam: Request extends { input: infer SearchParam }
        ? SearchParam
        : never;
      result: Response extends { records: (infer Result)[] } ? Result : never;
    }
  : never;

export type BaseSelectProps<
  T extends UseMutation<MutationDefinition<any, any, string, any, string>>,
> = SelectProps & {
  /** 获取数据的api */
  useApi: T;
  /** 单次请求条数 */
  defaultSize?: number;
  /** 距离底部多少距离时触发加载 */
  defaultTriggerHeight?: number;
  /** 搜索参数 */
  searchParam?: ParamsAndResult<T>["searchParam"];
  /** 下拉框选项映射 */
  optionMap: {
    label:
      | keyof ParamsAndResult<T>["result"]
      | ((
          item: ParamsAndResult<T>["result"],
          index: number,
        ) => React.ReactNode);
    value: keyof ParamsAndResult<T>["result"];
  };
};

export type BaseSelectRef = SelectRef;

const BaseSelect = <
  T extends UseMutation<MutationDefinition<any, any, string, any, string>>,
>(
  props: BaseSelectProps<T>,
  ref: React.Ref<BaseSelectRef>,
) => {
  const {
    searchParam,
    optionMap,
    defaultSize = 10,
    defaultTriggerHeight = 10,
    useApi,
    onClear,
    ...otherProps
  } = props;

  const keyword = useRef<string>(); // 搜索关键字
  const current = useRef<number>(1); // 当前页数
  const isSearching = useRef<boolean>(false); // 滚动加载标志位
  const total = useRef<number>(0); // 列表总数
  const [dataList, setDataList] = useState<ParamsAndResult<T>["result"][]>([]); // 数据列表

  const [getDataList, { isLoading }] = useApi();

  const options = useMemo(() => {
    const changeType = (data: unknown) => {
      if (
        typeof data === "string" ||
        typeof data === "number" ||
        typeof data === "undefined" ||
        data === null
      )
        return data;
      else return String(data);
    };

    const normalizeOptions = dataList.map((data, i) => ({
      label:
        optionMap.label instanceof Function
          ? optionMap.label(data, i)
          : (data[optionMap.label] as React.ReactNode),
      value: changeType(data[optionMap.value]),
    }));

    return normalizeOptions;
  }, [dataList, optionMap]);

  const refetchData = async () => {
    if (isSearching.current) return;
    isSearching.current = true;
    try {
      const mergeSearchParam = {
        ...(searchParam ?? {}),
        keyword: keyword.current,
      };

      const payload = await getDataList({
        current: current.current,
        size: defaultSize,
        input: mergeSearchParam,
      }).unwrap();
      total.current = payload.total;
      if (current.current === 1) {
        setDataList(payload.records);
      } else {
        setDataList((pre) => [...pre, ...payload.records]);
      }
    } catch (error: any) {
      message.error(error.message);
    } finally {
      isSearching.current = false;
    }
  };

  const handleClear = debounce(() => {
    keyword.current = undefined;
    current.current = 1;
    refetchData();
    onClear?.();
  }, 500);

  const handleSearch = debounce((value: string) => {
    keyword.current = value;
    current.current = 1;
    refetchData();
  }, 500);

  const handleScroll = async (e: any) => {
    if (dataList.length >= total.current) return;
    if (isSearching.current) return;
    const { target } = e;
    if (
      target.scrollTop + target.offsetHeight >=
      target.scrollHeight - defaultTriggerHeight
    ) {
      current.current++;
      await refetchData();
    }
  };

  useEffect(() => {
    // 搜索参数变更时,重新请求数据
    current.current = 1;
    refetchData();
  }, [searchParam]);

  return (
    <Select
      ref={ref}
      showSearch
      allowClear
      onClear={handleClear}
      loading={isLoading}
      onSearch={handleSearch}
      onPopupScroll={handleScroll}
      getPopupContainer={(triggerNode) => triggerNode.parentElement}
      options={options}
      filterOption={(input, option) =>
        (option?.label ?? "")
          .toString()
          .toLowerCase()
          .includes(input.toLowerCase())
      }
      dropdownRender={(originNode: ReactNode) => (
        <Spin spinning={isLoading}>{originNode}</Spin>
      )}
      {...otherProps}
    />
  );
};

// 修正泛型
export default React.forwardRef<BaseSelectRef, BaseSelectProps<any>>(
  BaseSelect,
) as <T extends UseMutation<MutationDefinition<any, any, string, any, string>>>(
  props: BaseSelectProps<T> & {
    ref?: React.Ref<BaseSelectRef | undefined>;
  },
) => React.ReactElement;

使用BaseSelect组件

使用BaseSelect组件需要传递一个请求api useApi,在这里指定序号与title作为label,id作为value,同时绑定ref,Antd Select提供的blur()focus()scrollTo(),也能够正常使用

ini 复制代码
const baseSelectRef = useRef<BaseSelectRef>();

const handleBlurClick = () => {
  baseSelectRef.current?.blur();
};
const handleFocusClick = () => {
  baseSelectRef.current?.focus();
};
const handleScrollClick = (distance: number) => () => {
  baseSelectRef.current?.scrollTo(distance);
};

return (
  <Space direction="vertical">
    <Space>
      <Button onClick={handleBlurClick}>取消焦点</Button>
      <Button onClick={handleFocusClick}>获取焦点</Button>
      <Button onClick={handleScrollClick(10)}>滚动至10px</Button>
      <Button onClick={handleScrollClick(100)}>滚动至100px</Button>
    </Space>
    <BaseSelect
      ref={baseSelectRef}
      style={{ width: 300 }}
      useApi={useGetMocksMutation}
      // 指定label按 序号与标题 显示
      // value为数据的id
      optionMap={{ label: (item, i) => `${i}:${item.title}`, value: "id" }}
    />
  </Space>
);

再次封装

根据业务需要,封装针对某个查询的select,填写默认的搜索参数和默认的样式等。以封装针对产品分页查询为例:

ini 复制代码
// 默认产品类型
const defaultParam: Partial<ProductSearch> = {
  type: "1",
};

type Props = Omit<
  BaseSelectProps<typeof useGetProductListMutation>,
  "useApi" | "searchParam" | "optionMap"
> & {
  optionMap?: {
    label: keyof Product | ((item: Product) => string);
    value: keyof Product;
  };
  searchParam?: Partial<ProductSearch>;
};
const ProductSelect = (props: Props, ref: React.Ref<BaseSelectRef>) => {
  const {
    placeholder = "请选择产品",
    optionMap = {
      label: ({ title, salePrice }) =>
        `${title}  ¥${priceToPresentation(salePrice)}`,
      value: "id",
    },
    searchParam = defaultParam,
    style = { width: 200 },
    ...otherProps
  } = props;

  return (
    <BaseSelect
      ref={ref}
      style={style}
      searchParam={searchParam}
      placeholder={placeholder}
      useApi={useGetProductListMutation}
      optionMap={optionMap}
      {...otherProps}
    />
  );
};

export default React.forwardRef<BaseSelectRef, Props>(ProductSelect);

// 使用:
// <ProductSelect />
相关推荐
一棵开花的树,枝芽无限靠近你1 分钟前
【PPTist】添加PPT模版
前端·学习·编辑器·html
陈王卜4 分钟前
django+boostrap实现发布博客权限控制
java·前端·django
景天科技苑12 分钟前
【vue3+vite】新一代vue脚手架工具vite,助力前端开发更快捷更高效
前端·javascript·vue.js·vite·vue项目·脚手架工具
SameX14 分钟前
HarmonyOS Next 安全生态构建与展望
前端·harmonyos
小行星12523 分钟前
前端预览pdf文件流
前端·javascript·vue.js
小行星12530 分钟前
前端把dom页面转为pdf文件下载和弹窗预览
前端·javascript·vue.js·pdf
Lysun00139 分钟前
[less] Operation on an invalid type
前端·vue·less·sass·scss
J总裁的小芒果1 小时前
Vue3 el-table 默认选中 传入的数组
前端·javascript·elementui·typescript
Lei_zhen961 小时前
记录一次electron-builder报错ENOENT: no such file or directory, rename xxxx的问题
前端·javascript·electron
咖喱鱼蛋1 小时前
Electron一些概念理解
前端·javascript·electron