前言

最近的开发迭代业主需要用到一个 Select 下拉加载功能,但项目组上没有这个组件,Antd Design官网也没有提供现成的,查阅资料也没有发现符合业务的组件,特此自己造轮子封装。

使用场景

注:当下拉框数据较多时,后端采用分页搜索模式,这个时候就可以使用这个组件。

实现思路

想要封装一个好的组件,我们需要考虑它的共用性,既然是下拉加载组件,它应具备以下功能:

  1. 应该继承 Select 组件的全部属性
  2. 滚动到底部,数据加载下一页,如果接口返回的条数小于当前设置的条数,就取消滚动加载功能
  3. 支持自定义 options

搭建基本结构

我们先看下后端返回的数据结构:
image.png
如果数据结构不一样,可根据后端要求修改:

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
import { useBoolean, useRequest, useSetState } from 'ahooks'
import { Select, Spin } from 'antd'
import React, { FC, useState } from 'react'

import { Res } from '@/interface/api';

import { ComponentProps, PagetionProps } from './interface'

const { Option } = Select;

const LazyLoadSelect: FC<ComponentProps> = ({
apiUrl,
pageRows = 15,
resultField = 'list',
fieldNames = {
label: 'id',
value: 'name',
},
emptyText = '没有更多了',
manual = false,
...props
}) => {
// 默认分页参数
const [requestParams, setRequestParams] = useSetState<PagetionProps>({
currentPage: 1,
pageRows,
searchKey: '',
})
// 是否还有更多数据
const [isEmptyData, { setTrue: setEmptyDataTrue, setFalse: setEmptyDataFalse }] = useBoolean(false)
// 请求的数据列表
const [requestList, setRequestList] = useState([])

/**
* @description: 获取请求数据
* @author: Cyan
*/
const { loading: requestLoading, runAsync: runAsyncRequestList } = useRequest(
async (params: PagetionProps): Promise<Res> => await apiUrl(params),
{
manual,
defaultParams: [{ ...requestParams }],
onSuccess: (res: Res) => {
if (res.code === 200) {
// 获取列表数据
const result = res?.data?.[resultField] || []
setRequestList([...requestList, ...result])
// 当返回条数小于分页条数,或者没有返回数据就代表没有更多数据
if (result?.length < requestParams.pageRows) {
setEmptyDataTrue()
}
}
},
},
);
return (
<Select
showSearch
loading={requestLoading}
filterOption={false}
placeholder="请选择"
{...props}
>
{
requestList?.map((item) => {
return (
<Option
{...item}
value={item[fieldNames.value]}
key={item[fieldNames.value]}
>
{item[fieldNames.label]}
</Option>
)
})
}
{/* 没有更多数据 */}
{
isEmptyData &&
<Option disabled>
<div style={{ color: '#bfbfbf', textAlign: 'center', fontSize: 12, pointerEvents: 'none' }}>{emptyText}</div>
</Option>
}
{/* 下拉加载按钮 */}
{
requestLoading &&
<Option disabled>
<div style={{ textAlign: 'center', pointerEvents: 'none' }}>
<Spin size='small' />
</div>
</Option>
}
</Select>
)
}
export default LazyLoadSelect

这里我使用了 ahooks库

完善功能

架子我们已经搭好了,现在在其添砖加瓦。

  1. 判断滚动距离,滚动到底部加载下一页
    这里我们用到了 Select组件onPopupScroll API

    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
    // 判断是否滚动到底部
    const { run: onPopupScroll } = useDebounceFn(
    async (e) => {
    e.persist();
    const { scrollTop, offsetHeight, scrollHeight } = e.target;
    // 距离底部多少距离开始加载下一页
    if (((scrollTop + offsetHeight) === scrollHeight) && !isEmptyData) {
    // 当滚动到底部时,我们下载下一页,同时更新分页状态
    setRequestParams({ currentPage: requestParams.currentPage + 1 })
    await runAsyncRequestList({ ...requestParams, currentPage: requestParams.currentPage + 1 })
    }
    },
    { wait: 350 },
    );

    <Select
    showSearch
    loading={requestLoading}
    filterOption={false}
    onPopupScroll={(e) => onPopupScroll(e)}
    onSearch={(value: string) => handleSearch(value)}
    placeholder="请选择"
    onClear={handlerClear}
    {...props}
    >
    </Select>
  2. 增加搜索功能
    组件支持搜索功能,当用户搜索后重新触发接口加载,并重置分页状态,这里还要加一个防抖函数,避免输入过程中频繁触发接口请求:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 重新请求
    const initRequest = async (newValue: string) => {
    // 搜索重置分页
    setRequestParams({ currentPage: 1, searchKey: newValue })
    setEmptyDataFalse()
    // 重置数据列表
    setRequestList([])
    await runAsyncRequestList({ ...requestParams, ...extraParams, currentPage: 1, searchKey: newValue })
    }

    // 搜索回调
    const { run: handleSearch } = useDebounceFn(
    async (newValue: string) => {
    // 重置参数,重新请求
    initRequest(newValue)
    },
    { wait: 500 },
    );
  3. 增加清空数据回调
    当用户搜索的数据只有一条时,选中后清空数据,这时候我们应该重新请求数据:

    1
    2
    3
    4
    5
    6
    7
    // 清除内容时的回调
    const handlerClear = async () => {
    // 当数据不足一页时,重新加载
    if (requestList.length < requestParams.pageRows) {
    initRequest('')
    }
    }
  4. 自定义 options
    有时候需求需要自定义内容,如下图:
    image.png
    这时我们还要提供一个 props 给组件自定义,并提供当前的列表数据:

    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
    // 父组件
    // 自定义 options
    const renderOptions = (ownerHouseList: OwnerHouseProps[]) => {
    return (
    ownerHouseList?.map((house) => {
    return (
    <Option
    value={house.id}
    label={`${house.address}(${house.houseCode})`}
    disabled={house.optional === 0}
    key={house.id}
    >
    <Row>
    <Col span={24}>{house.houseCode}</Col>
    <Col span={24}>盘源地址:{house.address}</Col>
    </Row>
    </Option>
    )
    })
    )
    }

    // 子组件
    {
    customizeOptions ? customizeOptions(requestList) :
    requestList?.map((item) => {
    return (
    <Option
    {...item}
    value={item[fieldNames.value]}
    key={item[fieldNames.value]}
    >
    {item[fieldNames.label]}
    </Option>
    )
    })
    }
  5. 细节完善
    作为开发者,我们还需要考虑组件的共用性:

    1. 接口可能返回的字段不一样
    2. 下拉滚动到底部多少距离触发
    3. 一些自定义文案
    4. 父组件需要调用子组件的请求方法

最终我们的代码如下:

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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
/*
* @Description: Select 下拉懒加载组件
* @Version: 2.0
* @Author: Cyan
* @Date: 2023-03-10 10:04:17
* @LastEditors: Cyan
* @LastEditTime: 2023-03-10 16:17:13
*/
import { useBoolean, useDebounceFn, useRequest, useSetState } from 'ahooks'
import { Select, Spin } from 'antd'
import React, { FC, useImperativeHandle, useState } from 'react'

import { Res } from '@/interface/api';

import { ComponentProps, PagetionProps } from './interface'

const { Option } = Select;

const LazyLoadSelect: FC<ComponentProps> = ({
apiUrl,
pageRows = 15,
resultField = 'list',
fieldNames = {
label: 'id',
value: 'name',
},
emptyText = '没有更多了',
onRef,
extraParams = {},
manual = false,
distanceBottom = 0,
customizeOptions,
...props
}) => {
// 默认分页参数
const [requestParams, setRequestParams] = useSetState<PagetionProps>({
currentPage: 1,
pageRows,
searchKey: '',
})
// 是否还有更多数据
const [isEmptyData, { setTrue: setEmptyDataTrue, setFalse: setEmptyDataFalse }] = useBoolean(false)
// 请求的数据列表
const [requestList, setRequestList] = useState([])

/**
* @description: 业主当事盘源列表
* @author: Cyan
*/
const { loading: requestLoading, runAsync: runAsyncRequestList } = useRequest(
async (params: PagetionProps): Promise<Res> => await apiUrl(params),
{
manual,
defaultParams: [{ ...requestParams, ...extraParams }],
onSuccess: (res: Res) => {
if (res.code === 200) {
// 获取列表数据
const result = res?.data?.[resultField] || []
setRequestList([...requestList, ...result])
// 当返回条数小于分页条数,或者没有返回数据就代表没有更多数据
if (result?.length < requestParams.pageRows) {
setEmptyDataTrue()
}
}
},
},
);

// 判断是否滚动到底部
const { run: onPopupScroll } = useDebounceFn(
async (e) => {
e.persist();
const { scrollTop, offsetHeight, scrollHeight } = e.target;
// 距离底部多少距离开始加载下一页
if (((scrollTop + offsetHeight + distanceBottom) >= scrollHeight) && !isEmptyData) {
setRequestParams({ currentPage: requestParams.currentPage + 1 })
await runAsyncRequestList({ ...requestParams, ...extraParams, currentPage: requestParams.currentPage + 1 })
}
},
{ wait: 350 },
);

// 重新请求
const initRequest = async (newValue: string) => {
// 搜索重置分页
setRequestParams({ currentPage: 1, searchKey: newValue })
setEmptyDataFalse()
// 重置数据列表
setRequestList([])
await runAsyncRequestList({ ...requestParams, ...extraParams, currentPage: 1, searchKey: newValue })
}

// 搜索回调
const { run: handleSearch } = useDebounceFn(
async (newValue: string) => {
// 重置参数,重新请求
initRequest(newValue)
},
{ wait: 500 },
);

// 清除内容时的回调
const handlerClear = async () => {
// 当数据不足一页时,重新加载
if (requestList.length < requestParams.pageRows) {
initRequest('')
}
}

// 用 useImperativeHandle 暴露一些外部ref能访问的属性
useImperativeHandle(onRef, () => ({ runAsyncRequestList, requestParams }))
return (
<Select
showSearch
loading={requestLoading}
filterOption={false}
onPopupScroll={(e) => onPopupScroll(e)}
onSearch={(value: string) => handleSearch(value)}
placeholder="请选择"
onClear={handlerClear}
{...props}
>
{
customizeOptions ? customizeOptions(requestList) :
requestList?.map((item) => {
return (
<Option
{...item}
value={item[fieldNames.value]}
key={item[fieldNames.value]}
>
{item[fieldNames.label]}
</Option>
)
})
}
{/* 没有更多数据 */}
{
isEmptyData &&
<Option disabled>
<div style={{ color: '#bfbfbf', textAlign: 'center', fontSize: 12, pointerEvents: 'none' }}>{emptyText}</div>
</Option>
}
{/* 下拉加载按钮 */}
{
requestLoading &&
<Option disabled>
<div style={{ textAlign: 'center', pointerEvents: 'none' }}>
<Spin size='small' />
</div>
</Option>
}
</Select>
)
}
export default LazyLoadSelect

使用方式

1
2
3
4
5
6
7
8
9
10
11
12
import LazyLoadSelect from '@/components/LazyLoadSelect'

<LazyLoadSelect
key="LazyLoadSelect"
onRef={lazyLoadSelectRef}
allowClear
placeholder="请搜索或选择经纪人姓名"
apiUrl={getBrokerInfoListForReport}
fieldNames={{ value: 'userId', label: 'userName' }}
onChange={onChangeUserName}
getPopupContainer={() => document.getElementById('black-container')}
/>

参数说明

参数说明类型默认值是否必传
apiUrl请求接口Promise-
pageRows每页显示条数number15-
resultField后端返回数据字段stringlist-
emptyText空数据的显示文案string没有更多了-
onRef绑定子组件实例React.RefObject--
extraParams额外参数object{}-
manual是否手动执行booleanfalse-
distanceBottom滚动距离多少触发加载,默认滚动到底部,也就是0number0-
customizeOptions自定义 option---
SelectPropsAntd Select选择器 Props,配置项---

注意事项

  1. 如果接口请求后,下拉框没看到数据,请检查参数 fieldNames 是否配置正确。

  2. 如果下拉过程出现数据重复等BUG,请检查后端返回的数据 fieldNames.value 是否唯一,且必传,否则组件会出现BUG。

  3. 如果需要自定义 option,请使用 customizeOptions 属性

  4. 如果组件的功能不符合您的业务场景,请联系我拓展完善。

让我们看看最终效果:
动画.gif

如果此文章对你有帮助,请帮我点个赞吧!