背景
常规开发过程中,一般性质的下拉框已经满足我们的日常使用需求,但涉及到复杂场景、大数据量的时候,为了考虑前端的渲染压力和交互的友好性,我们需要对现有的下拉框进行一定的改造,实现下拉分页的功能。这里,我使用的是 "ant-design-vue": "^4.0.3"
组件的 Select
组件,其他UI
框架也类似。
环境依赖
Vue3 + TypeScript
开发环境- 采用
Ant-design-Vue
UI组件 - 使用
axios
作为网络请求框架
期望实现
- 正常下拉框的所有功能
- 配置实现下拉分页
- 请求数据格式转换
- 加载时机自主选择
- 额外请求参数配置
功能实现
1、属性设置
根据如上设想,我给当前的自定义组件添加了如下的自定义属性:
export interface PageSelectPagination {
url?: string // 远程请求地址
method?: string // 请求方法
query?: Record<string, any> // 额外请求参数,path地址
data?: Record<string, any> // 额外请求参数,json data数据
pageSize?: number // 配置分页器分页大小,默认为10, 建议分页数要 >= 10, selct下拉需要足够的高度
pageFiled?: string // 配置分页请求页码请求参数名
pageSizeFiled?: string // 配置分页请求分页器请求参数名
resultFiled?: string // 数组参数,支持多级
totalFiled?: string // 总条数参数,支持多级
searchTextFiled?: string // search字段名
}
/**
* 设置默认的分页配置
*/
export const DefaultPagination: PageSelectPagination = {
pageSize: 10,
pageFiled: 'pageNo',
pageSizeFiled: 'pageSize',
method: 'POST',
resultFiled: 'data.list',
totalFiled: 'data.total',
searchTextFiled: 'keyword',
}
export enum LoadingType {
DEFAULT = 'default', // 默认加载,beforeOnMount
EXT_PARAMS_CHNAGE = 'ext-params-change', // extParam参数发生改变
CUSTOM = 'custom', // 主动发起
DROPDOWN = 'dropdown', // 下拉展开菜单的时候
}
/**
* 数据转换
*/
export type TransformFunc = (model: Record<string, any>) => any
const pageSelectProps = {
pagination: {
type: [Object, Boolean] as PropType<PageSelectPagination | false>,
default: false as const,
},
placeholder: {
type: String,
default: '请选择',
},
value: {
type: [Object, String, Boolean, Symbol, Number],
},
extParams: {
// 单独放出来,不与pagination中的配置混淆
type: Object as PropType<Record<string, any>>,
},
loadingType: {
// 首次加载的方式
type: String as PropType<LoadingType>,
default: LoadingType.DEFAULT,
},
/**
* 数据转换
*/
transform: {
type: Function as PropType<TransformFunc>,
},
request: {
type: Function,
},
remoteSearch: {
type: Boolean,
default: true,
},
}
export type PageSelectProps = ExtractPublicPropTypes<typeof pageSelectProps> & SelectProps
2、组件绘制
通过tsx
实现组件属性传递,基本传递原有Select
的所有功能,使其不影响原有组件属性的使用。
return () => {
const { placeholder } = props
const newAttrs = { ...attrs }
if (newAttrs.class)
delete newAttrs.class
if (newAttrs.style)
delete newAttrs.style
const selectProps: any = {
filterOption: !props.remoteSearch,
onDropdownVisibleChange,
onPopupScroll: handlePopupScroll,
placeholder,
}
if (attrs['showSearch'] || attrs['show-search'])
selectProps.onSearch = onSearch
return (
<div class={attrs.class} style={`${attrs.style} ;position:relative;`} >
<Select
options={options.value}
v-model:value={value.value}
class={attrs.class}
{...newAttrs}
{...emit}
{...selectProps}
notFoundContent={
fetching.value
? undefined
: (
<Empty style={{ margin: 0 }} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)
}
>
{{
...selectSlots,
...slots,
}}
</Select>
</div>
)
}
3、下拉分页
我这里使用a-select
的onPopupScroll
事件监听实现触底加载,其实也可以使用pagination
分页器实现手动分页,功能类似。
const handlePopupScroll = (e: Record<string, any>) => {
const { target } = e
const { scrollTop, scrollHeight, clientHeight } = target
if (scrollTop + clientHeight === scrollHeight) {
// 触底
// 请求下一页
const t = total.value
if (t && options.value && t > options.value?.length) {
loading.value = true
const p = page.value + 1
page.value = p
onPageChange(p)
}
}
}
4、完整实现
如下为该组件的完整代码:
import type { SelectProps } from 'ant-design-vue'
import { Empty, Select, Spin } from 'ant-design-vue'
import type { ExtractPublicPropTypes, PropType } from 'vue'
import { defineComponent, h } from 'vue'
import type { AxiosRequestConfig } from 'axios'
import { LoadingOutlined } from '@ant-design/icons-vue'
import { debounce } from 'lodash'
import type { PageSelectPagination, TransformFunc } from './typing'
import { DefaultPagination, LoadingType } from './typing'
import axios from '~/utils/request'
const pageSelectProps = {
pagination: {
type: [Object, Boolean] as PropType<PageSelectPagination | false>,
default: false as const,
},
placeholder: {
type: String,
default: '请选择',
},
value: {
type: [Object, String, Boolean, Symbol, Number],
},
extParams: {
type: Object as PropType<Record<string, any>>,
},
loadingType: {
// 首次加载的方式
type: String as PropType<LoadingType>,
default: LoadingType.DEFAULT,
},
/**
* 数据转换
*/
transform: {
type: Function as PropType<TransformFunc>,
},
request: {
type: Function,
},
remoteSearch: {
type: Boolean,
default: true,
},
}
export type PageSelectProps = ExtractPublicPropTypes<typeof pageSelectProps> & SelectProps
export type PageSelectEmits = ['update:value', 'update:ext-params']
const PageSelect = defineComponent<PageSelectProps, PageSelectEmits>(
(props: PageSelectProps, context) => {
const { attrs, expose, emit, slots } = context
const page = ref<number>(1)
const pageSize = ref<number>(10)
const total = ref<number>(0)
const paginationConfig = shallowRef<PageSelectPagination>()
const fetching = shallowRef<boolean>(false)
const options = ref<SelectProps['options']>([])
const loading = shallowRef<boolean>(false)
const searchText = ref<string>()
const extParams = ref<Record<string, any>>(props.extParams || {})
const value = ref(props.value)
watch(
() => props.extParams,
(newVal, _) => {
extParams.value = newVal || {}
page.value = 1
fetching.value = true
total.value = 0
options.value = []
if (props.loadingType === LoadingType.EXT_PARAMS_CHNAGE)
refresh()
},
{ deep: true },
)
watch(
() => props.value,
(newVal, _) => {
value.value = newVal
},
{ deep: true },
)
watch(value, (newVal, _) => {
emit('update:value', newVal)
}, { deep: true })
const getQueryParams = () => {
let params: Record<string, any> = {}
if (paginationConfig.value && paginationConfig.value.url) {
params[paginationConfig.value.pageFiled!] = page.value
params[paginationConfig.value.pageSizeFiled!] = pageSize.value
}
if (paginationConfig.value && searchText.value && String(searchText.value).trim()) {
params[paginationConfig.value.searchTextFiled!] = searchText.value
}
const extP = extParams.value
if (extP && Object.keys(extP).length)
params = Object.assign({}, params, extP)
return params
}
const onPageChange = () => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
fetchData()
}
const fillResult = (result: any) => {
const { code, msg } = result || {}
if (String(code) !== '0') {
console.error(msg || '请求失败,请检查接口配置')
options.value = []
fetching.value = false
loading.value = false
return
}
const p = page.value
let _data = result[paginationConfig.value!.resultFiled!]
const _total = result[paginationConfig.value!.totalFiled!]
total.value = Math.max(0, _total)
const transformFunc = props.transform
if (transformFunc && typeof transformFunc === 'function')
_data = (_data || []).map((item: any) => transformFunc(item))
if (p === 1) {
options.value = _data
}
else if (_data && Array.isArray(_data) && _data.length) {
options.value = options?.value ? options?.value?.concat(_data) : _data // 拼接
}
else {
if (options?.value && options?.value?.length > _total) {
page.value = 1
onPageChange(1)
}
}
}
const fetchData = () => {
if (props.request) {
props.request().then((res: any) => {
options.value = res
}).finally(() => {
setTimeout(() => {
fetching.value = false
loading.value = false
}, 200)
})
return
}
if (!paginationConfig.value || !paginationConfig.value.url) {
loading.value = false
setTimeout(() => {
fetching.value = false
}, 400)
return
}
const _data = getQueryParams()
const config: AxiosRequestConfig = {
url: paginationConfig.value.url,
method: paginationConfig.value.method,
params: paginationConfig.value.query,
data: paginationConfig.value.data,
}
if (String(config.method).toUpperCase() === 'GET')
config.params = Object.assign({}, config.params || {}, _data)
else config.data = Object.assign({}, config.data || {}, _data)
axios(config)
.then((res: any) => {
setTimeout(() => {
fillResult(res)
}, 200)
})
.finally(() => {
setTimeout(() => {
fetching.value = false
loading.value = false
}, 200)
})
}
const handlePopupScroll = (e: Record<string, any>) => {
const { target } = e
const { scrollTop, scrollHeight, clientHeight } = target
if (scrollTop + clientHeight === scrollHeight) {
const t = total.value
if (t && options.value && t > options.value?.length) {
loading.value = true
const p = page.value + 1
page.value = p
onPageChange(p)
}
}
}
const refresh = () => {
page.value = 1
total.value = 0
options.value = []
fetching.value = true
fetchData()
}
const onSearch = debounce((text: any) => {
if (props.remoteSearch) {
searchText.value = text
refresh()
}
}, 300)
const onDropdownVisibleChange = (open: boolean) => {
if (props.loadingType === LoadingType.DROPDOWN && open) {
if (!options.value || options.value.length === 0) {
refresh()
}
}
}
const initConfig = () => {
const { pagination } = props
if (pagination) {
const newConfig = Object.assign({},DefaultPagination,pagination)
paginationConfig.value = newConfig
page.value = 1
pageSize.value = newConfig?.pageSize || DefaultPagination.pageSize!
}
}
onBeforeMount(() => {
initConfig()
if (props.loadingType === LoadingType.DEFAULT)
refresh()
})
expose({
refresh,
})
const selectSlots = {
notFoundContent: () => {
if (fetching.value)
return <Spin size="small" />
else
return []
},
dropdownRender: ({ menuNode: menu }) => {
// console.log('get menuNode:', menu)
const indicator = h(LoadingOutlined, {
style: {
fontSize: '14px',
},
spin: true,
})
return (
<div>
{h(menu)}
{loading.value
? (
<div class="loading flex-center">
<Spin size="small" indicator={indicator} />
</div>
)
: (
<div></div>
)}
</div>
)
},
}
return () => {
const { placeholder } = props
const newAttrs = { ...attrs }
if (newAttrs.class)
delete newAttrs.class
if (newAttrs.style)
delete newAttrs.style
const selectProps: any = {
filterOption: !props.remoteSearch,
onDropdownVisibleChange,
onPopupScroll: handlePopupScroll,
placeholder,
}
if (attrs['showSearch'] || attrs['show-search'])
selectProps.onSearch = onSearch
return (
<div class={attrs.class} style={`${attrs.style} ;position:relative;`} >
<Select
options={options.value}
v-model:value={value.value}
class={attrs.class}
{...newAttrs}
{...emit}
{...selectProps}
notFoundContent={
fetching.value
? undefined
: (
<Empty style={{ margin: 0 }} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)
}
>
{{
...selectSlots,
...slots,
}}
</Select>
</div>
)
}
},
{
name: 'PageSelect',
inheritAttrs: false,
props: pageSelectProps,
emits: ['update:value', 'update:ext-params'],
},
)
export * from './typing'
export default PageSelect
5、使用
如下为一个简单的使用:
import type { PageSelectProps } from '~@/components/page-select'
import PageSelect, { LoadingType } from '~@/components/page-select'
const pageSelectRef = ref()
const pageQuery = ref({})
const id = ref()
const psProps: PageSelectProps = {
pagination: {
url: '<api-path>',
searchTextFiled: 'keywords',
resultFiled: 'data',
totalFiled: 'total',
},
loadingType: LoadingType.DEFAULT,
allowClear: true,
showSearch: true,
dropdownMatchSelectWidth: false,
optionLabelProp: 'title',
placeholder: '选择',
onChange: (val: any) => {
},
}
<template>
<PageSelect
ref="pageSelectRef"
v-model:value="id "
:ext-params="pageQuery"
:get-popup-container="(trigger) => trigger.parentElement.parentElement"
class="ml-0px w-200px"
v-bind="psProps"
/>
</template>