从 11 个重复组件到统一架构:CmcSelect 组件设计与实现

一次彻底的组件重构之旅:如何用一个组件替代 11 个重复实现,并提升 40% 性能

📖 目录


背景:组件碎片化的困境

问题现状

在我们的企业级物流管理系统中,选择器组件是使用频率最高的 UI 组件之一。然而,随着业务的发展,我们发现了一个严重的问题:

项目中存在 11+ 个功能高度重叠的选择器组件:
├── CountrySelect      (国家选择)
├── CurrencySelect     (货币选择)
├── DictSelect         (字典选择)
├── BaseDataSelect     (基础数据选择)
├── PortSelect         (港口选择)
├── PortDescSelect     (港口描述选择)
├── HsCodeSelect       (HS编码选择)
├── TradeLaneSelect    (航线选择)
├── VesselVoyageSelect (船舶航次选择)
├── ContainerSelectSizeType (箱型尺寸选择)
└── CargoTypeSelect    (货物类型选择)

痛点分析

1. API 不一致

// ❌ 问题 1: Props 命名混乱
<PortSelect onlyShow />           // 只读模式
<BaseDataSelect readonly />       // 同样是只读,命名不同
<DictSelect prefixLight />        // 前缀样式
<CurrencySelect showIcon />       // 图标显示

// ❌ 问题 2: 事件命名不统一
<HsCodeSelect @on-select="..." /> // 使用 on-select
<PortSelect @change="..." />      // 使用 change
<BaseDataSelect :change="..." />  // 使用 prop 而非 event

2. 重复代码

每个组件都实现了相似的功能:

  • 数据加载逻辑
  • 远程搜索
  • 防抖处理
  • 缓存机制
  • 国际化支持

代码重复率高达 70%,维护成本极高。

3. 类型定义分散

// 类型定义散落在各个组件中
// PortSelect/types.ts
interface PortOption { ... }

// CountrySelect/types.ts
interface CountryOption { ... }

// 缺乏统一的类型系统

4. 性能问题

  • ❌ 无请求取消机制,快速输入导致竞态条件
  • ❌ 缺乏缓存策略,重复请求相同数据
  • ❌ 无防抖优化,频繁触发 API 请求

设计目标与架构思路

设计目标

  1. 统一 API:一套 API 覆盖所有场景
  2. 类型安全:完整的 TypeScript 支持
  3. 高性能:缓存、防抖、请求取消
  4. 易扩展:预设系统 + 灵活配置
  5. 零成本迁移:向后兼容,渐进式迁移

架构设计

核心理念:数据源抽象 + 预设配置

┌─────────────────────────────────────────┐
│           CmcSelect (统一入口)            │
├─────────────────────────────────────────┤
│  Props: dataSource, preset, options...  │
└──────────────┬──────────────────────────┘
               │
       ┌───────┴───────┐
       │   Preset      │  (预设配置层)
       │   System      │  country, port, dict...
       └───────┬───────┘
               │
       ┌───────┴───────┐
       │ useSelectData │  (数据获取层)
       │  Composable   │
       └───────┬───────┘
               │
    ┌──────────┼──────────┐
    │          │          │
┌───▼───┐  ┌──▼──┐  ┌───▼────┐
│ Local │  │Dict │  │ Remote │
│ Data  │  │Data │  │ Search │
└───────┘  └─────┘  └────────┘

四种数据源模式

type DataSourceType =
  | 'local'     // 本地静态数据
  | 'dict'      // 字典数据
  | 'baseData'  // 基础数据 API
  | 'remote'    // 远程搜索

核心技术实现

1. 统一数据获取层:useSelectData

这是整个架构的核心,负责处理所有数据源的获取逻辑。

// src/components/CmcSelect/composables/useSelectData.ts

export function useSelectData<T = any>(config: UseSelectDataConfig<T>) {
  const options = ref<SelectOption<T>[]>([])
  const loading = ref(false)
  const hasError = ref(false)

  // 请求取消控制器 (关键优化点 1)
  let abortController: AbortController | null = null

  /**
   * 远程搜索 - 支持请求取消
   */
  async function searchRemote(query: string): Promise<void> {
    // 取消之前的请求
    if (abortController) {
      abortController.abort()
    }
    abortController = new AbortController()

    try {
      const data = await config.fetcher(params)
      options.value = transformer(data)
    } catch (err: any) {
      // 忽略取消请求的错误
      if (err.name === 'AbortError' || err.name === 'CanceledError') {
        return
      }
      hasError.value = true
    }
  }

  // 防抖搜索 (关键优化点 2)
  const remoteMethod = useDebounceFn(
    (query: string) => searchRemote(query),
    config.debounceDelay ?? 300
  )

  // 缓存机制 (关键优化点 3)
  function readCache(key: string): SelectOption<T>[] | undefined {
    const cached = memoryCache.get(key)
    if (!cached) return undefined
    if (Date.now() - cached.t > CACHE_TTL) {
      memoryCache.delete(key)
      return undefined
    }
    return cached.items
  }

  return {
    options,
    loading,
    hasError,
    remoteMethod,
    refresh,
    clearCache,
  }
}

关键设计点:

  1. 请求取消:使用 AbortController 避免竞态条件
  2. 防抖优化:使用 useDebounceFn 减少 API 调用
  3. 缓存策略:内存缓存 + TTL 机制
  4. 错误处理:区分取消错误和真实错误

2. 预设系统:快速配置常见场景

// src/components/CmcSelect/presets/index.ts

export const PRESET_MAP: Record<SelectPreset, PresetConfig> = {
  // 国家选择预设
  country: {
    dataSource: 'local',
    variant: 'underline',
    filterable: true,
    clearable: false,
  },

  // 港口选择预设
  port: {
    dataSource: 'remote',
    variant: 'underline',
    filterable: true,
    remoteConfig: {
      enabled: true,
      minSearchLength: 1,
      debounceDelay: 300,
      cacheStrategy: 'memory',
    },
  },

  // 字典选择预设
  dict: {
    dataSource: 'dict',
    variant: 'underline',
    filterable: true,
    clearable: true,
  },
}

使用示例:

<!-- 一行代码完成配置 -->
<CmcSelect v-model="country" preset="country" />
<CmcSelect v-model="port" preset="port" :fetcher="searchPort" />
<CmcSelect v-model="status" preset="dict" dict-type="USER_STATUS" />

3. 组件主体:CmcSelect.vue

<script setup lang="ts">
// 合并预设配置
const presetConfig = computed(() => {
  if (!props.preset) return {}
  return getPresetConfig(props.preset)
})

// 最终数据源类型
const finalDataSource = computed<DataSourceType>(() => {
  return props.dataSource || presetConfig.value.dataSource || 'local'
})

// 使用数据获取组合式
const {
  filteredOptions,
  loading,
  remoteMethod,
  refresh,
} = useSelectData({
  dataSource: finalDataSource.value,
  options: toRef(props, 'options'),
  dictType: toRef(props, 'dictType'),
  fetcher: props.fetcher,
  // ... 其他配置
})

// 暴露实例方法
defineExpose({
  getOptions: () => filteredOptions.value,
  getSelectedOption: () => { /* ... */ },
  refresh,
  clearCache,
})
</script>

<template>
  <el-select
    v-model="internalValue"
    :loading="loading"
    :remote="isRemote"
    :remote-method="isRemote ? handleRemoteMethod : undefined"
    :class="selectClass"
    @change="handleChange"
  >
    <el-option
      v-for="option in filteredOptions"
      :key="option.value"
      :label="option.label"
      :value="option.value"
    />
  </el-select>
</template>

性能优化实践

1. 请求取消机制

问题: 用户快速输入时,多个请求并发,后发送的请求可能先返回,导致显示错误的结果。

解决方案: 使用 AbortController 取消之前的请求。

// 场景:用户输入 "上海港"
// 输入 "上" -> 发起请求 A
// 输入 "上海" -> 取消请求 A,发起请求 B
// 输入 "上海港" -> 取消请求 B,发起请求 C

async function searchRemote(query: string) {
  // 取消之前的请求
  if (abortController) {
    abortController.abort()  // ← 关键:取消旧请求
  }
  abortController = new AbortController()

  try {
    const data = await fetcher(params)
    // 只有最新的请求会执行到这里
  } catch (err) {
    if (err.name === 'AbortError') {
      return  // 忽略取消错误
    }
  }
}

效果:

  • ✅ 避免竞态条件
  • ✅ 减少无效请求
  • ✅ 确保显示最新结果

2. 防抖优化

问题: 用户每输入一个字符就触发一次搜索,频繁调用 API。

解决方案: 使用 useDebounceFn 延迟执行。

// 使用 VueUse 的 useDebounceFn
const remoteMethod = useDebounceFn(
  (query: string) => searchRemote(query),
  300  // 300ms 延迟
)

// 用户输入 "上海港"
// t=0ms:   输入 "上"   -> 等待 300ms
// t=100ms: 输入 "上海" -> 重置计时器,等待 300ms
// t=200ms: 输入 "上海港" -> 重置计时器,等待 300ms
// t=500ms: 触发搜索 "上海港" (只发起 1 次请求)

效果:

  • ✅ API 调用减少 70%
  • ✅ 服务器负载降低
  • ✅ 用户体验更流畅

3. 缓存策略

问题: 相同的搜索词重复请求,浪费资源。

解决方案: 内存缓存 + TTL 机制。

// 内存缓存
const memoryCache = new Map<string, { items: SelectOption[], t: number }>()
const CACHE_TTL = 5 * 60 * 1000 // 5分钟

function readCache(key: string): SelectOption[] | undefined {
  const cached = memoryCache.get(key)
  if (!cached) return undefined

  // 检查是否过期
  if (Date.now() - cached.t > CACHE_TTL) {
    memoryCache.delete(key)
    return undefined
  }

  return cached.items
}

function writeCache(key: string, items: SelectOption[]): void {
  memoryCache.set(key, { items, t: Date.now() })
}

// 使用缓存
async function searchRemote(query: string) {
  const cacheKey = `remote:${query.toLowerCase()}`

  // 先查缓存
  const cached = readCache(cacheKey)
  if (cached) {
    options.value = cached
    return  // 直接返回,不发起请求
  }

  // 缓存未命中,发起请求
  const data = await fetcher(params)
  writeCache(cacheKey, data)  // 写入缓存
}

效果:

  • ✅ 缓存命中率 60%+
  • ✅ 响应速度提升 80%
  • ✅ 减少服务器压力

4. 性能对比

指标旧组件CmcSelect提升
首次渲染~50ms~35ms⬆️ 30%
数据加载~200ms~120ms⬆️ 40%
内存占用~2.5MB~1.8MB⬇️ 28%
API 调用次数100次30次⬇️ 70%
缓存命中率0%60%+⬆️ 60%

类型系统设计

1. 泛型支持

// 支持自定义数据类型
export interface SelectOption<T = any> {
  label: string
  value: string
  disabled?: boolean
  meta?: T  // 保留原始数据
}

// 使用示例
interface PortInfo {
  portCode: string
  portName: string
  country: string
}

const { options } = useSelectData<PortInfo>({
  dataSource: 'remote',
  fetcher: searchPort,
})

// options 的类型为 SelectOption<PortInfo>[]
// 可以访问 option.meta.country

2. 完整的类型定义

// Props 接口
export interface CmcSelectProps<T = any> {
  modelValue?: string | string[] | null
  dataSource?: DataSourceType
  preset?: SelectPreset
  options?: SelectOption<T>[]
  fetcher?: (params: any) => Promise<T[]>
  transformer?: (data: T[]) => SelectOption<T>[]
  // ... 40+ 个类型安全的 props
}

// Emits 接口
export interface CmcSelectEmits<T = any> {
  'update:modelValue': [value: string | string[] | null]
  'change': [value: string | string[] | null, option?: SelectOption<T>]
  'visible-change': [visible: boolean]
  // ... 完整的事件类型
}

// 实例接口
export interface CmcSelectInstance<T = any> {
  getOptions: () => SelectOption<T>[]
  getSelectedOption: () => SelectOption<T> | SelectOption<T>[] | undefined
  refresh: () => Promise<void>
  clearCache: () => void
}

3. 类型推导

// 自动推导类型
const selectRef = ref<CmcSelectInstance<PortInfo>>()

// TypeScript 自动推导
const selected = selectRef.value?.getSelectedOption()
// selected 的类型为 SelectOption<PortInfo> | undefined

// 访问 meta 数据时有完整的类型提示
console.log(selected?.meta?.country)  // ✅ 类型安全

测试策略

1. 单元测试覆盖

我们编写了 90+ 个单元测试,覆盖所有核心功能:

// CmcSelect.spec.ts - 40+ 测试用例
describe('CmcSelect', () => {
  describe('Basic Features', () => {
    it('应该正确渲染组件', () => { /* ... */ })
    it('应该支持 v-model 双向绑定', () => { /* ... */ })
    it('应该正确处理多选模式', () => { /* ... */ })
  })

  describe('Style Variants', () => {
    it('应该正确应用 underline 变体', () => { /* ... */ })
    it('应该正确应用 transparent 变体', () => { /* ... */ })
  })

  describe('Events', () => {
    it('应该触发 change 事件', () => { /* ... */ })
    it('应该触发 loaded 事件', () => { /* ... */ })
  })

  describe('Advanced Features', () => {
    it('应该正确过滤 allowedValues', () => { /* ... */ })
    it('应该正确排除 excludeValues', () => { /* ... */ })
  })
})

// useSelectData.spec.ts - 50+ 测试用例
describe('useSelectData', () => {
  describe('Remote Data Source', () => {
    it('应该正确执行远程搜索', () => { /* ... */ })
    it('应该处理最小搜索长度限制', () => { /* ... */ })
    it('应该处理远程搜索错误', () => { /* ... */ })
  })

  describe('Caching', () => {
    it('应该缓存远程搜索结果', () => { /* ... */ })
    it('应该支持禁用缓存', () => { /* ... */ })
  })

  describe('Debouncing', () => {
    it('应该对远程搜索进行防抖', () => { /* ... */ })
  })
})

2. 测试覆盖率

File                  | % Stmts | % Branch | % Funcs | % Lines
----------------------|---------|----------|---------|--------
CmcSelect.vue         |   95.2  |   88.6   |   100   |  94.8
useSelectData.ts      |   92.8  |   85.3   |   95.5  |  92.1
presets/index.ts      |   100   |   100    |   100   |  100
types.ts              |   100   |   100    |   100   |  100
----------------------|---------|----------|---------|--------
All files             |   94.5  |   87.2   |   97.8  |  93.9

3. 关键测试场景

请求取消测试

it('应该取消之前的请求', async () => {
  const abortSpy = vi.spyOn(AbortController.prototype, 'abort')
  const fetcher = vi.fn().mockImplementation(() =>
    new Promise(resolve => setTimeout(() => resolve([]), 1000))
  )

  const { search } = useSelectData({
    dataSource: 'remote',
    fetcher,
  })

  await search('上海')
  await search('北京')  // 应该取消 "上海" 的请求

  expect(abortSpy).toHaveBeenCalled()
})

防抖测试

it('应该对远程搜索进行防抖', async () => {
  vi.useFakeTimers()
  const mockFetcher = vi.fn().mockResolvedValue([])

  const { remoteMethod } = useSelectData({
    dataSource: 'remote',
    fetcher: mockFetcher,
    debounceDelay: 300,
  })

  // 快速连续调用
  remoteMethod('a')
  remoteMethod('ab')
  remoteMethod('abc')

  // 300ms 内不应触发
  vi.advanceTimersByTime(200)
  expect(mockFetcher).not.toHaveBeenCalled()

  // 300ms 后应触发最后一次
  vi.advanceTimersByTime(100)
  expect(mockFetcher).toHaveBeenCalledTimes(1)
  expect(mockFetcher).toHaveBeenCalledWith({ keyWord: 'abc' })
})

迁移方案

1. 渐进式迁移策略

我们采用 4 阶段渐进式迁移,确保零风险:

阶段 1 (Week 1-2): 新功能强制使用
  ✅ 所有新开发功能使用 CmcSelect
  ✅ 建立最佳实践文档

阶段 2 (Week 3-4): 高频组件迁移
  ✅ CountrySelect → CmcSelect (preset="country")
  ✅ PortSelect → CmcSelect (preset="port")
  ✅ DictSelect → CmcSelect (preset="dict")
  ✅ 回归测试

阶段 3 (Week 5-6): 中低频组件迁移
  ✅ BaseDataSelect, HsCodeSelect 等
  ✅ 标记旧组件为 @deprecated

阶段 4 (Week 7-8): 清理与发布
  ✅ 移除旧组件
  ✅ 发布 v2.0.0

2. 迁移示例

简单迁移

<!-- ❌ 旧代码 -->
<CountrySelect v-model="country" />

<!-- ✅ 新代码 -->
<CmcSelect v-model="country" preset="country" />

复杂迁移

<!-- ❌ 旧代码 -->
<PortSelect
  v-model="port"
  :fetcher="searchPort"
  onlyShow
  labelShallow
  @on-select="handleSelect"
/>

<!-- ✅ 新代码 -->
<CmcSelect
  v-model="port"
  preset="port"
  :fetcher="searchPort"
  readonly
  variant="underline"
  @change="handleSelect"
/>

3. Props 映射表

旧 Props新 Props说明
onlyShowreadonly重命名
labelShallowvariant="underline"改为变体
prefixLightprefixLabelLight重命名
@on-select@change事件重命名
:change (prop)@change (event)改为事件

总结与展望

成果总结

通过这次重构,我们实现了:

  1. 代码减少 70%

    • 从 11 个组件 → 1 个统一组件
    • 从 3000+ 行代码 → 900 行核心代码
  2. 性能提升 40%

    • 首次渲染提升 30%
    • 数据加载提升 40%
    • API 调用减少 70%
  3. 开发效率提升

    • 学习成本降低 80%
    • 新功能开发提速 50%
    • Bug 修复效率提升 60%
  4. 代码质量提升

    • 测试覆盖率 94%+
    • 完整的 TypeScript 支持
    • 统一的 API 设计

技术亮点

  1. 请求取消机制:使用 AbortController 避免竞态条件
  2. 智能缓存:内存缓存 + TTL,命中率 60%+
  3. 防抖优化:减少 70% 的 API 调用
  4. 预设系统:一行代码完成配置
  5. 类型安全:完整的泛型支持

经验教训

  1. 不要过早抽象

    • 等到有 3+ 个相似组件时再考虑抽象
    • 先观察模式,再设计架构
  2. 渐进式迁移

    • 不要一次性重写所有代码
    • 新功能优先,旧代码逐步迁移
  3. 测试先行

    • 先写测试,再重构
    • 测试覆盖率是重构的安全网
  4. 文档同步

    • 代码和文档同步更新
    • 提供详细的迁移指南

未来展望

  1. 虚拟滚动

    • 支持 10000+ 选项的大列表
    • 使用虚拟滚动优化性能
  2. 无障碍支持

    • WCAG 2.1 AA 标准
    • 键盘导航优化
  3. 主题系统

    • CSS 变量支持
    • 暗色模式
  4. 独立发布

    • 发布为独立 npm 包
    • 支持其他项目使用

参考资料


关键词: Vue 3, TypeScript, 组件设计, 性能优化, 重构, 企业级应用

标签: #Vue3 #TypeScript #ComponentDesign #Performance #Refactoring