胖蔡说技术
随便扯扯

Vue3中使用a-select封装分页下拉框

背景

常规开发过程中,一般性质的下拉框已经满足我们的日常使用需求,但涉及到复杂场景、大数据量的时候,为了考虑前端的渲染压力和交互的友好性,我们需要对现有的下拉框进行一定的改造,实现下拉分页的功能。这里,我使用的是 "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-selectonPopupScroll事件监听实现触底加载,其实也可以使用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>
赞(1) 打赏
转载请附上原文出处链接:胖蔡说技术 » Vue3中使用a-select封装分页下拉框
分享到: 更多 (0)

请小编喝杯咖啡~

支付宝扫一扫打赏

微信扫一扫打赏