Vue 基础体系 · 第 8/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 表单工程:受控输入、校验、异步提交、错误与可访问性
表单不是“把几个输入框放进 <form>”这么简单。一个可维护的 Vue 表单至少要同时处理五类问题:
- 输入值由谁持有,DOM 与响应式状态如何保持一致;
- 何时校验、校验什么,以及客户端校验与服务端校验的边界;
- 提交期间如何处理异步状态、重复提交、超时和请求竞态;
- 如何区分字段错误、业务错误和网络错误;
- 键盘用户、屏幕阅读器用户和使用辅助技术的用户,能否理解并完成流程。
下面的示例基于 Vue 3、Composition API、TypeScript 和 Vite。代码使用 Vue 3 稳定 API,不依赖实验性能力。
一、先建立表单状态模型
1. “受控输入”是什么意思
受控输入(controlled input)指的是:输入框当前显示的值,以应用状态为唯一事实来源。
以一个邮箱输入框为例,理想的数据流是:
用户输入
↓
input 事件
↓
更新响应式状态 form.email
↓
Vue 重新渲染
↓
<input> 的 value 等于 form.email
可以把它写成两个条件:
DOM.value = state.email
state.email' = event.target.value
其中:
state.email是 Vue 响应式状态中的邮箱值;DOM.value是浏览器输入框当前显示的值;state.email'表示处理输入事件后的新状态;event.target.value是用户刚刚输入的值。
如果只修改 DOM 而不更新状态,下一次 Vue 渲染可能覆盖用户输入;如果只修改状态而没有把状态绑定给 DOM,界面又不会反映程序修改。受控输入要求这两个方向都存在。
在 Vue 中,文本输入的:
<input v-model="email">
大致等价于:
<input
:value="email"
@input="email = ($event.target as HTMLInputElement).value"
/>
v-model 不是一个独立的数据存储系统,而是对“属性绑定 + 事件监听”的语法封装。
2. v-model 在不同元素上的语义不同
文本框通常使用 value 和 input 事件:
<input v-model="form.email">
复选框使用 checked 和 change 事件,绑定结果通常是布尔值或数组:
<input type="checkbox" v-model="form.accepted">
下拉框使用 value 和 change 事件:
<select v-model="form.role">
<option value="developer">开发者</option>
<option value="designer">设计师</option>
</select>
因此,不能机械地认为所有表单元素都等价于“读取 event.target.value”。例如复选框的语义是 event.target.checked,日期、数字和多选也有各自的数据表示。
.number 修饰符也不是类型安全的替代品:
<input v-model.number="age">
它会尝试把输入值转换为数字,但空值、非法值等情况仍需要业务代码处理。TypeScript 的类型声明不会自动验证运行时输入。
3. 组件上的 v-model
在 Vue 3 中,组件默认的 v-model 契约是:
父组件传入:modelValue
子组件发出:update:modelValue
父组件:
<UserNameInput v-model="form.name" />
子组件可以等价地理解为:
<UserNameInput
:model-value="form.name"
@update:model-value="form.name = $event"
/>
子组件不能直接修改 props,因为 props 是父组件传入的数据。一个最小的输入组件如下:
<!-- BaseTextField.vue -->
<script setup lang="ts">
defineProps<{
modelValue: string
label: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<label>
<span>{{ label }}</span>
<input
:value="modelValue"
@input="
emit(
'update:modelValue',
($event.target as HTMLInputElement).value
)
"
>
</label>
</template>
这里的组件契约是明确的:
modelValue是输入;update:modelValue是唯一的更新通道;- 子组件不会修改父组件的对象;
- 父组件仍然拥有表单数据的所有权。
如果组件需要多个双向绑定,例如同时编辑 firstName 和 lastName,Vue 3 还支持参数化的 v-model:first-name,对应的契约是 firstName 与 update:firstName。但组件应只暴露真实存在、可解释的模型,不要为了省事把整个表单对象隐式透传进去。
二、一个可运行的表单示例
下面的 App.vue 展示了一个注册表单,包含:
- 受控输入;
- 字段级同步校验;
- 提交时完整校验;
- 模拟服务端字段错误;
- 提交状态;
- 通用错误;
- 错误后的焦点移动;
label、aria-invalid、aria-describedby和状态播报。
它可以直接放入由 Vite 创建的 Vue 3 TypeScript 项目中运行。
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
type FormModel = {
name: string
email: string
password: string
accepted: boolean
}
type FieldName = keyof FormModel
type FieldErrors = Partial<Record<FieldName, string>>
type ServerValidationError = Error & {
fieldErrors?: FieldErrors
}
const formEl = ref<HTMLFormElement | null>(null)
const form = reactive<FormModel>({
name: '',
email: '',
password: '',
accepted: false,
})
const touched = reactive<Record<FieldName, boolean>>({
name: false,
email: false,
password: false,
accepted: false,
})
const errors = reactive<FieldErrors>({})
const submitted = ref(false)
const submitting = ref(false)
const submitMessage = ref('')
const fields: FieldName[] = [
'name',
'email',
'password',
'accepted',
]
function validateField(field: FieldName): string | undefined {
switch (field) {
case 'name':
if (!form.name.trim()) return '请输入姓名'
if (form.name.trim().length < 2) return '姓名至少需要 2 个字符'
return undefined
case 'email':
if (!form.email.trim()) return '请输入邮箱'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
return '请输入格式正确的邮箱'
}
return undefined
case 'password':
if (!form.password) return '请输入密码'
if (form.password.length < 8) return '密码至少需要 8 个字符'
return undefined
case 'accepted':
if (!form.accepted) return '请先同意服务条款'
return undefined
}
}
function validateAll(): boolean {
for (const field of fields) {
delete errors[field]
const message = validateField(field)
if (message) {
errors[field] = message
}
}
return Object.keys(errors).length === 0
}
function visibleError(field: FieldName): string | undefined {
if (!submitted.value && !touched[field]) {
return undefined
}
return errors[field]
}
function updateField<K extends FieldName>(
field: K,
value: FormModel[K],
) {
form[field] = value
// 用户开始修正字段后,移除旧错误。
// 新错误会在 blur 或再次提交时重新计算。
delete errors[field]
submitMessage.value = ''
}
function handleInput(field: FieldName, event: Event) {
const target = event.target
if (field === 'accepted') {
updateField(
field,
(target as HTMLInputElement).checked,
)
return
}
updateField(
field,
(target as HTMLInputElement).value,
)
}
function handleBlur(field: FieldName) {
touched[field] = true
const message = validateField(field)
if (message) {
errors[field] = message
} else {
delete errors[field]
}
}
async function focusFirstInvalidField() {
await nextTick()
formEl.value
?.querySelector<HTMLElement>('[aria-invalid="true"]')
?.focus()
}
function isServerValidationError(
error: unknown,
): error is ServerValidationError {
return (
error instanceof Error &&
'fieldErrors' in error
)
}
/**
* 这是一个可运行的模拟 API。
* 真实项目中通常将它替换为 fetch/axios 请求。
*/
async function fakeRegisterRequest(
data: FormModel,
): Promise<void> {
await new Promise((resolve) => {
window.setTimeout(resolve, 700)
})
if (data.email === 'taken@example.com') {
const error = new Error('邮箱已经被注册') as ServerValidationError
error.fieldErrors = {
email: '该邮箱已经被注册,请更换其他邮箱',
}
throw error
}
if (data.name.trim().toLowerCase() === 'server-error') {
throw new Error('服务暂时不可用,请稍后重试')
}
}
async function handleSubmit() {
if (submitting.value) {
return
}
submitted.value = true
submitMessage.value = ''
const valid = validateAll()
if (!valid) {
await focusFirstInvalidField()
return
}
submitting.value = true
try {
// 提交快照,而不是在异步过程中反复读取可变的 form。
const payload: FormModel = {
name: form.name.trim(),
email: form.email.trim(),
password: form.password,
accepted: form.accepted,
}
await fakeRegisterRequest(payload)
submitMessage.value = '注册成功'
} catch (error: unknown) {
if (isServerValidationError(error) && error.fieldErrors) {
Object.assign(errors, error.fieldErrors)
submitMessage.value = '请修正表单中的错误'
await focusFirstInvalidField()
} else {
submitMessage.value =
'提交失败,可能是网络或服务器问题,请稍后重试'
}
} finally {
submitting.value = false
}
}
const hasVisibleErrors = computed(() => {
return fields.some((field) => Boolean(visibleError(field)))
})
</script>
<template>
<main>
<h1>创建账号</h1>
<form
ref="formEl"
novalidate
@submit.prevent="handleSubmit"
>
<div
v-if="submitMessage"
role="status"
aria-live="polite"
>
{{ submitMessage }}
</div>
<div>
<label for="name">姓名</label>
<input
id="name"
name="name"
type="text"
autocomplete="name"
:value="form.name"
:aria-invalid="
visibleError('name') ? 'true' : undefined
"
:aria-describedby="
visibleError('name') ? 'name-error' : undefined
"
@input="handleInput('name', $event)"
@blur="handleBlur('name')"
/>
<p
v-if="visibleError('name')"
id="name-error"
role="alert"
>
{{ visibleError('name') }}
</p>
</div>
<div>
<label for="email">邮箱</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
:value="form.email"
:aria-invalid="
visibleError('email') ? 'true' : undefined
"
:aria-describedby="
visibleError('email') ? 'email-error' : undefined
"
@input="handleInput('email', $event)"
@blur="handleBlur('email')"
/>
<p
v-if="visibleError('email')"
id="email-error"
role="alert"
>
{{ visibleError('email') }}
</p>
</div>
<div>
<label for="password">密码</label>
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
:value="form.password"
:aria-invalid="
visibleError('password') ? 'true' : undefined
"
:aria-describedby="
visibleError('password')
? 'password-error'
: undefined
"
@input="handleInput('password', $event)"
@blur="handleBlur('password')"
/>
<p
v-if="visibleError('password')"
id="password-error"
role="alert"
>
{{ visibleError('password') }}
</p>
</div>
<div>
<label>
<input
name="accepted"
type="checkbox"
:checked="form.accepted"
:aria-invalid="
visibleError('accepted') ? 'true' : undefined
"
:aria-describedby="
visibleError('accepted')
? 'accepted-error'
: undefined
"
@change="handleInput('accepted', $event)"
@blur="handleBlur('accepted')"
/>
我同意服务条款
</label>
<p
v-if="visibleError('accepted')"
id="accepted-error"
role="alert"
>
{{ visibleError('accepted') }}
</p>
</div>
<button
type="submit"
:disabled="submitting"
:aria-busy="submitting ? 'true' : undefined"
>
{{ submitting ? '提交中…' : '注册' }}
</button>
<p v-if="hasVisibleErrors">
表单中仍有需要修正的内容。
</p>
</form>
</main>
</template>
这个示例中的关键状态
form 保存用户真正输入的数据:
const form = reactive<FormModel>({
name: '',
email: '',
password: '',
accepted: false,
})
touched 表示字段是否经历过失焦。它不代表字段一定有效,只代表用户已经离开过该字段。
errors 保存当前已计算出的错误。它也不等于“所有潜在错误”:在用户尚未操作字段时,可以选择不展示错误。
submitted 用来区分提交前和提交后的展示策略。提交后,即使某字段从未获得焦点,也应该向用户展示其错误,否则用户可能不知道为什么提交没有成功。
submitting 是异步状态的锁。它不仅用于显示“提交中”,还用于阻止同一个表单同时发出多个相同请求。
三、校验不是一个正则表达式,而是一个状态转换过程
1. 校验的形式化定义
设表单模型为:
x = (name, email, password, accepted)
每个字段有一个校验函数:
r_name(x) → 错误信息或空
r_email(x) → 错误信息或空
r_password(x) → 错误信息或空
r_accepted(x) → 错误信息或空
整个表单有效,当且仅当:
valid(x) =
r_name(x) = 空
∧ r_email(x) = 空
∧ r_password(x) = 空
∧ r_accepted(x) = 空
validateAll() 的执行过程就是:
- 清除旧的字段错误;
- 按字段调用规则;
- 将每个非空结果写入
errors; - 当
errors为空时返回true。
例如:
name = "A"
email = "taken@example.com"
password = "12345678"
accepted = true
客户端规则的中间结果可能是:
r_name = "姓名至少需要 2 个字符"
r_email = 空
r_password = 空
r_accepted = 空
因此客户端校验失败,不能提交。即便所有客户端规则都通过,也只能推出:
客户端规则通过
不能推出:
服务端一定接受
邮箱是否已注册、当前用户是否有权限、价格是否仍然有效,都必须由服务端最终判断。
2. 什么时候校验
常见触发点有三种:
输入时校验
每次输入都运行规则:
input → 更新值 → 运行校验 → 显示错误
优点是反馈及时,缺点是用户刚输入第一个字符就可能看到“格式错误”,容易造成噪声。密码强度指示器适合输入时更新,但必填错误不一定适合从第一个字符开始显示。
失焦时校验
blur → 标记 touched → 运行该字段校验
这通常是较平衡的默认策略:用户完成一个字段后,离开它时获得反馈。
提交时校验
提交时运行全部规则,并将错误焦点移动到第一个错误字段。这是不可省略的兜底路径,因为用户可能直接按 Enter 提交,也可能通过浏览器自动填充表单而没有触发预期的交互事件。
示例中的策略是:
- 输入时清除旧错误;
- 失焦时校验当前字段;
- 提交时校验全部字段;
- 提交失败后显示全部相关错误。
3. novalidate 的含义和边界
示例中的:
<form novalidate>
会关闭浏览器默认的约束验证 UI,但不会关闭 type="email"、required 等属性本身的语义,也不会阻止应用代码校验。
这样做的原因是避免出现两套互相竞争的错误展示:
- 浏览器弹出一套原生提示;
- Vue 又渲染另一套提示。
如果团队决定完全使用浏览器原生验证,也可以不写 novalidate,并通过 required、minlength、pattern 等属性定义约束。但原生验证气泡的文案、样式和跨浏览器交互不完全由 Vue 控制。无论采用哪种方式,服务端校验都不能省略。
4. 同步校验与异步校验必须分层
同步校验适合检查:
- 是否为空;
- 长度;
- 字符格式;
- 数值范围;
- 多个字段之间的简单关系。
异步校验适合检查:
- 用户名是否已占用;
- 邀请码是否有效;
- 服务端计算出的业务规则。
异步校验不能简单塞进同步的 validateField() 中。例如下面的逻辑会产生竞态:
用户输入 a → 请求 A
用户输入 ab → 请求 B
请求 B 先返回:ab 有效
请求 A 后返回:a 无效
如果没有版本判断,旧请求 A 会覆盖新状态,界面最终错误地显示 ab 无效。
一种通用解决方法是为每个字段维护请求序号:
let emailCheckVersion = 0
const emailChecking = ref(false)
const emailAsyncError = ref('')
async function checkEmailRemotely(email: string) {
const version = ++emailCheckVersion
emailChecking.value = true
emailAsyncError.value = ''
try {
const available = await checkEmailApi(email)
// 只有最后一次请求有权更新当前状态
if (version !== emailCheckVersion) {
return
}
if (!available) {
emailAsyncError.value = '该邮箱已经被注册'
}
} finally {
if (version === emailCheckVersion) {
emailChecking.value = false
}
}
}
如果后端支持取消请求,还可以使用 AbortController 节省资源:
let emailController: AbortController | undefined
async function checkEmail(email: string) {
emailController?.abort()
const controller = new AbortController()
emailController = controller
const response = await fetch(
`/api/email-availability?email=${encodeURIComponent(email)}`,
{ signal: controller.signal },
)
if (!response.ok) {
throw new Error('邮箱检查失败')
}
return response.json() as Promise<{ available: boolean }>
}
AbortController 解决的是取消旧请求,不应被误认为是完整的业务竞态解决方案。服务端仍可能已经处理了请求,客户端也应在需要时保留请求序号或请求标识判断。
四、异步提交是一个有状态的流程
一个提交过程可以建模为:
idle
├─ 本地校验失败 → invalid
└─ 本地校验通过 → submitting
├─ 成功 → succeeded
├─ 字段错误 → server-invalid
├─ 业务错误 → failed
└─ 网络/超时 → failed
stateDiagram-v2
[*] --> idle
idle --> invalid: validateAll() 失败
invalid --> idle: 用户修改字段
idle --> submitting: validateAll() 通过
submitting --> succeeded: HTTP 成功且业务成功
submitting --> serverInvalid: 服务端返回字段错误
submitting --> failed: 网络、超时或通用错误
serverInvalid --> idle: 用户修正字段
failed --> idle: 用户重试
succeeded --> [*]
这个模型揭示了一个重要事实:loading 不是唯一状态。至少要区分:
- 是否正在提交;
- 客户端是否有字段错误;
- 服务端是否返回字段错误;
- 是否发生通用失败;
- 是否已经成功。
1. 为什么要在提交前再次校验
即使每次失焦都校验过,提交时仍应重新校验,因为:
- 用户可能直接按 Enter;
- 程序可能修改了表单模型;
- 某些字段可能没有经历失焦;
- 校验规则可能依赖多个字段;
- 异步校验结果可能已经过期。
示例中的顺序是:
submitted.value = true
submitMessage.value = ''
const valid = validateAll()
if (!valid) {
await focusFirstInvalidField()
return
}
submitting.value = true
只有本地规则通过后,才进入网络请求阶段。这样不会用一个明确可以在客户端发现的错误去消耗服务端资源。
2. 为什么要提交数据快照
示例中没有直接把可变的 form 传给异步函数,而是创建了:
const payload: FormModel = {
name: form.name.trim(),
email: form.email.trim(),
password: form.password,
accepted: form.accepted,
}
原因是异步请求执行期间,用户可能继续编辑表单。如果请求函数在多个异步步骤中反复读取 form.email,一次请求可能前后看到不同的值。
提交快照把这次请求绑定到一个确定的数据版本:
用户点击提交
↓
读取 form
↓
生成 payload
↓
请求只使用 payload
这也方便日志记录、重试和测试。
3. 禁用按钮不是并发控制的全部
<button :disabled="submitting">
{{ submitting ? '提交中…' : '注册' }}
</button>
这能阻止常见的重复点击,但不能替代服务端幂等性。重复请求可能来自:
- 浏览器重试;
- 网络代理重传;
- 用户刷新页面;
- 多个客户端同时操作;
- 前端状态异常。
涉及创建订单、支付、注册等操作时,服务端应支持幂等键。典型流程是:
客户端生成 requestId
↓
每次重试携带同一个 requestId
↓
服务端对同一 requestId 只产生一次业务结果
前端的 submitting 解决交互层重复提交,服务端幂等键解决系统层重复执行,两者不是同一层问题。
4. 真实请求中的响应判断
fetch 在 HTTP 400 或 500 时通常不会自动 throw。因此不能只写:
const response = await fetch('/api/register')
const data = await response.json()
而应明确判断:
async function register(payload: FormModel) {
const response = await fetch('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
let body: unknown
try {
body = await response.json()
} catch {
body = undefined
}
if (!response.ok) {
const error = new Error('注册请求失败') as ServerValidationError
if (
typeof body === 'object' &&
body !== null &&
'fieldErrors' in body
) {
error.fieldErrors = (
body as { fieldErrors?: FieldErrors }
).fieldErrors
}
throw error
}
return body
}
这里的因果关系是:
response.ok只代表 HTTP 状态是否在 200–299;- 非 2xx 必须由应用代码转成可处理的错误;
- 返回体可能不是合法 JSON,因此解析也要防御;
- 字段错误需要转换为前端自己的
FieldErrors结构; - 模板只渲染可信的纯文本错误,不应直接使用
v-html输出服务端内容。
五、错误应该按来源和作用分类
1. 字段级客户端错误
例如:
请输入邮箱
密码至少需要 8 个字符
请先同意服务条款
它们与具体字段对应,适合放在控件附近,并通过 aria-describedby 关联。
2. 服务端字段错误
例如邮箱已经注册:
{
"fieldErrors": {
"email": "该邮箱已经被注册,请更换其他邮箱"
}
}
这类错误不能由前端本地规则推导出来,但仍然应映射到对应字段:
Object.assign(errors, error.fieldErrors)
服务端字段名必须经过边界转换。不能假设后端返回的任意字符串都一定是前端字段:
const allowedFields: FieldName[] = [
'name',
'email',
'password',
'accepted',
]
function pickFieldErrors(
input: Record<string, unknown>,
): FieldErrors {
const result: FieldErrors = {}
for (const field of allowedFields) {
if (typeof input[field] === 'string') {
result[field] = input[field]
}
}
return result
}
这样可以避免后端错误数据污染无关的 UI 状态。
3. 通用错误
网络断开、请求超时、服务端 500 等错误通常没有可靠的字段归属:
提交失败,可能是网络或服务器问题,请稍后重试
它们应显示在表单顶部或提交按钮附近,并通过 role="status" 或 aria-live 让辅助技术感知状态变化。
通用错误不应伪装成“邮箱格式错误”。错误归因错误会让用户修改无关字段,也会增加排障难度。
4. 成功状态
成功消息也需要可访问:
<p role="status" aria-live="polite">
{{ submitMessage }}
</p>
role="status" 的语义适合播报不需要用户立即打断当前操作的状态。严重、需要立即注意的错误才应谨慎使用更强的通知语义。不要给每一次按键都使用 role="alert",否则屏幕阅读器会被连续打断。
六、可访问性不是给输入框加几个 ARIA 属性
1. 可见标签优先于占位符
正确关系是:
<label for="email">邮箱</label>
<input id="email" name="email">
placeholder 只是输入示例,不是稳定的字段名称,因为:
- 用户输入后占位符消失;
- 对比度可能不足;
- 屏幕阅读器和认知辅助用户不应依赖它理解字段;
- 复杂表单需要持续可见的字段上下文。
2. 错误必须与字段建立程序关系
下面三个属性承担不同职责:
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? 'email-error' : undefined"
aria-invalid="true"表示当前值未通过校验;aria-describedby指向错误说明元素;id="email-error"必须与引用完全匹配。
错误元素应位于 DOM 中,并且内容是用户能理解的行动提示,而不是只写:
Invalid
更好的文本是:
请输入包含 @ 的有效邮箱地址
3. 提交失败后必须处理焦点
只把错误文字渲染出来并不等于用户能找到错误。提交失败后,示例执行:
await nextTick()
formEl.value
?.querySelector<HTMLElement>('[aria-invalid="true"]')
?.focus()
nextTick() 是必要的,因为错误状态更新后,错误 DOM 和 aria-invalid 属性要等 Vue 完成下一轮 DOM 更新才存在。
错误顺序应与视觉和 DOM 顺序一致。通常把焦点放到第一个错误字段,而不是把焦点强行移到页面顶部。这样键盘用户可以立即修正问题。
如果表单位于模态框、抽屉或路由页面中,还要处理容器级焦点:
- 打开时将焦点放入容器;
- 关闭时将焦点还给触发按钮;
- 模态框打开时限制焦点在模态框内部;
- 页面切换后将焦点放到新页面标题或主要内容。
这些行为不是 v-model 自动提供的,需要由组件或页面逻辑明确实现。
4. 键盘操作不能依赖鼠标事件
提交按钮应使用:
<button type="submit">提交</button>
而不是:
<div @click="handleSubmit">提交</div>
原生 <button> 自带:
- 键盘操作;
- 焦点行为;
- 表单提交语义;
- 辅助技术可识别的角色。
@submit.prevent 作用于 <form>,因此鼠标点击提交按钮、在输入框中按 Enter 等路径可以统一进入同一个提交处理函数。
不要用 @keydown 自己模拟所有按钮和输入行为,除非确实是在实现原生元素无法提供的交互。
5. disabled、aria-busy 和“看起来禁用”不同
<button
:disabled="submitting"
:aria-busy="submitting ? 'true' : undefined"
>
提交
</button>
disabled会阻止按钮获得焦点和交互;aria-busy表示相关区域仍在处理;- CSS 中降低透明度,只是视觉效果,不能替代语义。
如果提交期间允许用户继续编辑字段,可以只禁用提交按钮;如果必须冻结整个表单,需要额外考虑键盘用户是否还能获知当前状态,以及错误返回后焦点是否仍然合理。不要为了防止重复提交而把焦点从用户正在编辑的输入框强行移走。
七、透传属性与表单组件边界
封装输入组件时,经常需要将父组件传入的 id、name、disabled、aria-* 等属性传给真正的原生控件。
组件只有一个根元素时,Vue 默认会将未声明的属性透传到根元素。但表单组件常常有多个根节点,或者根节点是包裹元素,此时不能依赖默认行为。
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
})
defineProps<{
modelValue: string
label: string
error?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<div class="field">
<label :for="$attrs.id as string">
{{ label }}
</label>
<input
v-bind="$attrs"
:value="modelValue"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="
error ? `${String($attrs.id)}-error` : undefined
"
@input="
emit(
'update:modelValue',
($event.target as HTMLInputElement).value
)
"
/>
<p
v-if="error"
:id="`${String($attrs.id)}-error`"
role="alert"
>
{{ error }}
</p>
</div>
</template>
这里有几个边界:
$attrs包含未声明的属性和监听器,不是类型完整、结构固定的业务对象;v-bind="$attrs"可能把不希望进入原生<input>的属性一起传下去;id必须存在且唯一,否则label for和aria-describedby会失效;- 如果组件同时接收
error并自行设置aria-invalid,父组件传入的同名属性可能产生冲突; - 复杂组件应显式声明关键属性,而不是无限制透传。
因此,透传属性适合处理原生 HTML 边界,不能替代组件契约。对于 modelValue、错误文本、提交状态等业务状态,应使用明确的 props 和 emits。
一个字段组件的 API 可以设计为:
type FieldProps = {
modelValue: string
label: string
error?: string
disabled?: boolean
}
type FieldEmits = {
'update:modelValue': [value: string]
blur: []
}
父组件负责表单模型和提交,字段组件负责标签、输入控件、错误关联和局部交互。这样组件职责不会滑向“字段组件偷偷提交整个表单”。
八、常见错误及诊断方式
1. 直接修改 props
错误:
props.modelValue = value
表现通常是 Vue 警告,或者下一次父组件更新后子组件值被覆盖。正确方式是发出更新事件:
emit('update:modelValue', value)
2. 只监听 click,不监听表单提交
错误:
<button @click="handleSubmit">提交</button>
这可能遗漏 Enter 提交路径,也破坏了原生表单语义。应将逻辑放在:
<form @submit.prevent="handleSubmit">
3. 只在前端校验,不处理服务端拒绝
表现是客户端显示“校验通过”,点击后没有任何可解释反馈,或者把所有错误都归为“网络错误”。
诊断时检查:
- 是否判断了
response.ok; - 是否解析了服务端错误结构;
- 是否将服务端字段错误映射到了
errors; - 是否在
finally中恢复submitting。
4. 请求失败后按钮永久禁用
常见原因是只在成功分支执行:
submitting.value = false
异常分支没有恢复状态。异步提交必须使用 finally:
try {
await request()
} catch {
showError()
} finally {
submitting.value = false
}
5. 错误文字可见,但辅助技术读不到
按以下顺序排查:
label[for]是否等于输入框id;aria-describedby是否指向实际存在的元素;- 错误元素是否在错误状态时渲染;
aria-invalid是否只在真实错误时设置;- 错误是否被 CSS 隐藏到辅助技术也无法访问;
- 提交后焦点是否移动到了第一个错误字段。
6. 旧请求覆盖新输入
如果界面出现“已经改正确,却又显示旧错误”,通常是异步校验或搜索请求竞态。检查:
- 是否取消前一个请求;
- 是否维护请求序号;
- 是否只允许最新请求更新状态;
- 组件卸载时是否中止仍在进行的请求。
九、生产环境中的取舍
客户端校验与服务端校验
客户端校验的主要价值是即时反馈和减少明显无效请求;服务端校验负责安全性、数据完整性和最终业务决策。客户端规则可以与服务端共享规则描述,但不能因为“前端已经校验过”就信任客户端数据。
展示错误的时机
全部输入都实时显示错误,会让界面嘈杂;只在提交后显示错误,又会延迟反馈。常见折中是“失焦校验 + 提交兜底”,但密码强度、搜索过滤等交互可以采用输入时反馈。触发策略应由字段语义决定,而不是所有字段统一处理。
加载状态与取消
普通提交通常应锁定提交按钮,避免重复操作。长时间请求可以提供取消能力:
const controller = new AbortController()
fetch('/api/register', {
method: 'POST',
signal: controller.signal,
})
// 用户主动取消
controller.abort()
取消不是失败。用户取消请求时,不应提示“服务器错误”;应根据产品流程决定保持输入、清除加载状态,还是显示“已取消”。
密码与日志
密码不应写入普通调试日志、错误上报 payload 或 URL。提交数据快照时也要避免:
console.log(payload)
生产环境的错误追踪应对敏感字段脱敏。autocomplete="new-password" 能帮助浏览器正确处理新密码,但不能代替服务端安全策略。
十、判断一个表单是否真正完成
可以用以下因果链检查实现,而不是只检查页面是否“能点”:
输入框显示值
是否始终来自响应式模型?
↓
字段变化
是否通过明确事件更新模型?
↓
校验失败
是否关联到具体字段并可被读出?
↓
提交开始
是否阻止重复提交并显示忙碌状态?
↓
请求返回
是否区分成功、字段错误、业务错误、网络错误?
↓
提交失败
用户焦点是否到达第一个可修正的问题?
↓
请求重试或并发
旧结果是否可能覆盖新状态?
Vue 提供了 ref、reactive、computed、v-model、生命周期钩子和模板事件绑定,但它不会自动决定校验时机、错误分类、请求幂等性或焦点策略。表单工程的核心,是把这些状态和边界显式建模:
- 用受控输入保证数据流单一;
- 用分层校验保证反馈及时且服务端仍拥有最终权威;
- 用明确的异步状态处理提交生命周期;
- 用字段错误和通用错误分别表达可修正问题与系统故障;
- 用原生语义、稳定标签、ARIA 关系和焦点管理保证不同用户都能完成操作。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue Slots 与动态组件:内容分发、KeepAlive、Teleport 和异步组件
- 下一篇:Vue Composable 设计:复用状态、清理副作用、参数契约和测试
- 延伸:Vue 组件契约:Props、Emits、v-model、透传属性与边界
- 延伸:Vue 可访问性与交互质量:语义、键盘、焦点、ARIA 和动效
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论