likes
comments
collection
share

基于antdv4 select组件二次封装,简化加载远程数据、只读扩展、fieldNames扩展

作者站长头像
站长
· 阅读数 2

为什么要重复开发,像这样的组件一大堆🤣

  • 业务原因,api和的类型符合生成的请求方法,还有需要扩展一些功能
  • 技术栈,项目使用的是vue3、ts、antd4,符合这个条件的组件就比较少
  • 最最最重要的原因,活不多(●'◡'●)

功能使用

通过api获取数据、深层次的field-names(通过lodash-es实现)

在我的项目开发中api是通过工具(适用于openApi3)生成的遵守开发规范,所以在使用select组件时,只需要导入api ,就可以简单的加载数据🫠


<script setup lang="ts">
import { ref } from 'vue'
import { testApi } from '@/api'  // 此处api为项目中的业务api
const value = ref(199)
</script>
<template>
  <h2>API简单用法</h2>
  <c-select
    v-model:value="value"
    :field-names="{ label: 'info.name', value: 'id', options: 'children' }"
    style="width: 200px"
    :api="testApi"
  />
</template>

自定义空值

像一些id的选择,空值往往是0,但是在选择器中,0并不是一个有效的空值。在当前的组件中仅需要传入:null-value="0",即可指定0为空值

基于antdv4 select组件二次封装,简化加载远程数据、只读扩展、fieldNames扩展基于antdv4 select组件二次封装,简化加载远程数据、只读扩展、fieldNames扩展

只读功能插槽

在一些表单操作中往往需要只读的定制,所以组件不仅提供了readOnly的属性控制,还提供了对应的插槽,可以通过{selected,selectNodes }做自定义的回显。

基于antdv4 select组件二次封装,简化加载远程数据、只读扩展、fieldNames扩展

  <template #readOnly="{ selectNodes }">
      总共选择了: {{ selectNodes.length }}项
      <br>
      <a-tag v-for="node, i in selectNodes" :key="i" color="red">
        <component :is="node" />
      </a-tag>
 </template>

基于antdv4 select组件二次封装,简化加载远程数据、只读扩展、fieldNames扩展

话不多说直接上代码

组件核心代码

通过 useInjectFormConfig注入了属性,可以使用useAttrs替代

<script lang="ts" setup generic="T extends  DefaultOptionType, Api extends  DataApi<T>">
import { ref, useAttrs, watch } from 'vue'
import { Select } from 'ant-design-vue'
import type { ComponentSlots } from 'vue-component-type-helpers'
import { LoadingOutlined } from '@ant-design/icons-vue'
import type { DefaultOptionType, SelectValue } from 'ant-design-vue/lib/select'
import { useVModels } from '@vueuse/core'
import type { SelectProps } from '../types'
import { readOnlySlot, useInjectFormConfig } from '../../form/src/useFormConfig'
import { useSlotsHooks } from '../../hooks/useSlot'
import type { DataApi } from '../../global.types'
import { useFetchOptions, useReadComponents } from './useSelectTools'

defineOptions({
  inheritAttrs: false,
  group: 'form',
})

const props = withDefaults(defineProps<SelectProps<T, Api>>(), {
  bordered: true,
})

const emit = defineEmits<{
  'update:value': [value: SelectValue]
  'dropdownVisibleChange': [visible: boolean]
  'popupScroll': [e: UIEvent]
}>()

const slots = defineSlots<ComponentSlots<typeof Select> & { [readOnlySlot]: { selected: any } }>()

const attrs = useAttrs()

const { value } = useVModels(props, emit, { passive: true })

const { getBindValue } = useInjectFormConfig<SelectProps<T, Api>>({ name: 'select', attrs, props })

const { useExcludeSlots } = useSlotsHooks(slots, [readOnlySlot, 'suffixIcon'])

const { options, loading, onPopupScroll, onDropdownVisibleChange } = useFetchOptions(getBindValue, emit)

const { RenderReadNode, selectNodes } = useReadComponents(slots, getBindValue, options)

const modelValue = ref<SelectValue>()

watch(() => value?.value, () => {
  modelValue.value = value?.value
}, { immediate: true })

// #region 内部处理空值

watch(() => getBindValue.value.nullValue, () => {
  if (getBindValue.value.nullValue === value.value)
    modelValue.value = undefined
}, { immediate: true })

function updateValue(val: SelectValue) {
  if (value)
    value.value = val
  else
    modelValue.value = val
}

// #endregion
</script>

<template>
  <slot v-if="getBindValue?.readOnly" :name="readOnlySlot" :selected="getBindValue.value" :select-nodes="selectNodes">
    <RenderReadNode />
  </slot>

  <Select
    v-else v-bind="getBindValue" :value="modelValue" :options="options!" :field-names="undefined"
    @update:value="updateValue" @popup-scroll="onPopupScroll" @dropdown-visible-change="onDropdownVisibleChange"
  >
    <template #suffixIcon>
      <slot name="suffixIcon">
        <LoadingOutlined v-if="loading" spin />
      </slot>
    </template>
    <template v-for="_, key in useExcludeSlots" #[key]="data">
      <slot :name="key" v-bind="data || {}" />
    </template>
  </Select>
</template>

通过useFetchOptions 获取options

export function useReadComponents<T>(
  slots: any,
  props: Ref<Readonly<SelectProps<T, DataApi<T>>>>,
  options: Ref<Array<DefaultOptionType> | null>,
) {
  const selectNodes = shallowRef<Array<() => VNode>>([])

  /**
   * 检查给定的节点值是否在选择值列表中
   * @param nodeValue 节点值,可以是字符串或数字类型
   * @returns 如果节点值在选择值列表中则返回true,否则返回false
   */
  const isValueInSelectValue = (nodeValue: string | number) => {
    const { value: selectValue, labelInValue } = props.value

    const isItemSelected = (selectValue: SelectValue) => {
      if (labelInValue && typeof selectValue === 'object') {
        const _selectValue = selectValue as LabeledValue
        return _selectValue?.value === nodeValue
      }
      else {
        return selectValue === nodeValue
      }
    }

    if (Array.isArray(selectValue))
      return selectValue.some(v => isItemSelected(v))
    else
      return isItemSelected(selectValue)
  }

  const fetchOptionNodeByOptions = () => {
    selectNodes.value = []
    const deep = (options: Array<DefaultOptionType>) => {
      for (const item of options) {
        if (item.value && isValueInSelectValue(item.value))
          selectNodes.value.push(() => h('span', item.label))
        if (item.options)
          deep(item.options)
      }
    }

    deep(options.value || [])

    triggerRef(selectNodes)
  }

  const fetchOptionNodeBySlot = () => {
    selectNodes.value = []
    const deepNodeTree = (nodes: VNode[]) => {
      for (const node of nodes) {
        const children = node.children
        if (Array.isArray(children)) { deepNodeTree(children as VNode[]) }
        else if (typeof node.type === 'function') {
          if (node.type.name.includes('Option')) {
            const nodeProps = node.props as any
            const v = nodeProps?.value ?? nodeProps?.key
            if (isValueInSelectValue(v))
              selectNodes.value.push((node as any).children?.default || node)
          }
          else if (node.type.name.includes('OptGroup')) {
            const children = node.children as any
            if ('default' in children && typeof children.default === 'function')
              deepNodeTree(children.default())
          }
        }
      }
    }
    if ('default' in slots) {
      const slotsVNode = slots?.default()
      deepNodeTree(slotsVNode)
    }
    triggerRef(selectNodes)
  }

  const fetchOptionNode = () => {
    if (options.value)
      nextTick(fetchOptionNodeByOptions)
    else
      fetchOptionNodeBySlot()
  }

  watch([() => props.value.value, () => options.value, () => props.value.readOnly], () => {
    if (props.value.readOnly)
      fetchOptionNode()
  }, { immediate: true })

  const RenderReadNode = defineComponent(
    () => {
      return () => {
        return (
          <div style="display: inline-block;">
            {selectNodes.value.map(v => (<Tag>{v()}</Tag>))}
          </div>
        )
      }
    },
  )

  return { RenderReadNode, selectNodes }
}

通过useReadComponents 获取只读组件节点

export function useFetchOptions<T extends DefaultOptionType>(attrs: Ref<Readonly<SelectProps<T, DataApi<T>>>>, emit: ((evt: 'dropdownVisibleChange', visible: boolean) => void) & ((evt: 'popupScroll', e: UIEvent) => void)) {
  const options = ref<Array<DefaultOptionType> | null>(null)

  const loading = ref(false)

  const page = ref<PageParams>({ offset: 0, limit: 20 })

  const fetchCount = ref(0)

  /**
   * 将结果转换为默认选项类型数组
   * @param res - 可迭代对象或数组,包含要转换的数据
   * @returns 默认选项类型数组
   */
  const transformOptions = (res: IPagedEnumerable<T> | Array<T>): DefaultOptionType[] => {
    let data: Array<T> = []
    if (Array.isArray(res))
      data = res
    else if (res?.items)
      data = res.items

    return data.map((item) => {
      /**
       * 转换选项并获取 item 的 label、value 属性值
       */
      const options = transformOptions(get(item, attrs.value.fieldNames?.options || 'options'))
      const res: DefaultOptionType = {
        label: get(item, attrs.value.fieldNames?.label || 'label'),
        value: get(item, attrs.value.fieldNames?.value || 'value'),
        // option: item,
      }
      if (options.length)
        res.options = options
      if (item.disabled)
        res.disabled = item.disabled

      return res
    })
  }

  /**
   * 初始化options
   */
  const optionsInit = () => {
    options.value = attrs.value.options ? transformOptions(attrs.value.options as any) : null
    if (attrs.value.insertOptions)
      options.value = [...transformOptions(attrs.value.insertOptions), ...options.value || []]
  }

  const fetchApiData = async () => {
    if (!attrs.value.api)
      return
    try {
      fetchCount.value++
      loading.value = true
      let params = attrs.value.params
      if (attrs.value.page)
        params = { ...params, ...page.value }

      const res = await attrs.value.api(params, attrs.value.postParams)
      if (!options.value)
        options.value = []
      options.value.push(...transformOptions(res))
      loading.value = false
    }
    catch (e) {
      fetchCount.value--
      loading.value = false
      console.error(`select 调用api失败: ${e}`)
    }
  }

  const onDropdownVisibleChange = (visible: boolean) => {
    emit('dropdownVisibleChange', visible)
    if (visible && fetchCount.value === 0)
      fetchApiData()
  }

  const onPopupScroll = async (e: any) => {
    emit('popupScroll', e)
    if (!attrs.value.page)
      return
    const { scrollTop, offsetHeight, scrollHeight } = e.target
    if (Math.ceil(scrollTop + offsetHeight) >= scrollHeight) {
      const cur = options.value!.length / page.value.limit >= 1
        ? Math.ceil(options.value!.length / page.value.limit)
        : 1
      page.value.offset = cur * page.value.limit
      await fetchApiData()
    }
  }

  onMounted(() => {
    optionsInit()
    if (!attrs.value.options) {
      if (typeof attrs.value.page === 'object' && 'limit' in attrs.value.page && 'offset' in attrs.value.page)
        page.value = { ...attrs.value.page }
    }
  })

  watch([() => attrs.value.api, () => attrs.value.params, () => attrs.value.postParams, () => attrs.value.options], () => {
    if (attrs.value.api && !attrs.value.options) {
      fetchCount.value = 0
      optionsInit()
      fetchApiData()
    }
    else if (!attrs.value.api) {
      optionsInit()
    }
  }, { immediate: !!attrs.value.immediate })

  return {
    options,
    loading,
    onPopupScroll,
    onDropdownVisibleChange,
  }
}

总结

实现的功能大部分都是调库的,旨在扩展功能,还有增加对vue3的hook的理解。轻喷😢