// Created At 2026-05-19// P3
// Vue · Design System · Engineering

Vue 3 复杂组件开发实战:Select 与 Pagination 的 API 设计与状态管理

从数据格式适配到可搜索/多选,深入复杂组件的设计要点与逻辑复用

📚 系列导航

本系列共五篇,覆盖从设计令牌到 npm 发布的 Vue 3 组件库开发全流程:

  1. 设计令牌 vs 原子化 CSS:失败整合与融合之道(理念篇) —— 用 UnoCSS 映射设计令牌的失败经历,量化对比后得出设计令牌优先的结论。

  2. CSS 优先 + 组件薄封装:一个 25KB 组件库的极简实践(架构篇) —— 四层 CSS 架构、极简 Vue 组件、Vite 多入口构建、体积预算验证,单组件极简实现。

  3. Vue 3 简单组件开发实战:从 Button 组件看 API 设计(简单组件篇) —— Props 定义、变体系统、尺寸取舍、插槽设计、状态管理、无障碍支持及与主流 UI 库对比。

  4. Vue 3 复杂组件开发实战:Select 与 Pagination 的 API 设计(复杂组件篇) —— 数据格式适配、类型回溯、可搜索/多选、ARIA 键盘导航、组合式函数抽离及 SSR 适配,揭示工业级细节。

  5. 从代码到 npm:Vue 3 组件库发布实战与避坑指南(发布篇) —— nrm 源管理、2FA 配置、WebAuthn 网络代理避坑、本地链接测试、自动化脚本及工业级发布检查清单。

一、引言

如果说写 Button 组件是在享受写 CSS 变量的”涂料之美”,那么写 Select 和 Pagination 就是在应对原生 HTML 历史包袱的”泥潭摔跤”。简单组件是单向的数据消费者,而复杂组件则是数据适配器(兼容多格式)与状态协调器(可搜索、多选、键盘导航、SSR 安全)。

本文以 SelectPagination 为例,展示 v1.5.0 实际实现的复杂组件开发思路。

二、Select 下拉选择框:数据适配器

Select 组件需要接收一组选项,并允许用户选择其中一个。真实世界的 API 可能返回对象数组、字符串数组甚至数字数组,因此组件必须具备强大的数据适配能力。

2.1 需求分析

  • 支持对象数组 { label, value }(默认)
  • 支持自定义字段名(labelKey / valueKey
  • 支持字符串数组 ['选项A', '选项B']
  • 支持数字数组 [1, 2, 3]
  • 提供占位符(不可选中的默认选项)
  • 支持禁用选项(disabled: true
  • 可搜索模式(filterable):输入过滤 + 下拉面板
  • 多选模式(multiple + filterable):标签展示
  • 必须解决原生 <select> 返回字符串的类型陷阱
  • 完整 ARIA 键盘导航(listbox + option + aria-activedescendant)

2.2 双模式架构:原生模式 + 可搜索模式

v1.5.0 的 Select 支持双模式

  • 原生模式(默认):渲染原生 <select>,零 JS 开销
  • 可搜索模式filterable=true):渲染自定义输入框 + 下拉面板,支持搜索/多选/键盘导航
<!-- 原生模式:性能最优 -->
<Select v-model="category" :options="categories" />

<!-- 可搜索模式:过滤 + 下拉 -->
<Select v-model="fruit" :options="fruits" filterable />

<!-- 可搜索 + 多选 -->
<Select v-model="tags" :options="tags" filterable multiple />

2.3 API 设计与类型防腐

为了避免 any 造成类型污染,我们采用联合类型收窄:

export type SelectValue = string | number
export type SelectOption = string | number | Record<string, any>

interface Props {
  options?: SelectOption[]
  placeholder?: string
  size?: Size
  disabled?: boolean
  error?: boolean
  labelKey?: string // 默认 'label'
  valueKey?: string // 默认 'value'
  filterable?: boolean // 可搜索模式
  emptyText?: string
  maxHeight?: number // 下拉面板最大高度
  multiple?: boolean // 多选(需 filterable)
}

类型回溯解决原生 select 总是返回字符串的问题:

const handleNativeChange = (event: Event) => {
  const target = event.target as HTMLSelectElement
  const rawValue = target.value

  // 在原始 options 中找回原始类型(数字或对象值)
  const originalItem = props.options?.find(
    (item) => String(getValue(item)) === rawValue,
  )
  const finalValue = originalItem !== undefined ? getValue(originalItem) : rawValue

  modelValue.value = finalValue
  emit('change', finalValue)
}

这个逻辑保证 v-model 绑定的数字值不会意外变成字符串。

2.4 属性透传拆分

这是可搜索模式的关键细节——哪些属性透传到原生 input,哪些保留在外层 wrapper

const attrs = useAttrs()

/** 透传到原生表单元素的 form/aria 属性 */
const formAttrs = computed(() => {
  const result: Record<string, unknown> = {}
  for (const [key, value] of Object.entries(attrs)) {
    if (key.startsWith('aria-') || ['name', 'id', 'role', 'tabindex'].includes(key)) {
      result[key] = value
    }
  }
  return result
})

/** 保留在外层 wrapper 的其余属性(class/style/事件等) */
const wrapperAttrs = computed(() => {
  const result: Record<string, unknown> = {}
  for (const [key, value] of Object.entries(attrs)) {
    if (!(key in formAttrs.value)) {
      result[key] = value
    }
  }
  return result
})

为什么必须拆分?如果 aria-label 留在外层 wrapper 而未透传到实际 <input>,屏幕阅读器会无法识别输入框的可访问名称——在 a11y.test.ts 的 axe-core 检查中会报 aria-input-field-name 违规。

2.5 ARIA 键盘导航(WAI-ARIA Combobox 模式)

可搜索模式的键盘导航遵循 WAI-ARIA Combobox 模式:

<!-- 下拉面板 -->
<div
  v-if="isOpen"
  ref="dropdownRef"
  class="mg-select-dropdown"
  role="listbox"
  :aria-label="listboxAriaLabel"
  :aria-activedescendant="focusedIndex >= 0 ? getOptionId(focusedIndex) : undefined"
>
  <!-- 选项 -->
  <div
    v-for="(item, index) in filteredOptions"
    :id="getOptionId(index)"
    role="option"
    :aria-selected="isSelected(item)"
    :class="{ 'mg-select-option-focused': focusedIndex === index }"
    @click="selectOption(item)"
    @mouseenter="focusedIndex = index"
  >

支持的操作: - ArrowDown / ArrowUp:移动高亮(focusedIndex),并 scrollIntoView 保持可视 - Enter:选中当前高亮选项 - Esc:关闭下拉 - 每个选项有唯一 id(基于 useId()),供 aria-activedescendant 引用

2.6 多选模式

多选(multiple + filterable)将 modelValue 变为数组:

const modelValue = defineModel<SelectValue | SelectValue[]>({ default: '' })

// 多选时:切换选中
const selectOption = (item: SelectOption) => {
  if (isOptionDisabled(item)) return
  const value = getValue(item)

  if (props.multiple) {
    const current = multipleValues.value
    const isAlreadySelected = current.some((v) => String(v) === String(value))
    const next = isAlreadySelected
      ? current.filter((v) => String(v) !== String(value))
      : [...current, value]

    modelValue.value = next
    // 多选保持下拉打开,方便连续多选
    searchText.value = ''
    focusedIndex.value = -1
    nextTick(() => inputRef.value?.focus())
    return
  }

  // 单选:选择后关闭
  modelValue.value = value
  closeDropdown()
}

多选时: - 已选项渲染为标签(tag),每个标签有 aria-label="移除 {label}" 的删除按钮 - 选择后下拉保持打开(方便连续多选) - 输入框只显示搜索文本,已选标签在外部

三、Pagination 分页组件:状态同步器

3.1 API 设计(v1.5.0 实际)

interface Props {
  totalPages: number       // 总页数(必传)
  modelValue: number       // 当前页码(v-model)
  size?: "sm" | "md" | "lg"
  showQuickJump?: boolean  // 首尾页快速跳转按钮(默认 true)
  prevText?: string        // 上一页文案(走全局 i18n)
  nextText?: string
  firstText?: string
  lastText?: string
}

Pagination 使用 defineModel<number> 绑定当前页:

const currentPage = defineModel<number>({ required: true })

3.2 快速跳转 + 页码编辑

<!-- 显示模式:可点击的数字 → 进入编辑 -->
<span v-else class="mg-pagination-current" @click="startEdit">
  {{ currentPage }}
</span>

<!-- 编辑模式:输入框 -->
<input
  v-if="isEditing"
  v-model="inputPage"
  type="number"
  :min="1"
  :max="totalPages"
  @blur="commitJump"
  @keyup.enter="commitJump"
/>

commitJump 的极端边界防御:

const commitJump = () => {
  isEditing.value = false
  const newPage = parseInt(String(inputPage.value), 10)

  // 非法输入:放弃并恢复
  if (isNaN(newPage)) {
    inputPage.value = currentPage.value
    return
  }

  goToPage(newPage) // clamp 到 [1, totalPages]
}

const goToPage = (page: number) => {
  let newPage = page
  if (newPage < 1) newPage = 1
  if (newPage > props.totalPages) newPage = props.totalPages
  if (newPage === currentPage.value) return
  currentPage.value = newPage
  emit('change', newPage)
}

3.3 全局文案(i18n)

v1.5.0 加入了全局文案系统。Pagination 的所有 aria-label 和按钮文字都走配置链:

const texts = useTexts() // 响应式全局文案

const prevTextValue = computed(() => props.prevText ?? texts.value.paginationPrev)
const pageInfoLabel = computed(() =>
  formatTemplate(texts.value.paginationPageInfo, {
    current: currentPage.value,
    total: props.totalPages,
  }),
)

优先级:组件 prop > setConfig texts > 语言内置文案。文案支持 {current}/{total} 模板占位符。

四、组合式函数抽离

复杂组件往往需要抽离共享逻辑。v1.5.0 的核心 composables:

4.1 useFormField(Input/Textarea 共享)

处理 v-model 更新 + 原生事件透传:

export function useFormField(modelValue, emit) {
  const handleInput = (event: Event) => {
    modelValue.value = (event.target as HTMLInputElement).value
    emit('input', event)
  }
  // change/focus/blur 原生事件透传
  return { handleInput, handleChange, handleBlur, handleFocus }
}

4.2 useFloating(Popover/Tooltip 共享)

自研悬浮层定位引擎:视口翻转 + 边界修正 + ResizeObserver:

export function useFloating(options: UseFloatingOptions) {
  // 延迟显示/隐藏
  // 位置计算(按方向定位 + 视口翻转)
  // 滚动/窗口尺寸变化时重新定位
  // ResizeObserver 仅监听悬浮层自身尺寸
  // SSR 安全(isBrowser 守卫)
  return { triggerRef, floatingRef, visible, currentPlacement, floatStyle, show, hide, ... }
}

4.3 useOverlayBehavior(Modal/Drawer 共享,位于 useScrollLock.ts)

滚动锁定 + ESC 关闭 + 焦点陷阱:

// composables/useScrollLock.ts
export function useOverlayBehavior(isOpen, overlayRef, onClose, options) {
  // body 滚动锁定(模块级 lockCount 计数器,多实例安全)
  // ESC 键关闭
  // Tab 焦点陷阱
}

该函数与滚动锁定逻辑(lockBodyScroll/unlockBodyScroll)一同定义在 useScrollLock.ts 中——滚动锁、ESC 关闭、焦点陷阱三者在语义上同属”浮层行为”这一关注点,因此放在同一个文件内。

模块级锁计数解决多 Modal/Drawer 同时打开的滚动锁冲突——只有最后一个关闭时才恢复 body 滚动。

五、SSR 适配:useId 与 isBrowser

v1.5.0 的 SSR 适配比初版更完善,核心是两条:

5.1 useId() 保证 hydration 安全

所有需要 id 的组件(Modal/Drawer/Select/Tabs)使用 Vue 3 的 useId()

const selectBaseId = useId()
const getOptionId = (index: number): string => `${selectBaseId}-option-${index}`

useId() 在服务端与客户端生成一致的 ID,避免 hydration mismatch。

5.2 isBrowser 守卫

所有 DOM 操作添加浏览器环境守卫:

const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'

// 在 watch/onMounted/顶层代码中:
if (!isBrowser) return

配合 useScrollLock.ts 的模块级计数器,lockBodyScroll() 在非浏览器环境下直接跳过 DOM 操作。

5.3 createOverlay:命令式组件 SSR 安全

// composables/createOverlay.ts
export function createOverlay(component, props, containerClass) {
  if (!isBrowser) return null // SSR 返回 null
  // ...
}

六、数据与状态流向

┌─────────────────────────────────────────────────────────┐
│ 复杂组件数据流 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 外部数据 │ -> │ 数据适配器 │ -> │ 内部状态 │ │
│ │ (options) │ │ (getLabel/ │ │ (selected/ │ │
│ └─────────────┘ │ getValue) │ │ searchText) │ │
│ └─────────────┘ └──────┬──────┘ │
│ ↓ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 全局环境 │ <- │ 状态协调器 │ <- │ 用户交互 │ │
│ │ (i18n/SSR) │ │ (watch/event)│ │ (click/ │ │
│ └─────────────┘ └─────────────┘ │ keyboard) │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────┘

七、测试策略

v1.5.0 的 Select 有 40+ 测试,Pagination 有 14 测试,覆盖:

关注点 测试内容
类型回溯 原生模式数字数组 [10,20,30] 选中后 modelValue 仍为 number
ARIA 导航 aria-activedescendant 指向高亮选项、每个 option 有唯一 id
键盘操作 ArrowDown/Up 高亮、Enter 选中、Esc 关闭、边界不越界
多选 标签渲染、切换选中、tag 删除、Enter 连续多选
边界值 搜索空结果、外部 modelValue 变化、blur 时下拉保持打开
无障碍 axe-core 对 Select(原生+可搜索)无违规

以及 SSR 检查renderToString 确认组件在服务端不崩溃且浮层默认隐藏。

八、总结

关注点 简单组件(Button) 复杂组件(Select / Pagination)
Props 数量 较少(11) 较多(10-15)
数据格式 固定(字符串) 灵活(支持多种数组,可配置字段,类型防腐)
状态管理 无内部状态 可搜索文本、多选数组、编辑状态、下拉显隐
无障碍 原生语义 WAI-ARIA Combobox 模式(listbox/option/activedescendant)
逻辑复用 不需要 组合式函数(useFormField/useFloating/useOverlayBehavior)
SSR 适配 自动 useId hydration 安全 + isBrowser 守卫
i18n 少数文案 全局配置链(prop > setConfig > 内置)
测试策略 快照、事件触发 状态组合、边界值、键盘模拟、类型回溯、axe-core

一个优秀的复杂组件,对内要像吸尘器一样容纳各种奇葩的后端数据格式(通过 Key 映射和类型回溯),对外要像绅士一样克制地与全局环境(i18n、SSR、键盘)发生耦合。高内聚、低耦合,在这两类组件身上体现得淋漓尽致。

如果这篇文章对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay
© 2026 MOONGATE