为什么表单设计如此重要
表单是用户与应用交互的核心入口。无论是注册登录、数据录入还是搜索筛选,表单无处不在。一个设计良好的表单系统能够:
- 提升用户体验 - 清晰的布局、即时的反馈让用户操作顺畅
- 减少错误率 - 合理的校验和引导帮助用户正确填写
- 提高转化率 - 简洁高效的表单增加用户完成率
- 降低开发成本 - 统一的组件复用减少重复开发
本文将从设计原则到代码实现,全面解析如何构建专业的表单组件系统。
表单设计原则
核心设计理念
┌─────────────────────────────────────────────────┐
│ 表单设计金字塔 │
├─────────────────────────────────────────────────┤
│ 可访问性 │
│ (Accessibility) │
│ ───────────────────────── │
│ 一致性 (Consistency) │
│ ───────────────────────────── │
│ 即时反馈 (Immediate Feedback) │
│ ─────────────────────────────────── │
│ 渐进式披露 (Progressive Disclosure) │
│ ─────────────────────────────────────────── │
│ 简洁性 (Simplicity) │
└─────────────────────────────────────────────────┘
1. 简洁性原则
只收集必要信息,减少用户认知负担:
<!-- ❌ 过度收集信息 -->
<form>
<input name="firstName" />
<input name="middleName" />
<input name="lastName" />
<input name="nickname" />
<input name="title" />
</form>
<!-- ✅ 精简必要字段 -->
<form>
<input name="name" placeholder="姓名" />
<input name="email" placeholder="邮箱" />
</form>
2. 一致性原则
统一的视觉语言和交互模式:
| 元素 | 统一规范 |
|---|---|
| 标签位置 | 全局统一(顶部/左侧) |
| 必填标识 | 统一使用红色星号 |
| 错误提示 | 统一位置和样式 |
| 按钮顺序 | 主按钮在右,取消在左 |
| 间距 | 遵循8px网格系统 |
3. 即时反馈原则
用户操作后立即给予视觉反馈:
// 反馈类型和时机
const feedbackStrategy = {
// 输入时 - 字符计数、密码强度
onInput: ['characterCount', 'passwordStrength'],
// 失焦时 - 格式校验
onBlur: ['emailFormat', 'phoneFormat'],
// 提交时 - 完整性校验
onSubmit: ['requiredFields', 'businessRules'],
}
表单组件体系
组件分层架构
┌─────────────────────────────────────────────────┐
│ 应用层 │
│ LoginForm RegisterForm SearchForm │
├─────────────────────────────────────────────────┤
│ 组合层 │
│ FormField FormGroup FormSection │
├─────────────────────────────────────────────────┤
│ 基础层 │
│ Input Select Checkbox Radio Switch │
├─────────────────────────────────────────────────┤
│ 原子层 │
│ Label HelpText ErrorMessage Icon │
└─────────────────────────────────────────────────┘
原子组件设计
Label 标签组件
<!-- components/form/FormLabel.vue -->
<script setup lang="ts">
interface Props {
for?: string
required?: boolean
optional?: boolean
tooltip?: string
}
const props = withDefaults(defineProps<Props>(), {
required: false,
optional: false,
})
</script>
<template>
<label
:for="props.for"
class="form-label"
>
<slot />
<!-- 必填标识 -->
<span v-if="required" class="form-label__required">*</span>
<!-- 可选标识 -->
<span v-if="optional" class="form-label__optional">(可选)</span>
<!-- 提示图标 -->
<Tooltip v-if="tooltip" :content="tooltip">
<Icon name="info" class="form-label__tooltip" />
</Tooltip>
</label>
</template>
<style scoped>
.form-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 6px;
}
.form-label__required {
color: var(--color-error);
}
.form-label__optional {
font-weight: 400;
color: var(--color-text-secondary);
}
.form-label__tooltip {
width: 14px;
height: 14px;
color: var(--color-text-tertiary);
cursor: help;
}
</style>
HelpText 帮助文本
<!-- components/form/FormHelpText.vue -->
<script setup lang="ts">
interface Props {
type?: 'info' | 'warning' | 'error' | 'success'
}
const props = withDefaults(defineProps<Props>(), {
type: 'info',
})
const typeClass = computed(() => `form-help-text--${props.type}`)
</script>
<template>
<p :class="['form-help-text', typeClass]">
<Icon v-if="type === 'error'" name="alert-circle" />
<Icon v-if="type === 'warning'" name="alert-triangle" />
<Icon v-if="type === 'success'" name="check-circle" />
<slot />
</p>
</template>
<style scoped>
.form-help-text {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 12px;
margin-top: 6px;
line-height: 1.5;
}
.form-help-text--info {
color: var(--color-text-secondary);
}
.form-help-text--error {
color: var(--color-error);
}
.form-help-text--warning {
color: var(--color-warning);
}
.form-help-text--success {
color: var(--color-success);
}
</style>
基础输入组件
通用 Input 组件
<!-- components/form/FormInput.vue -->
<script setup lang="ts">
interface Props {
modelValue?: string | number
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'
placeholder?: string
disabled?: boolean
readonly?: boolean
size?: 'sm' | 'md' | 'lg'
status?: 'default' | 'error' | 'success' | 'warning'
prefix?: string
suffix?: string
clearable?: boolean
maxlength?: number
showCount?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
size: 'md',
status: 'default',
clearable: false,
showCount: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string | number]
'focus': [event: FocusEvent]
'blur': [event: FocusEvent]
'clear': []
}>()
const inputRef = ref<HTMLInputElement>()
const isFocused = ref(false)
// 输入值
const inputValue = computed({
get: () => props.modelValue ?? '',
set: (val) => emit('update:modelValue', val),
})
// 字符计数
const charCount = computed(() => String(inputValue.value).length)
// 样式类
const inputClasses = computed(() => [
'form-input',
`form-input--${props.size}`,
`form-input--${props.status}`,
{
'form-input--focused': isFocused.value,
'form-input--disabled': props.disabled,
'form-input--readonly': props.readonly,
'form-input--has-prefix': props.prefix,
'form-input--has-suffix': props.suffix || props.clearable,
},
])
// 清除内容
const handleClear = () => {
inputValue.value = ''
emit('clear')
inputRef.value?.focus()
}
// 焦点事件
const handleFocus = (e: FocusEvent) => {
isFocused.value = true
emit('focus', e)
}
const handleBlur = (e: FocusEvent) => {
isFocused.value = false
emit('blur', e)
}
// 暴露方法
defineExpose({
focus: () => inputRef.value?.focus(),
blur: () => inputRef.value?.blur(),
select: () => inputRef.value?.select(),
})
</script>
<template>
<div :class="inputClasses">
<!-- 前缀 -->
<span v-if="prefix" class="form-input__prefix">
{{ prefix }}
</span>
<!-- 输入框 -->
<input
ref="inputRef"
v-model="inputValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
class="form-input__native"
@focus="handleFocus"
@blur="handleBlur"
/>
<!-- 清除按钮 -->
<button
v-if="clearable && inputValue"
type="button"
class="form-input__clear"
@click="handleClear"
>
<Icon name="x" />
</button>
<!-- 后缀 -->
<span v-if="suffix" class="form-input__suffix">
{{ suffix }}
</span>
<!-- 字符计数 -->
<span v-if="showCount && maxlength" class="form-input__count">
{{ charCount }}/{{ maxlength }}
</span>
</div>
</template>
<style scoped>
.form-input {
display: inline-flex;
align-items: center;
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
transition: all 0.2s ease;
}
/* 尺寸变体 */
.form-input--sm {
height: 32px;
padding: 0 10px;
font-size: 13px;
}
.form-input--md {
height: 40px;
padding: 0 12px;
font-size: 14px;
}
.form-input--lg {
height: 48px;
padding: 0 16px;
font-size: 16px;
}
/* 状态变体 */
.form-input--focused {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.form-input--error {
border-color: var(--color-error);
}
.form-input--error.form-input--focused {
box-shadow: 0 0 0 3px var(--color-error-light);
}
.form-input--success {
border-color: var(--color-success);
}
.form-input--disabled {
background: var(--color-bg-disabled);
cursor: not-allowed;
}
/* 原生输入框 */
.form-input__native {
flex: 1;
border: none;
outline: none;
background: transparent;
font: inherit;
color: var(--color-text-primary);
}
.form-input__native::placeholder {
color: var(--color-text-placeholder);
}
.form-input__native:disabled {
cursor: not-allowed;
}
/* 前后缀 */
.form-input__prefix,
.form-input__suffix {
color: var(--color-text-secondary);
white-space: nowrap;
}
.form-input__prefix {
margin-right: 8px;
}
.form-input__suffix {
margin-left: 8px;
}
/* 清除按钮 */
.form-input__clear {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: 8px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.form-input__clear:hover {
background: var(--color-bg-quaternary);
color: var(--color-text-primary);
}
/* 字符计数 */
.form-input__count {
margin-left: 8px;
font-size: 12px;
color: var(--color-text-tertiary);
}
</style>
Select 选择器
<!-- components/form/FormSelect.vue -->
<script setup lang="ts">
interface Option {
value: string | number
label: string
disabled?: boolean
}
interface Props {
modelValue?: string | number | null
options: Option[]
placeholder?: string
disabled?: boolean
clearable?: boolean
searchable?: boolean
size?: 'sm' | 'md' | 'lg'
status?: 'default' | 'error' | 'success'
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
status: 'default',
clearable: false,
searchable: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string | number | null]
'change': [value: string | number | null]
}>()
const isOpen = ref(false)
const searchQuery = ref('')
const highlightedIndex = ref(-1)
// 当前选中项
const selectedOption = computed(() =>
props.options.find(opt => opt.value === props.modelValue)
)
// 过滤选项
const filteredOptions = computed(() => {
if (!props.searchable || !searchQuery.value) {
return props.options
}
const query = searchQuery.value.toLowerCase()
return props.options.filter(opt =>
opt.label.toLowerCase().includes(query)
)
})
// 选择选项
const selectOption = (option: Option) => {
if (option.disabled) return
emit('update:modelValue', option.value)
emit('change', option.value)
isOpen.value = false
searchQuery.value = ''
}
// 清除选择
const handleClear = (e: Event) => {
e.stopPropagation()
emit('update:modelValue', null)
emit('change', null)
}
// 键盘导航
const handleKeydown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
if (!isOpen.value) {
isOpen.value = true
} else {
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
filteredOptions.value.length - 1
)
}
break
case 'ArrowUp':
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
break
case 'Enter':
e.preventDefault()
if (isOpen.value && highlightedIndex.value >= 0) {
selectOption(filteredOptions.value[highlightedIndex.value])
} else {
isOpen.value = true
}
break
case 'Escape':
isOpen.value = false
break
}
}
// 点击外部关闭
onClickOutside(ref(null), () => {
isOpen.value = false
})
</script>
<template>
<div
class="form-select"
:class="[
`form-select--${size}`,
`form-select--${status}`,
{ 'form-select--open': isOpen, 'form-select--disabled': disabled }
]"
@keydown="handleKeydown"
>
<!-- 触发器 -->
<div
class="form-select__trigger"
tabindex="0"
@click="!disabled && (isOpen = !isOpen)"
>
<!-- 搜索框 -->
<input
v-if="searchable && isOpen"
v-model="searchQuery"
class="form-select__search"
placeholder="搜索..."
@click.stop
/>
<!-- 显示值 -->
<span v-else-if="selectedOption" class="form-select__value">
{{ selectedOption.label }}
</span>
<!-- 占位符 -->
<span v-else class="form-select__placeholder">
{{ placeholder }}
</span>
<!-- 清除按钮 -->
<button
v-if="clearable && selectedOption"
type="button"
class="form-select__clear"
@click="handleClear"
>
<Icon name="x" />
</button>
<!-- 箭头图标 -->
<Icon
name="chevron-down"
class="form-select__arrow"
:class="{ 'form-select__arrow--rotated': isOpen }"
/>
</div>
<!-- 下拉菜单 -->
<Transition name="dropdown">
<div v-if="isOpen" class="form-select__dropdown">
<div
v-for="(option, index) in filteredOptions"
:key="option.value"
class="form-select__option"
:class="{
'form-select__option--selected': option.value === modelValue,
'form-select__option--highlighted': index === highlightedIndex,
'form-select__option--disabled': option.disabled,
}"
@click="selectOption(option)"
@mouseenter="highlightedIndex = index"
>
{{ option.label }}
<Icon
v-if="option.value === modelValue"
name="check"
class="form-select__check"
/>
</div>
<!-- 无结果 -->
<div v-if="filteredOptions.length === 0" class="form-select__empty">
无匹配结果
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.form-select {
position: relative;
width: 100%;
}
.form-select__trigger {
display: flex;
align-items: center;
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.form-select--md .form-select__trigger {
height: 40px;
padding: 0 12px;
font-size: 14px;
}
.form-select--open .form-select__trigger {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.form-select__value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-select__placeholder {
flex: 1;
color: var(--color-text-placeholder);
}
.form-select__arrow {
width: 16px;
height: 16px;
color: var(--color-text-secondary);
transition: transform 0.2s ease;
}
.form-select__arrow--rotated {
transform: rotate(180deg);
}
.form-select__dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 1000;
}
.form-select__option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s ease;
}
.form-select__option--highlighted {
background: var(--color-bg-secondary);
}
.form-select__option--selected {
color: var(--color-primary);
font-weight: 500;
}
.form-select__option--disabled {
color: var(--color-text-disabled);
cursor: not-allowed;
}
.form-select__check {
width: 16px;
height: 16px;
color: var(--color-primary);
}
.form-select__empty {
padding: 20px;
text-align: center;
color: var(--color-text-secondary);
}
/* 下拉动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
组合组件
FormField 表单字段
<!-- components/form/FormField.vue -->
<script setup lang="ts">
interface Props {
label?: string
name?: string
required?: boolean
optional?: boolean
tooltip?: string
helpText?: string
error?: string
showError?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showError: true,
})
const hasError = computed(() => props.showError && !!props.error)
</script>
<template>
<div
class="form-field"
:class="{ 'form-field--error': hasError }"
>
<!-- 标签 -->
<FormLabel
v-if="label"
:for="name"
:required="required"
:optional="optional"
:tooltip="tooltip"
>
{{ label }}
</FormLabel>
<!-- 输入控件插槽 -->
<slot />
<!-- 帮助文本或错误提示 -->
<FormHelpText v-if="hasError" type="error">
{{ error }}
</FormHelpText>
<FormHelpText v-else-if="helpText" type="info">
{{ helpText }}
</FormHelpText>
</div>
</template>
<style scoped>
.form-field {
margin-bottom: 20px;
}
.form-field--error :deep(.form-input) {
border-color: var(--color-error);
}
.form-field--error :deep(.form-select__trigger) {
border-color: var(--color-error);
}
</style>
FormGroup 表单分组
<!-- components/form/FormGroup.vue -->
<script setup lang="ts">
interface Props {
title?: string
description?: string
collapsible?: boolean
defaultCollapsed?: boolean
}
const props = withDefaults(defineProps<Props>(), {
collapsible: false,
defaultCollapsed: false,
})
const isCollapsed = ref(props.defaultCollapsed)
const toggle = () => {
if (props.collapsible) {
isCollapsed.value = !isCollapsed.value
}
}
</script>
<template>
<fieldset class="form-group">
<!-- 标题区 -->
<legend
v-if="title"
class="form-group__header"
:class="{ 'form-group__header--clickable': collapsible }"
@click="toggle"
>
<span class="form-group__title">{{ title }}</span>
<Icon
v-if="collapsible"
name="chevron-down"
class="form-group__icon"
:class="{ 'form-group__icon--collapsed': isCollapsed }"
/>
</legend>
<!-- 描述 -->
<p v-if="description && !isCollapsed" class="form-group__description">
{{ description }}
</p>
<!-- 内容区 -->
<Transition name="collapse">
<div v-show="!isCollapsed" class="form-group__content">
<slot />
</div>
</Transition>
</fieldset>
</template>
<style scoped>
.form-group {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 24px;
}
.form-group__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.form-group__header--clickable {
cursor: pointer;
}
.form-group__title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.form-group__icon {
width: 20px;
height: 20px;
transition: transform 0.2s ease;
}
.form-group__icon--collapsed {
transform: rotate(-90deg);
}
.form-group__description {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: 16px;
}
.form-group__content {
display: flex;
flex-direction: column;
}
/* 折叠动画 */
.collapse-enter-active,
.collapse-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.collapse-enter-from,
.collapse-leave-to {
opacity: 0;
max-height: 0;
}
</style>
表单校验系统
校验规则设计
// composables/useFormValidation.ts
// 校验规则类型
type ValidationRule = {
validator: (value: any, formData?: Record<string, any>) => boolean
message: string
trigger?: 'blur' | 'change' | 'submit'
}
// 内置校验规则
export const validators = {
// 必填
required: (message = '此字段为必填项'): ValidationRule => ({
validator: (value) => {
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'string') return value.trim().length > 0
return value !== null && value !== undefined
},
message,
trigger: 'blur',
}),
// 邮箱格式
email: (message = '请输入有效的邮箱地址'): ValidationRule => ({
validator: (value) => {
if (!value) return true // 空值交给 required 校验
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return pattern.test(value)
},
message,
trigger: 'blur',
}),
// 手机号格式
phone: (message = '请输入有效的手机号'): ValidationRule => ({
validator: (value) => {
if (!value) return true
const pattern = /^1[3-9]\d{9}$/
return pattern.test(value)
},
message,
trigger: 'blur',
}),
// 最小长度
minLength: (min: number, message?: string): ValidationRule => ({
validator: (value) => {
if (!value) return true
return String(value).length >= min
},
message: message || `至少需要 ${min} 个字符`,
trigger: 'blur',
}),
// 最大长度
maxLength: (max: number, message?: string): ValidationRule => ({
validator: (value) => {
if (!value) return true
return String(value).length <= max
},
message: message || `最多允许 ${max} 个字符`,
trigger: 'change',
}),
// 数值范围
range: (min: number, max: number, message?: string): ValidationRule => ({
validator: (value) => {
if (value === null || value === undefined) return true
const num = Number(value)
return !isNaN(num) && num >= min && num <= max
},
message: message || `数值应在 ${min} 到 ${max} 之间`,
trigger: 'blur',
}),
// 正则匹配
pattern: (regex: RegExp, message: string): ValidationRule => ({
validator: (value) => {
if (!value) return true
return regex.test(value)
},
message,
trigger: 'blur',
}),
// 确认密码
confirm: (field: string, message = '两次输入不一致'): ValidationRule => ({
validator: (value, formData) => {
return value === formData?.[field]
},
message,
trigger: 'blur',
}),
// 自定义校验
custom: (
fn: (value: any, formData?: Record<string, any>) => boolean | Promise<boolean>,
message: string
): ValidationRule => ({
validator: fn,
message,
trigger: 'blur',
}),
}
// 表单校验 Composable
export function useFormValidation<T extends Record<string, any>>(
formData: Ref<T>,
rules: Record<keyof T, ValidationRule[]>
) {
const errors = ref<Record<string, string>>({})
const touched = ref<Record<string, boolean>>({})
// 校验单个字段
const validateField = async (
field: keyof T,
trigger: 'blur' | 'change' | 'submit' = 'submit'
): Promise<boolean> => {
const fieldRules = rules[field] || []
const value = formData.value[field]
for (const rule of fieldRules) {
// 检查触发时机
if (rule.trigger && rule.trigger !== trigger && trigger !== 'submit') {
continue
}
const isValid = await rule.validator(value, formData.value)
if (!isValid) {
errors.value[field as string] = rule.message
return false
}
}
// 清除错误
delete errors.value[field as string]
return true
}
// 校验所有字段
const validateAll = async (): Promise<boolean> => {
const results = await Promise.all(
Object.keys(rules).map(field => validateField(field as keyof T, 'submit'))
)
return results.every(Boolean)
}
// 标记字段为已触碰
const touchField = (field: keyof T) => {
touched.value[field as string] = true
}
// 重置校验状态
const resetValidation = () => {
errors.value = {}
touched.value = {}
}
// 获取字段错误
const getError = (field: keyof T): string | undefined => {
return errors.value[field as string]
}
// 是否有错误
const hasErrors = computed(() => Object.keys(errors.value).length > 0)
return {
errors,
touched,
validateField,
validateAll,
touchField,
resetValidation,
getError,
hasErrors,
}
}
表单校验使用示例
<!-- pages/register.vue -->
<script setup lang="ts">
import { validators, useFormValidation } from '~/composables/useFormValidation'
// 表单数据
const formData = ref({
username: '',
email: '',
password: '',
confirmPassword: '',
phone: '',
age: null as number | null,
})
// 校验规则
const rules = {
username: [
validators.required('请输入用户名'),
validators.minLength(3, '用户名至少3个字符'),
validators.maxLength(20, '用户名最多20个字符'),
validators.pattern(/^[a-zA-Z0-9_]+$/, '只能包含字母、数字和下划线'),
],
email: [
validators.required('请输入邮箱'),
validators.email(),
],
password: [
validators.required('请输入密码'),
validators.minLength(8, '密码至少8个字符'),
validators.pattern(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'密码必须包含大小写字母和数字'
),
],
confirmPassword: [
validators.required('请确认密码'),
validators.confirm('password', '两次输入的密码不一致'),
],
phone: [
validators.phone(),
],
age: [
validators.range(18, 100, '年龄必须在18到100岁之间'),
],
}
// 使用校验
const {
errors,
validateField,
validateAll,
touchField,
resetValidation,
getError,
hasErrors,
} = useFormValidation(formData, rules)
// 提交处理
const handleSubmit = async () => {
const isValid = await validateAll()
if (!isValid) {
console.log('表单校验失败', errors.value)
return
}
// 提交表单
console.log('提交数据', formData.value)
}
// 字段失焦处理
const handleBlur = (field: keyof typeof formData.value) => {
touchField(field)
validateField(field, 'blur')
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="register-form">
<FormGroup title="账户信息" description="创建您的账户">
<FormField
label="用户名"
name="username"
required
:error="getError('username')"
help-text="3-20个字符,只能包含字母、数字和下划线"
>
<FormInput
v-model="formData.username"
placeholder="请输入用户名"
:status="getError('username') ? 'error' : 'default'"
@blur="handleBlur('username')"
/>
</FormField>
<FormField
label="邮箱"
name="email"
required
:error="getError('email')"
>
<FormInput
v-model="formData.email"
type="email"
placeholder="请输入邮箱"
:status="getError('email') ? 'error' : 'default'"
@blur="handleBlur('email')"
/>
</FormField>
</FormGroup>
<FormGroup title="安全设置">
<FormField
label="密码"
name="password"
required
:error="getError('password')"
help-text="至少8个字符,包含大小写字母和数字"
>
<FormInput
v-model="formData.password"
type="password"
placeholder="请输入密码"
:status="getError('password') ? 'error' : 'default'"
@blur="handleBlur('password')"
/>
</FormField>
<FormField
label="确认密码"
name="confirmPassword"
required
:error="getError('confirmPassword')"
>
<FormInput
v-model="formData.confirmPassword"
type="password"
placeholder="请再次输入密码"
:status="getError('confirmPassword') ? 'error' : 'default'"
@blur="handleBlur('confirmPassword')"
/>
</FormField>
</FormGroup>
<FormGroup title="个人信息" collapsible>
<FormField
label="手机号"
name="phone"
optional
:error="getError('phone')"
>
<FormInput
v-model="formData.phone"
type="tel"
placeholder="请输入手机号"
:status="getError('phone') ? 'error' : 'default'"
@blur="handleBlur('phone')"
/>
</FormField>
<FormField
label="年龄"
name="age"
optional
:error="getError('age')"
>
<FormInput
v-model="formData.age"
type="number"
placeholder="请输入年龄"
:status="getError('age') ? 'error' : 'default'"
@blur="handleBlur('age')"
/>
</FormField>
</FormGroup>
<!-- 提交按钮 -->
<div class="form-actions">
<Button type="button" variant="outline" @click="resetValidation">
重置
</Button>
<Button type="submit" :disabled="hasErrors">
注册
</Button>
</div>
</form>
</template>
可访问性设计
ARIA 属性支持
<!-- components/form/AccessibleFormField.vue -->
<script setup lang="ts">
interface Props {
id: string
label: string
error?: string
description?: string
required?: boolean
}
const props = defineProps<Props>()
const descriptionId = computed(() => `${props.id}-description`)
const errorId = computed(() => `${props.id}-error`)
// 组合 aria-describedby
const ariaDescribedBy = computed(() => {
const ids: string[] = []
if (props.description) ids.push(descriptionId.value)
if (props.error) ids.push(errorId.value)
return ids.length ? ids.join(' ') : undefined
})
</script>
<template>
<div class="form-field" role="group">
<label :for="id" class="form-label">
{{ label }}
<span v-if="required" aria-label="必填">*</span>
</label>
<slot
:aria-describedby="ariaDescribedBy"
:aria-invalid="!!error"
:aria-required="required"
/>
<p
v-if="description && !error"
:id="descriptionId"
class="form-description"
>
{{ description }}
</p>
<p
v-if="error"
:id="errorId"
class="form-error"
role="alert"
aria-live="polite"
>
{{ error }}
</p>
</div>
</template>
键盘导航支持
// composables/useFormKeyboard.ts
export function useFormKeyboard() {
const formRef = ref<HTMLFormElement>()
// 获取可聚焦元素
const getFocusableElements = () => {
if (!formRef.value) return []
return Array.from(
formRef.value.querySelectorAll<HTMLElement>(
'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
// 聚焦下一个元素
const focusNext = () => {
const elements = getFocusableElements()
const currentIndex = elements.indexOf(document.activeElement as HTMLElement)
const nextIndex = (currentIndex + 1) % elements.length
elements[nextIndex]?.focus()
}
// 聚焦上一个元素
const focusPrev = () => {
const elements = getFocusableElements()
const currentIndex = elements.indexOf(document.activeElement as HTMLElement)
const prevIndex = (currentIndex - 1 + elements.length) % elements.length
elements[prevIndex]?.focus()
}
// 聚焦第一个错误字段
const focusFirstError = () => {
const errorField = formRef.value?.querySelector<HTMLElement>(
'[aria-invalid="true"]'
)
errorField?.focus()
}
return {
formRef,
focusNext,
focusPrev,
focusFirstError,
}
}
高级表单模式
动态表单字段
<!-- components/form/DynamicFieldList.vue -->
<script setup lang="ts">
interface Props {
modelValue: any[]
minItems?: number
maxItems?: number
addLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
minItems: 0,
maxItems: Infinity,
addLabel: '添加',
})
const emit = defineEmits<{
'update:modelValue': [value: any[]]
}>()
const items = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const canAdd = computed(() => items.value.length < props.maxItems)
const canRemove = computed(() => items.value.length > props.minItems)
const addItem = () => {
if (!canAdd.value) return
items.value = [...items.value, {}]
}
const removeItem = (index: number) => {
if (!canRemove.value) return
items.value = items.value.filter((_, i) => i !== index)
}
const moveItem = (from: number, to: number) => {
const newItems = [...items.value]
const [item] = newItems.splice(from, 1)
newItems.splice(to, 0, item)
items.value = newItems
}
</script>
<template>
<div class="dynamic-field-list">
<TransitionGroup name="list" tag="div" class="dynamic-field-list__items">
<div
v-for="(item, index) in items"
:key="item.id || index"
class="dynamic-field-list__item"
>
<!-- 拖拽手柄 -->
<div class="dynamic-field-list__handle">
<Icon name="grip-vertical" />
</div>
<!-- 字段内容 -->
<div class="dynamic-field-list__content">
<slot :item="item" :index="index" />
</div>
<!-- 操作按钮 -->
<div class="dynamic-field-list__actions">
<Button
v-if="index > 0"
variant="ghost"
size="sm"
@click="moveItem(index, index - 1)"
>
<Icon name="arrow-up" />
</Button>
<Button
v-if="index < items.length - 1"
variant="ghost"
size="sm"
@click="moveItem(index, index + 1)"
>
<Icon name="arrow-down" />
</Button>
<Button
variant="ghost"
size="sm"
:disabled="!canRemove"
@click="removeItem(index)"
>
<Icon name="trash" />
</Button>
</div>
</div>
</TransitionGroup>
<!-- 添加按钮 -->
<Button
v-if="canAdd"
variant="dashed"
class="dynamic-field-list__add"
@click="addItem"
>
<Icon name="plus" />
{{ addLabel }}
</Button>
</div>
</template>
<style scoped>
.dynamic-field-list__item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
margin-bottom: 12px;
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
}
.dynamic-field-list__handle {
cursor: grab;
color: var(--color-text-tertiary);
}
.dynamic-field-list__content {
flex: 1;
}
.dynamic-field-list__add {
width: 100%;
}
/* 列表动画 */
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-20px);
}
.list-leave-active {
position: absolute;
}
</style>
条件字段显示
<!-- 示例:根据条件显示不同字段 -->
<script setup lang="ts">
const formData = ref({
userType: 'personal' as 'personal' | 'business',
// 个人用户字段
idNumber: '',
// 企业用户字段
companyName: '',
businessLicense: '',
})
const isPersonal = computed(() => formData.value.userType === 'personal')
const isBusiness = computed(() => formData.value.userType === 'business')
</script>
<template>
<form>
<FormField label="用户类型" required>
<FormRadioGroup v-model="formData.userType">
<FormRadio value="personal">个人用户</FormRadio>
<FormRadio value="business">企业用户</FormRadio>
</FormRadioGroup>
</FormField>
<!-- 个人用户字段 -->
<Transition name="fade">
<FormField
v-if="isPersonal"
label="身份证号"
required
>
<FormInput v-model="formData.idNumber" placeholder="请输入身份证号" />
</FormField>
</Transition>
<!-- 企业用户字段 -->
<Transition name="fade">
<FormGroup v-if="isBusiness" title="企业信息">
<FormField label="公司名称" required>
<FormInput v-model="formData.companyName" placeholder="请输入公司名称" />
</FormField>
<FormField label="营业执照号" required>
<FormInput v-model="formData.businessLicense" placeholder="请输入营业执照号" />
</FormField>
</FormGroup>
</Transition>
</form>
</template>
最佳实践总结
设计清单
| 类别 | 最佳实践 |
|---|---|
| 布局 | 使用单列布局,复杂表单分组分步 |
| 标签 | 标签位于输入框上方,必填字段标星 |
| 占位符 | 仅作为格式提示,不替代标签 |
| 校验 | 即时反馈,避免提交后才提示 |
| 错误 | 错误信息具体明确,指导修正 |
| 按钮 | 主按钮突出,禁用态明确 |
| 移动端 | 输入框高度足够,键盘类型匹配 |
组件设计原则
- 单一职责 - 每个组件只做一件事
- 可组合性 - 支持灵活组合使用
- 可访问性 - 完整的 ARIA 支持
- 可定制性 - 通过 props 和插槽扩展
- 一致性 - 统一的 API 设计模式
总结
构建优秀的表单组件系统需要:
- 分层设计 - 原子组件、基础组件、组合组件、应用组件
- 校验系统 - 灵活的规则定义和触发时机
- 可访问性 - 完整的键盘和屏幕阅读器支持
- 用户体验 - 即时反馈、清晰引导、错误恢复
通过系统化的组件设计和统一的交互模式,可以大幅提升表单开发效率和用户体验。


