Vue 3 复杂组件开发实战:Select 与 Pagination 的 API 设计与状态管理
从数据格式适配到路由同步,深入复杂组件的设计要点与逻辑复用
一、引言
如果说写 Button 组件是在享受写 CSS 变量的“涂料之美”,那么写 Select 和 Pagination 就是在应对原生 HTML 历史包袱的“泥潭摔跤”。简单组件是单向的数据消费者,而复杂组件则是数据适配器(兼容多格式)与状态同步器(协同路由与键盘)。
本文以 Select 和 Pagination 为例,展示复杂组件的开发思路,涵盖:
- 灵活的数据格式支持(对象数组、字符串数组、数字数组)
- 自定义字段映射(
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 映射和类型回溯),对外要像绅士一样克制地与全局环境(路由、窗口键盘)发生耦合。高内聚、低耦合,在这两类组件身上体现得淋漓尽致。

