背景
产品要求选择框需要选择所有数据,而后端设计的接口返回的数据是分页数据,不能一次性获取全部数据。所有需要基于Antd Select组件进行封装。
设计思路
- 监听下拉滚动的滚动距离,当离接触指定距离时请求下一页的数据,正好Antd Select有属性
onPopupScroll
能够获取滚动距离。 - 组件内需要有变量记录当前页
current
、数据条数size
、数据总条数total
,当滚动到快结束时,current+1
请求数据,当获取的数据条数等于总条数时,不再请求数据。 - 允许搜索数据,使用变量
keyword
记录搜索关键字,并把current
置为1后请求数据。同时提供组件属性searchParam
传递请求时携带的参数。 - 需要选择框的数据与请求结果数据的映射
optionMap
,由于Antd Select的数据结构是label
和value
,所以需要指定请求数据的结果中的哪些参数做为label
和value
。同时也可以使用回调函数丰富label
的表现。 - 声明使用哪个请求接口,由于项目使用RTK Query,这里以RTK Query为例。
具体实现
推导useMutation钩子参数及返回值类型
项目中使用的Rtk Query进行数据请求。在React Query 中,有两种主要类型的查询:Query
和 Mutation
。
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 />