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

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

从数据格式适配到路由同步,深入复杂组件的设计要点与逻辑复用

一、引言

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

本文以 SelectPagination 为例,展示复杂组件的开发思路,涵盖:

  • 灵活的数据格式支持(对象数组、字符串数组、数字数组)
  • 自定义字段映射(labelKey / valueKey
  • 路由状态同步(页码、筛选条件与 URL 绑定)
  • 辅助功能(键盘翻页、移动端手势)
  • 逻辑抽离与复用(组合式函数)
  • 工业级细节:类型回溯、防御性编程、事件防污染、极端边界处理

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

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

2.1 需求分析

  • 支持对象数组 { label, value }(默认)
  • 支持自定义字段名(labelKey / valueKey
  • 支持字符串数组 ['选项A', '选项B']
  • 支持数字数组 [1, 2, 3]
  • 提供占位符(不可选中的默认选项)
  • 支持禁用选项(disabled: true
  • 支持错误状态、尺寸、禁用等常规属性
  • 必须解决原生 <select> 返回字符串的类型陷阱
  • 支持原生 <form> 提交(通过 name 属性)

2.2 API 设计与类型防腐

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

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

interface Props {
  modelValue?: string | number
  options?: SelectOption[]      // 联合类型,拒绝裸 any
  labelKey?: string             // 默认 'label'
  valueKey?: string             // 默认 'value'
  placeholder?: string
  name?: string                 // 支持原生 form 提交
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  error?: boolean
}

2.3 内部实现:防御性编程 + 类型回溯

<template>
  <select
    class="mg-select"
    :class="[`mg-select-${size}`, { 'mg-select-error': error }]"
    :value="modelValue"
    :disabled="disabled"
      :name="name"
    v-bind="$attrs"
    @change="handleChange"
  >
    <option v-if="placeholder" value="" disabled hidden>
      {{ placeholder }}
    </option>
    <option
      v-for="item in options || []"
      :key="getValue(item)"
      :value="getValue(item)"
      :disabled="item.disabled"
    >
      {{ getLabel(item) }}
    </option>
  </select>
</template>

<script setup lang="ts">
// ... 类型定义

const getLabel = (item: any): string => {
  if (item === null || item === undefined) return ''
  if (typeof item !== 'object') return String(item)
  const label = item[props.labelKey]
  return label !== undefined ? String(label) : String(item)
}

const getValue = (item: any): any => {
  if (item === null || item === undefined) return undefined
  if (typeof item !== 'object') return item
  const val = item[props.valueKey]
  return val !== undefined ? val : item
}

// 🔥 核心:解决原生 select 总是返回字符串的致命问题
const handleChange = (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

  emit('update:modelValue', finalValue)
  emit('change', finalValue)
}
</script>

防御性编程说明options || [] 确保即使外部传入 undefined 或延迟加载,组件也不会白屏。类型回溯逻辑保证了 v-model 绑定的数字值不会意外变成字符串。显式支持 name 属性,使 <select> 能够参与原生表单提交。

2.4 使用示例

<!-- 对象数组(默认字段) -->
<Select v-model="category" :options="categories" name="category" />

<!-- 自定义字段名 -->
<Select v-model="category" :options="cats" label-key="name" value-key="id" />

<!-- 字符串数组(类型自动保持) -->
<Select v-model="color" :options="['红', '绿', '蓝']" />

<!-- 数字数组 -->
<Select v-model="num" :options="[10, 20, 30]" />

<!-- 禁用选项 -->
<Select v-model="status" :options="statusOptions" />

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

分页组件需要与当前页码、每页条数配合,并提供上一页/下一页按钮,支持直接输入页码跳转。在 Nuxt 项目中往往需要将页码同步到 URL,并支持键盘左右键翻页(不干扰输入框)。

3.1 需求分析

  • 显示 当前页 / 总页数
  • 上一页/下一页按钮(边界禁用)
  • 点击当前页码变成输入框,支持直接输入跳转
  • 与路由 query 同步(页码变化时更新 URL)
  • 支持键盘左右键翻页(必须不干扰输入框
  • 极端边界防御(输入框清空、超出范围等)
  • 尺寸适配

3.2 API 设计

interface Props {
  currentPage: number       // v-model:current-page
  totalPages: number
  size?: 'sm' | 'md' | 'lg'
}

3.3 核心实现(含极端边界防御)

<template>
  <nav class="mg-pagination" :class="`mg-pagination-${size}`">
    <button class="mg-pagination-btn" :disabled="currentPage === 1" @click="goPrev">
      上一页
    </button>

    <span v-if="!editing" class="mg-pagination-current" @click="startEdit">
      {{ currentPage }}
    </span>
    <input
      v-else
      ref="inputRef"
      v-model="inputPage"
      type="number"
      class="mg-pagination-input"
      :min="1"
      :max="totalPages"
      @blur="commit"
      @keyup.enter="commit"
    />

    <span class="mg-pagination-sep">/</span>
    <span class="mg-pagination-total">{{ totalPages }}</span>

    <button class="mg-pagination-btn" :disabled="currentPage === totalPages" @click="goNext">
      下一页
    </button>
  </nav>
</template>

<script setup lang="ts">
import { ref, nextTick } from 'vue'

const props = defineProps<{ currentPage: number; totalPages: number; size?: string }>()
const emit = defineEmits<{ 'update:currentPage': [page: number] }>()

const editing = ref(false)
const inputPage = ref(props.currentPage)
const inputRef = ref<HTMLInputElement>()

const goPrev = () => { if (props.currentPage > 1) emit('update:currentPage', props.currentPage - 1) }
const goNext = () => { if (props.currentPage < props.totalPages) emit('update:currentPage', props.currentPage + 1) }

const startEdit = () => {
  editing.value = true
  inputPage.value = props.currentPage
  nextTick(() => inputRef.value?.focus())
}

const commit = () => {
  editing.value = false
  const raw = inputPage.value
  const pageNum = parseInt(String(raw), 10)

  // 非法输入直接放弃,不更新页码
  if (isNaN(pageNum)) return

  let page = Math.min(Math.max(pageNum, 1), props.totalPages)
  if (page !== props.currentPage) {
    emit('update:currentPage', page)
  }
}
</script>

3.4 键盘翻页逻辑(组合式函数)

为了保持组件纯净,将键盘事件抽离为 useKeyboardPagination,并确保不干扰输入框。

// composables/useKeyboardPagination.ts
import { onMounted, onUnmounted } from 'vue'

export function useKeyboardPagination(onPrev: () => void, onNext: () => void) {
  const handleKeyDown = (e: KeyboardEvent) => {
    // 🔥 关键:如果焦点在输入框或文本域内,不触发翻页
    const target = e.target as HTMLElement
    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return

    if (e.key === 'ArrowLeft') onPrev()
    if (e.key === 'ArrowRight') onNext()
  }

  onMounted(() => window.addEventListener('keydown', handleKeyDown))
  onUnmounted(() => window.removeEventListener('keydown', handleKeyDown))
}

3.5 路由同步与全能 Composable

为了展示“一键装配”的优雅,我们将页码路由同步、键盘事件、翻页方法整合为一个全能组合式函数。

// composables/useMoongatePagination.ts
import { ref, type Ref } from 'vue'
import { useRouteQueryNumber } from './useRouteQuery'
import { useKeyboardPagination } from './useKeyboardPagination'

export function useMoongatePagination(totalPages: Ref<number>) {
  const page = useRouteQueryNumber('page', { defaultValue: 1 })

  const goPrev = () => { if (page.value > 1) page.value-- }
  const goNext = () => { if (page.value < totalPages.value) page.value++ }

  // 内部直接装配键盘事件
  useKeyboardPagination(goPrev, goNext)

  return { page, goPrev, goNext }
}

在业务页面中使用:

<script setup>
const totalPages = ref(10)
const { page, goPrev, goNext } = useMoongatePagination(totalPages)
// 无需再单独处理键盘事件,一切自动完成
</script>

<template>
  <Pagination v-model:current-page="page" :total-pages="totalPages" />
  <!-- 你也可以自定义按钮,与分页组件状态联动 -->
  <button @click="goPrev">上一页</button>
  <button @click="goNext">下一页</button>
</template>

四、复杂组件的数据与状态流向

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

五、复杂组件的测试策略

关注点简单组件(Button)复杂组件(Select / Pagination)
测试策略快照测试、简单的 Click 事件触发状态快照组合、边界值测试(第0页/超出总页数)、DOM 聚焦与键盘模拟测试、类型回溯健壮性测试

六、总结

关注点简单组件(Button)复杂组件(Select / Pagination)
Props 数量较少(5-8)较多(10+)
数据格式固定(字符串)灵活(支持多种数组,可配置字段,类型防腐)
状态管理无内部状态可能有内部编辑状态、输入框显隐、极端边界防御
外部依赖路由、键盘事件、手势
逻辑复用不需要强烈推荐抽离 composable,并可整合全能函数
防御性编程高(需处理 undefined 数据、类型回溯、清空输入框恢复)
原生表单集成自动透传 name显式支持 name 属性,可参与 <form> 提交
测试策略快照、事件触发状态组合、边界值、键盘模拟、类型回溯

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

七、相关文章

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