为什么要重复开发,像这样的组件一大堆🤣
- 业务原因,api和的类型符合生成的请求方法,还有需要扩展一些功能
- 技术栈,项目使用的是vue3、ts、antd4,符合这个条件的组件就比较少
- 最最最重要的原因,活不多(●'◡'●)
功能使用
通过api获取数据、深层次的field-names(通过lodash-es实现)
在我的项目开发中api是通过工具(适用于openApi3)生成的遵守开发规范,所以在使用select组件时,只需要导入api ,就可以简单的加载数据🫠
typescript
<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为空值
只读功能插槽
在一些表单操作中往往需要只读的定制,所以组件不仅提供了readOnly的属性控制,还提供了对应的插槽,可以通过{selected,selectNodes }做自定义的回显。
typescript
<template #readOnly="{ selectNodes }">
总共选择了: {{ selectNodes.length }}项
<br>
<a-tag v-for="node, i in selectNodes" :key="i" color="red">
<component :is="node" />
</a-tag>
</template>
话不多说直接上代码
组件核心代码
通过 useInjectFormConfig注入了属性,可以使用useAttrs替代
typescript
<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
typescript
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 获取只读组件节点
typescript
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的理解。轻喷😢