api选项加载器

6 阅读7分钟

OptionLoader 使用文档

📖 简介

optionLoader 是一个通用的下拉框选项加载工具,用于简化项目中大量重复的选项数据加载逻辑。

解决的问题

在实际开发中,我们经常需要:

  • 调用 API 获取下拉框选项数据
  • 将数据转换为 { label, value } 格式
  • 填充到响应式变量中
  • 处理加载错误

这些重复的代码分散在各个组件中,维护成本高。optionLoader 将这些逻辑统一封装,提供简洁的配置式 API。


🚀 快速开始

基础用法

import { ref } from 'vue'
import { createOptionLoaderWithPostProcess } from '@/utils/optionLoader'
import { getUserList } from '@/api/user'

// 1. 定义响应式变量
const refUserOptions = ref<OptionProps[]>([])

// 2. 创建加载器
const userOptionLoader = createOptionLoaderWithPostProcess({
    api: getUserList,
    mapper: v => ({
        label: v.name,
        value: v.id
    }),
    targetRef: refUserOptions
})

// 3. 在组件中使用
onMounted(() => {
    userOptionLoader()
})
<template>
    <a-select v-model:value="selectedUser" :options="refUserOptions" />
</template>

📚 核心 API

createOptionLoaderWithPostProcess

创建单个选项加载器。

类型定义
function createOptionLoaderWithPostProcess<T, R extends OptionProps[] = OptionProps[]>(
    config: GenericOptionLoaderConfig<T, R>
): () => Promise<void>
配置参数
参数类型必填说明
api() => Promise<T[]>API 调用函数
mapper(item: T) => OptionProps数据映射函数
targetRefRef<R> | Ref<R>[]目标响应式变量(单个或数组)
postProcess(data: R, rawData: T[]) => void后处理函数

createOptionLoaders

批量创建多个选项加载器。

类型定义
function createOptionLoaders(
    configs: { [key: string]: GenericOptionLoaderConfig<any> }
): {
    loaders: Record<string, () => Promise<void>>
    loadAll: () => Promise<void>
    loadByKeys: (...keys: string[]) => Promise<void>
}
返回值
属性类型说明
loadersRecord<string, () => Promise<void>>加载器映射对象
loadAll() => Promise<void>加载所有选项
loadByKeys(...keys: string[]) => Promise<void>按 key 加载指定选项

💡 使用场景

场景1:单个选项加载

适用于只需要加载一个下拉框选项的场景。

// data.ts
import { ref } from 'vue'
import { createOptionLoaderWithPostProcess } from '@/utils/optionLoader'
import { getCurrencyList } from '@/api/currency'

export const refCurrencyOptions = ref<OptionProps[]>([])

export const currencyOptionLoader = createOptionLoaderWithPostProcess({
    api: getCurrencyList,
    mapper: v => ({
        label: `${v.code} - ${v.name}`,
        value: v.code
    }),
    targetRef: refCurrencyOptions
})
<!-- index.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { refCurrencyOptions, currencyOptionLoader } from './data'

onMounted(() => {
    currencyOptionLoader()
})
</script>

<template>
    <a-select
        v-model:value="currency"
        :options="refCurrencyOptions"
        placeholder="请选择币种"
    />
</template>

场景2:批量加载选项

适用于页面需要加载多个下拉框选项的场景。

// data.ts
import { ref } from 'vue'
import { createOptionLoaders } from '@/utils/optionLoader'
import { getCurrencyList, getCountryList, getBankList } from '@/api'

export const refCurrencyOptions = ref<OptionProps[]>([])
export const refCountryOptions = ref<OptionProps[]>([])
export const refBankOptions = ref<OptionProps[]>([])

export const { loaders, loadAll, loadByKeys } = createOptionLoaders({
    currency: {
        api: getCurrencyList,
        mapper: v => ({ label: v.code, value: v.code }),
        targetRef: refCurrencyOptions
    },
    country: {
        api: getCountryList,
        mapper: v => ({ label: v.name, value: v.code }),
        targetRef: refCountryOptions
    },
    bank: {
        api: getBankList,
        mapper: v => ({ label: v.bankName, value: v.bankCode }),
        targetRef: refBankOptions
    }
})
<!-- index.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { loadAll, loaders, loadByKeys } from './data'

onMounted(() => {
    // 方式1:加载所有选项(并行请求)
    loadAll()

    // 方式2:单独加载某个选项
    // loaders.currency()

    // 方式3:加载指定的几个选项
    // loadByKeys('currency', 'country')
})
</script>

场景3:多个下拉框共享同一数据源

适用于一次请求填充多个下拉框的场景。

// data.ts
export const refCurrencyOptions1 = ref<OptionProps[]>([])
export const refCurrencyOptions2 = ref<OptionProps[]>([])

export const currencyOptionLoader = createOptionLoaderWithPostProcess({
    api: getCurrencyList,
    mapper: v => ({ label: v.code, value: v.code }),
    // 传入数组,一次请求填充多个 ref
    targetRef: [refCurrencyOptions1, refCurrencyOptions2]
})
<template>
    <div>
        <a-select v-model:value="currency1" :options="refCurrencyOptions1" />
        <a-select v-model:value="currency2" :options="refCurrencyOptions2" />
    </div>
</template>

场景4:使用后处理函数

适用于需要在数据加载后执行额外逻辑的场景。

export const refUserOptions = ref<OptionProps[]>([])
export const refUserMap = ref<Map<string, User>>(new Map())

export const userOptionLoader = createOptionLoaderWithPostProcess({
    api: getUserList,
    mapper: v => ({
        label: v.name,
        value: v.id
    }),
    targetRef: refUserOptions,
    // 后处理:构建用户 ID 到用户对象的映射
    postProcess: (options, rawData) => {
        refUserMap.value = new Map(
            rawData.map(user => [user.id, user])
        )
    }
})

场景5:处理对象格式的 API 返回值

optionLoader 自动处理对象格式的返回值。

// API 返回格式:{ '1': 'USD', '2': 'CNY' }
export const currencyOptionLoader = createOptionLoaderWithPostProcess({
    api: getCurrencyEnum,  // 返回对象而非数组
    mapper: v => ({ label: v, value: v }),
    targetRef: refCurrencyOptions
})

// 自动转换为:
// [
//   { label: 'USD', value: 'USD' },
//   { label: 'CNY', value: 'CNY' }
// ]

🎯 最佳实践

1. 文件组织

推荐将选项加载器定义在独立的 data.ts 文件中:

src/views/yourModule/
├── index.vue          # 主组件
├── data.ts            # 选项加载器定义
└── components/        # 子组件

2. 命名规范

// 响应式变量:ref + 名称 + Options
const refCurrencyOptions = ref<OptionProps[]>([])

// 加载器:名称 + OptionLoader
const currencyOptionLoader = createOptionLoaderWithPostProcess({...})

// 批量加载器:解构命名
const { loaders, loadAll, loadByKeys } = createOptionLoaders({...})

3. 类型安全

// 定义 API 返回类型
interface Currency {
    code: string
    name: string
}

// 在 mapper 中会有类型提示
const currencyOptionLoader = createOptionLoaderWithPostProcess<Currency>({
    api: getCurrencyList,  // () => Promise<Currency[]>
    mapper: v => ({
        label: v.code,  // v 的类型为 Currency
        value: v.code
    }),
    targetRef: refCurrencyOptions
})

4. 错误处理

// 加载器内部已经处理了错误,不会抛出异常
onMounted(async () => {
    // 可以安全调用,不需要 try-catch
    await loadAll()

    // 如果需要知道加载是否成功,可以检查 ref 的值
    if (refCurrencyOptions.value.length === 0) {
        console.warn('币种选项加载失败')
    }
})

5. 性能优化

// ✅ 推荐:使用 loadAll() 并行加载
onMounted(() => {
    loadAll()  // 内部使用 Promise.all 并行请求
})

// ❌ 不推荐:串行加载
onMounted(async () => {
    await loaders.currency()
    await loaders.country()  // 等待上一个完成
    await loaders.bank()
})

⚠️ 注意事项

1. 不支持分页加载

optionLoader 适用于数据量较小的选项(几十到几百条),不支持分页加载。

// ❌ 不适用:大数据量的用户列表(需要分页)
const userOptionLoader = createOptionLoaderWithPostProcess({
    api: getUserList,  // 返回 10000+ 条数据
    // ...
})

// ✅ 适用:小数据量的币种列表
const currencyOptionLoader = createOptionLoaderWithPostProcess({
    api: getCurrencyList,  // 返回 100 条左右
    // ...
})

2. 不支持远程搜索

如果需要远程搜索功能,请使用 a-selectfilterOptiononSearch 属性。

<template>
    <!-- 本地搜索 -->
    <a-select
        v-model:value="currency"
        :options="refCurrencyOptions"
        show-search
        :filter-option="filterOption"
    />
</template>

<script setup lang="ts">
const filterOption = (input: string, option: OptionProps) => {
    return option.label.toLowerCase().includes(input.toLowerCase())
}
</script>

3. 组件销毁后数据不保留

optionLoader 将数据填充到组件的 ref 中,组件销毁后数据会丢失。

如果需要全局缓存,请使用 Pinia:

// stores/options.ts
import { defineStore } from 'pinia'

export const useOptionsStore = defineStore('options', () => {
    const currencyOptions = ref<OptionProps[]>([])

    const loadCurrency = async () => {
        if (currencyOptions.value.length > 0) return  // 已加载
        const res = await getCurrencyList()
        currencyOptions.value = res.map(v => ({ label: v.code, value: v.code }))
    }

    return { currencyOptions, loadCurrency }
})

🔧 高级用法

自定义选项格式

// 扩展 OptionProps 类型
interface CustomOptionProps extends OptionProps {
    disabled?: boolean
    extra?: any
}

const userOptionLoader = createOptionLoaderWithPostProcess<User, CustomOptionProps[]>({
    api: getUserList,
    mapper: v => ({
        label: v.name,
        value: v.id,
        disabled: !v.isActive,  // 禁用非活跃用户
        extra: { email: v.email }  // 额外数据
    }),
    targetRef: refUserOptions
})

条件加载

const { loaders, loadByKeys } = createOptionLoaders({
    currency: { /* ... */ },
    country: { /* ... */ },
    bank: { /* ... */ }
})

onMounted(() => {
    // 根据权限加载不同的选项
    if (hasPermission('view_currency')) {
        loaders.currency()
    }

    // 根据业务逻辑加载
    if (formType.value === 'international') {
        loadByKeys('currency', 'country')
    } else {
        loaders.bank()
    }
})

动态刷新

const refreshOptions = async () => {
    // 重新加载所有选项
    await loadAll()
    message.success('选项已刷新')
}

📊 性能对比

使用前

// 每个组件都要写这些重复代码
const refCurrencyOptions = ref<OptionProps[]>([])
const refCountryOptions = ref<OptionProps[]>([])
const refBankOptions = ref<OptionProps[]>([])

onMounted(async () => {
    try {
        const currencyRes = await getCurrencyList()
        refCurrencyOptions.value = currencyRes.map(v => ({
            label: v.code,
            value: v.code
        }))
    } catch (error) {
        console.error(error)
    }

    try {
        const countryRes = await getCountryList()
        refCountryOptions.value = countryRes.map(v => ({
            label: v.name,
            value: v.code
        }))
    } catch (error) {
        console.error(error)
    }

    // ... 更多重复代码
})

代码量: ~40 行


使用后

const { loadAll } = createOptionLoaders({
    currency: {
        api: getCurrencyList,
        mapper: v => ({ label: v.code, value: v.code }),
        targetRef: refCurrencyOptions
    },
    country: {
        api: getCountryList,
        mapper: v => ({ label: v.name, value: v.code }),
        targetRef: refCountryOptions
    },
    bank: {
        api: getBankList,
        mapper: v => ({ label: v.bankName, value: v.bankCode }),
        targetRef: refBankOptions
    }
})

onMounted(() => {
    loadAll()  // 并行加载
})

代码量: ~20 行 减少: 50% 性能: 并行请求,速度更快


🤝 可能的改进方向

  1. 加载状态管理:增加 loadingRef 参数
  2. 错误状态管理:增加 errorRef 参数
  3. 重试机制:增加 retry 配置
  4. 请求取消:支持 AbortController

📝 更新日志

v1.0.0 (2024-02-10)

  • ✨ 初始版本
  • ✅ 支持单个/批量/按需加载
  • ✅ 支持多目标填充
  • ✅ 支持后处理函数
  • ✅ 自动处理对象格式返回值

实现源码

/**
 * write by Mephisto
 */
import type { Ref } from 'vue'
import type { OptionProps } from 'ant-design-vue/lib/select'

// 类型工具:从函数返回类型中提取数组元素类型
type ExtractApiItemType<T> = T extends () => Promise<(infer U)[]> ? U : never

export type OptionMapper<T> = (item: T) => OptionProps

export interface GenericOptionLoaderConfig<T, R extends OptionProps[] = OptionProps[]> {
    /** API调用函数 */
    api: () => Promise<T[]>
    /** 数据映射函数 */
    mapper: OptionMapper<T>
    /**
     * 目标Ref响应式变量 或者 Ref响应式变量集合
     */
    targetRef: Ref<R> | Ref<R>[]
    /**
     * 后处理函数。比如拿到响应结果后去做一些其他的操作。
     */
    postProcess?: (data: R, rawData: T[]) => void
}

// 改进版:支持类型推导的配置接口
export interface InferredOptionLoaderConfig<ApiFunc extends () => Promise<any[]>> {
    /** API调用函数 */
    api: ApiFunc
    /** 数据映射函数 - 参数类型从 api 返回值自动推导 */
    mapper: OptionMapper<ExtractApiItemType<ApiFunc>>
    /**
     * 目标Ref响应式变量 或者 Ref响应式变量集合
     */
    targetRef: Ref<OptionProps[]> | Ref<OptionProps[]>[]
    /**
     * 后处理函数。比如拿到响应结果后去做一些其他的操作。
     */
    postProcess?: (data: OptionProps[], rawData: ExtractApiItemType<ApiFunc>[]) => void
}

/**
 * 定义单个选项加载配置(带类型推导)
 * 这个辅助函数用于捕获 API 的类型信息,让 mapper 能自动推导参数类型
 */
export const defineOptionLoaderConfig = <ApiFunc extends () => Promise<any[]>>(
    config: InferredOptionLoaderConfig<ApiFunc>
): InferredOptionLoaderConfig<ApiFunc> => {
    return config
}

/**
 * 选项加载器-创建单个
 * @param optionLoaderConfig 配置项
 */
export const createOptionLoaderWithPostProcess = <T, R extends OptionProps[] = OptionProps[]>(
    optionLoaderConfig: GenericOptionLoaderConfig<T, R>
) => {
    return async () => {
        try {
            const { api, mapper, targetRef, postProcess } = optionLoaderConfig
            const res = await api()
            if (res) {
                let options: OptionProps[] = []

                // 如果 res 返回的是一个普通对象
                // { '1': 'a101', '2': 'C006.01.01' }
                if (!Array.isArray(res) && typeof res === 'object') {
                    options = Object.values(res).map(mapper)
                } else {
                    options = res.map(mapper)
                }

                // 判断是单个还是多个
                if (Array.isArray(targetRef)) {
                    targetRef.forEach(refTarget => {
                        refTarget.value = [...options] as R
                    })
                } else {
                    targetRef.value = options as R
                }

                // 执行后处理(如果有)
                if (postProcess) {
                    postProcess(options as R, res)
                }
            }
        } catch (error) {
            console.error(error)
        }
    }
}

/**
 * 选项加载器-批量创建(改进版,支持类型推导)
 * @example
 * const configs = {
 *   user: {
 *     api: getUserList, // () => Promise<User[]>
 *     mapper: v => ({ label: v.name, value: v.id }), // v 自动推导为 User 类型
 *     targetRef: refUserOptions
 *   }
 * }
 * const { loaders, loadAll } = createOptionLoaders(configs)
 */
export const createOptionLoaders = <T extends Record<string, InferredOptionLoaderConfig<any>>>(
    configs: T
) => {
    // 使用 Record<keyof T, ...> 保留具体的 key 类型
    const loaders = {} as Record<keyof T, () => Promise<void>>

    // 创建选项加载器映射
    for (const [key, config] of Object.entries(configs)) {
        loaders[key as keyof T] = createOptionLoaderWithPostProcess(config as any)
    }
    const loadAll = async () => {
        await Promise.all(Object.values(loaders).map(loader => loader()))
    }
    // 使用 keyof T 获得具体的 key 提示
    const loadByKeys = async (...keys: (keyof T)[]) => {
        await Promise.all(keys.map(key => loaders[key]()))
    }
    return {
        /**
         * 单独加载
         * @example loaders.aaa()
         */
        loaders,
        /** 加载所有 */
        loadAll,
        /**
         * 部分批量加载
         * @example loadByKeys('key1', 'key2')
         */
        loadByKeys
    }
}

// 使用示例
// # 单个选项加载器
// export const dashBoardOptionLoader = createOptionLoaderWithPostProcess({
//     api: queryUserDashboardList,
//     mapper: v => ({
//         label: v.dashboardName,
//         value: v.id,
//         dimensionList: v.dimensionList
//     }),
//     targetRef: refDashBoardOptions
// })

// 组件里用
// onMounted(() => {
//     dashBoardOptionLoader()
// })

// # 批量创建加载器(新版 - 支持类型推导)
// const optionLoaderMap = {
//     reportPlan: defineOptionLoaderConfig({
//         api: queryUserReportConfigList,
//         mapper: v => ({ label: v.label, value: v.value }), // v 自动推导类型
//         targetRef: refReportPlanOptions
//     }),
//     currency: defineOptionLoaderConfig({
//         api: queryCurrencyList,
//         mapper: v => ({ label: v.code, value: v.code }), // v 自动推导类型
//         targetRef: refCurrencyOptions
//     })
// }
// export const { loaders, loadAll, loadByKeys } = createOptionLoaders(optionLoaderMap)

// 组件里用
// onMounted(() => {
//     loadAll()
//     loaders.reportPlan()
//     loadByKeys('reportPlan', 'currency')
// })